@oj-module/cli 0.1.21 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +17 -3
  3. package/devkit/manual.md +77 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
  6. package/shell-dist/assets/runtime.js +226 -89
  7. package/shell-dist/index.html +3 -3
  8. package/shell-dist/versions.json +3 -3
  9. package/src/build.ts +25 -0
  10. package/src/init.ts +3 -0
  11. package/templates/README.md +15 -0
  12. package/templates/api/config.yaml +14 -0
  13. package/templates/api/seed.mysql.sql +5 -0
  14. package/templates/api/seed.pg.sql +5 -0
  15. package/templates/api/src/_platform/_shared/respond.ts +18 -0
  16. package/templates/api/src/_platform/_shared/service/access.ts +99 -0
  17. package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
  18. package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
  19. package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
  20. package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
  21. package/templates/api/src/_platform/_shared/service/query.ts +53 -0
  22. package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
  23. package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
  24. package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
  25. package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
  26. package/templates/api/src/_platform/_shared/service/users.ts +163 -0
  27. package/templates/api/src/_platform/contacts/api.ts +22 -0
  28. package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
  29. package/templates/api/src/_platform/manifest.yaml +9 -1
  30. package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
  31. package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
  32. package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
  33. package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
  34. package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
  35. package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
  36. package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
  37. package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
  38. package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
  39. package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
  40. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
  41. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
  42. package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
  43. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
  44. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
  45. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
  46. package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
  47. package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
  48. package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
  49. package/templates/api/src/_platform/schema.yaml +76 -1
  50. package/templates/api/src/_platform/seed.mysql.sql +8 -0
  51. package/templates/api/src/_platform/seed.pg.sql +8 -0
  52. package/templates/api/src/_platform/seed.sql +19 -2
  53. package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
  54. package/templates/api/src/auth/_shared/ldap.ts +14 -0
  55. package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
  56. package/templates/api/src/auth/_shared/session.ts +163 -18
  57. package/templates/api/src/auth/api.ts +54 -44
  58. package/templates/api/src/auth/ldap-login/api.ts +29 -0
  59. package/templates/api/src/system/login-methods/api.ts +19 -0
  60. package/templates/api/src/system/manifest.yaml +5 -0
  61. package/templates/api/src/system/oplog/api.ts +53 -0
  62. package/templates/api/src/system/permissions/api.ts +26 -0
  63. package/templates/api/src/system/roles/api.ts +75 -0
  64. package/templates/api/src/system/roles/bindings/api.ts +29 -0
  65. package/templates/api/src/system/settings/api.ts +60 -0
  66. package/templates/api/src/system/users/api.ts +58 -0
  67. package/templates/api/src/system/users/forbidden/api.ts +26 -0
  68. package/templates/api/src/system/users/password/api.ts +26 -0
  69. package/templates/api/src/system/users/roles/api.ts +27 -0
  70. package/templates/api/src/web/user-info/api.ts +9 -18
  71. package/templates/api/tests/auth-ldap.spec.ts +173 -0
  72. package/templates/api/tests/auth-session.spec.ts +194 -0
  73. package/templates/api/tests/auth-throttle.spec.ts +74 -0
  74. package/templates/api/tests/fixture.ts +50 -0
  75. package/templates/api/tests/guard.spec.ts +62 -0
  76. package/templates/api/tests/query-utils.spec.ts +68 -0
  77. package/templates/api/tests/rbac-audit.spec.ts +105 -0
  78. package/templates/api/tests/rbac-contacts.spec.ts +85 -0
  79. package/templates/api/tests/rbac-guard.spec.ts +192 -0
  80. package/templates/api/tests/rbac-roles.spec.ts +159 -0
  81. package/templates/api/tests/rbac-seed.spec.ts +54 -0
  82. package/templates/api/tests/rbac-settings.spec.ts +79 -0
  83. package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
  84. package/templates/api/tests/rbac-tables.spec.ts +63 -0
  85. package/templates/api/tests/rbac-users.spec.ts +164 -0
  86. package/templates/api/vitest.config.ts +12 -0
  87. package/templates/openapi.json +1060 -31
  88. package/templates/pnpm-workspace.yaml +9 -0
  89. package/templates/web/src/auth/__client/api.schemas.ts +6 -1
  90. package/templates/web/src/auth/__client/api.ts +33 -1
  91. package/templates/web/src/login/entry.ts +17 -0
  92. package/templates/web/src/login/locales/en-US.json +12 -2
  93. package/templates/web/src/login/locales/zh-CN.json +12 -2
  94. package/templates/web/src/login/pages/change-password.tsx +74 -0
  95. package/templates/web/src/system/entry.ts +90 -0
  96. package/templates/web/src/system/locales/en-US.json +98 -0
  97. package/templates/web/src/system/locales/zh-CN.json +98 -0
  98. package/templates/web/src/system/pages/errors.ts +51 -0
  99. package/templates/web/src/system/pages/forbidden.tsx +35 -0
  100. package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
  101. package/templates/web/src/system/pages/oplog/index.tsx +91 -0
  102. package/templates/web/src/system/pages/roles/index.tsx +91 -0
  103. package/templates/web/src/system/pages/settings/index.tsx +106 -0
  104. package/templates/web/src/system/pages/users/index.tsx +168 -0
  105. package/templates/web/src/system/pages/users/roles.tsx +68 -0
  106. package/templates/web.config.ts +8 -0
  107. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,91 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { Alert, Button, Form, Input, InputNumber, Space, Table, Tooltip } from "antd";
