From d306de48e423615953260cc50d669d12dbebb8dd Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 09:57:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20SQLite=20=E5=BC=80=E5=8F=91=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/migration-runner.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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], }); -- 2.49.1 From 5e1ba70e59a388ce0c58194c5c601e764faf36ae Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 09:59:17 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=A4=9A=E9=A1=B9=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E5=B1=95=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/layouts/MainLayout.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 6590d38..71448dc 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -160,14 +160,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[] => { -- 2.49.1 From 29c55ebff1d1f61bec50254013147d9374cc5392 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 10:13:47 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E7=9A=84=E8=B7=AF=E7=94=B1=E5=81=9C=E9=9D=A0?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 3 + apps/admin/src/components/RouteDock/index.tsx | 196 ++++++++++++++++++ apps/admin/src/index.css | 92 ++++++++ apps/admin/src/layouts/MainLayout.tsx | 7 + package-lock.json | 56 +++++ 5 files changed, 354 insertions(+) create mode 100644 apps/admin/src/components/RouteDock/index.tsx 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/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/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 71448dc..3247032 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; @@ -287,6 +288,12 @@ const MainLayout: React.FC = () => { + =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", -- 2.49.1 From a9961d1a996ebb6ca0f435f632d32687702595f4 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 10:32:21 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=90=8D=E7=A7=B0=E4=B8=BA=E5=AD=A6=E7=94=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F=EF=BC=8C=E4=BF=AE=E6=94=B9=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E9=A1=B5=E9=9D=A2=E6=A0=87=E9=A2=98=E5=92=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=9A=84=E8=B4=A6=E5=8D=95=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/index.html | 2 +- apps/admin/src/layouts/MainLayout.tsx | 4 ++-- apps/admin/src/pages/Bills/bill-print.ts | 2 +- apps/admin/src/pages/Login/index.tsx | 4 ++-- apps/server/src/bills/bills-export.service.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) 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/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 3247032..54c5e33 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -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} 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/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index a3dd20a..4c3b365 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -56,9 +56,9 @@ const LoginPage: React.FC = () => { >
- 恭学教育基地管理系统 + 学生管理系统 -

水电费精准计费平台

+

学生综合管理平台

Date: Tue, 21 Jul 2026 10:43:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=E8=B0=83=E6=95=B4=E7=99=BB=E9=99=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=BE=93=E5=85=A5=E6=A1=86=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/pages/Login/index.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index 4c3b365..1846a96 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -60,20 +60,28 @@ 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 ( -- 2.49.1