添加可拖拽的路由停靠组件,支持动态标签管理,支持侧边栏多项同时展开 #31
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>恭学教育基地管理系统</title>
|
<title>学生管理系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"antd": "^6.3.6",
|
"antd": "^6.3.6",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
196
apps/admin/src/components/RouteDock/index.tsx
Normal file
196
apps/admin/src/components/RouteDock/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Tabs } from 'antd';
|
||||||
|
import type { TabsProps } from 'antd';
|
||||||
|
import type { Location } from 'react-router-dom';
|
||||||
|
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'gongxue-route-dock';
|
||||||
|
|
||||||
|
interface DockTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteDockProps {
|
||||||
|
location: Location;
|
||||||
|
menuItems: readonly AppMenuItem[];
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
draggable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableTabNodeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
'data-node-key': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenuLabel(items: readonly AppMenuItem[], pathname: string): string | undefined {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.key === pathname) return item.label;
|
||||||
|
if (item.children) {
|
||||||
|
const label = findMenuLabel(item.children, pathname);
|
||||||
|
if (label) return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteLabel(items: readonly AppMenuItem[], pathname: string): string {
|
||||||
|
const menuLabel = findMenuLabel(items, pathname);
|
||||||
|
if (menuLabel) return menuLabel;
|
||||||
|
if (/^\/students\/\d+\/profile$/.test(pathname)) return '学生档案';
|
||||||
|
if (/^\/classes\/\d+$/.test(pathname)) return '班级详情';
|
||||||
|
return pathname === '/' ? '首页' : '页面';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredTabs(): DockTab[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(
|
||||||
|
(tab): tab is DockTab =>
|
||||||
|
typeof tab?.key === 'string' && tab.key.startsWith('/') && typeof tab?.label === 'string',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props['data-node-key'],
|
||||||
|
});
|
||||||
|
const child = props.children as React.ReactElement<{ style?: React.CSSProperties }>;
|
||||||
|
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
ref: setNodeRef,
|
||||||
|
style: {
|
||||||
|
...child.props.style,
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
zIndex: isDragging ? 1 : undefined,
|
||||||
|
opacity: isDragging ? 0.92 : undefined,
|
||||||
|
boxShadow: isDragging ? '0 8px 20px rgba(29, 29, 31, 0.14)' : undefined,
|
||||||
|
},
|
||||||
|
...attributes,
|
||||||
|
...listeners,
|
||||||
|
} as React.HTMLAttributes<HTMLElement>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
|
||||||
|
const activeKey = `${location.pathname}${location.search}`;
|
||||||
|
const [tabs, setTabs] = useState<DockTab[]>(() => {
|
||||||
|
const storedTabs = readStoredTabs();
|
||||||
|
if (location.pathname === '/') return storedTabs;
|
||||||
|
if (storedTabs.some((tab) => tab.key === activeKey)) return storedTabs;
|
||||||
|
return [...storedTabs, { key: activeKey, label: getRouteLabel(menuItems, location.pathname) }];
|
||||||
|
});
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/') return;
|
||||||
|
setTabs((currentTabs) => {
|
||||||
|
const label = getRouteLabel(menuItems, location.pathname);
|
||||||
|
const existing = currentTabs.find((tab) => tab.key === activeKey);
|
||||||
|
if (!existing) return [...currentTabs, { key: activeKey, label }];
|
||||||
|
if (existing.label === label) return currentTabs;
|
||||||
|
return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
|
||||||
|
});
|
||||||
|
}, [activeKey, location.pathname, menuItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
||||||
|
() =>
|
||||||
|
tabs.map((tab) => ({
|
||||||
|
key: tab.key,
|
||||||
|
label: tab.label,
|
||||||
|
closable: tabs.length > 1,
|
||||||
|
})),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeTab = (targetKey: string) => {
|
||||||
|
const targetIndex = tabs.findIndex((tab) => tab.key === targetKey);
|
||||||
|
if (targetIndex < 0 || tabs.length === 1) return;
|
||||||
|
const nextTabs = tabs.filter((tab) => tab.key !== targetKey);
|
||||||
|
setTabs(nextTabs);
|
||||||
|
if (targetKey === activeKey) {
|
||||||
|
const nextActiveTab = nextTabs[Math.min(targetIndex, nextTabs.length - 1)];
|
||||||
|
if (nextActiveTab) onNavigate(nextActiveTab.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
setTabs((currentTabs) => {
|
||||||
|
const activeIndex = currentTabs.findIndex((tab) => tab.key === active.id);
|
||||||
|
const overIndex = currentTabs.findIndex((tab) => tab.key === over.id);
|
||||||
|
return activeIndex < 0 || overIndex < 0
|
||||||
|
? currentTabs
|
||||||
|
: arrayMove(currentTabs, activeIndex, overIndex);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => {
|
||||||
|
const tabBar = (
|
||||||
|
<DefaultTabBar {...tabBarProps}>
|
||||||
|
{(node) => {
|
||||||
|
if (!draggable) return node;
|
||||||
|
return (
|
||||||
|
<DraggableTabNode
|
||||||
|
{...(node as React.ReactElement<DraggableTabNodeProps>).props}
|
||||||
|
key={node.key}
|
||||||
|
>
|
||||||
|
{node}
|
||||||
|
</DraggableTabNode>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</DefaultTabBar>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!draggable) return tabBar;
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={tabs.map((tab) => tab.key)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{tabBar}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (location.pathname === '/' || tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="route-dock" aria-label="已打开页面">
|
||||||
|
<Tabs
|
||||||
|
type="editable-card"
|
||||||
|
size="small"
|
||||||
|
hideAdd
|
||||||
|
activeKey={activeKey}
|
||||||
|
items={tabItems}
|
||||||
|
animated={false}
|
||||||
|
onChange={onNavigate}
|
||||||
|
onEdit={(targetKey, action) => {
|
||||||
|
if (action === 'remove') closeTab(String(targetKey));
|
||||||
|
}}
|
||||||
|
renderTabBar={renderTabBar}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RouteDock;
|
||||||
@@ -36,6 +36,7 @@ import api from '../../api';
|
|||||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import EditableCell from '../EditableCell';
|
||||||
|
|
||||||
// ---- Types ----
|
// ---- 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> = [
|
const columns: ColumnsType<EnrollmentRecord> = [
|
||||||
{ title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
|
{
|
||||||
{ title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
|
title: '课程类别',
|
||||||
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
|
dataIndex: 'courseCategory',
|
||||||
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
|
render: (v: string, r) => (
|
||||||
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
|
<EditableCell
|
||||||
{ title: '开始日期', dataIndex: 'startDate', render: (v: string) => v || '-' },
|
value={v}
|
||||||
{ title: '结束日期', dataIndex: 'endDate', render: (v: string) => 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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (v: string) => {
|
render: (v: string, r) => {
|
||||||
const status = getEnrollmentStatus(v);
|
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> = [
|
const columns: ColumnsType<ExamScoreRecord> = [
|
||||||
{
|
{
|
||||||
title: '考试类型',
|
title: '考试类型',
|
||||||
dataIndex: 'examType',
|
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: '班级均分',
|
title: '班级均分',
|
||||||
dataIndex: 'classAvg',
|
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: '排名',
|
title: '排名',
|
||||||
dataIndex: 'rank',
|
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: '关联报读',
|
title: '关联报读',
|
||||||
dataIndex: 'enrollmentId',
|
dataIndex: 'enrollmentId',
|
||||||
render: (v: number | undefined) => {
|
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 '-';
|
if (v === undefined) return '-';
|
||||||
const enr = enrollments.find((e) => e.id === v);
|
const enr = enrollments.find((e) => e.id === v);
|
||||||
return enr ? formatEnrollmentDisplayName(enr) : String(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> = [
|
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: '记录类型',
|
title: '记录类型',
|
||||||
dataIndex: 'recordType',
|
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 (
|
return (
|
||||||
|
|||||||
@@ -59,6 +59,88 @@ canvas {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-dock {
|
||||||
|
position: sticky;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 90;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f7;
|
||||||
|
border-bottom: 1px solid #e5e5e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-nav {
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-nav::before {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab {
|
||||||
|
min-width: 112px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0 6px 0 0 !important;
|
||||||
|
padding: 0 10px 0 12px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.58) !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
border-color: #dedee2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab-active {
|
||||||
|
background: #fff !important;
|
||||||
|
border-color: #d8d8dc !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 0 #1677ff,
|
||||||
|
0 2px 7px rgba(29, 29, 31, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab-btn {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #4d4d4d;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab-remove:hover {
|
||||||
|
background: #ededf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-content-holder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared responsive toolbar: add these classes to page filter/action rows. */
|
/* Shared responsive toolbar: add these classes to page filter/action rows. */
|
||||||
.responsive-toolbar {
|
.responsive-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -135,6 +217,16 @@ canvas {
|
|||||||
line-height: 56px;
|
line-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-dock {
|
||||||
|
top: 56px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab {
|
||||||
|
min-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header .ant-btn {
|
.app-header .ant-btn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { usePermission } from '../hooks/usePermission';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { writePermissions } from '../auth/permission-store';
|
import { writePermissions } from '../auth/permission-store';
|
||||||
import NotificationBell from '../components/NotificationBell';
|
import NotificationBell from '../components/NotificationBell';
|
||||||
|
import RouteDock from '../components/RouteDock';
|
||||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -160,14 +161,13 @@ const MainLayout: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname !== prevPathname.current) {
|
if (location.pathname !== prevPathname.current) {
|
||||||
prevPathname.current = location.pathname;
|
prevPathname.current = location.pathname;
|
||||||
setOpenKeys(findOpenKeys(menuItems, location.pathname));
|
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
|
||||||
|
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
|
||||||
}
|
}
|
||||||
}, [location.pathname, menuItems]);
|
}, [location.pathname, menuItems]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((keys: string[]) => {
|
const handleOpenChange = useCallback((keys: string[]) => {
|
||||||
// 只保留最新打开的一个子菜单
|
setOpenKeys(keys);
|
||||||
const latestKey = keys[keys.length - 1];
|
|
||||||
setOpenKeys(latestKey ? [latestKey] : []);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
||||||
@@ -217,7 +217,7 @@ const MainLayout: React.FC = () => {
|
|||||||
borderBottom: '1px solid #e5e5e7',
|
borderBottom: '1px solid #e5e5e7',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? '恭' : '恭学教育基地'}
|
{collapsed ? '学' : '学生管理系统'}
|
||||||
</div>
|
</div>
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Sider>
|
</Sider>
|
||||||
@@ -230,7 +230,7 @@ const MainLayout: React.FC = () => {
|
|||||||
size={240}
|
size={240}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
className="app-navigation-drawer"
|
className="app-navigation-drawer"
|
||||||
title="恭学教育基地"
|
title="学生管理系统"
|
||||||
>
|
>
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -288,6 +288,12 @@ const MainLayout: React.FC = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
|
<RouteDock
|
||||||
|
location={location}
|
||||||
|
menuItems={menuItems}
|
||||||
|
onNavigate={navigate}
|
||||||
|
draggable={isDesktop}
|
||||||
|
/>
|
||||||
<Content
|
<Content
|
||||||
className="app-content"
|
className="app-content"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import {
|
import {
|
||||||
@@ -52,10 +53,38 @@ import {
|
|||||||
import './attendance.css';
|
import './attendance.css';
|
||||||
|
|
||||||
const DEFAULT_ATTENDANCE_PERIODS = [
|
const DEFAULT_ATTENDANCE_PERIODS = [
|
||||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
|
{
|
||||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
|
periodKey: 'morning_reading',
|
||||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
|
label: '早自习',
|
||||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
|
startTime: '07:30',
|
||||||
|
endTime: '08:30',
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
periodKey: 'morning',
|
||||||
|
label: '早课',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
sortOrder: 2,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
periodKey: 'afternoon',
|
||||||
|
label: '晚课',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '17:00',
|
||||||
|
sortOrder: 3,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
periodKey: 'evening_study',
|
||||||
|
label: '晚自习',
|
||||||
|
startTime: '18:30',
|
||||||
|
endTime: '21:00',
|
||||||
|
sortOrder: 4,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_META: Record<
|
const STATUS_META: Record<
|
||||||
@@ -319,7 +348,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
[workspace],
|
[workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||||
setStudentKeyword('');
|
setStudentKeyword('');
|
||||||
setCheckinFilter('all');
|
setCheckinFilter('all');
|
||||||
@@ -345,8 +373,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateLessonRecord = useCallback(
|
const updateLessonRecord = useCallback(async (record: AttendanceRecordItem, status: string) => {
|
||||||
async (record: AttendanceRecordItem, status: string) => {
|
|
||||||
const previous = record.status;
|
const previous = record.status;
|
||||||
setLessonRecords((items) =>
|
setLessonRecords((items) =>
|
||||||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||||||
@@ -359,15 +386,12 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
);
|
);
|
||||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||||||
}
|
}
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const schedules = workspace?.todaySchedules ?? [];
|
const schedules = workspace?.todaySchedules ?? [];
|
||||||
const startedCount = schedules.filter(
|
const startedCount = schedules.filter((item) =>
|
||||||
(item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
|
canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
|
||||||
).length;
|
).length;
|
||||||
const nextSchedule = schedules.find(
|
const nextSchedule = schedules.find(
|
||||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||||
@@ -382,7 +406,9 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
<div className="attendance-page teacher-attendance">
|
<div className="attendance-page teacher-attendance">
|
||||||
<section className="attendance-hero attendance-hero--teacher">
|
<section className="attendance-hero attendance-hero--teacher">
|
||||||
<div>
|
<div>
|
||||||
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
|
<span className="attendance-eyebrow">
|
||||||
|
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
|
||||||
|
</span>
|
||||||
<h1>今天,从课程开始</h1>
|
<h1>今天,从课程开始</h1>
|
||||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,18 +419,33 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
|
|
||||||
<Row gutter={[16, 16]} className="teacher-overview">
|
<Row gutter={[16, 16]} className="teacher-overview">
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节</small></div>
|
<div className="teacher-kpi">
|
||||||
|
<span>今日课程</span>
|
||||||
|
<strong>{schedules.length}</strong>
|
||||||
|
<small>节</small>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi"><span>已开始</span><strong>{startedCount}</strong><small>节,可查看考勤</small></div>
|
<div className="teacher-kpi">
|
||||||
|
<span>已开始</span>
|
||||||
|
<strong>{startedCount}</strong>
|
||||||
|
<small>节,可查看考勤</small>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi teacher-kpi--next"><span>下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
<div className="teacher-kpi teacher-kpi--next">
|
||||||
|
<span>下一节</span>
|
||||||
|
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
|
||||||
|
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<div className="attendance-section-heading">
|
<div className="attendance-section-heading">
|
||||||
<div><span>今日教学节奏</span><h2>我的课程</h2></div>
|
<div>
|
||||||
|
<span>今日教学节奏</span>
|
||||||
|
<h2>我的课程</h2>
|
||||||
|
</div>
|
||||||
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -413,7 +454,12 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
<Card className="attendance-empty-card">
|
<Card className="attendance-empty-card">
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
description={
|
||||||
|
<div>
|
||||||
|
<strong>今天还没有课程</strong>
|
||||||
|
<p>请联系教务管理员安排课程。</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -435,17 +481,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
width={960}
|
||||||
|
title={null}
|
||||||
|
className="attendance-drawer"
|
||||||
|
>
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
<p>
|
||||||
|
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} ·{' '}
|
||||||
|
{selectedSchedule?.startTime}–{selectedSchedule?.endTime} ·{' '}
|
||||||
|
{dayjs().format('YYYY-MM-DD')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{lessonSession && (
|
{lessonSession && (
|
||||||
<Alert
|
<Alert
|
||||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||||
showIcon
|
showIcon
|
||||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
title={
|
||||||
|
isAttendanceCompleted
|
||||||
|
? '本节课考勤已结算'
|
||||||
|
: '当前打卡结果;课程截止后将自动做最终结算'
|
||||||
|
}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -478,18 +538,30 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
dataSource={filteredLessonRecords}
|
dataSource={filteredLessonRecords}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: <Empty
|
emptyText: (
|
||||||
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
description={
|
||||||
/>,
|
lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '学生', dataIndex: ['student', 'name'],
|
title: '学生',
|
||||||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
dataIndex: ['student', 'name'],
|
||||||
|
render: (name: string) => (
|
||||||
|
<div className="student-cell">
|
||||||
|
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
|
||||||
|
<strong>{name || '-'}</strong>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '考勤结果', dataIndex: 'status', width: 230,
|
title: '考勤结果',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 230,
|
||||||
render: (value: string, record: AttendanceRecordItem) => {
|
render: (value: string, record: AttendanceRecordItem) => {
|
||||||
const checkedIn = value === 'present' || value === 'late';
|
const checkedIn = value === 'present' || value === 'late';
|
||||||
return (
|
return (
|
||||||
@@ -513,7 +585,16 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
{
|
||||||
|
title: '当前状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 105,
|
||||||
|
render: (value: string) => (
|
||||||
|
<AttendanceStatusTag
|
||||||
|
status={value === 'present' || value === 'late' ? 'present' : 'absent'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '打卡设备',
|
title: '打卡设备',
|
||||||
width: 220,
|
width: 220,
|
||||||
@@ -529,7 +610,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -553,14 +638,26 @@ const LessonCard: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
|
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
|
||||||
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
|
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
|
||||||
<div className="lesson-time"><strong>{schedule.startTime}</strong><span /><strong>{schedule.endTime}</strong></div>
|
<div className="lesson-time">
|
||||||
|
<strong>{schedule.startTime}</strong>
|
||||||
|
<span />
|
||||||
|
<strong>{schedule.endTime}</strong>
|
||||||
|
</div>
|
||||||
<div className="lesson-main">
|
<div className="lesson-main">
|
||||||
<div className="lesson-title-row"><h3>{schedule.subject}</h3><Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag></div>
|
<div className="lesson-title-row">
|
||||||
<p><TeamOutlined /> {className}<span>教室 {schedule.classroomId}</span></p>
|
<h3>{schedule.subject}</h3>
|
||||||
|
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<TeamOutlined /> {className}
|
||||||
|
<span>教室 {schedule.classroomId}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lesson-action">
|
<div className="lesson-action">
|
||||||
{phase === 'upcoming' ? (
|
{phase === 'upcoming' ? (
|
||||||
<Tooltip title="课程尚未开始"><Button disabled>等待上课</Button></Tooltip>
|
<Tooltip title="课程尚未开始">
|
||||||
|
<Button disabled>等待上课</Button>
|
||||||
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Button type="primary" onClick={onOpen}>
|
<Button type="primary" onClick={onOpen}>
|
||||||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||||||
@@ -571,7 +668,6 @@ const LessonCard: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
interface AdminStudentPanel {
|
interface AdminStudentPanel {
|
||||||
key: string;
|
key: string;
|
||||||
studentId: number;
|
studentId: number;
|
||||||
@@ -594,7 +690,11 @@ const ADMIN_METRIC_META = [
|
|||||||
|
|
||||||
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||||
const priority = ['absent', 'leave', 'present'];
|
const priority = ['absent', 'leave', 'present'];
|
||||||
return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent';
|
return (
|
||||||
|
priority.find((item) =>
|
||||||
|
records.some((record) => displayAttendanceStatus(record.status) === item),
|
||||||
|
) || 'absent'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
|
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
|
||||||
@@ -621,9 +721,7 @@ function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.values()).map((item) => {
|
return Array.from(map.values()).map((item) => {
|
||||||
const checked = item.records.filter(
|
const checked = item.records.filter((record) => record.status === 'present').length;
|
||||||
(record) => record.status === 'present',
|
|
||||||
).length;
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
primaryStatus: pickPrimaryStatus(item.records),
|
primaryStatus: pickPrimaryStatus(item.records),
|
||||||
@@ -660,7 +758,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||||
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const enabledPeriods = useMemo(
|
const enabledPeriods = useMemo(
|
||||||
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
[periods],
|
[periods],
|
||||||
@@ -712,7 +809,10 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
enabled: period.enabled ?? true,
|
enabled: period.enabled ?? true,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const data = await api.put<AttendancePeriodConfigItem[]>('/attendance-period-configs', payload);
|
const data = await api.put<AttendancePeriodConfigItem[]>(
|
||||||
|
'/attendance-period-configs',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
setPeriods(data);
|
setPeriods(data);
|
||||||
setPeriodModalOpen(false);
|
setPeriodModalOpen(false);
|
||||||
message.success('考勤时段配置已保存');
|
message.success('考勤时段配置已保存');
|
||||||
@@ -796,14 +896,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
}
|
}
|
||||||
setRefreshingDingTalk(true);
|
setRefreshingDingTalk(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.post<{ refreshed: number; imported: number; matched: number; errors: string[] }>(
|
const result = await api.post<{
|
||||||
'/attendance-records/refresh-dingtalk',
|
refreshed: number;
|
||||||
{
|
imported: number;
|
||||||
|
matched: number;
|
||||||
|
errors: string[];
|
||||||
|
}>('/attendance-records/refresh-dingtalk', {
|
||||||
date: attendanceDate.format('YYYY-MM-DD'),
|
date: attendanceDate.format('YYYY-MM-DD'),
|
||||||
classId,
|
classId,
|
||||||
session,
|
session,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
message.warning(result.errors[0]);
|
message.warning(result.errors[0]);
|
||||||
} else {
|
} else {
|
||||||
@@ -933,6 +1035,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveAdminRecordCell = async (
|
||||||
|
record: AttendanceRecordItem,
|
||||||
|
field: 'status' | 'remark',
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
await api.put(`/attendance-records/${record.id}`, { [field]: value });
|
||||||
|
message.success('考勤记录已保存');
|
||||||
|
await loadRecords();
|
||||||
|
};
|
||||||
|
|
||||||
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
|
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
|
||||||
const visibleStudents = useMemo(() => {
|
const visibleStudents = useMemo(() => {
|
||||||
@@ -952,7 +1063,8 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
const selectedClassOption = classId
|
const selectedClassOption = classId
|
||||||
? classOptions.find((item) => item.classId === classId)
|
? classOptions.find((item) => item.classId === classId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const selectedClass = selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
|
const selectedClass =
|
||||||
|
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
|
||||||
const overviewTeachers = selectedClassOption?.teachers ?? [];
|
const overviewTeachers = selectedClassOption?.teachers ?? [];
|
||||||
const headTeacherNames = classId
|
const headTeacherNames = classId
|
||||||
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
|
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
|
||||||
@@ -1004,7 +1116,20 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 105,
|
width: 105,
|
||||||
render: (value: string) => <AttendanceStatusTag status={displayAttendanceStatus(value)} />,
|
render: (value: string, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={displayAttendanceStatus(value)}
|
||||||
|
editor="select"
|
||||||
|
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: item.label,
|
||||||
|
}))}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSave={(next) => saveAdminRecordCell(record, 'status', next)}
|
||||||
|
>
|
||||||
|
<AttendanceStatusTag status={displayAttendanceStatus(value)} />
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '签到来源',
|
title: '签到来源',
|
||||||
@@ -1025,7 +1150,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
render: (value: string | null, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="textarea"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSave={(next) => saveAdminRecordCell(record, 'remark', next)}
|
||||||
|
>
|
||||||
|
{value || <span className="muted-text">—</span>}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
...(canEdit
|
...(canEdit
|
||||||
? [
|
? [
|
||||||
@@ -1418,7 +1552,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={periodModalOpen}
|
open={periodModalOpen}
|
||||||
title="考勤时段配置"
|
title="考勤时段配置"
|
||||||
@@ -1514,7 +1647,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
</Form.List>
|
</Form.List>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import PermissionButton from '../components/PermissionButton';
|
import PermissionButton from '../components/PermissionButton';
|
||||||
|
import EditableCell from '../components/EditableCell';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
|
|
||||||
interface ClassroomOption {
|
interface ClassroomOption {
|
||||||
@@ -119,6 +120,12 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
|
||||||
|
await api.put(`/attendance-devices/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attendance-devices/${id}`);
|
await api.delete(`/attendance-devices/${id}`);
|
||||||
@@ -130,37 +137,102 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<AttendanceDeviceRow> = [
|
const columns: ColumnsType<AttendanceDeviceRow> = [
|
||||||
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
|
{
|
||||||
|
title: '设备名称',
|
||||||
|
dataIndex: 'deviceName',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
required
|
||||||
|
permission="classroom:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'deviceName', next)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'SN 码',
|
title: 'SN 码',
|
||||||
dataIndex: 'deviceSn',
|
dataIndex: 'deviceSn',
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span>,
|
render: (value, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
required
|
||||||
|
permission="classroom:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'deviceSn', next)}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{value}</span>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '绑定教室',
|
title: '绑定教室',
|
||||||
dataIndex: ['classroom', 'name'],
|
dataIndex: ['classroom', 'name'],
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}`,
|
render: (_value, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={record.classroomId}
|
||||||
|
editor="select"
|
||||||
|
options={classrooms.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.building ? `${item.building} · ${item.name}` : item.name,
|
||||||
|
}))}
|
||||||
|
permission="classroom:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(record, 'classroomId', next)}
|
||||||
|
>
|
||||||
|
{record.classroom?.name || `教室 ${record.classroomId}`}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '位置',
|
title: '位置',
|
||||||
dataIndex: 'location',
|
dataIndex: 'location',
|
||||||
render: (value) => value || <span style={{ color: '#999' }}>—</span>,
|
render: (value, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
permission="classroom:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'location', next)}
|
||||||
|
>
|
||||||
|
{value || <span style={{ color: '#999' }}>—</span>}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (value: keyof typeof statusMeta) => (
|
render: (value: keyof typeof statusMeta, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '停用' },
|
||||||
|
]}
|
||||||
|
permission="classroom:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'status', next)}
|
||||||
|
>
|
||||||
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
|
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'notes',
|
dataIndex: 'notes',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (value) => value || <span style={{ color: '#999' }}>—</span>,
|
render: (value, record) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="textarea"
|
||||||
|
permission="classroom:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'notes', next)}
|
||||||
|
>
|
||||||
|
{value || <span style={{ color: '#999' }}>—</span>}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const buildBillPrintHtml = (bill: BillPrintData) => {
|
|||||||
<thead><tr><th>费用类型</th><th>说明</th><th>天数</th><th>总人天</th><th>金额(元)</th></tr></thead>
|
<thead><tr><th>费用类型</th><th>说明</th><th>天数</th><th>总人天</th><th>金额(元)</th></tr></thead>
|
||||||
<tbody>${rows || '<tr><td class="empty" colspan="5">暂无费用明细</td></tr>'}</tbody>
|
<tbody>${rows || '<tr><td class="empty" colspan="5">暂无费用明细</td></tr>'}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="footer">本账单由恭学教育基地管理系统自动生成</div>
|
<div class="footer">本账单由学生管理系统自动生成</div>
|
||||||
<script>window.addEventListener('load', function () { setTimeout(function () { window.print(); }, 100); });</script>
|
<script>window.addEventListener('load', function () { setTimeout(function () { window.print(); }, 100); });</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
// ---- Types ----
|
// ---- Types ----
|
||||||
@@ -167,6 +168,15 @@ const ClassesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = useCallback(
|
||||||
|
async (record: ClassItem, field: string, value: unknown) => {
|
||||||
|
await api.put(`/classes/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<ClassItem> = useMemo(
|
const columns: ColumnsType<ClassItem> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -174,33 +184,103 @@ const ClassesPage: React.FC = () => {
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
width: 120,
|
width: 120,
|
||||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
render: (v: string, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'name', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '编码',
|
||||||
|
dataIndex: 'code',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'code', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
|
||||||
{
|
{
|
||||||
title: '班型',
|
title: '班型',
|
||||||
dataIndex: 'classType',
|
dataIndex: 'classType',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: string) => <Tag>{TYPE_MAP[v] || v}</Tag>,
|
render: (v: string, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={Object.entries(TYPE_MAP).map(([value, label]) => ({ value, label }))}
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'classType', next)}
|
||||||
|
>
|
||||||
|
<Tag>{TYPE_MAP[v] || v}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '开班日期',
|
title: '开班日期',
|
||||||
dataIndex: 'startDate',
|
dataIndex: 'startDate',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v: string | null) => v || '-',
|
render: (v: string | null, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="date"
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'startDate', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '学员',
|
title: '学员',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`,
|
render: (_: unknown, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={r.maxStudents}
|
||||||
|
editor="number"
|
||||||
|
min={0}
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'maxStudents', next)}
|
||||||
|
>{`${r.studentCount || 0}/${r.maxStudents || '-'}`}</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: string) => {
|
render: (v: string, r: ClassItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={Object.entries(STATUS_MAP).map(([value, item]) => ({
|
||||||
|
value,
|
||||||
|
label: item.text,
|
||||||
|
}))}
|
||||||
|
permission="class:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'status', next)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
|
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
|
||||||
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||||
},
|
})()}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -237,7 +317,7 @@ const ClassesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[saveCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
|
||||||
@@ -214,6 +215,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/classroom-rentals/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/classroom-rentals/${id}`);
|
await api.delete(`/classroom-rentals/${id}`);
|
||||||
@@ -279,22 +286,49 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
title: '教室',
|
title: '教室',
|
||||||
width: 120,
|
width: 120,
|
||||||
dataIndex: 'classroom',
|
dataIndex: 'classroom',
|
||||||
render: (c: any) =>
|
render: (c: any, r: any) => (
|
||||||
c ? (
|
<EditableCell
|
||||||
|
value={r.classroomId}
|
||||||
|
editor="select"
|
||||||
|
options={classrooms
|
||||||
|
.filter((item) => item.status !== 'archived')
|
||||||
|
.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.building ? `${item.building} · ${item.name}` : item.name,
|
||||||
|
}))}
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(r, 'classroomId', next)}
|
||||||
|
>
|
||||||
|
{c ? (
|
||||||
<span>
|
<span>
|
||||||
{c.building ? `${c.building} · ` : ''}
|
{c.building ? `${c.building} · ` : ''}
|
||||||
{c.name}
|
{c.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
)}
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '承租机构',
|
title: '承租机构',
|
||||||
width: 100,
|
width: 100,
|
||||||
dataIndex: 'lesseeOrganization',
|
dataIndex: 'lesseeOrganization',
|
||||||
render: (t: any) =>
|
render: (t: any, r: any) => (
|
||||||
t ? (
|
<EditableCell
|
||||||
|
value={r.lesseeOrganizationId}
|
||||||
|
editor="select"
|
||||||
|
options={organizations
|
||||||
|
.filter((item) => item.status !== 'archived')
|
||||||
|
.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)}
|
||||||
|
>
|
||||||
|
{t ? (
|
||||||
<Tag
|
<Tag
|
||||||
color={t.color}
|
color={t.color}
|
||||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||||
@@ -303,10 +337,44 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
)}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开始日期',
|
||||||
|
dataIndex: 'startDate',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="date"
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(r, 'startDate', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结束日期',
|
||||||
|
dataIndex: 'endDate',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="date"
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(r, 'endDate', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '开始日期', dataIndex: 'startDate', width: 110 },
|
|
||||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
|
||||||
{
|
{
|
||||||
title: '时长',
|
title: '时长',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -319,13 +387,35 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
title: '日租金',
|
title: '日租金',
|
||||||
dataIndex: 'dailyRate',
|
dataIndex: 'dailyRate',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
render: (v: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="money"
|
||||||
|
min={0.01}
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
onSave={(next) => saveCell(r, 'dailyRate', next)}
|
||||||
|
>
|
||||||
|
{v ? `¥${v}` : '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '总额',
|
title: '总额',
|
||||||
dataIndex: 'totalAmount',
|
dataIndex: 'totalAmount',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
render: (v: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="money"
|
||||||
|
min={0.01}
|
||||||
|
permission="rental:edit"
|
||||||
|
disabled={r.effectiveStatus !== 'active'}
|
||||||
|
onSave={(next) => saveCell(r, 'totalAmount', next)}
|
||||||
|
>
|
||||||
|
{v ? `¥${v}` : '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -448,7 +538,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[classrooms, organizations],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
@@ -110,6 +111,12 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/classrooms/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/classrooms/${id}`);
|
await api.delete(`/classrooms/${id}`);
|
||||||
@@ -155,16 +162,83 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
width: 120,
|
width: 120,
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
|
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(r, 'name', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '楼栋',
|
||||||
|
dataIndex: 'building',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(r, 'building', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '楼层',
|
||||||
|
dataIndex: 'floor',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="number"
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(r, 'floor', next)}
|
||||||
|
>
|
||||||
|
{v ?? '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '楼栋', dataIndex: 'building', width: 80 },
|
|
||||||
{ title: '楼层', dataIndex: 'floor', width: 80 },
|
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
width: 90,
|
width: 90,
|
||||||
dataIndex: 'roomType',
|
dataIndex: 'roomType',
|
||||||
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={['大', '次大', '小'].map((value) => ({ value, label: value }))}
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(r, 'roomType', next)}
|
||||||
|
>
|
||||||
|
<Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '容量',
|
||||||
|
dataIndex: 'capacity',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="number"
|
||||||
|
min={0}
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(r, 'capacity', next)}
|
||||||
|
>
|
||||||
|
{v ?? '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '容量', dataIndex: 'capacity', width: 80 },
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -175,6 +249,17 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
) => {
|
) => {
|
||||||
const effectiveStatus = record.effectiveStatus || record.status;
|
const effectiveStatus = record.effectiveStatus || record.status;
|
||||||
return (
|
return (
|
||||||
|
<EditableCell
|
||||||
|
value={record.status}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '可用' },
|
||||||
|
{ value: 'maintenance', label: '维护中' },
|
||||||
|
]}
|
||||||
|
permission="classroom:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'status', next)}
|
||||||
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
record.currentUsage
|
record.currentUsage
|
||||||
@@ -186,6 +271,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</EditableCell>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
List,
|
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -18,6 +17,7 @@ import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||||
|
|
||||||
@@ -314,6 +314,20 @@ const DepositsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveInstallmentCell = async (
|
||||||
|
installmentId: number,
|
||||||
|
field: 'status' | 'paidDate',
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
await api.put(`/deposits/installments/${installmentId}`, { [field]: value });
|
||||||
|
message.success('分期记录已保存');
|
||||||
|
if (detailModal) {
|
||||||
|
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
|
||||||
|
setDetailModal(refreshed);
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteInstallment = async (installmentId: number) => {
|
const handleDeleteInstallment = async (installmentId: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/deposits/installments/${installmentId}`);
|
await api.delete(`/deposits/installments/${installmentId}`);
|
||||||
@@ -691,14 +705,59 @@ const DepositsPage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
{detailModal.installments && detailModal.installments.length > 0 ? (
|
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||||
<List
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
dataSource={detailModal.installments}
|
dataSource={detailModal.installments}
|
||||||
renderItem={(item) => (
|
columns={[
|
||||||
<List.Item
|
{
|
||||||
actions={[
|
title: '金额',
|
||||||
item.status === 'pending' && (
|
dataIndex: 'amount',
|
||||||
|
render: (value: number) => `¥${Number(value).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{ title: '到期日', dataIndex: 'dueDate' },
|
||||||
|
{
|
||||||
|
title: '实付日',
|
||||||
|
dataIndex: 'paidDate',
|
||||||
|
render: (value: string, item: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="date"
|
||||||
|
permission="deposit:edit"
|
||||||
|
onSave={(next) => saveInstallmentCell(item.id, 'paidDate', next)}
|
||||||
|
>
|
||||||
|
{value || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (value: string, item: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'pending', label: '待缴' },
|
||||||
|
{ value: 'paid', label: '已缴' },
|
||||||
|
{ value: 'overdue', label: '逾期' },
|
||||||
|
]}
|
||||||
|
permission="deposit:edit"
|
||||||
|
onSave={(next) => saveInstallmentCell(item.id, 'status', next)}
|
||||||
|
>
|
||||||
|
<Tag color={installmentStatusMap[value]?.color}>
|
||||||
|
{installmentStatusMap[value]?.text || value}
|
||||||
|
</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_: unknown, item: any) => (
|
||||||
|
<Space>
|
||||||
|
{item.status === 'pending' && (
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
key="pay"
|
|
||||||
permission="deposit:edit"
|
permission="deposit:edit"
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -707,14 +766,12 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
标记已缴
|
标记已缴
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
),
|
)}
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
key="archive"
|
|
||||||
title="确定归档?"
|
title="确定归档?"
|
||||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||||
>
|
>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
key="del"
|
|
||||||
permission="deposit:delete"
|
permission="deposit:delete"
|
||||||
size="small"
|
size="small"
|
||||||
danger
|
danger
|
||||||
@@ -722,18 +779,11 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Popconfirm>,
|
</Popconfirm>
|
||||||
].filter(Boolean)}
|
</Space>
|
||||||
>
|
),
|
||||||
<List.Item.Meta
|
},
|
||||||
title={`¥${Number(item.amount).toFixed(2)}`}
|
]}
|
||||||
description={`到期: ${item.dueDate}${item.paidDate ? ` | 缴纳: ${item.paidDate}` : ''}`}
|
|
||||||
/>
|
|
||||||
<Tag color={installmentStatusMap[item.status]?.color}>
|
|
||||||
{installmentStatusMap[item.status]?.text || item.status}
|
|
||||||
</Tag>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: '#999' }}>暂无分期记录</p>
|
<p style={{ color: '#999' }}>暂无分期记录</p>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
@@ -197,6 +198,24 @@ const ExpensesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveRoomCell = useCallback(
|
||||||
|
async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/expenses/room/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const savePersonalCell = useCallback(
|
||||||
|
async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/expenses/personal/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStudentUtility = async () => {
|
const handleStudentUtility = async () => {
|
||||||
const values = await utilityForm.validateFields();
|
const values = await utilityForm.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -257,25 +276,87 @@ const ExpensesPage: React.FC = () => {
|
|||||||
|
|
||||||
const roomColumns = useMemo(
|
const roomColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
|
{
|
||||||
|
title: '宿舍',
|
||||||
|
width: 120,
|
||||||
|
render: (_: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={r.roomId}
|
||||||
|
editor="select"
|
||||||
|
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => saveRoomCell(r, 'roomId', next)}
|
||||||
|
>
|
||||||
|
{r.room?.roomNumber || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '费用类型',
|
title: '费用类型',
|
||||||
width: 100,
|
width: 100,
|
||||||
dataIndex: 'expenseType',
|
dataIndex: 'expenseType',
|
||||||
render: (v: string) => <Tag>{typeMap[v] || v}</Tag>,
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={typeOptions}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => saveRoomCell(r, 'expenseType', next)}
|
||||||
|
>
|
||||||
|
<Tag>{typeMap[v] || v}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额',
|
title: '金额',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="money"
|
||||||
|
min={0.01}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => saveRoomCell(r, 'amount', next)}
|
||||||
|
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账单周期',
|
title: '账单周期',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`,
|
render: (_: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={[r.periodStart, r.periodEnd]}
|
||||||
|
editor="date-range"
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={async (next) => {
|
||||||
|
const [periodStart, periodEnd] = next as unknown as [string, string];
|
||||||
|
await api.put(`/expenses/room/${r.id}`, { periodStart, periodEnd });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
}}
|
||||||
|
>{`${r.periodStart} ~ ${r.periodEnd}`}</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="textarea"
|
||||||
|
permission="expense:edit"
|
||||||
|
onSave={(next) => saveRoomCell(r, 'description', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '说明', dataIndex: 'description', width: 150 },
|
|
||||||
{
|
{
|
||||||
title: '录入时间',
|
title: '录入时间',
|
||||||
width: 160,
|
width: 160,
|
||||||
@@ -326,21 +407,89 @@ const ExpensesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[setEditingRoom, roomForm, setRoomModal, fetchData, typeMap],
|
[rooms, typeOptions, typeMap, saveRoomCell, roomForm, fetchData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const personalColumns = useMemo(
|
const personalColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
{
|
||||||
|
title: '学生',
|
||||||
|
width: 120,
|
||||||
|
render: (_: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={r.studentId}
|
||||||
|
editor="select"
|
||||||
|
options={students.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => savePersonalCell(r, 'studentId', next)}
|
||||||
|
>
|
||||||
|
{r.student?.name || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '费用类型',
|
title: '费用类型',
|
||||||
width: 100,
|
width: 100,
|
||||||
dataIndex: 'expenseType',
|
dataIndex: 'expenseType',
|
||||||
render: (v: string) => <Tag color="orange">{typeMap[v] || v}</Tag>,
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={personalTypeOptions}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => savePersonalCell(r, 'expenseType', next)}
|
||||||
|
>
|
||||||
|
<Tag color="orange">{typeMap[v] || v}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="money"
|
||||||
|
min={0.01}
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => savePersonalCell(r, 'amount', next)}
|
||||||
|
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '日期',
|
||||||
|
dataIndex: 'expenseDate',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="date"
|
||||||
|
permission="expense:edit"
|
||||||
|
required
|
||||||
|
onSave={(next) => savePersonalCell(r, 'expenseDate', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="textarea"
|
||||||
|
permission="expense:edit"
|
||||||
|
onSave={(next) => savePersonalCell(r, 'description', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
|
||||||
{ title: '日期', dataIndex: 'expenseDate', width: 110 },
|
|
||||||
{ title: '说明', dataIndex: 'description', width: 150 },
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -386,7 +535,7 @@ const ExpensesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap],
|
[students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -56,24 +56,32 @@ const LoginPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}>
|
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}>
|
||||||
恭学教育基地管理系统
|
学生管理系统
|
||||||
</Title>
|
</Title>
|
||||||
<p style={{ color: '#86868b', marginTop: 8 }}>水电费精准计费平台</p>
|
<p style={{ color: '#86868b', marginTop: 8 }}>学生综合管理平台</p>
|
||||||
</div>
|
</div>
|
||||||
<Form name="login" onFinish={onFinish} size="large">
|
<Form name="login" layout="vertical" onFinish={onFinish} size="large">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="用户名"
|
label="用户名"
|
||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: '请输入用户名' }]}
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
>
|
>
|
||||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="密码"
|
label="密码"
|
||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true, message: '请输入密码' }]}
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
>
|
>
|
||||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag
|
|||||||
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
@@ -93,12 +94,25 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
|
||||||
|
await api.put(`/organizations/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '机构',
|
title: '机构',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (name: string, record: OrganizationItem) => (
|
render: (name: string, record: OrganizationItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
permission="organization:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'name', next)}
|
||||||
|
>
|
||||||
<Space>
|
<Space>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -117,22 +131,71 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
<Tag>外部机构</Tag>
|
<Tag>外部机构</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '机构编码',
|
title: '机构编码',
|
||||||
dataIndex: 'code',
|
dataIndex: 'code',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (value: string) => <code>{value}</code>,
|
render: (value: string, record: OrganizationItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
required
|
||||||
|
permission="organization:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'code', next)}
|
||||||
|
>
|
||||||
|
<code>{value}</code>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '联系人',
|
title: '联系人',
|
||||||
dataIndex: 'contactName',
|
dataIndex: 'contactName',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (value?: string) => value || '-',
|
render: (value: string | undefined, record: OrganizationItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
permission="organization:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'contactName', next)}
|
||||||
|
>
|
||||||
|
{value || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '电话',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
width: 140,
|
||||||
|
render: (value: string | undefined, record: OrganizationItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
permission="organization:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'phone', next)}
|
||||||
|
>
|
||||||
|
{value || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string | undefined, record: OrganizationItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="textarea"
|
||||||
|
permission="organization:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'notes', next)}
|
||||||
|
>
|
||||||
|
{value || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
|
|
||||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empt
|
|||||||
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
interface PermissionItem {
|
interface PermissionItem {
|
||||||
@@ -133,20 +134,73 @@ const RolesPage: React.FC = () => {
|
|||||||
ai: 'AI 配置',
|
ai: 'AI 配置',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const permissionOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
allPerms
|
||||||
|
.flatMap((group) => group.permissions)
|
||||||
|
.map((item) => ({ value: item.id, label: `${item.group} · ${item.name}` })),
|
||||||
|
[allPerms],
|
||||||
|
);
|
||||||
|
const saveCell = useCallback(
|
||||||
|
async (record: RoleItem, field: string, value: unknown) => {
|
||||||
|
await api.put(`/rbac/roles/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
{
|
||||||
{ title: '描述', dataIndex: 'description', width: 200 },
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string, r: RoleItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="role:edit"
|
||||||
|
onSave={(next) => saveCell(r, 'name', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 200,
|
||||||
|
render: (v: string, r: RoleItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="textarea"
|
||||||
|
permission="role:edit"
|
||||||
|
onSave={(next) => saveCell(r, 'description', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '权限标签',
|
title: '权限标签',
|
||||||
dataIndex: 'permissions',
|
dataIndex: 'permissions',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (perms: PermissionItem[]) =>
|
render: (perms: PermissionItem[], record: RoleItem) => (
|
||||||
perms?.length > 0 ? (
|
<EditableCell
|
||||||
|
value={perms?.map((item) => item.id) || []}
|
||||||
|
editor="multi-select"
|
||||||
|
options={permissionOptions}
|
||||||
|
permission="role:edit"
|
||||||
|
onSave={(next) => saveCell(record, 'permissionIds', next)}
|
||||||
|
>
|
||||||
|
{perms?.length > 0 ? (
|
||||||
<Tag color="blue">{perms.length} 个权限</Tag>
|
<Tag color="blue">{perms.length} 个权限</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="default">无权限</Tag>
|
<Tag color="default">无权限</Tag>
|
||||||
|
)}
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -186,7 +240,7 @@ const RolesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[permissionOptions, saveCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGroupCheckAll = (group: string, checked: boolean) => {
|
const handleGroupCheckAll = (group: string, checked: boolean) => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
@@ -199,6 +200,26 @@ const RoomsPage: React.FC = () => {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveRoomCell = async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/rooms/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBedCell = async (record: BedItem, field: string, value: unknown) => {
|
||||||
|
if (!drawerRoom) return;
|
||||||
|
await api.put(`/rooms/${drawerRoom.id}/beds/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchBeds(drawerRoom.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLockerCell = async (record: LockerItem, field: string, value: unknown) => {
|
||||||
|
if (!drawerRoom) return;
|
||||||
|
await api.put(`/rooms/${drawerRoom.id}/lockers/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchLockers(drawerRoom.id);
|
||||||
|
};
|
||||||
const fetchBeds = useCallback(async (roomId: number) => {
|
const fetchBeds = useCallback(async (roomId: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
|
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
|
||||||
@@ -337,27 +358,123 @@ const RoomsPage: React.FC = () => {
|
|||||||
dataIndex: 'roomNumber',
|
dataIndex: 'roomNumber',
|
||||||
width: 100,
|
width: 100,
|
||||||
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
|
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'roomNumber', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '楼栋',
|
||||||
|
dataIndex: 'building',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'building', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '楼层',
|
||||||
|
dataIndex: 'floor',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="number"
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'floor', next)}
|
||||||
|
>
|
||||||
|
{v ?? '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'roomType',
|
||||||
|
width: 90,
|
||||||
|
render: (v: any, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'roomType', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '楼栋', dataIndex: 'building', width: 80 },
|
|
||||||
{ title: '楼层', dataIndex: 'floor', width: 80 },
|
|
||||||
{ title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' },
|
|
||||||
{
|
{
|
||||||
title: '租赁类型',
|
title: '租赁类型',
|
||||||
dataIndex: 'rentalCategory',
|
dataIndex: 'rentalCategory',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: string) => {
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'long', label: '长租' },
|
||||||
|
{ value: 'short', label: '短租' },
|
||||||
|
]}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'rentalCategory', next)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
||||||
if (v === 'short') return <Tag color="green">短租</Tag>;
|
if (v === 'short') return <Tag color="green">短租</Tag>;
|
||||||
return '-';
|
return '-';
|
||||||
},
|
})()}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '月租金',
|
title: '月租金',
|
||||||
dataIndex: 'monthlyRate',
|
dataIndex: 'monthlyRate',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: number) => (v ? `¥${v}` : '-'),
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="money"
|
||||||
|
min={0}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'monthlyRate', next)}
|
||||||
|
>
|
||||||
|
{v ? `¥${v}` : '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '额定人数',
|
||||||
|
dataIndex: 'capacity',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'capacity', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '额定人数', dataIndex: 'capacity', width: 80 },
|
|
||||||
{
|
{
|
||||||
title: '当前入住',
|
title: '当前入住',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -377,7 +494,22 @@ const RoomsPage: React.FC = () => {
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
render: (s: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={s}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '可入住' },
|
||||||
|
{ value: 'full', label: '已满' },
|
||||||
|
{ value: 'maintenance', label: '维修中' },
|
||||||
|
]}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={r.status === 'archived'}
|
||||||
|
onSave={(next) => saveRoomCell(r, 'status', next)}
|
||||||
|
>
|
||||||
|
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -778,21 +910,65 @@ const RoomsPage: React.FC = () => {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '编号', dataIndex: 'bedNumber', width: 80 },
|
{
|
||||||
|
title: '编号',
|
||||||
|
dataIndex: 'bedNumber',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string, r: BedItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveBedCell(r, 'bedNumber', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (s: string) => {
|
render: (s: string, r: BedItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={s}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '空闲' },
|
||||||
|
{ value: 'occupied', label: '占用' },
|
||||||
|
{ value: 'maintenance', label: '维修' },
|
||||||
|
]}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveBedCell(r, 'status', next)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
const map: Record<string, { text: string; color: string }> = {
|
const map: Record<string, { text: string; color: string }> = {
|
||||||
available: { text: '空闲', color: 'green' },
|
available: { text: '空闲', color: 'green' },
|
||||||
occupied: { text: '占用', color: 'blue' },
|
occupied: { text: '占用', color: 'blue' },
|
||||||
maintenance: { text: '维修', color: 'orange' },
|
maintenance: { text: '维修', color: 'orange' },
|
||||||
};
|
};
|
||||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||||
|
})()}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
render: (v: string, r: BedItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="textarea"
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveBedCell(r, 'notes', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -885,21 +1061,65 @@ const RoomsPage: React.FC = () => {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '编号', dataIndex: 'lockerNumber', width: 80 },
|
{
|
||||||
|
title: '编号',
|
||||||
|
dataIndex: 'lockerNumber',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string, r: LockerItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveLockerCell(r, 'lockerNumber', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (s: string) => {
|
render: (s: string, r: LockerItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={s}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '空闲' },
|
||||||
|
{ value: 'occupied', label: '占用' },
|
||||||
|
{ value: 'maintenance', label: '维修' },
|
||||||
|
]}
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveLockerCell(r, 'status', next)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
const map: Record<string, { text: string; color: string }> = {
|
const map: Record<string, { text: string; color: string }> = {
|
||||||
available: { text: '空闲', color: 'green' },
|
available: { text: '空闲', color: 'green' },
|
||||||
occupied: { text: '占用', color: 'blue' },
|
occupied: { text: '占用', color: 'blue' },
|
||||||
maintenance: { text: '维修', color: 'orange' },
|
maintenance: { text: '维修', color: 'orange' },
|
||||||
};
|
};
|
||||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||||
|
})()}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
render: (v: string, r: LockerItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
editor="textarea"
|
||||||
|
permission="room:edit"
|
||||||
|
disabled={drawerRoom?.status === 'archived'}
|
||||||
|
onSave={(next) => saveLockerCell(r, 'notes', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
@@ -170,7 +171,14 @@ const StudentsPage: React.FC = () => {
|
|||||||
message.error(err?.message || '加载失败,请稍后重试');
|
message.error(err?.message || '加载失败,请稍后重试');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId]);
|
}, [
|
||||||
|
searchName,
|
||||||
|
showArchived,
|
||||||
|
filterStatus,
|
||||||
|
filterOrganizationId,
|
||||||
|
filterClassId,
|
||||||
|
filterTeacherId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -213,6 +221,15 @@ const StudentsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = useCallback(
|
||||||
|
async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/students/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/students/${id}`);
|
await api.delete(`/students/${id}`);
|
||||||
@@ -376,9 +393,15 @@ const StudentsPage: React.FC = () => {
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string, record: any) => (
|
render: (v: string, record: any) => (
|
||||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'name', next)}
|
||||||
|
>
|
||||||
{v}
|
{v}
|
||||||
</Button>
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -407,7 +430,16 @@ const StudentsPage: React.FC = () => {
|
|||||||
title: '学号',
|
title: '学号',
|
||||||
dataIndex: 'studentNo',
|
dataIndex: 'studentNo',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string) => v || '-',
|
render: (v: string, record: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'studentNo', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '身份证',
|
title: '身份证',
|
||||||
@@ -431,8 +463,36 @@ const StudentsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
{
|
||||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
title: '民族',
|
||||||
|
dataIndex: 'ethnicity',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string, record: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'ethnicity', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '紧急联系人',
|
||||||
|
dataIndex: 'emergencyContact',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string, record: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'emergencyContact', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '紧急联系人电话',
|
title: '紧急联系人电话',
|
||||||
dataIndex: 'emergencyPhone',
|
dataIndex: 'emergencyPhone',
|
||||||
@@ -459,8 +519,17 @@ const StudentsPage: React.FC = () => {
|
|||||||
title: '所属机构',
|
title: '所属机构',
|
||||||
dataIndex: 'organization',
|
dataIndex: 'organization',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (organization: { name?: string } | null) =>
|
render: (organization: { name?: string } | null, record: any) => (
|
||||||
organization?.name ? (
|
<EditableCell
|
||||||
|
value={record.organizationId}
|
||||||
|
editor="select"
|
||||||
|
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
required
|
||||||
|
onSave={(next) => saveCell(record, 'organizationId', next)}
|
||||||
|
>
|
||||||
|
{organization?.name ? (
|
||||||
<Tag
|
<Tag
|
||||||
color="purple"
|
color="purple"
|
||||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
@@ -469,20 +538,49 @@ const StudentsPage: React.FC = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
)}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '负责人',
|
||||||
|
dataIndex: 'supervisor',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string, record: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'supervisor', next)}
|
||||||
|
>
|
||||||
|
{v || '-'}
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (s: string) => (
|
render: (s: string, record: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={s}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '在读' },
|
||||||
|
{ value: 'graduated', label: '已毕业' },
|
||||||
|
{ value: 'withdrawn', label: '已退训' },
|
||||||
|
]}
|
||||||
|
permission="student:edit"
|
||||||
|
disabled={record.status === 'archived'}
|
||||||
|
onSave={(next) => saveCell(record, 'status', next)}
|
||||||
|
>
|
||||||
<Tag
|
<Tag
|
||||||
color={statusMap[s]?.color}
|
color={statusMap[s]?.color}
|
||||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
>
|
>
|
||||||
{statusMap[s]?.text || s}
|
{statusMap[s]?.text || s}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -547,7 +645,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleViewSensitive, openDrawer, showArchived, organizations],
|
[handleViewSensitive, openDrawer, showArchived, organizations, saveCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { EditOutlined } from '@ant-design/icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
|
|
||||||
interface TeacherRow {
|
interface TeacherRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -101,6 +102,15 @@ const TeachersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProfileCell = useCallback(
|
||||||
|
async (record: TeacherRow, field: string, value: unknown) => {
|
||||||
|
await api.put(`/rbac/teachers/${record.id}/profile`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||||||
@@ -133,14 +143,33 @@ const TeachersPage: React.FC = () => {
|
|||||||
dataIndex: 'profile',
|
dataIndex: 'profile',
|
||||||
key: 'subjects',
|
key: 'subjects',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-',
|
render: (p: TeacherRow['profile'], r: TeacherRow) => (
|
||||||
|
<EditableCell
|
||||||
|
value={p?.subjects || []}
|
||||||
|
editor="tags"
|
||||||
|
options={(p?.subjects || []).map((value) => ({ value, label: value }))}
|
||||||
|
permission="teacher:edit"
|
||||||
|
onSave={(next) => saveProfileCell(r, 'subjects', next)}
|
||||||
|
>
|
||||||
|
{p?.subjects?.join('、') || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '入职日期',
|
title: '入职日期',
|
||||||
dataIndex: 'profile',
|
dataIndex: 'profile',
|
||||||
key: 'joinedAt',
|
key: 'joinedAt',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (p: TeacherRow['profile']) => p?.joinedAt || '-',
|
render: (p: TeacherRow['profile'], r: TeacherRow) => (
|
||||||
|
<EditableCell
|
||||||
|
value={p?.joinedAt}
|
||||||
|
editor="date"
|
||||||
|
permission="teacher:edit"
|
||||||
|
onSave={(next) => saveProfileCell(r, 'joinedAt', next)}
|
||||||
|
>
|
||||||
|
{p?.joinedAt || '-'}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -178,7 +207,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[saveProfileCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form';
|
import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form';
|
||||||
|
|
||||||
@@ -153,17 +154,64 @@ const UsersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCell = useCallback(
|
||||||
|
async (record: any, field: string, value: unknown) => {
|
||||||
|
await api.put(`/rbac/users/${record.id}`, { [field]: value });
|
||||||
|
message.success('已保存');
|
||||||
|
await fetchData();
|
||||||
|
},
|
||||||
|
[fetchData],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
{
|
||||||
{ title: '姓名', dataIndex: 'name', width: 120 },
|
title: '用户名',
|
||||||
|
dataIndex: 'username',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="user:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'username', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={v}
|
||||||
|
required
|
||||||
|
permission="user:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'name', next)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'roles',
|
dataIndex: 'roles',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (v: any[]) =>
|
render: (v: any[], record: any) => (
|
||||||
v && v.length > 0 ? (
|
<EditableCell
|
||||||
|
value={v?.map((item) => item.id) || []}
|
||||||
|
editor="multi-select"
|
||||||
|
options={roles.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
|
permission="user:edit"
|
||||||
|
disabled={record.isArchived}
|
||||||
|
onSave={(next) => saveCell(record, 'roleIds', next)}
|
||||||
|
>
|
||||||
|
{v && v.length > 0 ? (
|
||||||
v.map((r: any) => (
|
v.map((r: any) => (
|
||||||
<Tag key={r.id} color="blue">
|
<Tag key={r.id} color="blue">
|
||||||
{r.name}
|
{r.name}
|
||||||
@@ -171,13 +219,29 @@ const UsersPage: React.FC = () => {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Tag color="default">无角色</Tag>
|
<Tag color="default">无角色</Tag>
|
||||||
|
)}
|
||||||
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'isActive',
|
dataIndex: 'isActive',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
render: (v: boolean, r: any) => (
|
||||||
|
<EditableCell
|
||||||
|
value={String(v)}
|
||||||
|
editor="select"
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: '启用' },
|
||||||
|
{ value: 'false', label: '禁用' },
|
||||||
|
]}
|
||||||
|
permission="user:edit"
|
||||||
|
disabled={r.isArchived}
|
||||||
|
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
|
||||||
|
>
|
||||||
|
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最后登录',
|
title: '最后登录',
|
||||||
@@ -244,7 +308,7 @@ const UsersPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[roles, saveCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class BillsExportService {
|
|||||||
const bills = await qb.getMany();
|
const bills = await qb.getMany();
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = '恭学教育基地管理系统';
|
workbook.creator = '学生管理系统';
|
||||||
|
|
||||||
// Sheet 1: 账单汇总
|
// Sheet 1: 账单汇总
|
||||||
const ws = workbook.addWorksheet('账单汇总');
|
const ws = workbook.addWorksheet('账单汇总');
|
||||||
@@ -244,7 +244,7 @@ export class BillsExportService {
|
|||||||
doc
|
doc
|
||||||
.fontSize(8)
|
.fontSize(8)
|
||||||
.fillColor('#999')
|
.fillColor('#999')
|
||||||
.text('本账单由恭学教育基地管理系统自动生成', { align: 'center' });
|
.text('本账单由学生管理系统自动生成', { align: 'center' });
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ config();
|
|||||||
const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql';
|
const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql';
|
||||||
|
|
||||||
export async function runMigrationsOnStartup(): Promise<void> {
|
export async function runMigrationsOnStartup(): Promise<void> {
|
||||||
|
// 该迁移由 MySQL 生成;SQLite 开发环境由 AppModule 中的 TypeORM synchronize 建表。
|
||||||
|
if (!isMySQL) return;
|
||||||
|
|
||||||
const ds = new DataSource({
|
const ds = new DataSource({
|
||||||
type: isMySQL ? 'mysql' : 'better-sqlite3',
|
type: 'mysql',
|
||||||
host: isMySQL ? (process.env.DB_HOST || 'localhost') : undefined,
|
host: process.env.DB_HOST || 'localhost',
|
||||||
port: isMySQL ? Number(process.env.DB_PORT || 3306) : undefined,
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
username: isMySQL ? (process.env.DB_USERNAME || 'root') : undefined,
|
username: process.env.DB_USERNAME || 'root',
|
||||||
password: isMySQL ? (process.env.DB_PASSWORD || '') : undefined,
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_DATABASE || (isMySQL ? 'dorm_billing' : 'dorm_billing.db'),
|
database: process.env.DB_DATABASE || 'dorm_billing',
|
||||||
charset: isMySQL ? 'utf8mb4' : undefined,
|
charset: 'utf8mb4',
|
||||||
migrations: [InitialSchema1784520727860],
|
migrations: [InitialSchema1784520727860],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -26,6 +26,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"antd": "^6.3.6",
|
"antd": "^6.3.6",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
@@ -1089,6 +1092,59 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user