3
+ import dayjs from "dayjs";
4
+ import { useState } from "react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { systemOplogGet } from "../../__client/api";
8
+ import { isForbidden } from "../errors";
9
+ import { ForbiddenPanel } from "../forbidden";
10
+
11
+ interface OplogFilters {
12
+ action?: string
13
+ username?: string
14
+ module?: string
15
+ limit?: number
16
+ }
17
+
18
+ /**
19
+ * 操作日志(AC1-29/D13):action 精确 / username 前缀 / module 精确,limit 钳 200。
20
+ * truncated=true 表示结果被截断(服务端 paged),顶部提示缩小过滤范围。
21
+ */
22
+ export default function OplogPage() {
23
+ const { t } = useTranslation();
24
+ const [filters, setFilters] = useState<OplogFilters>({});
25
+
26
+ const query = useQuery({
27
+ queryKey: ["system", "oplog", filters],
28
+ queryFn: () => systemOplogGet(filters),
29
+ });
30
+
31
+ if (query.error && isForbidden(query.error))
32
+ return <ForbiddenPanel t={t} error={query.error} />;
33
+
34
+ const columns = [
35
+ { title: t("system:oplog.columns.time"), dataIndex: "create_time", render: (ms: number) => dayjs(ms).format("YYYY-MM-DD HH:mm:ss") },
36
+ { title: t("system:oplog.columns.username"), dataIndex: "username" },
37
+ { title: t("system:oplog.columns.action"), dataIndex: "action" },
38
+ { title: t("system:oplog.columns.module"), dataIndex: "module" },
39
+ { title: t("system:oplog.columns.target"), dataIndex: "target" },
40
+ {
41
+ title: t("system:oplog.columns.detail"),
42
+ dataIndex: "detail",
43
+ ellipsis: { showTitle: false },
44
+ render: (detail: string | null) => (detail ? <Tooltip title={detail}>{detail}</Tooltip> : null),
45
+ },
46
+ { title: t("system:oplog.columns.level"), dataIndex: "level" },
47
+ ];
48
+
49
+ return (
50
+ <Space direction="vertical" style={{ width: "100%" }}>
51
+ <Form
52
+ layout="inline"
53
+ onFinish={values => setFilters({
54
+ action: values.action || undefined,
55
+ username: values.username || undefined,
56
+ module: values.module || undefined,
57
+ limit: values.limit || undefined,
58
+ })}
59
+ >
60
+ <Form.Item name="action" label={t("system:oplog.filterAction")}>
61
+ <Input allowClear />
62
+ </Form.Item>
63
+ <Form.Item name="username" label={t("system:oplog.filterUsername")}>
64
+ <Input allowClear />
65
+ </Form.Item>
66
+ <Form.Item name="module" label={t("system:oplog.filterModule")}>
67
+ <Input allowClear />
68
+ </Form.Item>
69
+ <Form.Item name="limit" label={t("system:oplog.filterLimit")} initialValue={50}>
70
+ <InputNumber min={1} max={200} />
71
+ </Form.Item>
72
+ <Form.Item>
73
+ <Space>
74
+ <Button type="primary" htmlType="submit">{t("system:common.search")}</Button>
75
+ <Button onClick={() => { setFilters({}); }}>
76
+ {t("system:common.reset")}
77
+ </Button>
78
+ </Space>
79
+ </Form.Item>
80
+ </Form>
81
+ {query.data?.truncated ? <Alert type="warning" showIcon message={t("system:oplog.truncated")} /> : null}
82
+ <Table
83
+ rowKey="id"
84
+ loading={query.isLoading}
85
+ dataSource={query.data?.list ?? []}
86
+ columns={columns}
87
+ pagination={{ hideOnSinglePage: true, pageSize: 50 }}
88
+ />
89
+ </Space>
90
+ );
91
+ }
@@ -0,0 +1,91 @@
1
+ import type { SystemPermissionsGetData, SystemRolesGetData } from "../../__client/api";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { Alert, Space, Table, Tabs, Tag } from "antd";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { systemPermissionsGet, systemRolesGet } from "../../__client/api";
8
+ import { isForbidden } from "../errors";
9
+ import { ForbiddenPanel } from "../forbidden";
10
+
11
+ type RoleRow = SystemRolesGetData[number];
12
+ type PermRow = SystemPermissionsGetData[number];
13
+
14
+ /**
15
+ * 角色/权限(C2,只读视图):写出口唯一为 upsertSolutionPerms(解决方案工具链),
16
+ * 页面不暴露建/删角色与绑定编辑(后端有 POST/DELETE,规格决定不开放 UI)。
17
+ */
18
+ export default function RolesPage() {
19
+ const { t } = useTranslation();
20
+ const rolesQuery = useQuery({ queryKey: ["system", "roles"], queryFn: systemRolesGet });
21
+ const permsQuery = useQuery({ queryKey: ["system", "permissions"], queryFn: systemPermissionsGet });
22
+
23
+ if ((rolesQuery.error && isForbidden(rolesQuery.error)) || (permsQuery.error && isForbidden(permsQuery.error)))
24
+ return <ForbiddenPanel t={t} error={rolesQuery.error ?? permsQuery.error} />;
25
+
26
+ const roleColumns = [
27
+ {
28
+ title: t("system:rolesPage.tabRoles"),
29
+ dataIndex: "name",
30
+ render: (name: string, row: RoleRow) => (
31
+ <Space>
32
+ <span>{name}</span>
33
+ {row.builtin ? <Tag>{t("system:rolesPage.builtin")}</Tag> : null}
34
+ {row.deprecated ? <Tag color="default">{t("system:rolesPage.deprecated")}</Tag> : null}
35
+ </Space>
36
+ ),
37
+ },
38
+ { title: "module", dataIndex: "module" },
39
+ { title: "title", dataIndex: "title" },
40
+ { title: t("system:rolesPage.holders"), dataIndex: "holders" },
41
+ {
42
+ title: t("system:rolesPage.permissions"),
43
+ dataIndex: "permissions",
44
+ render: (perms: string[]) => perms.map(p => <Tag key={p}>{p}</Tag>),
45
+ },
46
+ ];
47
+
48
+ const permColumns = [
49
+ {
50
+ title: "code",
51
+ dataIndex: "code",
52
+ render: (code: string, row: PermRow) => (row.deprecated ? <Tag color="default">{code}</Tag> : <span>{code}</span>),
53
+ },
54
+ { title: "module", dataIndex: "module" },
55
+ {
56
+ // title_key 是跨模块 namespace(如 wiki:perm.*),本模块 locale 不命中回退 code(R6)
57
+ title: t("system:rolesPage.titleKey"),
58
+ dataIndex: "title_key",
59
+ render: (key: string | null, row: PermRow) => (key ? t(key, { defaultValue: row.code }) : row.code),
60
+ },
61
+ {
62
+ title: t("system:rolesPage.deprecated"),
63
+ dataIndex: "deprecated",
64
+ render: (dep: boolean) => (dep ? <Tag color="default">{t("system:rolesPage.deprecated")}</Tag> : null),
65
+ },
66
+ ];
67
+
68
+ return (
69
+ <Tabs
70
+ items={[
71
+ {
72
+ key: "roles",
73
+ label: t("system:rolesPage.tabRoles"),
74
+ children: (
75
+ <>
76
+ <Alert style={{ marginBottom: 12 }} type="info" showIcon message={t("system:rolesPage.readonlyHint")} />
77
+ <Table rowKey="name" loading={rolesQuery.isLoading} dataSource={rolesQuery.data ?? []} columns={roleColumns} pagination={{ hideOnSinglePage: true }} />
78
+ </>
79
+ ),
80
+ },
81
+ {
82
+ key: "perms",
83
+ label: t("system:rolesPage.tabPerms"),
84
+ children: (
85
+ <Table rowKey="code" loading={permsQuery.isLoading} dataSource={permsQuery.data ?? []} columns={permColumns} pagination={{ hideOnSinglePage: true }} />
86
+ ),
87
+ },
88
+ ]}
89
+ />
90
+ );
91
+ }
@@ -0,0 +1,106 @@
1
+ import type { TFunction } from "i18next";
2
+
3
+ import type { SystemSettingsGetData } from "../../__client/api";
4
+
5
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
+ import { App, Button, Card, Input, Space, Switch, Tooltip } from "antd";
7
+ import { useState } from "react";
8
+ import { useTranslation } from "react-i18next";
9
+
10
+ import { systemSettingsGet, systemSettingsPut } from "../../__client/api";
11
+ import { explainError, isForbidden } from "../errors";
12
+ import { ForbiddenPanel } from "../forbidden";
13
+
14
+ type SettingItem = SystemSettingsGetData[number]["items"][number];
15
+
16
+ /** 只动框架键(规格 §3):按 module 字段过滤,业务自定义键(如 wiki 的 mw_config)与 market.*(security 组,super-only 写)不进入 */
17
+ const FRAMEWORK_MODULES = new Set(["framework", "ldap", "sso"]);
18
+
19
+ /** 单项行:bool → Switch("1"/"0");secret → 只读 ***;其余 Input 失焦/回车保存 */
20
+ function SettingRow({ item, t, onSave }: { item: SettingItem, t: TFunction, onSave: (key: string, value: string) => void }) {
21
+ const [draft, setDraft] = useState<string | null>(null);
22
+
23
+ const label = item.label_key ? t(item.label_key, { defaultValue: item.key }) : item.key;
24
+
25
+ const save = (value: string) => {
26
+ onSave(item.key, value);
27
+ setDraft(null);
28
+ };
29
+
30
+ if (item.secret) {
31
+ return (
32
+ <Space>
33
+ <Tooltip title={t("system:settingsPage.secretHint")}>
34
+ <Input style={{ width: 320 }} value="***" disabled />
35
+ </Tooltip>
36
+ <span>{label}</span>
37
+ </Space>
38
+ );
39
+ }
40
+
41
+ if (item.type === "bool") {
42
+ return (
43
+ <Space>
44
+ <Switch checked={item.value === "1"} onChange={checked => save(checked ? "1" : "0")} />
45
+ <span>{label}</span>
46
+ </Space>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <Space>
52
+ <Input
53
+ style={{ width: 320 }}
54
+ value={draft ?? item.value ?? ""}
55
+ onChange={e => setDraft(e.target.value)}
56
+ onPressEnter={() => draft !== null && save(draft)}
57
+ />
58
+ <Button type="primary" size="small" disabled={draft === null || draft === item.value} onClick={() => draft !== null && save(draft)}>
59
+ {t("system:common.save")}
60
+ </Button>
61
+ <span>{label}</span>
62
+ </Space>
63
+ );
64
+ }
65
+
66
+ /**
67
+ * 站点设置(PRD §4.3):GET 按组列(secret 读取面掩码 ***),逐项 PUT 写值。
68
+ * secret 项不可在线改(后端 value 掩码,写回 "***" 会被后端原样入库——禁编辑即防线)。
69
+ */
70
+ export default function SettingsPage() {
71
+ const { t } = useTranslation();
72
+ const { message } = App.useApp();
73
+ const queryClient = useQueryClient();
74
+
75
+ const query = useQuery({ queryKey: ["system", "settings"], queryFn: systemSettingsGet });
76
+
77
+ const putMut = useMutation({
78
+ mutationFn: (body: { key: string, value: string }) => systemSettingsPut(body),
79
+ onSuccess: () => {
80
+ message.success(t("system:settingsPage.saved"));
81
+ queryClient.invalidateQueries({ queryKey: ["system", "settings"] });
82
+ },
83
+ onError: e => message.error(explainError(e, t)),
84
+ });
85
+
86
+ if (query.error && isForbidden(query.error))
87
+ return <ForbiddenPanel t={t} error={query.error} />;
88
+
89
+ const groups = (query.data ?? [])
90
+ .map(g => ({ ...g, items: g.items.filter(i => FRAMEWORK_MODULES.has(i.module)) }))
91
+ .filter(g => g.items.length > 0);
92
+
93
+ return (
94
+ <Space direction="vertical" style={{ width: "100%" }}>
95
+ {groups.map(g => (
96
+ <Card key={g.group} title={g.group}>
97
+ <Space direction="vertical" style={{ width: "100%" }}>
98
+ {g.items.map(item => (
99
+ <SettingRow key={item.key} item={item} t={t} onSave={(key, value) => putMut.mutate({ key, value })} />
100
+ ))}
101
+ </Space>
102
+ </Card>
103
+ ))}
104
+ </Space>
105
+ );
106
+ }
@@ -0,0 +1,168 @@
1
+ import type { SystemUsersGetData } from "../../__client/api";
2
+
3
+ import { PlusOutlined } from "@ant-design/icons";
4
+ import { useUserStore } from "@oj-module/runtime";
5
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
+ import { App, Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag } from "antd";
7
+ import { useState } from "react";
8
+ import { useTranslation } from "react-i18next";
9
+
10
+ import { useNavigate } from "react-router";
11
+
12
+ import { systemUsersForbiddenPut, systemUsersGet, systemUsersPasswordPut, systemUsersPost } from "../../__client/api";
13
+ import { explainError, isForbidden } from "../errors";
14
+ import { ForbiddenPanel } from "../forbidden";
15
+
16
+ type UserRow = SystemUsersGetData[number];
17
+
18
+ /**
19
+ * 用户管理(C1):GET /system/users 列表 + 建号 / 禁用切换 / 重置口令。
20
+ * 本人行禁用一切自操作(后端 assertNotSelf 400 兜底,前端不给会失败的按钮,R3);
21
+ * 成员资格入口仅 superadmin 可见(成员资格写是 superadmin-only,规格 §4)。
22
+ */
23
+ export default function UsersPage() {
24
+ const { t } = useTranslation();
25
+ const { message } = App.useApp();
26
+ const navigate = useNavigate();
27
+ const queryClient = useQueryClient();
28
+ const me = useUserStore(s => s.id);
29
+ const myRoles = useUserStore(s => s.roles);
30
+ const isSuper = myRoles.includes("superadmin");
31
+
32
+ const [createOpen, setCreateOpen] = useState(false);
33
+ const [createForm] = Form.useForm<{ username: string, password: string, displayName?: string, email?: string }>();
34
+ const [pwdTarget, setPwdTarget] = useState<UserRow | null>(null);
35
+ const [pwdForm] = Form.useForm<{ password: string }>();
36
+
37
+ const usersQuery = useQuery({ queryKey: ["system", "users"], queryFn: systemUsersGet });
38
+
39
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ["system", "users"] });
40
+
41
+ const forbiddenMut = useMutation({
42
+ mutationFn: (input: { uid: number, forbidden: boolean }) => systemUsersForbiddenPut({ query: { uid: input.uid }, body: { forbidden: input.forbidden } }),
43
+ onSuccess: invalidate,
44
+ onError: e => message.error(explainError(e, t)),
45
+ });
46
+
47
+ const createMut = useMutation({
48
+ mutationFn: (body: { username: string, password: string, displayName?: string, email?: string }) => systemUsersPost(body),
49
+ onSuccess: () => {
50
+ message.success(t("system:users.createOk"));
51
+ setCreateOpen(false);
52
+ createForm.resetFields();
53
+ invalidate();
54
+ },
55
+ onError: e => message.error(explainError(e, t)),
56
+ });
57
+
58
+ const pwdMut = useMutation({
59
+ mutationFn: (input: { uid: number, password: string }) => systemUsersPasswordPut({ query: { uid: input.uid }, body: { password: input.password } }),
60
+ onSuccess: () => {
61
+ message.success(t("system:common.saved"));
62
+ setPwdTarget(null);
63
+ pwdForm.resetFields();
64
+ invalidate();
65
+ },
66
+ onError: e => message.error(explainError(e, t)),
67
+ });
68
+
69
+ if (usersQuery.error && isForbidden(usersQuery.error))
70
+ return <ForbiddenPanel t={t} error={usersQuery.error} />;
71
+
72
+ const columns = [
73
+ { title: t("system:users.columns.username"), dataIndex: "username" },
74
+ { title: t("system:users.columns.displayName"), dataIndex: "display_name" },
75
+ { title: t("system:users.columns.email"), dataIndex: "email" },
76
+ {
77
+ title: t("system:users.columns.roles"),
78
+ dataIndex: "roles",
79
+ render: (roles: string[]) => roles.map(r => <Tag key={r}>{r}</Tag>),
80
+ },
81
+ {
82
+ title: t("system:users.columns.status"),
83
+ dataIndex: "is_forbidden",
84
+ render: (forbidden: boolean, row: UserRow) => (
85
+ <Space>
86
+ <Popconfirm
87
+ title={t("system:users.forbidden")}
88
+ description={`${row.username} → ${forbidden ? "false" : "true"}`}
89
+ onConfirm={() => forbiddenMut.mutate({ uid: row.id, forbidden: !forbidden })}
90
+ >
91
+ <Switch size="small" checked={forbidden} disabled={String(row.id) === me} />
92
+ </Popconfirm>
93
+ {row.must_change ? <Tag color="orange">{t("system:users.firstLoginHint")}</Tag> : null}
94
+ </Space>
95
+ ),
96
+ },
97
+ {
98
+ title: t("system:users.columns.actions"),
99
+ key: "actions",
100
+ render: (_: unknown, row: UserRow) => (
101
+ <Space>
102
+ <Button size="small" disabled={String(row.id) === me} onClick={() => setPwdTarget(row)}>
103
+ {t("system:users.resetPwd")}
104
+ </Button>
105
+ {isSuper
106
+ ? (
107
+ <Button size="small" type="link" onClick={() => navigate(`/system/users/${row.id}/roles`)}>
108
+ {t("system:users.membership")}
109
+ </Button>
110
+ )
111
+ : null}
112
+ </Space>
113
+ ),
114
+ },
115
+ ];
116
+
117
+ return (
118
+ <Space direction="vertical" style={{ width: "100%" }}>
119
+ <Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
120
+ {t("system:users.create")}
121
+ </Button>
122
+ <Table
123
+ rowKey="id"
124
+ loading={usersQuery.isLoading}
125
+ dataSource={usersQuery.data ?? []}
126
+ columns={columns}
127
+ pagination={{ hideOnSinglePage: true }}
128
+ />
129
+
130
+ <Modal
131
+ title={t("system:users.create")}
132
+ open={createOpen}
133
+ onCancel={() => setCreateOpen(false)}
134
+ confirmLoading={createMut.isPending}
135
+ onOk={() => createForm.submit()}
136
+ >
137
+ <Form form={createForm} layout="vertical" onFinish={values => createMut.mutate(values)}>
138
+ <Form.Item name="username" label={t("system:users.columns.username")} rules={[{ required: true }]} extra="ldap:/ sso: 为外部身份源保留">
139
+ <Input />
140
+ </Form.Item>
141
+ <Form.Item name="password" label="Password" rules={[{ required: true }, { min: 6 }]} extra={t("system:users.pwdPlaceholder")}>
142
+ <Input.Password />
143
+ </Form.Item>
144
+ <Form.Item name="displayName" label={t("system:users.columns.displayName")}>
145
+ <Input />
146
+ </Form.Item>
147
+ <Form.Item name="email" label={t("system:users.columns.email")}>
148
+ <Input />
149
+ </Form.Item>
150
+ </Form>
151
+ </Modal>
152
+
153
+ <Modal
154
+ title={`${t("system:users.resetPwd")}: ${pwdTarget?.username ?? ""}`}
155
+ open={pwdTarget !== null}
156
+ onCancel={() => setPwdTarget(null)}
157
+ confirmLoading={pwdMut.isPending}
158
+ onOk={() => pwdForm.submit()}
159
+ >
160
+ <Form form={pwdForm} layout="vertical" onFinish={values => pwdTarget && pwdMut.mutate({ uid: pwdTarget.id, password: values.password })}>
161
+ <Form.Item name="password" label="Password" rules={[{ required: true }, { min: 6 }]} extra={t("system:users.pwdPlaceholder")}>
162
+ <Input.Password />
163
+ </Form.Item>
164
+ </Form>
165
+ </Modal>
166
+ </Space>
167
+ );
168
+ }
@@ -0,0 +1,68 @@
1
+ import { useUserStore } from "@oj-module/runtime";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { App, Button, Card, Checkbox, Space } from "antd";
4
+ import { useState } from "react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { useNavigate, useParams } from "react-router";
8
+
9
+ import { systemRolesGet, systemUsersGet, systemUsersRolesPut } from "../../__client/api";
10
+ import { isForbidden } from "../errors";
11
+ import { ForbiddenPanel } from "../forbidden";
12
+
13
+ /**
14
+ * 成员资格(AC1-7,superadmin-only):整集替换某用户角色。后端无 GET 单用户端点,
15
+ * 当前角色从 GET /system/users 列表按 uid 回显;写走 PUT /system/users/roles?uid=。
16
+ * admin(非 superadmin)手输 URL 进入后提交会被 service 层 403 → 页面渲染无权限(§3.2)。
17
+ */
18
+ export default function UserRolesPage() {
19
+ const { t } = useTranslation();
20
+ const { message } = App.useApp();
21
+ const navigate = useNavigate();
22
+ const { uid } = useParams();
23
+ const queryClient = useQueryClient();
24
+ const me = useUserStore(s => s.id);
25
+
26
+ // null = 尚未改动,回显服务端值;一旦勾选即持本地整集
27
+ const [selected, setSelected] = useState<string[] | null>(null);
28
+
29
+ const usersQuery = useQuery({ queryKey: ["system", "users"], queryFn: systemUsersGet });
30
+ const rolesQuery = useQuery({ queryKey: ["system", "roles"], queryFn: systemRolesGet });
31
+
32
+ const saveMut = useMutation({
33
+ mutationFn: (roles: string[]) => systemUsersRolesPut({ query: { uid: Number(uid) }, body: { roles } }),
34
+ onSuccess: () => {
35
+ message.success(t("system:userRoles.saved"));
36
+ queryClient.invalidateQueries({ queryKey: ["system", "users"] });
37
+ navigate("/system/users");
38
+ },
39
+ });
40
+
41
+ const user = usersQuery.data?.find(u => String(u.id) === uid);
42
+ if (usersQuery.error && isForbidden(usersQuery.error))
43
+ return <ForbiddenPanel t={t} error={usersQuery.error} />;
44
+
45
+ const value = selected ?? user?.roles ?? [];
46
+ const isSelf = String(user?.id) === me;
47
+
48
+ return (
49
+ <Card title={`${t("system:userRoles.title")}: ${user?.username ?? `#${uid}`}`}>
50
+ <Space direction="vertical">
51
+ <Checkbox.Group
52
+ options={(rolesQuery.data ?? []).map(r => ({ label: `${r.name}${r.deprecated ? "(已废弃)" : ""}`, value: r.name }))}
53
+ value={value}
54
+ onChange={v => setSelected(v)}
55
+ />
56
+ {value.length === 0 ? <span>{t("system:userRoles.none")}</span> : null}
57
+ <Button
58
+ type="primary"
59
+ disabled={isSelf || selected === null}
60
+ loading={saveMut.isPending}
61
+ onClick={() => saveMut.mutate(value)}
62
+ >
63
+ {t("system:common.save")}
64
+ </Button>
65
+ </Space>
66
+ </Card>
67
+ );
68
+ }
@@ -36,5 +36,13 @@ export default {
36
36
  entry: "web/src/notification/entry.ts",
37
37
  enabled: true,
38
38
  },
39
+ {
40
+ // 系统管理:用户/成员资格/角色权限/操作日志/站点设置/登录方式(oj 后端 /api/system/*)。
41
+ // __client 不随模板分发:init 后跑一次 `oj openapi -o openapi.json && ojm api` 生成;
42
+ // 生成前该模块加载失败(菜单缺失)属预期,README「系统管理模块」节有说明
43
+ name: "system",
44
+ entry: "web/src/system/entry.ts",
45
+ enabled: true,
46
+ },
39
47
  ],
40
48
  };
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.1.21",
12
- "@oj-module/runtime/contract": "0.1.21",
13
- "@oj-module/runtime/contract/errors": "0.1.21",
11
+ "@oj-module/runtime": "0.2.1",
12
+ "@oj-module/runtime/contract": "0.2.1",
13
+ "@oj-module/runtime/contract/errors": "0.2.1",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",