@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.
- package/devkit/README.md +1 -1
- package/devkit/SKILL.md +17 -3
- package/devkit/manual.md +77 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
- package/shell-dist/assets/runtime.js +226 -89
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/build.ts +25 -0
- package/src/init.ts +3 -0
- package/templates/README.md +15 -0
- package/templates/api/config.yaml +14 -0
- package/templates/api/seed.mysql.sql +5 -0
- package/templates/api/seed.pg.sql +5 -0
- package/templates/api/src/_platform/_shared/respond.ts +18 -0
- package/templates/api/src/_platform/_shared/service/access.ts +99 -0
- package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
- package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
- package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
- package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
- package/templates/api/src/_platform/_shared/service/query.ts +53 -0
- package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
- package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
- package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
- package/templates/api/src/_platform/_shared/service/users.ts +163 -0
- package/templates/api/src/_platform/contacts/api.ts +22 -0
- package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
- package/templates/api/src/_platform/manifest.yaml +9 -1
- package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
- package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
- package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
- package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
- package/templates/api/src/_platform/schema.yaml +76 -1
- package/templates/api/src/_platform/seed.mysql.sql +8 -0
- package/templates/api/src/_platform/seed.pg.sql +8 -0
- package/templates/api/src/_platform/seed.sql +19 -2
- package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
- package/templates/api/src/auth/_shared/ldap.ts +14 -0
- package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
- package/templates/api/src/auth/_shared/session.ts +163 -18
- package/templates/api/src/auth/api.ts +54 -44
- package/templates/api/src/auth/ldap-login/api.ts +29 -0
- package/templates/api/src/system/login-methods/api.ts +19 -0
- package/templates/api/src/system/manifest.yaml +5 -0
- package/templates/api/src/system/oplog/api.ts +53 -0
- package/templates/api/src/system/permissions/api.ts +26 -0
- package/templates/api/src/system/roles/api.ts +75 -0
- package/templates/api/src/system/roles/bindings/api.ts +29 -0
- package/templates/api/src/system/settings/api.ts +60 -0
- package/templates/api/src/system/users/api.ts +58 -0
- package/templates/api/src/system/users/forbidden/api.ts +26 -0
- package/templates/api/src/system/users/password/api.ts +26 -0
- package/templates/api/src/system/users/roles/api.ts +27 -0
- package/templates/api/src/web/user-info/api.ts +9 -18
- package/templates/api/tests/auth-ldap.spec.ts +173 -0
- package/templates/api/tests/auth-session.spec.ts +194 -0
- package/templates/api/tests/auth-throttle.spec.ts +74 -0
- package/templates/api/tests/fixture.ts +50 -0
- package/templates/api/tests/guard.spec.ts +62 -0
- package/templates/api/tests/query-utils.spec.ts +68 -0
- package/templates/api/tests/rbac-audit.spec.ts +105 -0
- package/templates/api/tests/rbac-contacts.spec.ts +85 -0
- package/templates/api/tests/rbac-guard.spec.ts +192 -0
- package/templates/api/tests/rbac-roles.spec.ts +159 -0
- package/templates/api/tests/rbac-seed.spec.ts +54 -0
- package/templates/api/tests/rbac-settings.spec.ts +79 -0
- package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
- package/templates/api/tests/rbac-tables.spec.ts +63 -0
- package/templates/api/tests/rbac-users.spec.ts +164 -0
- package/templates/api/vitest.config.ts +12 -0
- package/templates/openapi.json +1060 -31
- package/templates/pnpm-workspace.yaml +9 -0
- package/templates/web/src/auth/__client/api.schemas.ts +6 -1
- package/templates/web/src/auth/__client/api.ts +33 -1
- package/templates/web/src/login/entry.ts +17 -0
- package/templates/web/src/login/locales/en-US.json +12 -2
- package/templates/web/src/login/locales/zh-CN.json +12 -2
- package/templates/web/src/login/pages/change-password.tsx +74 -0
- package/templates/web/src/system/entry.ts +90 -0
- package/templates/web/src/system/locales/en-US.json +98 -0
- package/templates/web/src/system/locales/zh-CN.json +98 -0
- package/templates/web/src/system/pages/errors.ts +51 -0
- package/templates/web/src/system/pages/forbidden.tsx +35 -0
- package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
- package/templates/web/src/system/pages/oplog/index.tsx +91 -0
- package/templates/web/src/system/pages/roles/index.tsx +91 -0
- package/templates/web/src/system/pages/settings/index.tsx +106 -0
- package/templates/web/src/system/pages/users/index.tsx +168 -0
- package/templates/web/src/system/pages/users/roles.tsx +68 -0
- package/templates/web.config.ts +8 -0
- 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
|
+
}
|
package/templates/web.config.ts
CHANGED
|
@@ -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
|
|
12
|
-
"@oj-module/runtime/contract": "0.1
|
|
13
|
-
"@oj-module/runtime/contract/errors": "0.1
|
|
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",
|