@oj-module/cli 0.1.21 → 0.2.2

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 (109) 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 +54 -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/__client/api.schemas.ts +150 -0
  96. package/templates/web/src/system/__client/api.ts +358 -0
  97. package/templates/web/src/system/entry.ts +90 -0
  98. package/templates/web/src/system/locales/en-US.json +98 -0
  99. package/templates/web/src/system/locales/zh-CN.json +98 -0
  100. package/templates/web/src/system/pages/errors.ts +51 -0
  101. package/templates/web/src/system/pages/forbidden.tsx +35 -0
  102. package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
  103. package/templates/web/src/system/pages/oplog/index.tsx +91 -0
  104. package/templates/web/src/system/pages/roles/index.tsx +91 -0
  105. package/templates/web/src/system/pages/settings/index.tsx +106 -0
  106. package/templates/web/src/system/pages/users/index.tsx +168 -0
  107. package/templates/web/src/system/pages/users/roles.tsx +68 -0
  108. package/templates/web.config.ts +8 -0
  109. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 错误归一辅助:兼容两种形态——生成物 client 抛的 ContractApiError(code/msg)
3
+ * 与 ky HTTPError(response.status / data 信封)。判断尽量宽,避免依赖单一
4
+ * 形态的脆弱字段(ky 版本间 HTTPError.data 行为有差异)。
5
+ */
6
+
7
+ /** HTTP 状态:ContractApiError.code 优先,HTTPError 回落 response.status */
8
+ function statusOf(e: unknown): number | undefined {
9
+ const err = e as { code?: number, response?: Response } | null;
10
+ if (typeof err?.code === "number")
11
+ return err.code;
12
+ return err?.response?.status;
13
+ }
14
+
15
+ /** 该错误是否为 403(页面切「无权限/引导」面板的判据) */
16
+ export function isForbidden(e: unknown): boolean {
17
+ return statusOf(e) === 403;
18
+ }
19
+
20
+ /** must_change 判定:悬挂期 403 的 msg 为后端固定文案(access.ts 硬编码) */
21
+ export function isMustChange(e: unknown): boolean {
22
+ const err = e as { msg?: string, data?: { code?: string } } | null;
23
+ return err?.data?.code === "must_change" || (err?.msg ?? "").includes("password change required");
24
+ }
25
+
26
+ /** 人话 msg:优先信封 msg,未知透传后端原文 */
27
+ export function errMsg(e: unknown): string {
28
+ const err = e as { msg?: string, data?: { msg?: string } } | null;
29
+ return err?.msg ?? err?.data?.msg ?? (e instanceof Error ? e.message : String(e));
30
+ }
31
+
32
+ /**
33
+ * 已知业务错误 → i18n 人话;未知透传后端 msg。与后端 fail(status, message)
34
+ * 的 message 逐字匹配(users.ts 等 service 的 fail 文案)。
35
+ */
36
+ export function explainError(e: unknown, t: (key: string) => string): string {
37
+ if (isForbidden(e))
38
+ return t("system:err.forbidden");
39
+ if (isMustChange(e))
40
+ return t("system:err.mustChange.title");
41
+ const msg = errMsg(e);
42
+ if (msg.includes("yourself"))
43
+ return t("system:err.selfAction");
44
+ if (msg.includes("password too short"))
45
+ return t("system:err.pwdTooShort");
46
+ if (msg.includes("username exists"))
47
+ return t("system:err.usernameExists");
48
+ if (msg.includes("reserved"))
49
+ return t("system:err.usernameReserved");
50
+ return msg;
51
+ }
@@ -0,0 +1,35 @@
1
+ import type { TFunction } from "i18next";
2
+
3
+ import { Button, Card, Result } from "antd";
4
+ import { useNavigate } from "react-router";
5
+
6
+ import { isMustChange } from "./errors";
7
+
8
+ /**
9
+ * 页面级「无权限」面板:路由可进但 API 403——规格 §4:渲染引导而非落 error
10
+ * boundary。路由级门禁由 requiredRoles 过滤(B16)负责,本组件不承担。
11
+ *
12
+ * 特判 must_change(后端对未改密用户拒绝全部管理端点):渲染改密引导卡跳
13
+ * /change-password——表单唯一真源在 login 模块,此处不重复实现(DRY)。
14
+ */
15
+ export function ForbiddenPanel({ t, error }: { t: TFunction, error?: unknown }) {
16
+ const navigate = useNavigate();
17
+
18
+ if (isMustChange(error)) {
19
+ return (
20
+ <Card style={{ maxWidth: 480, margin: "48px auto" }}>
21
+ <Result
22
+ status="warning"
23
+ title={t("system:err.mustChange.title")}
24
+ subTitle={t("system:err.mustChange.hint")}
25
+ extra={(
26
+ <Button type="primary" onClick={() => navigate("/change-password")}>
27
+ {t("system:err.mustChange.go")}
28
+ </Button>
29
+ )}
30
+ />
31
+ </Card>
32
+ );
33
+ }
34
+ return <Result status="403" title={t("system:err.forbidden")} />;
35
+ }
@@ -0,0 +1,29 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { Card, Descriptions, Tag } from "antd";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { systemLoginMethodsGet } from "../../__client/api";
6
+
7
+ const METHOD_KEY: Record<string, string> = {
8
+ local: "system:loginMethodsPage.methodLocal",
9
+ ldap: "system:loginMethodsPage.methodLdap",
10
+ sso: "system:loginMethodsPage.methodSso",
11
+ };
12
+
13
+ /** 登录方式发现(C5/D2,只读):local 恒在,ldap/sso 由设置项开关。 */
14
+ export default function LoginMethodsPage() {
15
+ const { t } = useTranslation();
16
+ const query = useQuery({ queryKey: ["system", "login-methods"], queryFn: systemLoginMethodsGet });
17
+
18
+ return (
19
+ <Card loading={query.isLoading} title={t("system:loginMethodsPage.title")}>
20
+ <Descriptions column={1}>
21
+ <Descriptions.Item label={t("system:menu.loginMethods")}>
22
+ {(query.data?.methods ?? []).map(m => (
23
+ <Tag key={m}>{METHOD_KEY[m] ? t(METHOD_KEY[m]) : m}</Tag>
24
+ ))}
25
+ </Descriptions.Item>
26
+ </Descriptions>
27
+ </Card>
28
+ );
29
+ }
@@ -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 已随模板分发(与 auth/notification 等一致),开箱即用;契约变更后重跑
42
+ // `oj openapi -o openapi.json && ojm api` 可刷新,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.2",
12
+ "@oj-module/runtime/contract": "0.2.2",
13
+ "@oj-module/runtime/contract/errors": "0.2.2",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",