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:
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface AttachmentRecord {
|
||||
category: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface AttendanceRecordItem {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user