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.
|
* renders an ECharts option built from it.
|
||||||
*/
|
*/
|
||||||
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
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);
|
const [instance, setInstance] = useState<EChartsType | null>(null);
|
||||||
if (!chart) return null;
|
if (!chart) return null;
|
||||||
// 空数据集:渲染明确占位,而不是一张空白图
|
// 空数据集:渲染明确占位,而不是一张空白图
|
||||||
if (!chart.rows || chart.rows.length === 0) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="ai-chat-chart-card">
|
<div className="ai-chat-chart-card">
|
||||||
<div className="ai-chat-chart-card__header">
|
<div className="ai-chat-chart-card__header">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useLayoutEffect } from 'react';
|
||||||
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,11 +76,14 @@ export function useXCardSurface(surfaceId: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const surfaceKey = surfaceId;
|
const surfaceKey = surfaceId;
|
||||||
if (idRef.current !== surfaceKey) {
|
// 渲染期保持纯函数:ref 变更放到 layout effect 里
|
||||||
// 组件复用到新 surface 时,清空历史命令重新初始化
|
useLayoutEffect(() => {
|
||||||
commandsRef.current = [];
|
if (idRef.current !== surfaceKey) {
|
||||||
idRef.current = surfaceKey;
|
// 组件复用到新 surface 时,清空历史命令重新初始化
|
||||||
}
|
commandsRef.current = [];
|
||||||
|
idRef.current = surfaceKey;
|
||||||
|
}
|
||||||
|
}, [surfaceKey]);
|
||||||
|
|
||||||
return { commands, pushCommands };
|
return { commands, pushCommands };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ const EditableCell = <Value,>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter' && editor !== 'textarea') {
|
if (event.key === 'Enter' && editor !== 'textarea') {
|
||||||
|
// 这些编辑器会自己消费 Enter(确认/提交选中值),不重复触发单元格保存
|
||||||
|
if (
|
||||||
|
editor === 'select' ||
|
||||||
|
editor === 'multi-select' ||
|
||||||
|
editor === 'tags' ||
|
||||||
|
editor === 'date' ||
|
||||||
|
editor === 'date-range'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await save();
|
await save();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
|
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
|
||||||
onApplied();
|
onApplied();
|
||||||
reset();
|
reset();
|
||||||
|
} else {
|
||||||
|
// 接口返回 success:false 时也要结束「处理中」并给出错误提示
|
||||||
|
message.error(res.log?.message || '处理失败,请检查后重试');
|
||||||
|
setStep('match');
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string };
|
const err = e as { message?: string };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { App, Button, Popconfirm, Space, Table, Upload } from 'antd';
|
import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error';
|
|||||||
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
|
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
|
||||||
import type { AttachmentRecord, TabProps } 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[] }> = ({
|
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||||||
data,
|
data,
|
||||||
studentId,
|
studentId,
|
||||||
@@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const canPurgeArchive = hasPermission('archive:purge');
|
const canPurgeArchive = hasPermission('archive:purge');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<AttachmentPreview | null>(null);
|
||||||
|
// 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览
|
||||||
|
const previewSeqRef = useRef(0);
|
||||||
|
|
||||||
const deleteAttachmentMutation = useApiMutation(
|
const deleteAttachmentMutation = useApiMutation(
|
||||||
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
|
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
|
||||||
@@ -33,6 +50,39 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
{ invalidate: [['archive', studentId]] },
|
{ 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) => {
|
const handleDelete = async (attachmentId: number) => {
|
||||||
try {
|
try {
|
||||||
await deleteAttachmentMutation.mutateAsync(attachmentId);
|
await deleteAttachmentMutation.mutateAsync(attachmentId);
|
||||||
@@ -72,22 +122,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_: unknown, record: AttachmentRecord) => (
|
render: (_: unknown, record: AttachmentRecord) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button size="small" icon={<EyeOutlined />} onClick={() => openAttachment(record)}>
|
||||||
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>
|
</Button>
|
||||||
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
||||||
@@ -149,6 +184,25 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
}}
|
}}
|
||||||
style={{ marginTop: 16 }}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface AttachmentRecord {
|
|||||||
category: string;
|
category: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendanceRecordItem {
|
export interface AttendanceRecordItem {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import { App } from 'antd';
|
import { App } from 'antd';
|
||||||
import type { FormInstance } from 'antd';
|
import type { FormInstance } from 'antd';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
@@ -46,7 +46,8 @@ export function useDirtyGuard(form: FormInstance) {
|
|||||||
[isDirty, modal],
|
[isDirty, modal],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { confirmClose, snapshot, isDirty };
|
// 用 useMemo 稳定返回对象引用,避免消费方 useEffect 依赖每次渲染都变化
|
||||||
|
return useMemo(() => ({ confirmClose, snapshot, isDirty }), [confirmClose, snapshot, isDirty]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useDirtyGuard;
|
export default useDirtyGuard;
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export const ADMIN_METRIC_META = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||||
const priority = ['absent', 'leave', 'present'];
|
const priority = ['absent', 'leave', 'late', 'present'];
|
||||||
return (
|
return (
|
||||||
priority.find((item) =>
|
priority.find((item) =>
|
||||||
records.some((record) => displayAttendanceStatus(record.status) === 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) => {
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
primaryStatus: pickPrimaryStatus(item.records),
|
primaryStatus: pickPrimaryStatus(item.records),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import { getAttendanceExperience } from './attendance-workspace';
|
import { getAttendanceExperience } from './attendance-workspace';
|
||||||
@@ -6,14 +6,11 @@ import { TeacherAttendanceWorkspace } from './teacher';
|
|||||||
import { AdminAttendanceArchive } from './admin';
|
import { AdminAttendanceArchive } from './admin';
|
||||||
import './attendance.css';
|
import './attendance.css';
|
||||||
|
|
||||||
function readCurrentRoles(): string[] {
|
|
||||||
const roles = useUserStore.getState().user?.roles;
|
|
||||||
return Array.isArray(roles) ? roles : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AttendancePage: React.FC = () => {
|
const AttendancePage: React.FC = () => {
|
||||||
const { permissions, hasPermission } = usePermission();
|
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);
|
const experience = getAttendanceExperience(permissions, roles);
|
||||||
|
|
||||||
if (experience === 'teacher') {
|
if (experience === 'teacher') {
|
||||||
|
|||||||
@@ -383,18 +383,23 @@ const RolesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
<Checkbox.Group
|
<Space wrap>
|
||||||
value={selectedPermIds}
|
{group.permissions.map((p) => (
|
||||||
onChange={(vals) => setSelectedPermIds(vals as number[])}
|
<Checkbox
|
||||||
>
|
key={p.id}
|
||||||
<Space wrap>
|
checked={selectedPermIds.includes(p.id)}
|
||||||
{group.permissions.map((p) => (
|
onChange={(e) => {
|
||||||
<Checkbox key={p.id} value={p.id}>
|
setSelectedPermIds((prev) =>
|
||||||
{p.name}
|
e.target.checked
|
||||||
</Checkbox>
|
? [...prev, p.id]
|
||||||
))}
|
: prev.filter((id) => id !== p.id),
|
||||||
</Space>
|
);
|
||||||
</Checkbox.Group>
|
}}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -341,7 +341,8 @@ const SchedulesPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (modalMode === 'create' && !selectedCell) return;
|
// 周视图走 selectedCell;月视图走 selectedDate(此时 selectedCell 为 null)
|
||||||
|
if (modalMode === 'create' && !selectedCell && !selectedDate) return;
|
||||||
if (modalMode === 'edit' && !editingSchedule) return;
|
if (modalMode === 'edit' && !editingSchedule) return;
|
||||||
try {
|
try {
|
||||||
const values = (await form.validateFields()) as ScheduleFormValues;
|
const values = (await form.validateFields()) as ScheduleFormValues;
|
||||||
|
|||||||
Reference in New Issue
Block a user