forked from wangziqi/gongxue-base
feat: 支持表格单元格快捷编辑
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
import EditableCell, {
|
||||
editableValuesEqual,
|
||||
normalizeEditableValue,
|
||||
serializeEditableValue,
|
||||
} from './index';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root?.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('editable cell value mapping', () => {
|
||||
it('normalizes and serializes date values', () => {
|
||||
const value = normalizeEditableValue('2026-07-21', 'date');
|
||||
expect(dayjs.isDayjs(value)).toBe(true);
|
||||
expect(serializeEditableValue(value, 'date')).toBe('2026-07-21');
|
||||
});
|
||||
|
||||
it('normalizes and serializes date ranges', () => {
|
||||
const value = normalizeEditableValue(['2026-07-01', '2026-07-31'], 'date-range');
|
||||
expect(serializeEditableValue(value, 'date-range')).toEqual(['2026-07-01', '2026-07-31']);
|
||||
});
|
||||
|
||||
it('converts numeric input and preserves zero', () => {
|
||||
expect(serializeEditableValue(normalizeEditableValue('12.50', 'money'), 'money')).toBe(12.5);
|
||||
expect(serializeEditableValue(0, 'number')).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes empty and multi-select values', () => {
|
||||
expect(serializeEditableValue('', 'number')).toBeUndefined();
|
||||
expect(normalizeEditableValue(undefined, 'multi-select')).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares structured values without reference equality', () => {
|
||||
expect(editableValuesEqual([1, 2], [1, 2])).toBe(true);
|
||||
expect(editableValuesEqual(' a ', 'a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editable cell interactions', () => {
|
||||
it('saves a single-select value immediately when an option is clicked', async () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
EditableCell,
|
||||
{
|
||||
value: 'active',
|
||||
editor: 'select',
|
||||
options: [
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
],
|
||||
onSave,
|
||||
},
|
||||
'启用',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
container?.querySelector('.editable-cell')?.dispatchEvent(
|
||||
new MouseEvent('dblclick', { bubbles: true }),
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
const options = Array.from(document.querySelectorAll<HTMLElement>('.ant-select-item-option'));
|
||||
const disabledOption = options.find((option) => option.textContent?.includes('停用'));
|
||||
expect(disabledOption).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
disabledOption?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
expect(onSave).toHaveBeenCalledWith('disabled');
|
||||
expect(container.querySelector('.editable-cell--editing')).toBeNull();
|
||||
});
|
||||
});
|
||||
277
apps/admin/src/components/EditableCell/index.tsx
Normal file
277
apps/admin/src/components/EditableCell/index.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
import './style.css';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
|
||||
let replayingOutsideAction = false;
|
||||
|
||||
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 JSON.stringify(left ?? null) === JSON.stringify(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 [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draft, setDraft] = useState<unknown>(() =>
|
||||
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
|
||||
);
|
||||
const enabled = !disabled && (!permission || hasPermission(permission));
|
||||
|
||||
const original = useMemo(
|
||||
() =>
|
||||
serializeEditableValue(
|
||||
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
|
||||
editor,
|
||||
),
|
||||
[editor, formatValue, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
}
|
||||
}, [editing, editor, formatValue, value]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
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)) {
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '保存失败');
|
||||
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 && activeCell?.id === cellId) activeCell.save = save;
|
||||
return () => {
|
||||
if (activeCell?.id === cellId) activeCell = null;
|
||||
};
|
||||
}, [editing, save]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (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;
|
||||
replayingOutsideAction = true;
|
||||
actionTarget.click();
|
||||
queueMicrotask(() => {
|
||||
replayingOutsideAction = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
};
|
||||
}, [editing, save]);
|
||||
|
||||
const beginEdit = async () => {
|
||||
if (!enabled || saving) return;
|
||||
if (activeCell && activeCell.id !== idRef.current) {
|
||||
const saved = await activeCell.save();
|
||||
if (!saved) return;
|
||||
}
|
||||
activeCell = { id: idRef.current, save };
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const onKeyDown = async (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && editor !== 'textarea') {
|
||||
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" />;
|
||||
} else if (editor === 'date-range') {
|
||||
const { placeholder: _placeholder, ...rangeProps } = commonProps;
|
||||
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" />;
|
||||
} 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()}
|
||||
>
|
||||
{editing ? (
|
||||
<Spin spinning={saving}>{control}</Spin>
|
||||
) : (
|
||||
<Tooltip title={enabled ? '双击编辑' : undefined}>{children}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableCell;
|
||||
46
apps/admin/src/components/EditableCell/style.css
Normal file
46
apps/admin/src/components/EditableCell/style.css
Normal file
@@ -0,0 +1,46 @@
|
||||
.editable-cell {
|
||||
min-height: 30px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editable-cell--enabled {
|
||||
cursor: cell;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editable-cell--enabled:not(.editable-cell--editing):hover {
|
||||
border-color: #91caff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.editable-cell--editing {
|
||||
cursor: text;
|
||||
padding: 0;
|
||||
margin: -4px -6px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
min-width: 88px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editable-cell--editing .ant-spin-nested-loading,
|
||||
.editable-cell--editing .ant-spin-container,
|
||||
.editable-cell--editing .ant-input-number,
|
||||
.editable-cell--editing .ant-picker,
|
||||
.editable-cell--editing .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editable-cell-dropdown {
|
||||
min-width: 140px !important;
|
||||
}
|
||||
|
||||
.editable-cell-dropdown .ant-select-item-option-content {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import EditableCell from '../EditableCell';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -409,20 +410,131 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
|
||||
await api.put(`/archive/enrollments/${record.id}`, { [field]: value });
|
||||
message.success('报读记录已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const columns: ColumnsType<EnrollmentRecord> = [
|
||||
{ title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
|
||||
{ title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
|
||||
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
|
||||
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
|
||||
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
|
||||
{ title: '开始日期', dataIndex: 'startDate', render: (v: string) => v || '-' },
|
||||
{ title: '结束日期', dataIndex: 'endDate', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '课程类别',
|
||||
dataIndex: 'courseCategory',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={COURSE_CATEGORY_OPTIONS}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'courseCategory', next)}
|
||||
>
|
||||
{getCourseCategoryLabel(v)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班型',
|
||||
dataIndex: 'classType',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={CLASS_TYPE_OPTIONS}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'classType', next)}
|
||||
>
|
||||
{getClassTypeLabel(v)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班级名称',
|
||||
dataIndex: 'className',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'className', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班主任',
|
||||
dataIndex: 'headTeacher',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'headTeacher', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任课教师',
|
||||
dataIndex: 'subjectTeacher',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'subjectTeacher', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始日期',
|
||||
dataIndex: 'startDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'startDate', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结束日期',
|
||||
dataIndex: 'endDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'endDate', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => {
|
||||
render: (v: string, r) => {
|
||||
const status = getEnrollmentStatus(v);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
return (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
|
||||
value,
|
||||
label: item.text,
|
||||
}))}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'status', next)}
|
||||
>
|
||||
<Tag color={status.color}>{status.text}</Tag>
|
||||
</EditableCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -520,34 +632,137 @@ const ExamScoresTab: React.FC<
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => {
|
||||
await api.put(`/archive/exam-scores/${record.id}`, { [field]: value });
|
||||
message.success('考试成绩已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ExamScoreRecord> = [
|
||||
{
|
||||
title: '考试类型',
|
||||
dataIndex: 'examType',
|
||||
render: (v: string) => EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={EXAM_TYPE_OPTIONS}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'examType', next)}
|
||||
>
|
||||
{EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考试名称',
|
||||
dataIndex: 'examName',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'examName', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subject',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'subject', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成绩',
|
||||
dataIndex: 'score',
|
||||
render: (v: number, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
min={0}
|
||||
required
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'score', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '考试名称', dataIndex: 'examName', render: (v: string) => v || '-' },
|
||||
{ title: '科目', dataIndex: 'subject' },
|
||||
{ title: '成绩', dataIndex: 'score' },
|
||||
{
|
||||
title: '班级均分',
|
||||
dataIndex: 'classAvg',
|
||||
render: (v: number | undefined) => (v !== undefined ? v : '-'),
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
min={0}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'classAvg', next)}
|
||||
>
|
||||
{v !== undefined ? v : '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排名',
|
||||
dataIndex: 'rank',
|
||||
render: (v: number | undefined) => (v !== undefined ? v : '-'),
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
min={1}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'rank', next)}
|
||||
>
|
||||
{v !== undefined ? v : '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考试日期',
|
||||
dataIndex: 'examDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'examDate', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '关联报读',
|
||||
dataIndex: 'enrollmentId',
|
||||
render: (v: number | undefined) => {
|
||||
if (v === undefined) return '-';
|
||||
const enr = enrollments.find((e) => e.id === v);
|
||||
return enr ? formatEnrollmentDisplayName(enr) : String(v);
|
||||
},
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={enrollments.map((item) => ({
|
||||
value: item.id,
|
||||
label: formatEnrollmentDisplayName(item),
|
||||
}))}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'enrollmentId', next)}
|
||||
>
|
||||
{(() => {
|
||||
if (v === undefined) return '-';
|
||||
const enr = enrollments.find((e) => e.id === v);
|
||||
return enr ? formatEnrollmentDisplayName(enr) : String(v);
|
||||
})()}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -656,16 +871,87 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: LearningRecord, field: string, value: unknown) => {
|
||||
await api.put(`/archive/learning-records/${record.id}`, { [field]: value });
|
||||
message.success('学情记录已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LearningRecord> = [
|
||||
{ title: '记录日期', dataIndex: 'recordDate' },
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'recordDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'recordDate', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'recordType',
|
||||
render: (v: string) => RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={RECORD_TYPE_OPTIONS}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'recordType', next)}
|
||||
>
|
||||
{RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
ellipsis: true,
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'content', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '跟进方式',
|
||||
dataIndex: 'followUpMethod',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'followUpMethod', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '下一步计划',
|
||||
dataIndex: 'nextStep',
|
||||
render: (v: string, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveCell(r, 'nextStep', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '跟进方式', dataIndex: 'followUpMethod', render: (v: string) => v || '-' },
|
||||
{ title: '下一步计划', dataIndex: 'nextStep', render: (v: string) => v || '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user