由 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)
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
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;
|