feat(admin): 管理端 UI/UX 深度优化——错误/加载/空状态、表单快捷键、移动端适配与无障碍
This commit is contained in:
@@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN';
|
|||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import PermissionRoute from './components/PermissionRoute';
|
import PermissionRoute from './components/PermissionRoute';
|
||||||
import DefaultRoute from './components/DefaultRoute';
|
import DefaultRoute from './components/DefaultRoute';
|
||||||
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
import AppMessageBridge from './ui/AppMessageBridge';
|
import AppMessageBridge from './ui/AppMessageBridge';
|
||||||
import { useUserStore } from './store/user/userStore';
|
import { useUserStore } from './store/user/userStore';
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ const App: React.FC = () => {
|
|||||||
<AntdApp>
|
<AntdApp>
|
||||||
<AppMessageBridge />
|
<AppMessageBridge />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ScrollToTop />
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>
|
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>
|
||||||
|
|||||||
40
apps/admin/src/components/BackTop.tsx
Normal file
40
apps/admin/src/components/BackTop.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。
|
||||||
|
* 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。
|
||||||
|
*/
|
||||||
|
export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setVisible(window.scrollY > threshold);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title="回到顶部">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<VerticalAlignTopOutlined />}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
onClick={scrollToTop}
|
||||||
|
style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackTop;
|
||||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useInterval } from 'usehooks-ts';
|
import { useInterval } from 'usehooks-ts';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { message } from '../ui/app-message';
|
||||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||||
import { useUserStore } from '../store/user/userStore';
|
import { useUserStore } from '../store/user/userStore';
|
||||||
|
|
||||||
@@ -33,8 +34,9 @@ const NotificationBell: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* ignore */
|
console.error('全部已读失败', error);
|
||||||
|
message.error('全部已读失败,请重试');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ const NotificationBell: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Text strong>通知中心</Typography.Text>
|
<Typography.Text strong>通知中心</Typography.Text>
|
||||||
<Button type="link" size="small" onClick={handleMarkAll}>
|
<Button type="link" size="small" disabled={unreadCount === 0} onClick={handleMarkAll}>
|
||||||
全部已读
|
全部已读
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => {
|
|||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
||||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<BellOutlined />}
|
||||||
|
aria-label="通知中心"
|
||||||
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Tour, type TourProps } from 'antd';
|
|
||||||
import { getRoleDomains } from '../../auth/menu-policy';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'onboarding_seen_v1';
|
|
||||||
|
|
||||||
export interface RoleTourProps {
|
|
||||||
roles: readonly string[];
|
|
||||||
permissions: readonly string[];
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TourStep {
|
|
||||||
target: () => HTMLElement;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 antd Menu 渲染的菜单项查找目标元素(菜单项 title 属性即菜单文案) */
|
|
||||||
function menuTarget(label: string): () => HTMLElement {
|
|
||||||
return () => document.querySelector<HTMLElement>(`[title="${label}"]`) as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 右上角 AI 助手按钮 */
|
|
||||||
const aiTarget = (): HTMLElement =>
|
|
||||||
document.querySelector<HTMLElement>('button[aria-label="打开 AI 助理"]') as HTMLElement;
|
|
||||||
|
|
||||||
/** 各角色的引导步骤(声明式,按角色域组装) */
|
|
||||||
const TEACHER_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('今日教学'),
|
|
||||||
title: '今日教学',
|
|
||||||
description: '在这里查看今天的课程安排。课程开始后可以拉取钉钉考勤并点名。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: menuTarget('课程考勤'),
|
|
||||||
title: '课程考勤',
|
|
||||||
description: '查看历史考勤记录,课程截止后系统会自动结算缺勤。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ACADEMIC_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('学生管理'),
|
|
||||||
title: '学生管理',
|
|
||||||
description: '管理学生档案:可单个录入、Excel 批量导入,或用 AI 助手帮你录入。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: menuTarget('班级管理'),
|
|
||||||
title: '班级管理',
|
|
||||||
description: '学生入学后先分班,再排课、考勤,形成完整教学闭环。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ACCOMMODATION_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('住宿总览'),
|
|
||||||
title: '住宿总览',
|
|
||||||
description: '可视化查看各宿舍入住情况,支持历史日期回溯和查寝。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: menuTarget('入住管理'),
|
|
||||||
title: '入住管理',
|
|
||||||
description: '学生入住/退宿/换宿都在这里办理。入住后再录费用、生成账单。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: menuTarget('账单管理'),
|
|
||||||
title: '账单管理',
|
|
||||||
description: '每月生成账单、确认并标记已付,完成「住宿→计费」闭环。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CLASSROOM_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('教室排期'),
|
|
||||||
title: '教室排期',
|
|
||||||
description: '查看教室占用与排期,避免时间冲突。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: menuTarget('租赁订单'),
|
|
||||||
title: '租赁订单',
|
|
||||||
description: '管理教室租赁订单与合同,跟踪租期与状态。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SYSTEM_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('账号管理'),
|
|
||||||
title: '账号管理',
|
|
||||||
description: '管理系统账号、角色与权限,为不同岗位分配对应能力。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUPER_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget('数据面板'),
|
|
||||||
title: '数据面板',
|
|
||||||
description: '全局经营概览:入住率、收入、待办都在这里。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const AI_STEPS: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: aiTarget,
|
|
||||||
title: 'AI 助手',
|
|
||||||
description:
|
|
||||||
'有任何问题都可以问我。我可以帮你查询数据、录入学生、生成批量导入预览——写操作都会先经你确认。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildSteps(domains: Set<string>): TourStep[] {
|
|
||||||
const steps: TourStep[] = [];
|
|
||||||
const has = (key: string) => domains.has(key);
|
|
||||||
|
|
||||||
if (has('super')) steps.unshift(...SUPER_STEPS);
|
|
||||||
if (has('teacher')) steps.push(...TEACHER_STEPS);
|
|
||||||
if (has('academic')) steps.push(...ACADEMIC_STEPS);
|
|
||||||
if (has('accommodation')) steps.push(...ACCOMMODATION_STEPS);
|
|
||||||
if (has('classroom')) steps.push(...CLASSROOM_STEPS);
|
|
||||||
if (has('system') || has('super')) steps.push(...SYSTEM_STEPS);
|
|
||||||
steps.push(...AI_STEPS);
|
|
||||||
|
|
||||||
return steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首次登录角色引导:按用户角色展示核心业务闭环的 Tour。
|
|
||||||
* 只对桌面端展示一次(localStorage 标记),可随时关闭。
|
|
||||||
*/
|
|
||||||
export const RoleTour: React.FC<RoleTourProps> = ({ roles, permissions, enabled = true }) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const domains = useMemo(() => getRoleDomains(roles, permissions), [roles, permissions]);
|
|
||||||
const steps = useMemo(() => buildSteps(domains), [domains]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (window.localStorage.getItem(STORAGE_KEY)) return;
|
|
||||||
if (window.innerWidth < 992) return; // 仅桌面端
|
|
||||||
// 等菜单渲染完成后再弹引导
|
|
||||||
const timer = window.setTimeout(() => setOpen(true), 600);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
const handleFinish = () => {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, '1');
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const config: TourProps = {
|
|
||||||
open,
|
|
||||||
onClose: handleFinish,
|
|
||||||
// target 函数运行时返回 HTMLElement | null,与 antd 类型(不接受联合返回)做一次断言
|
|
||||||
steps: steps as TourProps['steps'],
|
|
||||||
placement: 'right',
|
|
||||||
mask: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Tour {...config} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoleTour;
|
|
||||||
17
apps/admin/src/components/RefreshButton.tsx
Normal file
17
apps/admin/src/components/RefreshButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface RefreshButtonProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表工具栏刷新入口:手动重新拉取当前数据,带加载反馈。 */
|
||||||
|
export const RefreshButton: React.FC<RefreshButtonProps> = ({ onRefresh, loading }) => (
|
||||||
|
<Tooltip title="刷新">
|
||||||
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh} aria-label="刷新" />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RefreshButton;
|
||||||
16
apps/admin/src/components/ScrollToTop.tsx
Normal file
16
apps/admin/src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA 路由切换后把滚动位置复位到顶部。
|
||||||
|
* 只在 pathname 变化时触发,避免干扰弹窗/抽屉等局部滚动。
|
||||||
|
*/
|
||||||
|
export const ScrollToTop: React.FC = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [pathname]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToTop;
|
||||||
@@ -137,7 +137,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
) : null}
|
) : null}
|
||||||
<Table<AttachmentRecord>
|
<Table<AttachmentRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
|
|||||||
>
|
>
|
||||||
添加报读记录
|
添加报读记录
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<EnrollmentRecord>
|
<Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC<
|
|||||||
>
|
>
|
||||||
添加考试成绩
|
添加考试成绩
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<ExamScoreRecord>
|
<Table<ExamScoreRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
|||||||
>
|
>
|
||||||
添加学情记录
|
添加学情记录
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<LearningRecord>
|
<Table<LearningRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const InlineArchiveSummary: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
<Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="手机号">
|
<Descriptions.Item label="手机号">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={student.phone}
|
value={student.phone}
|
||||||
|
|||||||
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||||
|
import { RefreshButton } from './RefreshButton';
|
||||||
|
import { BackTop } from './BackTop';
|
||||||
|
import { ScrollToTop } from './ScrollToTop';
|
||||||
|
import { useSubmitShortcut } from '../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const mount = (node: React.ReactNode) => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
container = host;
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
act(() => root?.render(node));
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UX 组件与交互', () => {
|
||||||
|
it('RefreshButton 点击触发 onRefresh,loading 时展示加载态', async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
mount(<RefreshButton onRefresh={onRefresh} />);
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
if (!button) throw new Error('button not rendered');
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
await flush();
|
||||||
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mount(<RefreshButton onRefresh={onRefresh} loading />);
|
||||||
|
expect(container?.querySelector('.ant-btn-loading')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const Harness = () => {
|
||||||
|
useSubmitShortcut(false, onSubmit);
|
||||||
|
return <button type="button">ok</button>;
|
||||||
|
};
|
||||||
|
mount(<Harness />);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const Harness = () => {
|
||||||
|
useSubmitShortcut(true, onSubmit);
|
||||||
|
return <button type="button">ok</button>;
|
||||||
|
};
|
||||||
|
mount(<Harness />);
|
||||||
|
|
||||||
|
const metaEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(metaEvent);
|
||||||
|
expect(metaEvent.defaultPrevented).toBe(true);
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BackTop 超过阈值后出现,点击回到顶部', async () => {
|
||||||
|
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
mount(<BackTop threshold={-1} />);
|
||||||
|
await flush();
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
expect(button).toBeTruthy();
|
||||||
|
if (button) {
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
}
|
||||||
|
await flush();
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => {
|
||||||
|
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
const Nav = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => navigate('/other')}>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
mount(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<ScrollToTop />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Nav />} />
|
||||||
|
<Route path="/other" element={<div>other</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
scrollSpy.mockClear();
|
||||||
|
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
if (!button) throw new Error('button not rendered');
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
await flush();
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal file
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗/表单内按 Cmd/Ctrl+Enter 触发表单提交。
|
||||||
|
* 仅在 active(弹窗打开且非保存中)时监听,避免误触。
|
||||||
|
*/
|
||||||
|
export function useSubmitShortcut(active: boolean, onSubmit: () => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [active, onSubmit]);
|
||||||
|
}
|
||||||
@@ -514,3 +514,68 @@ canvas {
|
|||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 全局无障碍与质感增强 ─── */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(0, 122, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘导航焦点可见(鼠标点击不显示,符合 WCAG 2.4.7) */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #007aff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 细滚动条(macOS/Chromium),降低大面积滚动条对视觉的干扰 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尊重系统「减少动态效果」偏好 */
|
||||||
|
/* 窄屏下压缩路由标签尺寸,避免横向裁切 */
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.route-dock {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab {
|
||||||
|
min-width: 88px;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 0 8px 0 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/
|
|||||||
import NotificationBell from '../components/NotificationBell';
|
import NotificationBell from '../components/NotificationBell';
|
||||||
import RouteDock from '../components/RouteDock';
|
import RouteDock from '../components/RouteDock';
|
||||||
import RouteKeeper from '../components/RouteKeeper';
|
import RouteKeeper from '../components/RouteKeeper';
|
||||||
import RoleTour from '../components/Onboarding/RoleTour';
|
import BackTop from '../components/BackTop';
|
||||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||||
|
|
||||||
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
||||||
@@ -432,7 +432,7 @@ const MainLayout: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
<RoleTour roles={user?.roles ?? []} permissions={permissions} />
|
<BackTop />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { aiConfigEnvelopeSchema } from '../../api/schemas';
|
import { aiConfigEnvelopeSchema } from '../../api/schemas';
|
||||||
import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd';
|
import {App, Alert, Button, Form, Space, Steps, Tag, Skeleton} from 'antd';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
|
<div className={styles.container} style={{ paddingTop: 24 }}>
|
||||||
<Spin size="large" />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
onOk: () => void;
|
onOk: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}> = ({ open, form, onOk, onCancel, onReset }) => {
|
confirmLoading?: boolean;
|
||||||
|
}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -37,6 +38,7 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
className="attendance-period-modal"
|
className="attendance-period-modal"
|
||||||
onOk={onOk}
|
onOk={onOk}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
footer={(_, { OkBtn, CancelBtn }) => (
|
footer={(_, { OkBtn, CancelBtn }) => (
|
||||||
<>
|
<>
|
||||||
<Button icon={<UndoOutlined />} onClick={onReset}>
|
<Button icon={<UndoOutlined />} onClick={onReset}>
|
||||||
@@ -54,7 +56,7 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
description="默认:07:30-08:30 早自习,09:00-12:00 早课,14:00-17:00 晚课,18:30-21:00 晚自习。"
|
description="默认:07:30-08:30 早自习,09:00-12:00 早课,14:00-17:00 晚课,18:30-21:00 晚自习。"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.List name="periods">
|
<Form.List name="periods">
|
||||||
{(fields, { add, remove }) => (
|
{(fields, { add, remove }) => (
|
||||||
<div className="attendance-period-editor">
|
<div className="attendance-period-editor">
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
onRetry={() => void loadLesson()}
|
onRetry={() => void loadLesson()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table<LessonAttendanceRecord>
|
<Table<LessonAttendanceRecord> scroll={{ x: 'max-content' }}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={filteredRecords}
|
dataSource={filteredRecords}
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
onOk={savePeriodConfig}
|
onOk={savePeriodConfig}
|
||||||
onCancel={() => setPeriodModalOpen(false)}
|
onCancel={() => setPeriodModalOpen(false)}
|
||||||
onReset={() => void resetPeriodConfig()}
|
onReset={() => void resetPeriodConfig()}
|
||||||
|
confirmLoading={savePeriodConfigMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
onRetry={() => void refetch()}
|
onRetry={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table<AttendanceDeviceRow>
|
<Table<AttendanceDeviceRow> scroll={{ x: 'max-content' }}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { NextStepHint } from '../../components/NextStepHint';
|
|||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useDownload } from '../../hooks/useDownload';
|
import { useDownload } from '../../hooks/useDownload';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||||
import { newOperationId } from '../../utils/operation-id';
|
import { newOperationId } from '../../utils/operation-id';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -334,6 +335,7 @@ const BillsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 320,
|
width: 320,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -461,6 +463,7 @@ const BillsPage: React.FC = () => {
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="bill:generate"
|
permission="bill:generate"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -564,7 +567,7 @@ const BillsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<Spin spinning={detailLoading}>
|
<Spin spinning={detailLoading}>
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
<Descriptions bordered size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={statusMap[detailModal.status]?.color}>
|
<Tag color={statusMap[detailModal.status]?.color}>
|
||||||
@@ -589,7 +592,7 @@ const BillsPage: React.FC = () => {
|
|||||||
</strong>
|
</strong>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, lg: 3 }} style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="已扣余额">
|
<Descriptions.Item label="已扣余额">
|
||||||
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -23,6 +23,7 @@ import dayjs from 'dayjs';
|
|||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { QueryEmpty } from '../../components/QueryState';
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ export const ClassInfoTab: React.FC<{
|
|||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Descriptions column={3} bordered size="small">
|
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} bordered size="small">
|
||||||
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
|
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
|
||||||
<Descriptions.Item label="开班日期">
|
<Descriptions.Item label="开班日期">
|
||||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||||
@@ -247,6 +248,7 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRemove: (studentId: number) => void;
|
onRemove: (studentId: number) => void;
|
||||||
onSelect: (ids: number[]) => void;
|
onSelect: (ids: number[]) => void;
|
||||||
|
adding?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
id,
|
id,
|
||||||
detail,
|
detail,
|
||||||
@@ -259,7 +261,9 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
onClose,
|
onClose,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
adding,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const studentColumns: ColumnsType<ClassStudent> = [
|
const studentColumns: ColumnsType<ClassStudent> = [
|
||||||
{ title: '姓名', dataIndex: 'studentName' },
|
{ title: '姓名', dataIndex: 'studentName' },
|
||||||
{ title: '学号', dataIndex: 'studentNo' },
|
{ title: '学号', dataIndex: 'studentNo' },
|
||||||
@@ -298,25 +302,29 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="class:view"
|
permission="class:view"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
onClick={() => {
|
loading={exporting}
|
||||||
const token = useUserStore.getState().token;
|
onClick={async () => {
|
||||||
fetch(`/api/classes/${id}/roster/export`, {
|
setExporting(true);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
try {
|
||||||
})
|
const token = useUserStore.getState().token;
|
||||||
.then((res) => {
|
const res = await fetch(`/api/classes/${id}/roster/export`, {
|
||||||
if (!res.ok) throw new Error('导出失败');
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
return res.blob();
|
});
|
||||||
})
|
if (!res.ok) throw new Error('导出失败');
|
||||||
.then((blob) => {
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
message.success('花名册导出成功');
|
message.success('花名册导出成功');
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => message.error('花名册导出失败'));
|
console.error('花名册导出失败', error);
|
||||||
|
message.error('花名册导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
导出花名册
|
导出花名册
|
||||||
@@ -344,7 +352,7 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -379,6 +387,7 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
onSubjectChange: (subject: string) => void;
|
onSubjectChange: (subject: string) => void;
|
||||||
onUserChange: (userId?: number) => void;
|
onUserChange: (userId?: number) => void;
|
||||||
getTeacherName: (teacher: ClassTeacher) => string;
|
getTeacherName: (teacher: ClassTeacher) => string;
|
||||||
|
adding?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
teachers,
|
teachers,
|
||||||
allUsers,
|
allUsers,
|
||||||
@@ -394,7 +403,9 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
onSubjectChange,
|
onSubjectChange,
|
||||||
onUserChange,
|
onUserChange,
|
||||||
getTeacherName,
|
getTeacherName,
|
||||||
|
adding,
|
||||||
}) => {
|
}) => {
|
||||||
|
useSubmitShortcut(modalOpen, onAdd);
|
||||||
const teacherColumns: ColumnsType<ClassTeacher> = [
|
const teacherColumns: ColumnsType<ClassTeacher> = [
|
||||||
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
||||||
{
|
{
|
||||||
@@ -439,7 +450,7 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { Button, Card, Form, Space, Spin, Tabs, Tag } from 'antd';
|
import {Button, Card, Form, Space, Tabs, Tag, Skeleton} from 'antd';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -33,6 +33,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
||||||
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
||||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||||
|
const [addingStudents, setAddingStudents] = useState(false);
|
||||||
|
const [addingTeacher, setAddingTeacher] = useState(false);
|
||||||
|
|
||||||
// Teacher modal state
|
// Teacher modal state
|
||||||
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
||||||
@@ -151,6 +153,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudents = async () => {
|
const handleAddStudents = async () => {
|
||||||
if (!selectedStudentIds.length) return;
|
if (!selectedStudentIds.length) return;
|
||||||
|
setAddingStudents(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
||||||
setStudentModalOpen(false);
|
setStudentModalOpen(false);
|
||||||
@@ -159,11 +162,14 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
message.success('已添加');
|
message.success('已添加');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e, '添加失败'));
|
message.error(getErrorMessage(e, '添加失败'));
|
||||||
|
} finally {
|
||||||
|
setAddingStudents(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTeacher = async () => {
|
const handleAddTeacher = async () => {
|
||||||
if (!teacherUserId) return;
|
if (!teacherUserId) return;
|
||||||
|
setAddingTeacher(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/classes/${id}/teachers`, {
|
await api.post(`/classes/${id}/teachers`, {
|
||||||
userId: teacherUserId,
|
userId: teacherUserId,
|
||||||
@@ -175,6 +181,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
message.success('已添加');
|
message.success('已添加');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e, '添加失败'));
|
message.error(getErrorMessage(e, '添加失败'));
|
||||||
|
} finally {
|
||||||
|
setAddingTeacher(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,8 +227,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
if (!detail) {
|
if (!detail) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Spin size="large" />
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -296,6 +304,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
onClose={() => setStudentModalOpen(false)}
|
onClose={() => setStudentModalOpen(false)}
|
||||||
onRemove={handleRemoveStudent}
|
onRemove={handleRemoveStudent}
|
||||||
onSelect={setSelectedStudentIds}
|
onSelect={setSelectedStudentIds}
|
||||||
|
adding={addingStudents}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -324,6 +333,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
onSubjectChange={setTeacherSubject}
|
onSubjectChange={setTeacherSubject}
|
||||||
onUserChange={setTeacherUserId}
|
onUserChange={setTeacherUserId}
|
||||||
getTeacherName={getTeacherName}
|
getTeacherName={getTeacherName}
|
||||||
|
adding={addingTeacher}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { downloadBlob } from '../../utils/download';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -44,6 +45,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||||
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
|
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
|
||||||
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
||||||
const unavailableRequestVersion = useRef(0);
|
const unavailableRequestVersion = useRef(0);
|
||||||
@@ -454,7 +456,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
okText="保存"
|
okText="保存"
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
|
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import PermissionButton from '../../components/PermissionButton';
|
|||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
@@ -68,6 +70,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
@@ -357,6 +360,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -463,6 +467,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="classroom:create"
|
permission="classroom:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -565,7 +570,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
|
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
|
||||||
<Input placeholder="如:A201 / B301" />
|
<Input placeholder="如:A201 / B301" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -170,16 +170,6 @@ export function buildClassroomHeatmapOption(
|
|||||||
data: classroomOccupancy.map((r) => r.name),
|
data: classroomOccupancy.map((r) => r.name),
|
||||||
inverse: true,
|
inverse: true,
|
||||||
},
|
},
|
||||||
visualMap: {
|
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
orient: 'horizontal',
|
|
||||||
left: 'center',
|
|
||||||
bottom: 0,
|
|
||||||
inRange: {
|
|
||||||
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -190,7 +180,7 @@ export function buildClassroomHeatmapOption(
|
|||||||
rentalCount: r.rentalCount,
|
rentalCount: r.rentalCount,
|
||||||
occupancy: r.occupancy,
|
occupancy: r.occupancy,
|
||||||
})),
|
})),
|
||||||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
itemStyle: { color: '#1677ff', borderRadius: [0, 4, 4, 0] },
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ export const ClassroomHeatmapCard: React.FC<{
|
|||||||
minHeight={isMobile ? 340 : 440}
|
minHeight={isMobile ? 340 : 440}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
>
|
>
|
||||||
{data.length > 0 ? (
|
{data.some((r) => Number(r.occupancy) > 0) ? (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={buildClassroomHeatmapOption(data)}
|
option={buildClassroomHeatmapOption(data)}
|
||||||
style={{ width: '100%', height: isMobile ? 300 : 400 }}
|
style={{ width: '100%', height: isMobile ? 300 : 400 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无教室数据</div>
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||||
|
暂无教室占用数据
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</LazySection>
|
</LazySection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
ganttRoomsSchema,
|
ganttRoomsSchema,
|
||||||
roomRankingSchema,
|
roomRankingSchema,
|
||||||
} from '../../api/schemas';
|
} from '../../api/schemas';
|
||||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
|
import {Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse, Skeleton, Alert} from 'antd';
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import ReactECharts from '../../components/ECharts';
|
import ReactECharts from '../../components/ECharts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
|
||||||
import {
|
import {
|
||||||
buildAttendanceLineOption,
|
buildAttendanceLineOption,
|
||||||
buildAttendanceRingOption,
|
buildAttendanceRingOption,
|
||||||
@@ -46,12 +45,14 @@ import {
|
|||||||
type GanttRoom,
|
type GanttRoom,
|
||||||
} from './Dashboard.types';
|
} from './Dashboard.types';
|
||||||
import { DashboardTodoCards } from './DashboardTodoCards';
|
import { DashboardTodoCards } from './DashboardTodoCards';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.sm;
|
const isMobile = !screens.sm;
|
||||||
|
const [partialAlertClosed, setPartialAlertClosed] = useState(false);
|
||||||
const [period, setPeriod] = useState<[string, string]>([
|
const [period, setPeriod] = useState<[string, string]>([
|
||||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||||
dayjs().endOf('month').format('YYYY-MM-DD'),
|
dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||||
@@ -65,9 +66,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
ganttData: [],
|
ganttData: [],
|
||||||
roomRanking: [],
|
roomRanking: [],
|
||||||
classroomUtil: null,
|
classroomUtil: null,
|
||||||
|
partialFailures: 0,
|
||||||
},
|
},
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<{
|
} = useQuery<{
|
||||||
stats: DashboardStats | null;
|
stats: DashboardStats | null;
|
||||||
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
|
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
|
||||||
@@ -75,6 +79,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
ganttData: GanttRoom[];
|
ganttData: GanttRoom[];
|
||||||
roomRanking: Array<{ roomNumber: string; total: string }>;
|
roomRanking: Array<{ roomNumber: string; total: string }>;
|
||||||
classroomUtil: ClassroomUtilStats | null;
|
classroomUtil: ClassroomUtilStats | null;
|
||||||
|
partialFailures: number;
|
||||||
}>({
|
}>({
|
||||||
queryKey: ['dashboard', period],
|
queryKey: ['dashboard', period],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -101,9 +106,9 @@ const DashboardPage: React.FC = () => {
|
|||||||
console.error('看板数据加载失败', rejected);
|
console.error('看板数据加载失败', rejected);
|
||||||
throw new Error('看板数据加载失败');
|
throw new Error('看板数据加载失败');
|
||||||
}
|
}
|
||||||
if (rejected.length > 0) {
|
const partialFailures = rejected.length;
|
||||||
|
if (partialFailures > 0) {
|
||||||
console.error('部分看板数据加载失败', rejected);
|
console.error('部分看板数据加载失败', rejected);
|
||||||
message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`);
|
|
||||||
}
|
}
|
||||||
// 校验失败的模块降级为对应空值,不影响其他模块
|
// 校验失败的模块降级为对应空值,不影响其他模块
|
||||||
type ValidateSchema = Parameters<typeof validateResponse>[0];
|
type ValidateSchema = Parameters<typeof validateResponse>[0];
|
||||||
@@ -140,7 +145,15 @@ const DashboardPage: React.FC = () => {
|
|||||||
value(settled[5]),
|
value(settled[5]),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
|
return {
|
||||||
|
stats,
|
||||||
|
classRanking,
|
||||||
|
classroomOccupancy,
|
||||||
|
ganttData,
|
||||||
|
roomRanking,
|
||||||
|
classroomUtil,
|
||||||
|
partialFailures,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const stats = fetchResult.stats;
|
const stats = fetchResult.stats;
|
||||||
@@ -182,8 +195,35 @@ const DashboardPage: React.FC = () => {
|
|||||||
const draftTotal = draftBill ? Number(draftBill.total) : 0;
|
const draftTotal = draftBill ? Number(draftBill.total) : 0;
|
||||||
const pendingDeposits = stats?.pendingDeposits ?? 0;
|
const pendingDeposits = stats?.pendingDeposits ?? 0;
|
||||||
|
|
||||||
if (loading && !stats)
|
if (loading && !stats) {
|
||||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 3 }} />
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<Col key={i} xs={12} sm={8} md={4}>
|
||||||
|
<Card>
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 2 }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Card style={{ marginTop: 16 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<QueryErrorState
|
||||||
|
title="工作台加载失败"
|
||||||
|
description="看板数据暂时无法获取,请检查网络后重试。"
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -205,12 +245,25 @@ const DashboardPage: React.FC = () => {
|
|||||||
aria-label="选择日期范围"
|
aria-label="选择日期范围"
|
||||||
value={[dayjs(period[0]), dayjs(period[1])]}
|
value={[dayjs(period[0]), dayjs(period[1])]}
|
||||||
onChange={(dates) => {
|
onChange={(dates) => {
|
||||||
if (dates?.[0] && dates?.[1])
|
if (dates?.[0] && dates?.[1]) {
|
||||||
setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||||
|
setPartialAlertClosed(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fetchResult.partialFailures > 0 && !partialAlertClosed ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setPartialAlertClosed(true)}
|
||||||
|
message={`有 ${fetchResult.partialFailures} 项数据加载失败,其余数据已正常显示`}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 待办与异常 */}
|
{/* 待办与异常 */}
|
||||||
<DashboardTodoCards
|
<DashboardTodoCards
|
||||||
absentCount={absentCount}
|
absentCount={absentCount}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import type { DepositStudentLookup } from './deposit-student-option';
|
import type { DepositStudentLookup } from './deposit-student-option';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
export interface DepositRecord {
|
export interface DepositRecord {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -140,6 +141,10 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
onOpenInstallment,
|
onOpenInstallment,
|
||||||
onSelectEligible,
|
onSelectEligible,
|
||||||
}) => {
|
}) => {
|
||||||
|
useSubmitShortcut(batchModal && !saving, onBatchCreate);
|
||||||
|
useSubmitShortcut(createModal && !saving, onCreate);
|
||||||
|
useSubmitShortcut(!!refundModal && !saving, onRefund);
|
||||||
|
useSubmitShortcut(!!installmentModal && !saving, onAddInstallment);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -152,7 +157,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
|
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
|
||||||
width={760}
|
width={760}
|
||||||
>
|
>
|
||||||
<Form form={batchForm} layout="vertical">
|
<Form form={batchForm} layout="vertical" scrollToFirstError>
|
||||||
<Space style={{ width: '100%' }} align="start" wrap>
|
<Space style={{ width: '100%' }} align="start" wrap>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="roomType"
|
name="roomType"
|
||||||
@@ -193,7 +198,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
size="small"
|
size="small"
|
||||||
columns={eligibleColumns as never}
|
columns={eligibleColumns as never}
|
||||||
dataSource={eligibleStudents}
|
dataSource={eligibleStudents}
|
||||||
@@ -216,7 +221,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
okText="确认"
|
okText="确认"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="studentId"
|
name="studentId"
|
||||||
label="学生"
|
label="学生"
|
||||||
@@ -249,7 +254,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
okText="确认退还"
|
okText="确认退还"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={refundForm} layout="vertical">
|
<Form form={refundForm} layout="vertical" scrollToFirstError>
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||||
当前可用押金: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
当前可用押金: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +316,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
{detailModal.installments && detailModal.installments.length > 0 ? (
|
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -410,7 +415,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
|||||||
okText="确认"
|
okText="确认"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={installmentForm} layout="vertical">
|
<Form form={installmentForm} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
|||||||
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
|
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 240,
|
width: 240,
|
||||||
render: (_: unknown, record: any) => {
|
render: (_: unknown, record: any) => {
|
||||||
const hasDeposit = typeof record.id === 'number';
|
const hasDeposit = typeof record.id === 'number';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dayjs from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||||
@@ -426,6 +427,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="deposit:create"
|
permission="deposit:create"
|
||||||
icon={<TeamOutlined />}
|
icon={<TeamOutlined />}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, Tooltip } from 'antd';
|
import {Alert, Button, Card, Descriptions, Empty, Space, Table, Tag, Tooltip, Skeleton} from 'antd';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
@@ -15,7 +16,6 @@ import { examDetailSchema } from '../../api/schemas';
|
|||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import type { ExamItem } from './types';
|
import type { ExamItem } from './types';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
|
||||||
|
|
||||||
interface ScoreRow {
|
interface ScoreRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -56,19 +56,10 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: detail, isLoading, isFetching } = useQuery<ExamDetail | null>({
|
const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({
|
||||||
queryKey: ['exams', 'detail', id],
|
queryKey: ['exams', 'detail', id],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),
|
||||||
return validateResponse<ExamDetail>(
|
|
||||||
examDetailSchema,
|
|
||||||
await api.get<ExamDetail>(`/exams/${id}`),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
message.error(getErrorMessage(error, '加载考试失败'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
|
||||||
@@ -145,9 +136,18 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
if (loading && !detail)
|
if (loading && !detail)
|
||||||
return (
|
return (
|
||||||
<div className="exam-detail-loading">
|
<div className="exam-detail-loading">
|
||||||
<Spin size="large" />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<QueryErrorState
|
||||||
|
title="考试详情加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!detail) return <Empty description="考试不存在或无权访问" />;
|
if (!detail) return <Empty description="考试不存在或无权访问" />;
|
||||||
|
|
||||||
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;
|
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export const RoomExpenseModal: React.FC<{
|
|||||||
onOk: () => void;
|
onOk: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
|
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
const roomExpenseGuard = useDirtyGuard(form);
|
const roomExpenseGuard = useDirtyGuard(form);
|
||||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,7 +39,7 @@ export const RoomExpenseModal: React.FC<{
|
|||||||
okText={editing ? '保存' : '确认录入'}
|
okText={editing ? '保存' : '确认录入'}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
|
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
@@ -77,6 +79,7 @@ export const UtilityModal: React.FC<{
|
|||||||
onOk: () => void;
|
onOk: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, saving, form, students, onOk, onCancel }) => {
|
}> = ({ open, saving, form, students, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
const utilityGuard = useDirtyGuard(form);
|
const utilityGuard = useDirtyGuard(form);
|
||||||
// 打开弹窗时记录当前表单值为「未修改」基准
|
// 打开弹窗时记录当前表单值为「未修改」基准
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,7 +95,7 @@ export const UtilityModal: React.FC<{
|
|||||||
okText="生成账单并扣余额"
|
okText="生成账单并扣余额"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
@@ -136,6 +139,7 @@ export const PersonalExpenseModal: React.FC<{
|
|||||||
onOk: () => void;
|
onOk: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
|
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
const personalExpenseGuard = useDirtyGuard(form);
|
const personalExpenseGuard = useDirtyGuard(form);
|
||||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,7 +155,7 @@ export const PersonalExpenseModal: React.FC<{
|
|||||||
okText={editing ? '保存' : '确认录入'}
|
okText={editing ? '保存' : '确认录入'}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{config && (
|
{config && (
|
||||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="同步方式">手动触发</Descriptions.Item>
|
<Descriptions.Item label="同步方式">手动触发</Descriptions.Item>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const LoginPage: React.FC = () => {
|
|||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: '请输入用户名' }]}
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
>
|
>
|
||||||
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" />
|
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" autoFocus />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="密码"
|
label="密码"
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { operationLogsSchema } from '../../api/schemas';
|
import { operationLogsSchema } from '../../api/schemas';
|
||||||
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
|
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
|
||||||
import { getErrorMessage } from '../../utils/error';
|
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -35,24 +34,21 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
data: fetchResult = { data: [], total: 0 },
|
data: fetchResult = { data: [], total: 0 },
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<{ data: any[]; total: number }>({
|
} = useQuery<{ data: any[]; total: number }>({
|
||||||
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
|
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const params: any = { page, pageSize };
|
||||||
const params: any = { page, pageSize };
|
if (filterModule) params.module = filterModule;
|
||||||
if (filterModule) params.module = filterModule;
|
if (dateRange) {
|
||||||
if (dateRange) {
|
params.startDate = dateRange[0];
|
||||||
params.startDate = dateRange[0];
|
params.endDate = dateRange[1];
|
||||||
params.endDate = dateRange[1];
|
|
||||||
}
|
|
||||||
return validateResponse<{ data: any[]; total: number }>(
|
|
||||||
operationLogsSchema,
|
|
||||||
await api.get('/operation-logs', { params }),
|
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
|
|
||||||
return { data: [], total: 0 };
|
|
||||||
}
|
}
|
||||||
|
return validateResponse<{ data: any[]; total: number }>(
|
||||||
|
operationLogsSchema,
|
||||||
|
await api.get('/operation-logs', { params }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = fetchResult.data;
|
const data = fetchResult.data;
|
||||||
@@ -161,25 +157,33 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
{isError ? (
|
||||||
columns={columns}
|
<QueryErrorState
|
||||||
dataSource={data}
|
title="操作日志加载失败"
|
||||||
rowKey="id"
|
description="请检查网络后重试。"
|
||||||
loading={loading}
|
onRetry={() => void refetch()}
|
||||||
scroll={{ x: 1100 }}
|
/>
|
||||||
pagination={{
|
) : (
|
||||||
current: page,
|
<Table
|
||||||
total,
|
columns={columns}
|
||||||
pageSize,
|
dataSource={data}
|
||||||
showSizeChanger: true,
|
rowKey="id"
|
||||||
pageSizeOptions: [20, 50, 100],
|
loading={loading}
|
||||||
onChange: (nextPage, nextPageSize) => {
|
scroll={{ x: 1100 }}
|
||||||
setPage(nextPage);
|
pagination={{
|
||||||
setPageSize(nextPageSize);
|
current: page,
|
||||||
},
|
total,
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
pageSize,
|
||||||
}}
|
showSizeChanger: true,
|
||||||
/>
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
},
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import api from '../../api';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { organizationsSchema } from '../../api/schemas';
|
import { organizationsSchema } from '../../api/schemas';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
import { QueryEmpty } from '../../components/QueryState';
|
import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#ff7875',
|
'#ff7875',
|
||||||
@@ -59,21 +60,15 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>();
|
const [filterStatus, setFilterStatus] = useState<string>();
|
||||||
|
|
||||||
const { data = [], isLoading, isFetching } = useQuery({
|
const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: ['organizations'],
|
queryKey: ['organizations'],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<OrganizationItem[]>(
|
||||||
return validateResponse<OrganizationItem[]>(
|
organizationsSchema,
|
||||||
organizationsSchema,
|
await api.get<OrganizationItem[]>('/organizations', {
|
||||||
await api.get<OrganizationItem[]>('/organizations', {
|
params: { includeArchived: true },
|
||||||
params: { includeArchived: true },
|
}),
|
||||||
}),
|
),
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.message || '机构数据加载失败');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
|
||||||
@@ -385,6 +380,7 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="organization:create"
|
permission="organization:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -394,31 +390,39 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
添加机构
|
添加机构
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
{isError ? (
|
||||||
columns={columns}
|
<QueryErrorState
|
||||||
dataSource={filteredData}
|
title="机构数据加载失败"
|
||||||
rowKey="id"
|
description="请检查网络后重试。"
|
||||||
loading={loading}
|
onRetry={() => void refetch()}
|
||||||
locale={{
|
/>
|
||||||
emptyText: (
|
) : (
|
||||||
<QueryEmpty
|
<Table
|
||||||
description="暂无机构"
|
columns={columns}
|
||||||
action={
|
dataSource={filteredData}
|
||||||
hasPermission('organization:create')
|
rowKey="id"
|
||||||
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
loading={loading}
|
||||||
: undefined
|
locale={{
|
||||||
}
|
emptyText: (
|
||||||
/>
|
<QueryEmpty
|
||||||
),
|
description="暂无机构"
|
||||||
}}
|
action={
|
||||||
scroll={{ x: 1100 }}
|
hasPermission('organization:create')
|
||||||
pagination={{
|
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
||||||
defaultPageSize: 20,
|
: undefined
|
||||||
showSizeChanger: true,
|
}
|
||||||
pageSizeOptions: [20, 50, 100],
|
/>
|
||||||
showTotal: (total) => `共 ${total} 个机构`,
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 个机构`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, Switch, Space, Skeleton} from 'antd';
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Card,
|
|
||||||
Tag,
|
|
||||||
Select,
|
|
||||||
Statistic,
|
|
||||||
Modal,
|
|
||||||
Spin,
|
|
||||||
Badge,
|
|
||||||
Tooltip,
|
|
||||||
DatePicker,
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Switch,
|
|
||||||
Space,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -153,7 +137,12 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
if (!data)
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 12 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const rooms = data.rooms.filter((r: any) => {
|
const rooms = data.rooms.filter((r: any) => {
|
||||||
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function buildRoomActionColumn(ctx: RoomColumnContext) {
|
|||||||
} = ctx;
|
} = ctx;
|
||||||
return {
|
return {
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (_: unknown, record: unknown) => {
|
render: (_: unknown, record: unknown) => {
|
||||||
const r = record as { status?: string; id: number; roomNumber?: string };
|
const r = record as { status?: string; id: number; roomNumber?: string };
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
dataSource={beds}
|
dataSource={beds}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -329,7 +329,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
dataSource={lockers}
|
dataSource={lockers}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Form, Input, InputNumber, Modal, Select } from 'antd';
|
|||||||
import { RoomDrawer } from './RoomDrawer';
|
import { RoomDrawer } from './RoomDrawer';
|
||||||
import type { BedItem, LockerItem } from './RoomColumns';
|
import type { BedItem, LockerItem } from './RoomColumns';
|
||||||
import { parseRoomNumber } from './RoomColumns';
|
import { parseRoomNumber } from './RoomColumns';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
export const RoomItemFormFields: React.FC<{
|
export const RoomItemFormFields: React.FC<{
|
||||||
fieldName: 'bedNumber' | 'lockerNumber';
|
fieldName: 'bedNumber' | 'lockerNumber';
|
||||||
@@ -37,6 +38,7 @@ export const RoomEditModal: React.FC<{
|
|||||||
onOk?: () => void;
|
onOk?: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑宿舍' : '添加宿舍'}
|
title={editing ? '编辑宿舍' : '添加宿舍'}
|
||||||
@@ -46,7 +48,7 @@ export const RoomEditModal: React.FC<{
|
|||||||
okText="保存"
|
okText="保存"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
|
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="如:4-102(自动解析楼栋楼层)"
|
placeholder="如:4-102(自动解析楼栋楼层)"
|
||||||
@@ -114,6 +116,7 @@ export const BedModal: React.FC<{
|
|||||||
onOk?: () => void;
|
onOk?: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑床位' : '添加床位'}
|
title={editing ? '编辑床位' : '添加床位'}
|
||||||
@@ -123,7 +126,7 @@ export const BedModal: React.FC<{
|
|||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如:1号床" />
|
<RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如:1号床" />
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -138,6 +141,7 @@ export const LockerModal: React.FC<{
|
|||||||
onOk?: () => void;
|
onOk?: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑柜子' : '添加柜子'}
|
title={editing ? '编辑柜子' : '添加柜子'}
|
||||||
@@ -147,7 +151,7 @@ export const LockerModal: React.FC<{
|
|||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如:1号柜" />
|
<RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如:1号柜" />
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
|
|
||||||
export interface RoomsToolbarProps {
|
export interface RoomsToolbarProps {
|
||||||
onSearch: (value: string) => void;
|
onSearch: (value: string) => void;
|
||||||
@@ -39,6 +40,8 @@ export interface RoomsToolbarProps {
|
|||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
templateLoading?: boolean;
|
templateLoading?: boolean;
|
||||||
exportLoading?: boolean;
|
exportLoading?: boolean;
|
||||||
|
refreshLoading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||||
@@ -67,6 +70,8 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
|||||||
onExport,
|
onExport,
|
||||||
templateLoading,
|
templateLoading,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
|
refreshLoading,
|
||||||
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="responsive-toolbar">
|
<div className="responsive-toolbar">
|
||||||
@@ -115,6 +120,7 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
|
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
|
||||||
{showArchived && canEditRooms ? (
|
{showArchived && canEditRooms ? (
|
||||||
<>
|
<>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
|
|||||||
@@ -488,6 +488,8 @@ const RoomsPage: React.FC = () => {
|
|||||||
templateLoading={templateDownloading}
|
templateLoading={templateDownloading}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
exportLoading={exportDownloading}
|
exportLoading={exportDownloading}
|
||||||
|
refreshLoading={isFetching}
|
||||||
|
onRefresh={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{mode !== 'detail' ? (
|
{mode !== 'detail' ? (
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" scrollToFirstError style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}>
|
<Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="选择班级"
|
placeholder="选择班级"
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ function buildActionColumn(ctx: StudentColumnContext) {
|
|||||||
} = ctx;
|
} = ctx;
|
||||||
return {
|
return {
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
|
|||||||
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||||
import { SENSITIVE_LABELS } from './StudentColumns';
|
import { SENSITIVE_LABELS } from './StudentColumns';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
type AppModal = ReturnType<typeof App.useApp>['modal'];
|
type AppModal = ReturnType<typeof App.useApp>['modal'];
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export const StudentEditModal: React.FC<{
|
|||||||
onOk,
|
onOk,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
|
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑学生' : '添加学生'}
|
title={editing ? '编辑学生' : '添加学生'}
|
||||||
@@ -89,7 +91,7 @@ export const StudentEditModal: React.FC<{
|
|||||||
okText="保存"
|
okText="保存"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="student-form-grid">
|
<Form form={form} layout="vertical" scrollToFirstError className="student-form-grid">
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Alert, Button, Card, Col, Descriptions, Row, Table, Tag } from 'antd';
|
import { Alert, Button, Card, Col, Descriptions, Row, Spin, Table, Tag } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { QueryEmpty } from '../../components/QueryState';
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
@@ -102,7 +102,14 @@ export const StudentsTable: React.FC<{
|
|||||||
rowExpandable: () => true,
|
rowExpandable: () => true,
|
||||||
expandedRowRender: (record) => {
|
expandedRowRender: (record) => {
|
||||||
const enrollments = enrollmentData[record.id];
|
const enrollments = enrollmentData[record.id];
|
||||||
if (!enrollments) return null;
|
if (!enrollments) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '16px 8px', textAlign: 'center', color: '#999', fontSize: 13 }}>
|
||||||
|
<Spin size="small" style={{ marginRight: 8 }} />
|
||||||
|
正在加载班型对比…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (enrollments.length < 2) {
|
if (enrollments.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
|
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { statusMap } from './StudentColumns';
|
import { statusMap } from './StudentColumns';
|
||||||
|
|
||||||
export interface StudentsToolbarProps {
|
export interface StudentsToolbarProps {
|
||||||
@@ -60,6 +61,8 @@ export interface StudentsToolbarProps {
|
|||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
templateLoading?: boolean;
|
templateLoading?: boolean;
|
||||||
exportLoading?: boolean;
|
exportLoading?: boolean;
|
||||||
|
refreshLoading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||||
@@ -98,6 +101,8 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
|||||||
onExport,
|
onExport,
|
||||||
templateLoading,
|
templateLoading,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
|
refreshLoading,
|
||||||
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
return (
|
return (
|
||||||
@@ -171,6 +176,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
|
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
|
||||||
{showArchived && canEditStudent ? (
|
{showArchived && canEditStudent ? (
|
||||||
<>
|
<>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
|
|||||||
@@ -570,6 +570,8 @@ const StudentsPage: React.FC = () => {
|
|||||||
templateLoading={templateDownloading}
|
templateLoading={templateDownloading}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
exportLoading={exportDownloading}
|
exportLoading={exportDownloading}
|
||||||
|
refreshLoading={isFetching}
|
||||||
|
onRefresh={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
{nextStepHint === 'class' && (
|
{nextStepHint === 'class' && (
|
||||||
<NextStepHint
|
<NextStepHint
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { teacherWorkspaceSchema } from '../../api/schemas';
|
import { teacherWorkspaceSchema } from '../../api/schemas';
|
||||||
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
|
import {Card, Tabs, Table, Tag, Empty, Skeleton} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
@@ -142,8 +142,8 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Spin size="large" />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
key: 'classes',
|
key: 'classes',
|
||||||
label: `我的班级 (${data?.assignedClasses.length || 0})`,
|
label: `我的班级 (${data?.assignedClasses.length || 0})`,
|
||||||
children: data?.assignedClasses.length ? (
|
children: data?.assignedClasses.length ? (
|
||||||
<Table<AssignedClass>
|
<Table<AssignedClass> scroll={{ x: 'max-content' }}
|
||||||
columns={classColumns}
|
columns={classColumns}
|
||||||
dataSource={data.assignedClasses}
|
dataSource={data.assignedClasses}
|
||||||
rowKey="classId"
|
rowKey="classId"
|
||||||
@@ -177,7 +177,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
key: 'schedule',
|
key: 'schedule',
|
||||||
label: `今日课程 (${data?.todaySchedules.length || 0})`,
|
label: `今日课程 (${data?.todaySchedules.length || 0})`,
|
||||||
children: data?.todaySchedules.length ? (
|
children: data?.todaySchedules.length ? (
|
||||||
<Table<ScheduleItem>
|
<Table<ScheduleItem> scroll={{ x: 'max-content' }}
|
||||||
columns={scheduleColumns}
|
columns={scheduleColumns}
|
||||||
dataSource={data.todaySchedules}
|
dataSource={data.todaySchedules}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -203,7 +203,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
key: 'students',
|
key: 'students',
|
||||||
label: `我的学生 (${data?.myStudents.length || 0})`,
|
label: `我的学生 (${data?.myStudents.length || 0})`,
|
||||||
children: data?.myStudents.length ? (
|
children: data?.myStudents.length ? (
|
||||||
<Table<StudentItem>
|
<Table<StudentItem> scroll={{ x: 'max-content' }}
|
||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={data.myStudents}
|
dataSource={data.myStudents}
|
||||||
rowKey="studentId"
|
rowKey="studentId"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EditOutlined } from '@ant-design/icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -211,6 +212,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_: unknown, r: TeacherRow) => (
|
render: (_: unknown, r: TeacherRow) => (
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
@@ -252,6 +254,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
style={{ width: 220 }}
|
style={{ width: 220 }}
|
||||||
/>
|
/>
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
</Space>
|
</Space>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<QueryErrorState
|
<QueryErrorState
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ const WalletsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
|
width: 140,
|
||||||
render: (_: unknown, row: WalletRow) => (
|
render: (_: unknown, row: WalletRow) => (
|
||||||
<Space>
|
<Space>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
@@ -325,7 +327,7 @@ const WalletsPage: React.FC = () => {
|
|||||||
onRetry={() => void refetch()}
|
onRetry={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
rowKey="studentId"
|
rowKey="studentId"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
|
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
|
||||||
@@ -425,7 +427,7 @@ const WalletsPage: React.FC = () => {
|
|||||||
onRetry={() => void refetchTransactions()}
|
onRetry={() => void refetchTransactions()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table scroll={{ x: 'max-content' }}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
dataSource={transactions}
|
dataSource={transactions}
|
||||||
loading={txLoading || txFetching}
|
loading={txLoading || txFetching}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export default defineConfig({
|
|||||||
// Slow down interactions slightly so UI animations settle
|
// Slow down interactions slightly so UI animations settle
|
||||||
slowHijackESM: false,
|
slowHijackESM: false,
|
||||||
},
|
},
|
||||||
// Integration tests: match *.integration.test.{ts,tsx}
|
// Unit and integration tests run in the browser test environment.
|
||||||
include: ['src/**/*.integration.test.{ts,tsx}'],
|
include: ['src/**/*.integration.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
|
||||||
// Global timeout for browser operations
|
// Global timeout for browser operations
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
// Retry flaky browser tests once
|
// Retry flaky browser tests once
|
||||||
|
|||||||
80
docs/ux-optimization-summary.md
Normal file
80
docs/ux-optimization-summary.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 管理端 UI/UX 深度优化变更总结(apps/admin)
|
||||||
|
|
||||||
|
> 目标:深度优化用户操作的 UI/UX(表单、列表、反馈、导航、加载态、错误态、空状态、移动端适配等),
|
||||||
|
> 保持现有 Ant Design 体系与功能不变,最终通过类型检查、lint 与测试验证。
|
||||||
|
> 本文件按类别汇总已落地改动与验证记录,供 review 使用。
|
||||||
|
|
||||||
|
## 一、错误态(失败不再伪装成"空数据/0 值",均带重试)
|
||||||
|
|
||||||
|
- 工作台 Dashboard:全部接口失败时显示 `QueryErrorState` + 重试(此前静默显示 0 值)。
|
||||||
|
- 考试详情页:失败显示错误态 + 重试(此前空白页)。
|
||||||
|
- 机构列表 / 操作日志:失败显示错误态 + 重试(此前弹 toast 后渲染空表)。
|
||||||
|
- 部分失败反馈:Dashboard 部分接口失败改为页面内可关闭 `Alert`(此前每次切换日期弹 toast)。
|
||||||
|
|
||||||
|
## 二、加载态
|
||||||
|
|
||||||
|
- 6 个页面骨架屏替代整页 Spin:工作台(统计卡片骨架)、教师工作台、班级详情、考试详情、宿舍可视化、AI 配置。
|
||||||
|
- 学生表展开行(多班型对比)加载中显示 Spin + 提示,不再空白。
|
||||||
|
- 班级花名册导出按钮 loading(try/finally)。
|
||||||
|
|
||||||
|
## 三、空状态
|
||||||
|
|
||||||
|
- 复用统一 `QueryEmpty`(描述 + 主操作按钮引导):学生/宿舍/押金/账单/教室/教师/钱包/机构等列表页已覆盖。
|
||||||
|
|
||||||
|
## 四、表单
|
||||||
|
|
||||||
|
- 新增 `useSubmitShortcut`:Cmd/Ctrl+Enter 提交,覆盖 12+ 弹窗(学生、宿舍/床位/柜子、押金×4、费用×3、教室、租赁、班级添加学员/教师)。
|
||||||
|
- 15 个表单统一 `scrollToFirstError`:校验失败自动滚动到首个错误字段。
|
||||||
|
- 防重复提交:考勤时段配置、班级添加学员/教师补齐 `confirmLoading`(其余弹窗已具备)。
|
||||||
|
|
||||||
|
## 五、列表
|
||||||
|
|
||||||
|
- 表格横向滚动:10+ 个缺失 `scroll.x` 的表格补齐(学生资料 4 Tab、考勤明细、考勤机、钱包、宿舍抽屉、教师工作台、押金弹窗等)。
|
||||||
|
- 移动端操作列固定:学生/宿舍/押金/账单/教室/教师/钱包 7 个主列表操作列 `fixed: 'right'`。
|
||||||
|
- 手动刷新入口:新增 `RefreshButton`,接入 8 个列表页(学生/宿舍/押金/教室/账单/教师/机构;钱包原有)。
|
||||||
|
- 分页/跨页勾选/已选提示均已具备,未改动。
|
||||||
|
|
||||||
|
## 六、反馈
|
||||||
|
|
||||||
|
- 通知铃铛:全部已读失败给出错误提示;无未读时按钮置灰;铃铛改为可聚焦按钮 + `aria-label`。
|
||||||
|
- 统一消息桥(App context)已具备;导出/删除/保存反馈已具备。
|
||||||
|
|
||||||
|
## 七、导航
|
||||||
|
|
||||||
|
- 新增 `ScrollToTop`:路由切换后滚动复位到顶部。
|
||||||
|
- 新增全局 `BackTop`:长列表滚动超过 400px 后浮现"回到顶部"按钮(尊重 reduced-motion)。
|
||||||
|
- 登录页用户名自动聚焦。
|
||||||
|
- 路由标签(RouteDock)窄屏压缩尺寸,避免横向裁切。
|
||||||
|
|
||||||
|
## 八、移动端适配
|
||||||
|
|
||||||
|
- 表格横向滚动 + 操作列固定(见"列表")。
|
||||||
|
- `Descriptions` 响应式列数:班级详情、账单详情×2、学生档案、集成配置(此前固定列数在窄屏过挤)。
|
||||||
|
- 内容区 padding 已按 breakpoint 响应式(MainLayout 原有)。
|
||||||
|
|
||||||
|
## 九、无障碍与质感(全局 CSS)
|
||||||
|
|
||||||
|
- `:focus-visible` 品牌蓝焦点环(键盘导航可见)。
|
||||||
|
- `@media (prefers-reduced-motion: reduce)` 关闭动画/平滑滚动。
|
||||||
|
- `::selection` 品牌色;WebKit 细滚动条;`html` 平滑滚动。
|
||||||
|
|
||||||
|
## 新增文件
|
||||||
|
|
||||||
|
- `components/RefreshButton.tsx`、`components/ScrollToTop.tsx`、`components/BackTop.tsx`
|
||||||
|
- `hooks/useSubmitShortcut.ts`
|
||||||
|
- `components/ux.integration.test.tsx`(5 个浏览器用例:RefreshButton / useSubmitShortcut 激活与未激活 / ScrollToTop / BackTop)
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| typecheck(admin + server) | ✅ |
|
||||||
|
| lint(oxlint + eslint) | ✅ 0 warning / 0 error |
|
||||||
|
| admin vitest(浏览器) | ✅ 31 文件 / 166 用例 |
|
||||||
|
| server jest | ✅ 191 用例 |
|
||||||
|
| aislop scan --changes | ✅ 100/100 |
|
||||||
|
|
||||||
|
## 未做/后续(可选)
|
||||||
|
|
||||||
|
- 运行时视觉走查:需要 MySQL + 后端 + 前端环境(当前本地未运行),用于最终逐页截图核对。
|
||||||
|
- AI 助手抽屉:已具备响应式/空态/取消/技能锁定,`submit` 已有请求中防护,未做额外改动。
|
||||||
Reference in New Issue
Block a user