diff --git a/apps/admin/index.html b/apps/admin/index.html index e2d8673..c27aa47 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -4,7 +4,7 @@ - 恭学教育基地管理系统 + 学生管理系统
diff --git a/apps/admin/package.json b/apps/admin/package.json index c60087b..d7d7673 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,6 +14,9 @@ }, "dependencies": { "@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", "axios": "^1.15.1", "dayjs": "^1.11.20", diff --git a/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts b/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts new file mode 100644 index 0000000..d1a7099 --- /dev/null +++ b/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts @@ -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 | 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('.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(); + }); +}); diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx new file mode 100644 index 0000000..c69a132 --- /dev/null +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -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: 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; +} + +let activeCell: { id: string; save: () => Promise } | 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, + children, + editor = 'text', + options, + permission, + disabled, + required, + min, + max, + placeholder, + formatValue, + parseValue, + onSave, +}: EditableCellProps) => { + const { hasPermission } = usePermission(); + const idRef = useRef(crypto.randomUUID()); + const rootRef = useRef(null); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [draft, setDraft] = useState(() => + 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).target.value + : next, + ), + onKeyDown, + }; + + let control: React.ReactNode; + if (editor === 'select' || editor === 'multi-select' || editor === 'tags') { + control = ( + ; + } + + return ( +
void beginEdit()} + > + {editing ? ( + {control} + ) : ( + {children} + )} +
+ ); +}; + +export default EditableCell; diff --git a/apps/admin/src/components/EditableCell/style.css b/apps/admin/src/components/EditableCell/style.css new file mode 100644 index 0000000..e47adf1 --- /dev/null +++ b/apps/admin/src/components/EditableCell/style.css @@ -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; +} diff --git a/apps/admin/src/components/RouteDock/index.tsx b/apps/admin/src/components/RouteDock/index.tsx new file mode 100644 index 0000000..962f72a --- /dev/null +++ b/apps/admin/src/components/RouteDock/index.tsx @@ -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 { + '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> = ({ ...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); +}; + +const RouteDock: React.FC = ({ location, menuItems, onNavigate, draggable }) => { + const activeKey = `${location.pathname}${location.search}`; + const [tabs, setTabs] = useState(() => { + 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>( + () => + 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 = ( + + {(node) => { + if (!draggable) return node; + return ( + ).props} + key={node.key} + > + {node} + + ); + }} + + ); + + if (!draggable) return tabBar; + return ( + + tab.key)} + strategy={horizontalListSortingStrategy} + > + {tabBar} + + + ); + }; + + if (location.pathname === '/' || tabs.length === 0) return null; + + return ( + + ); +}; + +export default RouteDock; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 3877f22..b723ac6 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -36,6 +36,7 @@ import api from '../../api'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { useViewSensitive } from '../../hooks/useViewSensitive'; import { message } from '../../ui/app-message'; +import EditableCell from '../EditableCell'; // ---- Types ---- @@ -409,20 +410,131 @@ const EnrollmentsTab: React.FC = ({ } }; + const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => { + await api.put(`/archive/enrollments/${record.id}`, { [field]: value }); + message.success('报读记录已保存'); + onRefresh(); + }; + const columns: ColumnsType = [ - { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel }, - { title: '班型', dataIndex: 'classType', render: getClassTypeLabel }, - { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, - { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, - { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, - { title: '开始日期', dataIndex: 'startDate', render: (v: string) => v || '-' }, - { title: '结束日期', dataIndex: 'endDate', render: (v: string) => v || '-' }, + { + title: '课程类别', + dataIndex: 'courseCategory', + render: (v: string, r) => ( + saveCell(r, 'courseCategory', next)} + > + {getCourseCategoryLabel(v)} + + ), + }, + { + title: '班型', + dataIndex: 'classType', + render: (v: string, r) => ( + saveCell(r, 'classType', next)} + > + {getClassTypeLabel(v)} + + ), + }, + { + title: '班级名称', + dataIndex: 'className', + render: (v: string, r) => ( + saveCell(r, 'className', next)} + > + {v || '-'} + + ), + }, + { + title: '班主任', + dataIndex: 'headTeacher', + render: (v: string, r) => ( + saveCell(r, 'headTeacher', next)} + > + {v || '-'} + + ), + }, + { + title: '任课教师', + dataIndex: 'subjectTeacher', + render: (v: string, r) => ( + saveCell(r, 'subjectTeacher', next)} + > + {v || '-'} + + ), + }, + { + title: '开始日期', + dataIndex: 'startDate', + render: (v: string, r) => ( + saveCell(r, 'startDate', next)} + > + {v || '-'} + + ), + }, + { + title: '结束日期', + dataIndex: 'endDate', + render: (v: string, r) => ( + saveCell(r, 'endDate', next)} + > + {v || '-'} + + ), + }, { title: '状态', dataIndex: 'status', - render: (v: string) => { + render: (v: string, r) => { const status = getEnrollmentStatus(v); - return {status.text}; + return ( + ({ + value, + label: item.text, + }))} + permission="student:edit" + onSave={(next) => saveCell(r, 'status', next)} + > + {status.text} + + ); }, }, ]; @@ -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 = [ { title: '考试类型', dataIndex: 'examType', - render: (v: string) => EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v, + render: (v: string, r) => ( + saveCell(r, 'examType', next)} + > + {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} + + ), + }, + { + title: '考试名称', + dataIndex: 'examName', + render: (v: string, r) => ( + saveCell(r, 'examName', next)} + > + {v || '-'} + + ), + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string, r) => ( + saveCell(r, 'subject', next)} + > + {v} + + ), + }, + { + title: '成绩', + dataIndex: 'score', + render: (v: number, r) => ( + saveCell(r, 'score', next)} + > + {v} + + ), }, - { title: '考试名称', dataIndex: 'examName', render: (v: string) => v || '-' }, - { title: '科目', dataIndex: 'subject' }, - { title: '成绩', dataIndex: 'score' }, { title: '班级均分', dataIndex: 'classAvg', - render: (v: number | undefined) => (v !== undefined ? v : '-'), + render: (v: number | undefined, r) => ( + saveCell(r, 'classAvg', next)} + > + {v !== undefined ? v : '-'} + + ), }, { title: '排名', dataIndex: 'rank', - render: (v: number | undefined) => (v !== undefined ? v : '-'), + render: (v: number | undefined, r) => ( + saveCell(r, 'rank', next)} + > + {v !== undefined ? v : '-'} + + ), + }, + { + title: '考试日期', + dataIndex: 'examDate', + render: (v: string, r) => ( + saveCell(r, 'examDate', next)} + > + {v || '-'} + + ), }, - { title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' }, { title: '关联报读', dataIndex: 'enrollmentId', - render: (v: number | undefined) => { - if (v === undefined) return '-'; - const enr = enrollments.find((e) => e.id === v); - return enr ? formatEnrollmentDisplayName(enr) : String(v); - }, + render: (v: number | undefined, r) => ( + ({ + value: item.id, + label: formatEnrollmentDisplayName(item), + }))} + permission="student:edit" + onSave={(next) => saveCell(r, 'enrollmentId', next)} + > + {(() => { + if (v === undefined) return '-'; + const enr = enrollments.find((e) => e.id === v); + return enr ? formatEnrollmentDisplayName(enr) : String(v); + })()} + + ), }, ]; @@ -656,16 +871,87 @@ const LearningTab: React.FC = ({ } }; + 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 = [ - { title: '记录日期', dataIndex: 'recordDate' }, + { + title: '记录日期', + dataIndex: 'recordDate', + render: (v: string, r) => ( + saveCell(r, 'recordDate', next)} + > + {v} + + ), + }, { title: '记录类型', dataIndex: 'recordType', - render: (v: string) => RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v, + render: (v: string, r) => ( + saveCell(r, 'recordType', next)} + > + {RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} + + ), + }, + { + title: '内容', + dataIndex: 'content', + ellipsis: true, + render: (v: string, r) => ( + saveCell(r, 'content', next)} + > + {v} + + ), + }, + { + title: '跟进方式', + dataIndex: 'followUpMethod', + render: (v: string, r) => ( + saveCell(r, 'followUpMethod', next)} + > + {v || '-'} + + ), + }, + { + title: '下一步计划', + dataIndex: 'nextStep', + render: (v: string, r) => ( + saveCell(r, 'nextStep', next)} + > + {v || '-'} + + ), }, - { title: '内容', dataIndex: 'content', ellipsis: true }, - { title: '跟进方式', dataIndex: 'followUpMethod', render: (v: string) => v || '-' }, - { title: '下一步计划', dataIndex: 'nextStep', render: (v: string) => v || '-' }, ]; return ( diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index e974909..40c233e 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -59,6 +59,88 @@ canvas { 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. */ .responsive-toolbar { display: flex; @@ -135,6 +217,16 @@ canvas { line-height: 56px; } + .route-dock { + top: 56px; + height: 42px; + padding: 5px 8px; + } + + .route-dock .ant-tabs-tab { + min-width: 104px; + } + .app-header .ant-btn { width: 40px; min-height: 40px; diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 6590d38..54c5e33 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -32,6 +32,7 @@ import { usePermission } from '../hooks/usePermission'; import api from '../api'; import { writePermissions } from '../auth/permission-store'; import NotificationBell from '../components/NotificationBell'; +import RouteDock from '../components/RouteDock'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; const { Header, Sider, Content } = Layout; @@ -160,14 +161,13 @@ const MainLayout: React.FC = () => { useEffect(() => { if (location.pathname !== prevPathname.current) { prevPathname.current = location.pathname; - setOpenKeys(findOpenKeys(menuItems, location.pathname)); + const routeOpenKeys = findOpenKeys(menuItems, location.pathname); + setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]); } }, [location.pathname, menuItems]); const handleOpenChange = useCallback((keys: string[]) => { - // 只保留最新打开的一个子菜单 - const latestKey = keys[keys.length - 1]; - setOpenKeys(latestKey ? [latestKey] : []); + setOpenKeys(keys); }, []); const transformToMenuItems = (items: AppMenuItem[]): any[] => { @@ -217,7 +217,7 @@ const MainLayout: React.FC = () => { borderBottom: '1px solid #e5e5e7', }} > - {collapsed ? '恭' : '恭学教育基地'} + {collapsed ? '学' : '学生管理系统'} {menuContent} @@ -230,7 +230,7 @@ const MainLayout: React.FC = () => { size={240} styles={{ body: { padding: 0 } }} className="app-navigation-drawer" - title="恭学教育基地" + title="学生管理系统" > {menuContent} @@ -288,6 +288,12 @@ const MainLayout: React.FC = () => { + { [workspace], ); - const openAttendance = useCallback(async (schedule: TodaySchedule) => { setStudentKeyword(''); setCheckinFilter('all'); @@ -345,29 +373,25 @@ const TeacherAttendanceWorkspace: React.FC = () => { } }, []); - const updateLessonRecord = useCallback( - async (record: AttendanceRecordItem, status: string) => { - const previous = record.status; + const updateLessonRecord = useCallback(async (record: AttendanceRecordItem, status: string) => { + const previous = record.status; + setLessonRecords((items) => + items.map((item) => (item.id === record.id ? { ...item, status } : item)), + ); + try { + await api.put(`/attendance-records/${record.id}`, { status }); + } catch (error: unknown) { setLessonRecords((items) => - items.map((item) => (item.id === record.id ? { ...item, status } : item)), + items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), ); - try { - await api.put(`/attendance-records/${record.id}`, { status }); - } catch (error: unknown) { - setLessonRecords((items) => - items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), - ); - message.error((error as { message?: string })?.message || '更新考勤失败'); - } - }, - [], - ); - + message.error((error as { message?: string })?.message || '更新考勤失败'); + } + }, []); const now = new Date(); const schedules = workspace?.todaySchedules ?? []; - const startedCount = schedules.filter( - (item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)), + const startedCount = schedules.filter((item) => + canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)), ).length; const nextSchedule = schedules.find( (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', @@ -382,7 +406,9 @@ const TeacherAttendanceWorkspace: React.FC = () => {
- TEACHING DAY · {dayjs().format('MM月DD日 dddd')} + + TEACHING DAY · {dayjs().format('MM月DD日 dddd')} +

今天,从课程开始

课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。

@@ -393,18 +419,33 @@ const TeacherAttendanceWorkspace: React.FC = () => { -
今日课程{schedules.length}
+
+ 今日课程 + {schedules.length} + +
-
已开始{startedCount}节,可查看考勤
+
+ 已开始 + {startedCount} + 节,可查看考勤 +
-
下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+
+ 下一节 + {nextSchedule ? nextSchedule.startTime : '—'} + {nextSchedule?.subject || '今天没有更多课程'} +
-
今日教学节奏

我的课程

+
+ 今日教学节奏 +

我的课程

+
课程开始后可拉取钉钉考勤记录
@@ -413,7 +454,12 @@ const TeacherAttendanceWorkspace: React.FC = () => { 今天还没有课程

请联系教务管理员安排课程。

} + description={ +
+ 今天还没有课程 +

请联系教务管理员安排课程。

+
+ } /> ) : ( @@ -435,17 +481,31 @@ const TeacherAttendanceWorkspace: React.FC = () => { )} - setDrawerOpen(false)} width={960} title={null} className="attendance-drawer"> + setDrawerOpen(false)} + width={960} + title={null} + className="attendance-drawer" + >
LESSON ATTENDANCE

{selectedSchedule?.subject || '课程考勤'}

-

{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}

+

+ {selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} ·{' '} + {selectedSchedule?.startTime}–{selectedSchedule?.endTime} ·{' '} + {dayjs().format('YYYY-MM-DD')} +

{lessonSession && ( )} @@ -478,18 +538,30 @@ const TeacherAttendanceWorkspace: React.FC = () => { dataSource={filteredLessonRecords} pagination={false} locale={{ - emptyText: , + emptyText: ( + + ), }} columns={[ { - title: '学生', dataIndex: ['student', 'name'], - render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, + title: '学生', + dataIndex: ['student', 'name'], + render: (name: string) => ( +
+ {name?.slice(0, 1)} + {name || '-'} +
+ ), }, { - title: '考勤结果', dataIndex: 'status', width: 230, + title: '考勤结果', + dataIndex: 'status', + width: 230, render: (value: string, record: AttendanceRecordItem) => { const checkedIn = value === 'present' || value === 'late'; return ( @@ -513,7 +585,16 @@ const TeacherAttendanceWorkspace: React.FC = () => { ); }, }, - { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, + { + title: '当前状态', + dataIndex: 'status', + width: 105, + render: (value: string) => ( + + ), + }, { title: '打卡设备', width: 220, @@ -529,7 +610,11 @@ const TeacherAttendanceWorkspace: React.FC = () => { ); }, }, - { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, + { + title: '备注', + dataIndex: 'remark', + render: (value: string | null) => value || , + }, ]} />
@@ -553,14 +638,26 @@ const LessonCard: React.FC<{ return (
{String(index).padStart(2, '0')}
-
{schedule.startTime}{schedule.endTime}
+
+ {schedule.startTime} + + {schedule.endTime} +
-

{schedule.subject}

{phaseMeta.label}
-

{className}教室 {schedule.classroomId}

+
+

{schedule.subject}

+ {phaseMeta.label} +
+

+ {className} + 教室 {schedule.classroomId} +

{phase === 'upcoming' ? ( - + + + ) : (
); }; diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index fbfa8da..1a2eedc 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined } from '@ant-design/icons'; import api from '../api'; import PermissionButton from '../components/PermissionButton'; +import EditableCell from '../components/EditableCell'; import { message } from '../ui/app-message'; 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) => { try { await api.delete(`/attendance-devices/${id}`); @@ -130,37 +137,102 @@ const AttendanceDevicesPage: React.FC = () => { }; const columns: ColumnsType = [ - { title: '设备名称', dataIndex: 'deviceName', width: 180 }, + { + title: '设备名称', + dataIndex: 'deviceName', + width: 180, + render: (value: string, record) => ( + saveCell(record, 'deviceName', next)} + > + {value} + + ), + }, { title: 'SN 码', dataIndex: 'deviceSn', width: 220, - render: (value) => {value}, + render: (value, record) => ( + saveCell(record, 'deviceSn', next)} + > + {value} + + ), }, { title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, - render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}`, + render: (_value, record) => ( + ({ + 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}`} + + ), }, { title: '位置', dataIndex: 'location', - render: (value) => value || , + render: (value, record) => ( + saveCell(record, 'location', next)} + > + {value || } + + ), }, { title: '状态', dataIndex: 'status', width: 90, - render: (value: keyof typeof statusMeta) => ( - {statusMeta[value]?.text || value} + render: (value: keyof typeof statusMeta, record) => ( + saveCell(record, 'status', next)} + > + {statusMeta[value]?.text || value} + ), }, { title: '备注', dataIndex: 'notes', ellipsis: true, - render: (value) => value || , + render: (value, record) => ( + saveCell(record, 'notes', next)} + > + {value || } + + ), }, { title: '操作', diff --git a/apps/admin/src/pages/Bills/bill-print.ts b/apps/admin/src/pages/Bills/bill-print.ts index a4cff31..529908b 100644 --- a/apps/admin/src/pages/Bills/bill-print.ts +++ b/apps/admin/src/pages/Bills/bill-print.ts @@ -103,7 +103,7 @@ export const buildBillPrintHtml = (bill: BillPrintData) => { 费用类型说明天数总人天金额(元) ${rows || '暂无费用明细'} - + `; diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 519850f..80c65be 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -21,6 +21,7 @@ import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; // ---- 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 = useMemo( () => [ { @@ -174,33 +184,103 @@ const ClassesPage: React.FC = () => { dataIndex: 'name', width: 120, sorter: (a, b) => a.name.localeCompare(b.name), + render: (v: string, r: ClassItem) => ( + saveCell(r, 'name', next)} + > + {v} + + ), + }, + { + title: '编码', + dataIndex: 'code', + width: 140, + render: (v: string, r: ClassItem) => ( + saveCell(r, 'code', next)} + > + {v} + + ), }, - { title: '编码', dataIndex: 'code', width: 140 }, { title: '班型', dataIndex: 'classType', width: 100, - render: (v: string) => {TYPE_MAP[v] || v}, + render: (v: string, r: ClassItem) => ( + ({ value, label }))} + permission="class:edit" + disabled={r.isArchived} + onSave={(next) => saveCell(r, 'classType', next)} + > + {TYPE_MAP[v] || v} + + ), }, { title: '开班日期', dataIndex: 'startDate', width: 110, - render: (v: string | null) => v || '-', + render: (v: string | null, r: ClassItem) => ( + saveCell(r, 'startDate', next)} + > + {v || '-'} + + ), }, { title: '学员', width: 100, - render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, + render: (_: unknown, r: ClassItem) => ( + saveCell(r, 'maxStudents', next)} + >{`${r.studentCount || 0}/${r.maxStudents || '-'}`} + ), }, { title: '状态', dataIndex: 'status', width: 100, - render: (v: string) => { - const cfg = STATUS_MAP[v] || { color: 'default', text: v }; - return {cfg.text}; - }, + render: (v: string, r: ClassItem) => ( + ({ + 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 }; + return {cfg.text}; + })()} + + ), }, { title: '操作', @@ -237,7 +317,7 @@ const ClassesPage: React.FC = () => { ), }, ], - [], + [saveCell], ); return ( diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index f4314fc..5087781 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -26,6 +26,7 @@ import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; 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) => { try { await api.delete(`/classroom-rentals/${id}`); @@ -279,34 +286,95 @@ const ClassroomRentalsPage: React.FC = () => { title: '教室', width: 120, dataIndex: 'classroom', - render: (c: any) => - c ? ( - - {c.building ? `${c.building} · ` : ''} - {c.name} - - ) : ( - '-' - ), + render: (c: any, r: any) => ( + 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 ? ( + + {c.building ? `${c.building} · ` : ''} + {c.name} + + ) : ( + '-' + )} + + ), }, { title: '承租机构', width: 100, dataIndex: 'lesseeOrganization', - render: (t: any) => - t ? ( - - {t.name} - - ) : ( - '-' - ), + render: (t: any, r: any) => ( + 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 ? ( + + {t.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '开始日期', + dataIndex: 'startDate', + width: 110, + render: (v: string, r: any) => ( + saveCell(r, 'startDate', next)} + > + {v} + + ), + }, + { + title: '结束日期', + dataIndex: 'endDate', + width: 110, + render: (v: string, r: any) => ( + saveCell(r, 'endDate', next)} + > + {v} + + ), }, - { title: '开始日期', dataIndex: 'startDate', width: 110 }, - { title: '结束日期', dataIndex: 'endDate', width: 110 }, { title: '时长', width: 80, @@ -319,13 +387,35 @@ const ClassroomRentalsPage: React.FC = () => { title: '日租金', dataIndex: 'dailyRate', width: 100, - render: (v: any) => (v ? `¥${v}` : '-'), + render: (v: any, r: any) => ( + saveCell(r, 'dailyRate', next)} + > + {v ? `¥${v}` : '-'} + + ), }, { title: '总额', dataIndex: 'totalAmount', width: 100, - render: (v: any) => (v ? `¥${v}` : '-'), + render: (v: any, r: any) => ( + saveCell(r, 'totalAmount', next)} + > + {v ? `¥${v}` : '-'} + + ), }, { title: '状态', @@ -448,7 +538,7 @@ const ClassroomRentalsPage: React.FC = () => { ), }, ], - [], + [classrooms, organizations], ); return ( diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 60d25a6..cee3b67 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -23,6 +23,7 @@ import { } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; const statusMap: Record = { @@ -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) => { try { await api.delete(`/classrooms/${id}`); @@ -155,16 +162,83 @@ const ClassroomsPage: React.FC = () => { width: 120, dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name), + render: (v: string, r: any) => ( + saveCell(r, 'name', next)} + > + {v} + + ), + }, + { + title: '楼栋', + dataIndex: 'building', + width: 80, + render: (v: string, r: any) => ( + saveCell(r, 'building', next)} + > + {v || '-'} + + ), + }, + { + title: '楼层', + dataIndex: 'floor', + width: 80, + render: (v: number, r: any) => ( + saveCell(r, 'floor', next)} + > + {v ?? '-'} + + ), }, - { title: '楼栋', dataIndex: 'building', width: 80 }, - { title: '楼层', dataIndex: 'floor', width: 80 }, { title: '类型', width: 90, dataIndex: 'roomType', - render: (v: string) => {v || '-'}, + render: (v: string, r: any) => ( + ({ value, label: value }))} + permission="classroom:edit" + disabled={r.status === 'archived'} + onSave={(next) => saveCell(r, 'roomType', next)} + > + {v || '-'} + + ), + }, + { + title: '容量', + dataIndex: 'capacity', + width: 80, + render: (v: number, r: any) => ( + saveCell(r, 'capacity', next)} + > + {v ?? '-'} + + ), }, - { title: '容量', dataIndex: 'capacity', width: 80 }, { title: '状态', width: 100, @@ -175,17 +249,29 @@ const ClassroomsPage: React.FC = () => { ) => { const effectiveStatus = record.effectiveStatus || record.status; return ( - saveCell(record, 'status', next)} > - - {statusMap[effectiveStatus]?.text || effectiveStatus} - - + + + {statusMap[effectiveStatus]?.text || effectiveStatus} + + + ); }, }, diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 8c5f02c..5549800 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -10,7 +10,6 @@ import { Space, Tag, Popconfirm, - List, Card, Empty, } from 'antd'; @@ -18,6 +17,7 @@ import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant- import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; 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(`/deposits/${detailModal.id}`); + setDetailModal(refreshed); + } + await fetchData(); + }; + const handleDeleteInstallment = async (installmentId: number) => { try { await api.delete(`/deposits/installments/${installmentId}`); @@ -691,49 +705,85 @@ const DepositsPage: React.FC = () => { {detailModal.installments && detailModal.installments.length > 0 ? ( - ( - } - onClick={() => handlePayInstallment(item.id)} - > - 标记已缴 - - ), - handleDeleteInstallment(item.id)} + columns={[ + { + title: '金额', + dataIndex: 'amount', + render: (value: number) => `¥${Number(value).toFixed(2)}`, + }, + { title: '到期日', dataIndex: 'dueDate' }, + { + title: '实付日', + dataIndex: 'paidDate', + render: (value: string, item: any) => ( + saveInstallmentCell(item.id, 'paidDate', next)} > - } + {value || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + render: (value: string, item: any) => ( + saveInstallmentCell(item.id, 'status', next)} + > + + {installmentStatusMap[value]?.text || value} + + + ), + }, + { + title: '操作', + render: (_: unknown, item: any) => ( + + {item.status === 'pending' && ( + } + onClick={() => handlePayInstallment(item.id)} + > + 标记已缴 + + )} + handleDeleteInstallment(item.id)} > - 归档 - - , - ].filter(Boolean)} - > - - - {installmentStatusMap[item.status]?.text || item.status} - - - )} + } + > + 归档 + + + + ), + }, + ]} /> ) : (

暂无分期记录

diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index d0f923b..613320f 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -26,6 +26,7 @@ import { import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { downloadBlob } from '../../utils/download'; 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 values = await utilityForm.validateFields(); setSaving(true); @@ -257,25 +276,87 @@ const ExpensesPage: React.FC = () => { const roomColumns = useMemo( () => [ - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, + { + title: '宿舍', + width: 120, + render: (_: any, r: any) => ( + ({ value: item.id, label: item.roomNumber }))} + permission="expense:edit" + required + onSave={(next) => saveRoomCell(r, 'roomId', next)} + > + {r.room?.roomNumber || '-'} + + ), + }, { title: '费用类型', width: 100, dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, + render: (v: string, r: any) => ( + saveRoomCell(r, 'expenseType', next)} + > + {typeMap[v] || v} + + ), }, { title: '金额', dataIndex: 'amount', width: 100, - render: (v: number) => `¥${Number(v).toFixed(2)}`, + render: (v: number, r: any) => ( + saveRoomCell(r, 'amount', next)} + >{`¥${Number(v).toFixed(2)}`} + ), }, { title: '账单周期', width: 200, - render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, + render: (_: any, r: any) => ( + { + 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}`} + ), + }, + { + title: '说明', + dataIndex: 'description', + width: 150, + render: (v: string, r: any) => ( + saveRoomCell(r, 'description', next)} + > + {v || '-'} + + ), }, - { title: '说明', dataIndex: 'description', width: 150 }, { title: '录入时间', width: 160, @@ -326,21 +407,89 @@ const ExpensesPage: React.FC = () => { ), }, ], - [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap], + [rooms, typeOptions, typeMap, saveRoomCell, roomForm, fetchData], ); const personalColumns = useMemo( () => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { + title: '学生', + width: 120, + render: (_: any, r: any) => ( + ({ value: item.id, label: item.name }))} + permission="expense:edit" + required + onSave={(next) => savePersonalCell(r, 'studentId', next)} + > + {r.student?.name || '-'} + + ), + }, { title: '费用类型', width: 100, dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, + render: (v: string, r: any) => ( + savePersonalCell(r, 'expenseType', next)} + > + {typeMap[v] || v} + + ), + }, + { + title: '金额', + dataIndex: 'amount', + render: (v: number, r: any) => ( + savePersonalCell(r, 'amount', next)} + >{`¥${Number(v).toFixed(2)}`} + ), + }, + { + title: '日期', + dataIndex: 'expenseDate', + width: 110, + render: (v: string, r: any) => ( + savePersonalCell(r, 'expenseDate', next)} + > + {v} + + ), + }, + { + title: '说明', + dataIndex: 'description', + width: 150, + render: (v: string, r: any) => ( + savePersonalCell(r, 'description', next)} + > + {v || '-'} + + ), }, - { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '日期', dataIndex: 'expenseDate', width: 110 }, - { title: '说明', dataIndex: 'description', width: 150 }, { title: '操作', width: 120, @@ -386,7 +535,7 @@ const ExpensesPage: React.FC = () => { ), }, ], - [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap], + [students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData], ); return ( diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index a3dd20a..1846a96 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -56,24 +56,32 @@ const LoginPage: React.FC = () => { >
- 恭学教育基地管理系统 + 学生管理系统 -

水电费精准计费平台

+

学生综合管理平台

-
+ - } placeholder="用户名" /> + } + placeholder="用户名" + autoComplete="username" + /> - } placeholder="密码" /> + } + placeholder="密码" + autoComplete="current-password" + /> + ), }, { @@ -407,7 +430,16 @@ const StudentsPage: React.FC = () => { title: '学号', dataIndex: 'studentNo', width: 120, - render: (v: string) => v || '-', + render: (v: string, record: any) => ( + saveCell(record, 'studentNo', next)} + > + {v || '-'} + + ), }, { 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) => ( + saveCell(record, 'ethnicity', next)} + > + {v || '-'} + + ), + }, + { + title: '紧急联系人', + dataIndex: 'emergencyContact', + width: 100, + render: (v: string, record: any) => ( + saveCell(record, 'emergencyContact', next)} + > + {v || '-'} + + ), + }, { title: '紧急联系人电话', dataIndex: 'emergencyPhone', @@ -459,30 +519,68 @@ const StudentsPage: React.FC = () => { title: '所属机构', dataIndex: 'organization', width: 100, - render: (organization: { name?: string } | null) => - organization?.name ? ( - - {organization.name} - - ) : ( - '-' - ), + render: (organization: { name?: string } | null, record: any) => ( + ({ value: item.id, label: item.name }))} + permission="student:edit" + disabled={record.status === 'archived'} + required + onSave={(next) => saveCell(record, 'organizationId', next)} + > + {organization?.name ? ( + + {organization.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '负责人', + dataIndex: 'supervisor', + width: 100, + render: (v: string, record: any) => ( + saveCell(record, 'supervisor', next)} + > + {v || '-'} + + ), }, - { title: '负责人', dataIndex: 'supervisor', width: 100 }, { title: '状态', dataIndex: 'status', width: 80, - render: (s: string) => ( - ( + saveCell(record, 'status', next)} > - {statusMap[s]?.text || s} - + + {statusMap[s]?.text || s} + + ), }, { @@ -547,7 +645,7 @@ const StudentsPage: React.FC = () => { ), }, ], - [handleViewSensitive, openDrawer, showArchived, organizations], + [handleViewSensitive, openDrawer, showArchived, organizations, saveCell], ); return ( diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index c7cb9cd..55aa89b 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -4,6 +4,7 @@ import { EditOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import EditableCell from '../../components/EditableCell'; interface TeacherRow { 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( () => [ { title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, @@ -133,14 +143,33 @@ const TeachersPage: React.FC = () => { dataIndex: 'profile', key: 'subjects', width: 130, - render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', + render: (p: TeacherRow['profile'], r: TeacherRow) => ( + ({ value, label: value }))} + permission="teacher:edit" + onSave={(next) => saveProfileCell(r, 'subjects', next)} + > + {p?.subjects?.join('、') || '-'} + + ), }, { title: '入职日期', dataIndex: 'profile', key: 'joinedAt', width: 110, - render: (p: TeacherRow['profile']) => p?.joinedAt || '-', + render: (p: TeacherRow['profile'], r: TeacherRow) => ( + saveProfileCell(r, 'joinedAt', next)} + > + {p?.joinedAt || '-'} + + ), }, { title: '状态', @@ -178,7 +207,7 @@ const TeachersPage: React.FC = () => { ), }, ], - [], + [saveProfileCell], ); return ( diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index a994737..d633aac 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -10,6 +10,7 @@ import { import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form'; @@ -153,31 +154,94 @@ 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( () => [ { 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) => ( + saveCell(r, 'username', next)} + > + {v} + + ), + }, + { + title: '姓名', + dataIndex: 'name', + width: 120, + render: (v: string, r: any) => ( + saveCell(r, 'name', next)} + > + {v} + + ), + }, { title: '角色', dataIndex: 'roles', width: 200, - render: (v: any[]) => - v && v.length > 0 ? ( - v.map((r: any) => ( - - {r.name} - - )) - ) : ( - 无角色 - ), + render: (v: any[], record: any) => ( + 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) => ( + + {r.name} + + )) + ) : ( + 无角色 + )} + + ), }, { title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '禁用'}, + render: (v: boolean, r: any) => ( + saveCell(r, 'isActive', String(next) === 'true')} + > + {v ? '启用' : '禁用'} + + ), }, { title: '最后登录', @@ -244,7 +308,7 @@ const UsersPage: React.FC = () => { ), }, ], - [], + [roles, saveCell], ); return ( diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index 258c5df..ba01a07 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -35,7 +35,7 @@ export class BillsExportService { const bills = await qb.getMany(); const workbook = new ExcelJS.Workbook(); - workbook.creator = '恭学教育基地管理系统'; + workbook.creator = '学生管理系统'; // Sheet 1: 账单汇总 const ws = workbook.addWorksheet('账单汇总'); @@ -244,7 +244,7 @@ export class BillsExportService { doc .fontSize(8) .fillColor('#999') - .text('本账单由恭学教育基地管理系统自动生成', { align: 'center' }); + .text('本账单由学生管理系统自动生成', { align: 'center' }); doc.end(); } diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index b37c94c..e0ff8d7 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -7,14 +7,17 @@ config(); const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql'; export async function runMigrationsOnStartup(): Promise { + // 该迁移由 MySQL 生成;SQLite 开发环境由 AppModule 中的 TypeORM synchronize 建表。 + if (!isMySQL) return; + const ds = new DataSource({ - type: isMySQL ? 'mysql' : 'better-sqlite3', - host: isMySQL ? (process.env.DB_HOST || 'localhost') : undefined, - port: isMySQL ? Number(process.env.DB_PORT || 3306) : undefined, - username: isMySQL ? (process.env.DB_USERNAME || 'root') : undefined, - password: isMySQL ? (process.env.DB_PASSWORD || '') : undefined, - database: process.env.DB_DATABASE || (isMySQL ? 'dorm_billing' : 'dorm_billing.db'), - charset: isMySQL ? 'utf8mb4' : undefined, + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 3306), + username: process.env.DB_USERNAME || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_DATABASE || 'dorm_billing', + charset: 'utf8mb4', migrations: [InitialSchema1784520727860], }); diff --git a/package-lock.json b/package-lock.json index 08d36ee..fd2db69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,9 @@ "version": "0.0.0", "dependencies": { "@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", "axios": "^1.15.1", "dayjs": "^1.11.20", @@ -1089,6 +1092,59 @@ "@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": { "version": "1.10.0", "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",