Files
gongxue-base/apps/admin/src/components/EditableCell/index.tsx
wangziqi a32c0a0731 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)
2026-08-09 21:29:54 +08:00

366 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import equal from 'fast-deep-equal';
import { usePermission } from '../../hooks/usePermission';
import { useTimeout } from 'usehooks-ts';
import { useEditableCellStore } from '../../store/editableCell/editableCellStore';
import { message } from '../../ui/app-message';
import './style.css';
import { getErrorMessage } from '../../utils/error';
export type EditableCellEditor =
| 'text'
| 'textarea'
| 'number'
| 'money'
| 'date'
| 'date-range'
| 'select'
| 'multi-select'
| 'tags';
export interface EditableCellOption {
label: React.ReactNode;
value: string | number | boolean;
}
export interface EditableCellProps<Value = unknown> {
value: Value;
children?: React.ReactNode;
editor?: EditableCellEditor;
options?: EditableCellOption[];
permission?: string;
disabled?: boolean;
required?: boolean;
min?: number;
max?: number;
placeholder?: string;
formatValue?: (value: Value) => unknown;
parseValue?: (value: unknown) => Value;
onSave: (value: Value) => Promise<void>;
}
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
if (editor === 'date') return value ? dayjs(value as string) : null;
if (editor === 'date-range')
return Array.isArray(value) ? value.map((item) => dayjs(item as string)) : null;
if (editor === 'number' || editor === 'money') {
return value === null || value === undefined || value === '' ? null : Number(value);
}
if (editor === 'multi-select' || editor === 'tags') return Array.isArray(value) ? value : [];
return value ?? '';
}
export function serializeEditableValue(value: unknown, editor: EditableCellEditor) {
if (editor === 'date') return value ? (value as Dayjs).format('YYYY-MM-DD') : undefined;
if (editor === 'date-range')
return Array.isArray(value)
? value.map((item) => (item as Dayjs).format('YYYY-MM-DD'))
: undefined;
if (editor === 'number' || editor === 'money') {
return value === null || value === undefined || value === '' ? undefined : Number(value);
}
if (typeof value === 'string') return value.trim();
return value;
}
export function editableValuesEqual(left: unknown, right: unknown) {
return equal(left ?? null, right ?? null);
}
function isEditorOverlay(target: EventTarget | null) {
return (
target instanceof Element &&
!!target.closest('.ant-select-dropdown, .ant-picker-dropdown, .ant-tooltip, .ant-message')
);
}
const EditableCell = <Value,>({
value,
children,
editor = 'text',
options,
permission,
disabled,
required,
min,
max,
placeholder,
formatValue,
parseValue,
onSave,
}: EditableCellProps<Value>) => {
const { hasPermission } = usePermission();
const idRef = useRef(crypto.randomUUID());
const rootRef = useRef<HTMLDivElement>(null);
const touchStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const lastTouchTapRef = useRef<{ time: number; x: number; y: number } | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<unknown>(() =>
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
);
// 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值
const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null);
// 撤销入口 6 秒后自动消失useTimeout 在 undoMeta 置空/组件卸载时自动清理
useTimeout(() => setUndoMeta(null), undoMeta ? 6_000 : null);
const enabled = !disabled && (!permission || hasPermission(permission));
const original = useMemo(
() =>
serializeEditableValue(
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
editor,
),
[editor, formatValue, value],
);
const cancel = useCallback(() => {
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
}, [editor, formatValue, value]);
const saveValue = useCallback(
async (nextDraft: unknown) => {
if (saving) return false;
const serialized = serializeEditableValue(nextDraft, editor);
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
message.error('该字段不能为空');
return false;
}
if (editableValuesEqual(serialized, original)) {
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
return true;
}
setSaving(true);
const previousValue = original;
try {
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
// 提供 6 秒内的撤销入口把旧值再保存一次useTimeout 负责到时自动清除
setUndoMeta({ serializedPrevious: previousValue });
return true;
} catch (error) {
message.error(getErrorMessage(error, '保存失败'));
return false;
} finally {
setSaving(false);
}
},
[editor, onSave, original, parseValue, required, saving],
);
const save = useCallback(() => saveValue(draft), [draft, saveValue]);
useEffect(() => {
const cellId = idRef.current;
if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
return () => useEditableCellStore.getState().clearIfActive(cellId);
}, [editing, save]);
useEffect(() => {
if (!editing) return;
const onPointerDown = (event: PointerEvent) => {
if (useEditableCellStore.getState().replayingOutsideAction) return;
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
const actionTarget =
event.target instanceof Element
? (event.target.closest(
'button, a, input, label, [role="button"], .ant-pagination-item, .ant-pagination-prev, .ant-pagination-next',
) as HTMLElement | null)
: null;
if (!actionTarget) {
void save();
return;
}
event.preventDefault();
event.stopPropagation();
void save().then((saved) => {
if (!saved) return;
useEditableCellStore.getState().setReplayingOutsideAction(true);
actionTarget.click();
queueMicrotask(() => {
useEditableCellStore.getState().setReplayingOutsideAction(false);
});
});
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
};
}, [editing, save]);
const beginEdit = async () => {
if (!enabled || saving) return;
const { activeCell } = useEditableCellStore.getState();
if (activeCell && activeCell.id !== idRef.current) {
const saved = await activeCell.save();
if (!saved) return;
}
useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
// 重新进入编辑时清掉上一次的撤销入口
setUndoMeta(null);
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
setEditing(true);
};
const handleUndo = async () => {
if (!undoMeta) return;
setUndoMeta(null);
try {
await onSave(
parseValue
? parseValue(undoMeta.serializedPrevious)
: (undoMeta.serializedPrevious as Value),
);
message.success('已撤销修改');
} catch (error) {
message.error(getErrorMessage(error, '撤销失败'));
}
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch' || editing) return;
touchStartRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
};
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const touchStart = touchStartRef.current;
touchStartRef.current = null;
if (!touchStart || event.pointerType !== 'touch' || event.pointerId !== touchStart.pointerId) {
return;
}
const moved = Math.hypot(event.clientX - touchStart.x, event.clientY - touchStart.y);
if (moved > 8) {
lastTouchTapRef.current = null;
return;
}
const now = Date.now();
const lastTap = lastTouchTapRef.current;
const isDoubleTap =
!!lastTap &&
now - lastTap.time <= 450 &&
Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= 24;
lastTouchTapRef.current = isDoubleTap
? null
: { time: now, x: event.clientX, y: event.clientY };
if (isDoubleTap) void beginEdit();
};
const onKeyDown = async (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
cancel();
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;
}
if (event.key === 'Tab') await save();
};
const commonProps = {
autoFocus: true,
value: draft as never,
placeholder,
disabled: saving,
onChange: (next: unknown) =>
setDraft(
next && typeof next === 'object' && 'target' in next
? (next as React.ChangeEvent<HTMLInputElement>).target.value
: next,
),
onKeyDown,
};
let control: React.ReactNode;
if (editor === 'select' || editor === 'multi-select' || editor === 'tags') {
control = (
<Select
{...commonProps}
onChange={(next) => {
setDraft(next);
if (editor === 'select') void saveValue(next);
}}
mode={editor === 'multi-select' ? 'multiple' : editor === 'tags' ? 'tags' : undefined}
options={options}
open
popupMatchSelectWidth={false}
popupClassName="editable-cell-dropdown"
/>
);
} else if (editor === 'date') {
control = <DatePicker {...commonProps} format="YYYY-MM-DD" open />;
} else if (editor === 'date-range') {
const { placeholder: _placeholder, ...rangeProps } = commonProps;
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" open />;
} else if (editor === 'number' || editor === 'money') {
control = (
<InputNumber {...commonProps} min={min} max={max} precision={editor === 'money' ? 2 : 0} />
);
} else if (editor === 'textarea') {
control = <Input.TextArea {...commonProps} autoSize={{ minRows: 1, maxRows: 4 }} />;
} else {
control = <Input {...commonProps} />;
}
return (
<div
ref={rootRef}
className={`editable-cell${enabled ? ' editable-cell--enabled' : ''}${editing ? ' editable-cell--editing' : ''}`}
onDoubleClick={() => void beginEdit()}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => {
touchStartRef.current = null;
lastTouchTapRef.current = null;
}}
>
{editing ? (
<Spin spinning={saving}>{control}</Spin>
) : (
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>
<span className="editable-cell-display">
{children}
{undoMeta ? (
<button
type="button"
className="editable-cell-undo"
onClick={(event) => {
event.stopPropagation();
void handleUndo();
}}
>
</button>
) : null}
</span>
</Tooltip>
)}
</div>
);
};
export default EditableCell;