fix(admin): 前端功能与安全修复(角色勾选/月视图新建/附件预览/考勤 late 等)

由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用
- JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref
- AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护
- 考勤 late 计入出勤率与主状态

Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
2026-08-09 21:29:54 +08:00
parent f50301148d
commit a32c0a0731
11 changed files with 134 additions and 49 deletions

View File

@@ -201,11 +201,16 @@ interface ChartPreviewProps {
* renders an ECharts option built from it.
*/
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
// 空/无数据时先短路,避免 buildOption 在空数据集上执行
const hasData = !!chart && !!chart.rows && chart.rows.length > 0;
const option = useMemo<EChartsOption>(
() => (chart && hasData ? buildOption(chart) : {}),
[chart, hasData],
);
const [instance, setInstance] = useState<EChartsType | null>(null);
if (!chart) return null;
// 空数据集:渲染明确占位,而不是一张空白图
if (!chart.rows || chart.rows.length === 0) {
if (!hasData) {
return (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">

View File

@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { useLayoutEffect } from 'react';
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
/**
@@ -75,11 +76,14 @@ export function useXCardSurface(surfaceId: string) {
);
const surfaceKey = surfaceId;
if (idRef.current !== surfaceKey) {
// 组件复用到新 surface 时,清空历史命令重新初始化
commandsRef.current = [];
idRef.current = surfaceKey;
}
// 渲染期保持纯函数ref 变更放到 layout effect 里
useLayoutEffect(() => {
if (idRef.current !== surfaceKey) {
// 组件复用到新 surface 时,清空历史命令重新初始化
commandsRef.current = [];
idRef.current = surfaceKey;
}
}, [surfaceKey]);
return { commands, pushCommands };
}

View File

@@ -263,6 +263,16 @@ const EditableCell = <Value,>({
return;
}
if (event.key === 'Enter' && editor !== 'textarea') {
// 这些编辑器会自己消费 Enter确认/提交选中值),不重复触发单元格保存
if (
editor === 'select' ||
editor === 'multi-select' ||
editor === 'tags' ||
editor === 'date' ||
editor === 'date-range'
) {
return;
}
event.preventDefault();
await save();
return;

View File

@@ -146,6 +146,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
onApplied();
reset();
} else {
// 接口返回 success:false 时也要结束「处理中」并给出错误提示
message.error(res.log?.message || '处理失败,请检查后重试');
setStep('match');
}
} catch (e: unknown) {
const err = e as { message?: string };

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { App, Button, Popconfirm, Space, Table, Upload } from 'antd';
import React, { useRef, useState } from 'react';
import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
import api from '../../api';
@@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error';
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
import type { AttachmentRecord, TabProps } from './shared';
type AttachmentPreview = {
url: string;
name: string;
kind: 'image' | 'pdf';
};
/** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */
function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' {
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image';
if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf';
return 'download';
}
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
data,
studentId,
@@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
const { hasPermission } = usePermission();
const canPurgeArchive = hasPermission('archive:purge');
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<AttachmentPreview | null>(null);
// 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览
const previewSeqRef = useRef(0);
const deleteAttachmentMutation = useApiMutation(
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
@@ -33,6 +50,39 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
{ invalidate: [['archive', studentId]] },
);
const closePreview = () => {
previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地
if (preview?.url) URL.revokeObjectURL(preview.url);
setPreview(null);
};
const openAttachment = async (record: AttachmentRecord) => {
const seq = ++previewSeqRef.current;
try {
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
responseType: 'blob',
});
if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应
const kind = getAttachmentKind(record.fileName, record.mimeType);
const url = URL.createObjectURL(blob);
if (kind === 'download') {
// 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容
const a = document.createElement('a');
a.href = url;
a.download = record.fileName || 'attachment';
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} else {
if (preview?.url) URL.revokeObjectURL(preview.url);
setPreview({ url, name: record.fileName || 'attachment', kind });
}
} catch (e: unknown) {
message.error(getErrorMessage(e, '查看失败'));
}
};
const handleDelete = async (attachmentId: number) => {
try {
await deleteAttachmentMutation.mutateAsync(attachmentId);
@@ -72,22 +122,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
title: '操作',
render: (_: unknown, record: AttachmentRecord) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={async () => {
try {
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e: unknown) {
message.error(getErrorMessage(e, '查看失败'));
}
}}
>
<Button size="small" icon={<EyeOutlined />} onClick={() => openAttachment(record)}>
</Button>
{hasPermission('student:edit') && record.status !== 'archived' ? (
@@ -149,6 +184,25 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
}}
style={{ marginTop: 16 }}
/>
<Modal
title={preview?.name}
open={!!preview}
footer={null}
onCancel={closePreview}
width={preview?.kind === 'pdf' ? 900 : undefined}
destroyOnHidden
>
{preview?.kind === 'image' ? (
<img src={preview.url} alt={preview.name} style={{ width: '100%' }} />
) : preview?.kind === 'pdf' ? (
<iframe
src={preview.url}
title={preview.name}
sandbox=""
style={{ width: '100%', height: '70vh', border: 'none' }}
/>
) : null}
</Modal>
</div>
);
};

View File

@@ -76,6 +76,7 @@ export interface AttachmentRecord {
category: string;
fileName: string;
fileSize: number;
mimeType?: string;
}
export interface AttendanceRecordItem {

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { App } from 'antd';
import type { FormInstance } from 'antd';
import equal from 'fast-deep-equal';
@@ -46,7 +46,8 @@ export function useDirtyGuard(form: FormInstance) {
[isDirty, modal],
);
return { confirmClose, snapshot, isDirty };
// 用 useMemo 稳定返回对象引用,避免消费方 useEffect 依赖每次渲染都变化
return useMemo(() => ({ confirmClose, snapshot, isDirty }), [confirmClose, snapshot, isDirty]);
}
export default useDirtyGuard;

View File

@@ -157,7 +157,7 @@ export const ADMIN_METRIC_META = [
];
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
const priority = ['absent', 'leave', 'present'];
const priority = ['absent', 'leave', 'late', 'present'];
return (
priority.find((item) =>
records.some((record) => displayAttendanceStatus(record.status) === item),
@@ -189,7 +189,10 @@ export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminS
}
return Array.from(map.values()).map((item) => {
const checked = item.records.filter((record) => record.status === 'present').length;
// late 与 present 一样视为已出勤(与 attendance-workspace 一致)
const checked = item.records.filter(
(record) => record.status === 'present' || record.status === 'late',
).length;
return {
...item,
primaryStatus: pickPrimaryStatus(item.records),

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore';
import { getAttendanceExperience } from './attendance-workspace';
@@ -6,14 +6,11 @@ import { TeacherAttendanceWorkspace } from './teacher';
import { AdminAttendanceArchive } from './admin';
import './attendance.css';
function readCurrentRoles(): string[] {
const roles = useUserStore.getState().user?.roles;
return Array.isArray(roles) ? roles : [];
}
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
// 订阅 store角色变化时重新计算体验不再只在首渲染读一次
const rolesRef = useUserStore((s) => s.user?.roles);
const roles = Array.isArray(rolesRef) ? rolesRef : [];
const experience = getAttendanceExperience(permissions, roles);
if (experience === 'teacher') {

View File

@@ -383,18 +383,23 @@ const RolesPage: React.FC = () => {
}
style={{ marginBottom: 8 }}
>
<Checkbox.Group
value={selectedPermIds}
onChange={(vals) => setSelectedPermIds(vals as number[])}
>
<Space wrap>
{group.permissions.map((p) => (
<Checkbox key={p.id} value={p.id}>
{p.name}
</Checkbox>
))}
</Space>
</Checkbox.Group>
<Space wrap>
{group.permissions.map((p) => (
<Checkbox
key={p.id}
checked={selectedPermIds.includes(p.id)}
onChange={(e) => {
setSelectedPermIds((prev) =>
e.target.checked
? [...prev, p.id]
: prev.filter((id) => id !== p.id),
);
}}
>
{p.name}
</Checkbox>
))}
</Space>
</Card>
))}
</div>

View File

@@ -341,7 +341,8 @@ const SchedulesPage: React.FC = () => {
};
const handleSubmit = async () => {
if (modalMode === 'create' && !selectedCell) return;
// 周视图走 selectedCell月视图走 selectedDate此时 selectedCell 为 null
if (modalMode === 'create' && !selectedCell && !selectedDate) return;
if (modalMode === 'edit' && !editingSchedule) return;
try {
const values = (await form.validateFields()) as ScheduleFormValues;