aios-management-web 0.1.0

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 (91) hide show
  1. package/.env.json +21 -0
  2. package/README.md +257 -0
  3. package/data/management-console.db +0 -0
  4. package/data/management-console.db-shm +0 -0
  5. package/data/management-console.db-wal +0 -0
  6. package/dist/assets/index-CV_wjCAG.js +464 -0
  7. package/dist/assets/index-DfMPB0eV.css +1 -0
  8. package/dist/index.html +13 -0
  9. package/docs/spec.md +199 -0
  10. package/index.html +12 -0
  11. package/package.json +37 -0
  12. package/scripts/reset-kernel.js +59 -0
  13. package/scripts/reset-password.js +22 -0
  14. package/server/fakes.js +57 -0
  15. package/server/index.js +21 -0
  16. package/server/src/api/middleware/auth.js +29 -0
  17. package/server/src/api/middleware/internal.js +44 -0
  18. package/server/src/api/routes/index.js +677 -0
  19. package/server/src/app.js +90 -0
  20. package/server/src/background/index.js +106 -0
  21. package/server/src/background/protocol.js +15 -0
  22. package/server/src/config/env.js +90 -0
  23. package/server/src/db/index.js +501 -0
  24. package/server/src/infra/mqtt/management-rpc-client.js +213 -0
  25. package/server/src/infra/providers/hzg-provider-client.js +39 -0
  26. package/server/src/infra/s3/object-storage.js +97 -0
  27. package/server/src/services/agent-quota.js +54 -0
  28. package/server/src/services/agent-service.js +696 -0
  29. package/server/src/services/agent-status-sync-service.js +132 -0
  30. package/server/src/services/audit-log-service.js +39 -0
  31. package/server/src/services/auth-service.js +153 -0
  32. package/server/src/services/catalog-sync-service.js +712 -0
  33. package/server/src/services/external-service.js +308 -0
  34. package/server/src/services/kernel-reset-service.js +86 -0
  35. package/server/src/services/portal-service.js +555 -0
  36. package/server/src/services/system-service.js +580 -0
  37. package/server/src/services/topic-ping-service.js +282 -0
  38. package/server/src/utils/errors.js +36 -0
  39. package/server/src/utils/security.js +22 -0
  40. package/server/test/agent-service-alignment.test.js +316 -0
  41. package/server/test/agent-service-create.test.js +662 -0
  42. package/server/test/agent-status-sync-service.test.js +167 -0
  43. package/server/test/agent-update-audit.test.js +63 -0
  44. package/server/test/auth-middleware.test.js +71 -0
  45. package/server/test/background-services.test.js +160 -0
  46. package/server/test/catalog-sync-service.test.js +920 -0
  47. package/server/test/db-reset-migration.test.js +123 -0
  48. package/server/test/env-config.test.js +68 -0
  49. package/server/test/external-service.test.js +380 -0
  50. package/server/test/hzg-provider-client.test.js +50 -0
  51. package/server/test/internal-auth-middleware.test.js +66 -0
  52. package/server/test/kernel-reset-service.test.js +112 -0
  53. package/server/test/management-rpc-client.test.js +105 -0
  54. package/server/test/portal-service-access-tokens.test.js +121 -0
  55. package/server/test/portal-service-alignment.test.js +318 -0
  56. package/server/test/portal-service-management-logs.test.js +114 -0
  57. package/server/test/reset-kernel-cli.test.js +23 -0
  58. package/server/test/service-api-auth-middleware.test.js +59 -0
  59. package/server/test/system-service-alignment.test.js +265 -0
  60. package/server/test/topic-ping-service.test.js +182 -0
  61. package/server/test/usage-refresh-audit-route.test.js +82 -0
  62. package/src/App.jsx +1 -0
  63. package/src/api.js +1 -0
  64. package/src/app/App.jsx +346 -0
  65. package/src/app/api-client.js +112 -0
  66. package/src/components/AppShell.jsx +117 -0
  67. package/src/components/CardTitleWithReload.jsx +20 -0
  68. package/src/components/DeleteActionButton.jsx +31 -0
  69. package/src/main.jsx +14 -0
  70. package/src/pages/AgentsPage.jsx +647 -0
  71. package/src/pages/AiosUsersPage.jsx +151 -0
  72. package/src/pages/DashboardPage.jsx +72 -0
  73. package/src/pages/LoginPage.jsx +41 -0
  74. package/src/pages/SettingsPage.jsx +431 -0
  75. package/src/pages/SkillsPage.jsx +175 -0
  76. package/src/pages/SystemLogsPage.jsx +349 -0
  77. package/src/pages/SystemsPage.jsx +498 -0
  78. package/src/pages/TemplatesPage.jsx +207 -0
  79. package/src/pages/UserManagementPage.jsx +25 -0
  80. package/src/pages/UsersPage.jsx +192 -0
  81. package/src/pages/system-logs/SystemLogsTabs.jsx +362 -0
  82. package/src/styles.css +222 -0
  83. package/src/utils/format.js +63 -0
  84. package/test/.reports/fast-2026-05-25T08-32-39-420Z.json +299 -0
  85. package/test/integration/common.js +208 -0
  86. package/test/integration/fast.js +135 -0
  87. package/test/integration/full.js +306 -0
  88. package/test/run-browser-e2e.js +212 -0
  89. package/test/run-jasmine.js +21 -0
  90. package/test/setup.js +1 -0
  91. package/vite.config.js +12 -0
@@ -0,0 +1,82 @@
1
+ import express from "express";
2
+
3
+ import { createRoutes } from "../src/api/routes/index.js";
4
+
5
+ function listen(app) {
6
+ return new Promise((resolve) => {
7
+ const server = app.listen(0, "127.0.0.1", () => resolve(server));
8
+ });
9
+ }
10
+
11
+ function close(server) {
12
+ return new Promise((resolve, reject) => {
13
+ server.close((error) => {
14
+ if (error) {
15
+ reject(error);
16
+ return;
17
+ }
18
+ resolve();
19
+ });
20
+ });
21
+ }
22
+
23
+ it("writes audit logs when manually refreshing agent usage", async () => {
24
+ const auditWrites = [];
25
+ const app = express();
26
+ app.use(express.json());
27
+ app.use("/api", createRoutes({
28
+ authService: {
29
+ getLocalApiUser() {
30
+ return {
31
+ id: 1,
32
+ username: "aios",
33
+ role: "aios-admin"
34
+ };
35
+ },
36
+ assertAdmin() {}
37
+ },
38
+ auditLogService: {
39
+ write(entry) {
40
+ auditWrites.push(entry);
41
+ }
42
+ },
43
+ catalogSyncService: {
44
+ async refreshAgentUsage({ trigger }) {
45
+ return {
46
+ status: "success",
47
+ trigger_source: trigger,
48
+ summary: {
49
+ refreshed_agents: 1
50
+ }
51
+ };
52
+ }
53
+ },
54
+ portalService: {},
55
+ env: {}
56
+ }));
57
+
58
+ const server = await listen(app);
59
+ try {
60
+ const address = server.address();
61
+ const response = await fetch(`http://127.0.0.1:${address.port}/api/settings/usage-refresh`, {
62
+ method: "POST",
63
+ headers: {
64
+ "Content-Type": "application/json"
65
+ },
66
+ body: "{}"
67
+ });
68
+ const body = await response.json();
69
+
70
+ expect(response.status).toBe(200);
71
+ expect(body.status).toBe("success");
72
+ expect(body.trigger_source).toBe("manual");
73
+ expect(auditWrites).toEqual([{
74
+ userId: 1,
75
+ username: "aios",
76
+ action: "同步员工用量",
77
+ detail: "手动触发数字员工用量同步"
78
+ }]);
79
+ } finally {
80
+ await close(server);
81
+ }
82
+ });
package/src/App.jsx ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./app/App.jsx";
package/src/api.js ADDED
@@ -0,0 +1 @@
1
+ export { api, authTokenStore } from "./app/api-client.js";
@@ -0,0 +1,346 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ApiOutlined,
4
+ DashboardOutlined,
5
+ FileTextOutlined,
6
+ LockOutlined,
7
+ RobotOutlined,
8
+ SettingOutlined,
9
+ TeamOutlined,
10
+ ToolOutlined
11
+ } from "@ant-design/icons";
12
+ import { ConfigProvider, Form, Input, Modal, message } from "antd";
13
+ import zhCN from "antd/locale/zh_CN";
14
+
15
+ import { api, authTokenStore } from "./api-client.js";
16
+ import { AppShell } from "../components/AppShell.jsx";
17
+ import { AgentsPage } from "../pages/AgentsPage.jsx";
18
+ import { DashboardPage } from "../pages/DashboardPage.jsx";
19
+ import { LoginPage } from "../pages/LoginPage.jsx";
20
+ import { SettingsPage } from "../pages/SettingsPage.jsx";
21
+ import { SkillsPage } from "../pages/SkillsPage.jsx";
22
+ import { SystemLogsPage } from "../pages/SystemLogsPage.jsx";
23
+ import { SystemsPage } from "../pages/SystemsPage.jsx";
24
+ import { TemplatesPage } from "../pages/TemplatesPage.jsx";
25
+ import { UserManagementPage } from "../pages/UserManagementPage.jsx";
26
+
27
+ const menuItems = [
28
+ { key: "dashboard", label: "仪表盘", icon: <DashboardOutlined /> },
29
+ { key: "agents", label: "数字员工", icon: <RobotOutlined /> },
30
+ { key: "templates", label: "数字员工模板", icon: <FileTextOutlined /> },
31
+ { key: "skills", label: "全局技能", icon: <ToolOutlined /> },
32
+ { key: "systems", label: "业务系统", icon: <ApiOutlined /> },
33
+ { key: "systemLogs", label: "系统日志", icon: <FileTextOutlined /> },
34
+ { key: "userManagement", label: "用户管理", icon: <TeamOutlined /> },
35
+ { key: "settings", label: "系统设置", icon: <SettingOutlined /> }
36
+ ];
37
+
38
+ function getPasswordPromptKey() {
39
+ const token = authTokenStore.get();
40
+ return token ? `aios-password-prompted:${token}` : "";
41
+ }
42
+
43
+ function hasPromptedPasswordChange() {
44
+ const key = getPasswordPromptKey();
45
+ return key ? sessionStorage.getItem(key) === "1" : false;
46
+ }
47
+
48
+ function markPasswordPrompted() {
49
+ const key = getPasswordPromptKey();
50
+ if (key) {
51
+ sessionStorage.setItem(key, "1");
52
+ }
53
+ }
54
+
55
+ function hasRunningSyncTask(boot) {
56
+ return [
57
+ boot?.agent_sync,
58
+ boot?.usage_refresh,
59
+ boot?.skill_sync,
60
+ boot?.template_sync,
61
+ boot?.system_sync
62
+ ].some((taskStatus) => taskStatus?.status === "running");
63
+ }
64
+
65
+ export default function App() {
66
+ const [boot, setBoot] = useState(null);
67
+ const [selectedKey, setSelectedKey] = useState("dashboard");
68
+ const [messageApi, contextHolder] = message.useMessage();
69
+ const [passwordOpen, setPasswordOpen] = useState(false);
70
+ const [passwordForm] = Form.useForm();
71
+ const messageApiRef = useRef(messageApi);
72
+
73
+ messageApiRef.current = messageApi;
74
+
75
+ useEffect(() => {
76
+ const notifyUnhandledError = (error, fallbackMessage) => {
77
+ const normalized = api.normalizeError(error, fallbackMessage);
78
+ const prefix = normalized.context ? `${normalized.context}: ` : "";
79
+ messageApiRef.current.error(`${prefix}${normalized.message || fallbackMessage}`);
80
+ };
81
+
82
+ const onWindowError = (event) => {
83
+ notifyUnhandledError(event.error || event.message, "页面发生异常");
84
+ };
85
+
86
+ const onUnhandledRejection = (event) => {
87
+ notifyUnhandledError(event.reason, "请求处理失败");
88
+ };
89
+
90
+ window.addEventListener("error", onWindowError);
91
+ window.addEventListener("unhandledrejection", onUnhandledRejection);
92
+
93
+ return () => {
94
+ window.removeEventListener("error", onWindowError);
95
+ window.removeEventListener("unhandledrejection", onUnhandledRejection);
96
+ };
97
+ }, []);
98
+
99
+ const loadBootstrap = async () => {
100
+ try {
101
+ const data = await api.get("/api/bootstrap");
102
+ setBoot(data);
103
+ if (data.current_user?.must_change_password && !hasPromptedPasswordChange()) {
104
+ markPasswordPrompted();
105
+ setPasswordOpen(true);
106
+ }
107
+ } catch {
108
+ authTokenStore.set("");
109
+ setBoot(null);
110
+ }
111
+ };
112
+
113
+ useEffect(() => {
114
+ if (authTokenStore.get()) {
115
+ void loadBootstrap();
116
+ }
117
+ }, []);
118
+
119
+ const currentUser = boot?.current_user;
120
+ const settings = boot?.settings;
121
+ const accessTokens = boot?.access_tokens || [];
122
+ const agentSync = boot?.agent_sync;
123
+ const skillSync = boot?.skill_sync;
124
+ const templateSync = boot?.template_sync;
125
+ const systemSync = boot?.system_sync;
126
+ const usageRefresh = boot?.usage_refresh;
127
+
128
+ useEffect(() => {
129
+ if (!currentUser || !hasRunningSyncTask(boot)) {
130
+ return undefined;
131
+ }
132
+
133
+ const timer = window.setInterval(async () => {
134
+ try {
135
+ const data = await api.get("/api/bootstrap");
136
+ setBoot(data);
137
+ } catch {
138
+ // 后台状态刷新失败时保留当前页面状态,下一轮继续尝试。
139
+ }
140
+ }, 3000);
141
+
142
+ return () => window.clearInterval(timer);
143
+ }, [boot, currentUser]);
144
+
145
+ const page = useMemo(() => {
146
+ switch (selectedKey) {
147
+ case "dashboard":
148
+ return <DashboardPage />;
149
+ case "userManagement":
150
+ return <UserManagementPage currentUser={currentUser} />;
151
+ case "templates":
152
+ return <TemplatesPage />;
153
+ case "skills":
154
+ return <SkillsPage />;
155
+ case "agents":
156
+ return <AgentsPage />;
157
+ case "systems":
158
+ return <SystemsPage />;
159
+ case "systemLogs":
160
+ return <SystemLogsPage />;
161
+ case "settings":
162
+ return (
163
+ <SettingsPage
164
+ settings={settings}
165
+ accessTokens={accessTokens}
166
+ agentSyncStatus={agentSync}
167
+ skillSyncStatus={skillSync}
168
+ templateSyncStatus={templateSync}
169
+ systemSyncStatus={systemSync}
170
+ usageRefreshStatus={usageRefresh}
171
+ onSave={async (values) => {
172
+ const next = await api.put("/api/settings", values);
173
+ setBoot((current) => ({ ...current, settings: next }));
174
+ messageApi.success("设置已保存");
175
+ }}
176
+ onCreateAccessToken={async () => {
177
+ const next = await api.post("/api/settings/access-tokens", {});
178
+ setBoot((current) => ({
179
+ ...current,
180
+ access_tokens: [next, ...(current?.access_tokens || [])]
181
+ }));
182
+ messageApi.success("访问令牌已生成");
183
+ }}
184
+ onDeleteAccessToken={async (token) => {
185
+ await api.delete(`/api/settings/access-tokens/${encodeURIComponent(token)}`);
186
+ setBoot((current) => ({
187
+ ...current,
188
+ access_tokens: (current?.access_tokens || []).filter((item) => item.token !== token)
189
+ }));
190
+ messageApi.success("访问令牌已删除");
191
+ }}
192
+ onUsageRefresh={async () => {
193
+ try {
194
+ const next = await api.post("/api/settings/usage-refresh", {});
195
+ setBoot((current) => ({ ...current, usage_refresh: next }));
196
+ if (next.status === "success") {
197
+ messageApi.success("数字员工用量同步完成");
198
+ return;
199
+ }
200
+ messageApi.error(next.error_message || "数字员工用量同步失败");
201
+ } catch (error) {
202
+ const normalized = api.normalizeError(error, "数字员工用量同步失败");
203
+ messageApi.error(normalized.message || "数字员工用量同步失败");
204
+ }
205
+ }}
206
+ onServerStatus={async () => {
207
+ try {
208
+ const next = await api.post("/api/settings/server-status", {});
209
+ return next?.result;
210
+ } catch (error) {
211
+ const normalized = api.normalizeError(error, "查询服务器状态失败");
212
+ messageApi.error(normalized.message || "查询服务器状态失败");
213
+ throw error;
214
+ }
215
+ }}
216
+ onAgentSync={async () => {
217
+ const next = await api.post("/api/settings/agent-sync", {});
218
+ setBoot((current) => ({ ...current, agent_sync: next }));
219
+ return next;
220
+ }}
221
+ onSkillSync={async () => {
222
+ const next = await api.post("/api/settings/skill-sync", {});
223
+ setBoot((current) => ({ ...current, skill_sync: next }));
224
+ return next;
225
+ }}
226
+ onTemplateSync={async () => {
227
+ const next = await api.post("/api/settings/template-sync", {});
228
+ setBoot((current) => ({ ...current, template_sync: next }));
229
+ return next;
230
+ }}
231
+ onSystemSync={async () => {
232
+ const next = await api.post("/api/settings/system-sync", {});
233
+ setBoot((current) => ({ ...current, system_sync: next }));
234
+ return next;
235
+ }}
236
+ onServerRestart={async () => {
237
+ try {
238
+ const next = await api.post("/api/settings/server-restart", {});
239
+ const output = typeof next?.result === "string"
240
+ ? next.result
241
+ : JSON.stringify(next?.result ?? {}, null, 2);
242
+ messageApi.success(output || "重启指令已发送");
243
+ } catch (error) {
244
+ const normalized = api.normalizeError(error, "重启服务失败");
245
+ messageApi.error(normalized.message || "重启服务失败");
246
+ throw error;
247
+ }
248
+ }}
249
+ onServerDiagnostics={async () => {
250
+ try {
251
+ const next = await api.post("/api/settings/server-diagnostics", {});
252
+ return next?.result;
253
+ } catch (error) {
254
+ const normalized = api.normalizeError(error, "服务器诊断失败");
255
+ messageApi.error(normalized.message || "服务器诊断失败");
256
+ throw error;
257
+ }
258
+ }}
259
+ />
260
+ );
261
+ default:
262
+ return null;
263
+ }
264
+ }, [accessTokens, agentSync, currentUser, messageApi, selectedKey, settings, skillSync, systemSync, templateSync, usageRefresh]);
265
+
266
+ if (!currentUser || !settings) {
267
+ return (
268
+ <>
269
+ {contextHolder}
270
+ <LoginPage
271
+ onLogin={async (values) => {
272
+ try {
273
+ const result = await api.post("/api/auth/login", values);
274
+ authTokenStore.set(result.token);
275
+ await loadBootstrap();
276
+ } catch (error) {
277
+ messageApi.error(error.message || "登录失败");
278
+ }
279
+ }}
280
+ />
281
+ </>
282
+ );
283
+ }
284
+
285
+ return (
286
+ <ConfigProvider
287
+ locale={zhCN}
288
+ theme={{
289
+ token: {
290
+ colorPrimary: settings.theme_color || "#07c160"
291
+ }
292
+ }}
293
+ >
294
+ {contextHolder}
295
+ <AppShell
296
+ settings={settings}
297
+ selectedKey={selectedKey}
298
+ menuItems={menuItems}
299
+ onSelect={setSelectedKey}
300
+ onLogout={async () => {
301
+ await api.post("/api/auth/logout", {});
302
+ authTokenStore.set("");
303
+ setBoot(null);
304
+ }}
305
+ onChangePassword={() => setPasswordOpen(true)}
306
+ >
307
+ {page}
308
+ </AppShell>
309
+ <Modal
310
+ open={passwordOpen}
311
+ title={<><LockOutlined /> 修改密码</>}
312
+ okText="确认"
313
+ cancelText="取消"
314
+ onCancel={() => setPasswordOpen(false)}
315
+ onOk={async () => {
316
+ const values = await passwordForm.validateFields();
317
+ await api.post("/api/auth/change-password", values);
318
+ messageApi.success("密码已更新");
319
+ setPasswordOpen(false);
320
+ passwordForm.resetFields();
321
+ await loadBootstrap();
322
+ }}
323
+ >
324
+ <Form form={passwordForm} layout="vertical">
325
+ <Form.Item
326
+ name="currentPassword"
327
+ label="当前密码"
328
+ rules={[{ required: true, message: "请输入当前密码" }]}
329
+ >
330
+ <Input.Password />
331
+ </Form.Item>
332
+ <Form.Item
333
+ name="nextPassword"
334
+ label="新密码"
335
+ rules={[
336
+ { required: true, message: "请输入新密码" },
337
+ { min: 8, message: "新密码长度至少 8 位" }
338
+ ]}
339
+ >
340
+ <Input.Password />
341
+ </Form.Item>
342
+ </Form>
343
+ </Modal>
344
+ </ConfigProvider>
345
+ );
346
+ }
@@ -0,0 +1,112 @@
1
+ const TOKEN_KEY = "aios-web-auth-token";
2
+
3
+ export const authTokenStore = {
4
+ get() {
5
+ return localStorage.getItem(TOKEN_KEY) || "";
6
+ },
7
+ set(token) {
8
+ if (token) {
9
+ localStorage.setItem(TOKEN_KEY, token);
10
+ return;
11
+ }
12
+ localStorage.removeItem(TOKEN_KEY);
13
+ }
14
+ };
15
+
16
+ function normalizeError(error, fallbackMessage = "请求失败", context = "") {
17
+ if (error instanceof Error) {
18
+ if (context && !error.context) {
19
+ error.context = context;
20
+ }
21
+ return error;
22
+ }
23
+
24
+ const message = typeof error === "string" && error ? error : fallbackMessage;
25
+ const nextError = new Error(message);
26
+ if (context) {
27
+ nextError.context = context;
28
+ }
29
+ return nextError;
30
+ }
31
+
32
+ async function request(path, options = {}) {
33
+ const headers = new Headers(options.headers || {});
34
+ const token = authTokenStore.get();
35
+ const method = options.method || "GET";
36
+ const context = `${method} ${path}`;
37
+
38
+ if (token && !headers.has("Authorization")) {
39
+ headers.set("Authorization", `Bearer ${token}`);
40
+ }
41
+
42
+ const body = options.formData ? options.formData : options.body ? JSON.stringify(options.body) : undefined;
43
+ if (!options.formData && body !== undefined) {
44
+ headers.set("Content-Type", "application/json");
45
+ }
46
+
47
+ let response;
48
+ try {
49
+ response = await fetch(path, {
50
+ method,
51
+ headers,
52
+ body
53
+ });
54
+ } catch (error) {
55
+ throw normalizeError(error, "网络请求失败", context);
56
+ }
57
+
58
+ if (response.status === 204) {
59
+ return null;
60
+ }
61
+
62
+ let payload = null;
63
+ try {
64
+ payload = await response.json();
65
+ } catch {
66
+ payload = null;
67
+ }
68
+
69
+ if (!response.ok) {
70
+ throw normalizeError(
71
+ payload?.message || payload?.code || `请求失败 (${response.status})`,
72
+ "请求失败",
73
+ context
74
+ );
75
+ }
76
+
77
+ return payload;
78
+ }
79
+
80
+ export const api = {
81
+ get: (path) => request(path),
82
+ post: (path, body) => request(path, { method: "POST", body }),
83
+ put: (path, body) => request(path, { method: "PUT", body }),
84
+ delete: (path) => request(path, { method: "DELETE" }),
85
+ multipart: (path, formData, method = "POST") => request(path, { method, formData }),
86
+ download: async (path) => {
87
+ const headers = new Headers();
88
+ const token = authTokenStore.get();
89
+ const context = `GET ${path}`;
90
+ if (token) {
91
+ headers.set("Authorization", `Bearer ${token}`);
92
+ }
93
+
94
+ let response;
95
+ try {
96
+ response = await fetch(path, { headers });
97
+ } catch (error) {
98
+ throw normalizeError(error, "下载失败", context);
99
+ }
100
+ if (!response.ok) {
101
+ let message = "请求失败";
102
+ try {
103
+ const payload = await response.json();
104
+ message = payload.message || payload.code || message;
105
+ } catch {}
106
+ throw normalizeError(message, "下载失败", context);
107
+ }
108
+
109
+ return await response.blob();
110
+ },
111
+ normalizeError
112
+ };
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { Button, Layout, List, Space, Typography } from "antd";
3
+
4
+ const { Header, Sider, Content } = Layout;
5
+ const { Text } = Typography;
6
+
7
+ function normalizeHexColor(color, fallback = "#07c160") {
8
+ if (typeof color !== "string") {
9
+ return fallback;
10
+ }
11
+
12
+ const value = color.trim();
13
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) {
14
+ return value.toLowerCase();
15
+ }
16
+
17
+ if (/^#[0-9a-fA-F]{3}$/.test(value)) {
18
+ return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`.toLowerCase();
19
+ }
20
+
21
+ return fallback;
22
+ }
23
+
24
+ function hexToRgb(color) {
25
+ const normalized = normalizeHexColor(color);
26
+ return {
27
+ r: Number.parseInt(normalized.slice(1, 3), 16),
28
+ g: Number.parseInt(normalized.slice(3, 5), 16),
29
+ b: Number.parseInt(normalized.slice(5, 7), 16)
30
+ };
31
+ }
32
+
33
+ function rgbToHex({ r, g, b }) {
34
+ const clamp = (value) => Math.max(0, Math.min(255, Math.round(value)));
35
+ return `#${[clamp(r), clamp(g), clamp(b)].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
36
+ }
37
+
38
+ function mixColors(colorA, colorB, ratio) {
39
+ const start = hexToRgb(colorA);
40
+ const end = hexToRgb(colorB);
41
+ const weight = Math.max(0, Math.min(1, ratio));
42
+
43
+ return rgbToHex({
44
+ r: start.r + (end.r - start.r) * weight,
45
+ g: start.g + (end.g - start.g) * weight,
46
+ b: start.b + (end.b - start.b) * weight
47
+ });
48
+ }
49
+
50
+ export function AppShell({
51
+ settings,
52
+ selectedKey,
53
+ menuItems,
54
+ onSelect,
55
+ onLogout,
56
+ onChangePassword,
57
+ children
58
+ }) {
59
+ const themeColor = normalizeHexColor(settings.theme_color);
60
+ const siderStart = mixColors(themeColor, "#08110d", 0.8);
61
+ const siderEnd = mixColors(themeColor, "#050a07", 0.9);
62
+ const siderPanel = mixColors(themeColor, "#0c1711", 0.74);
63
+ const siderActive = mixColors(themeColor, "#ffffff", 0.22);
64
+ const siderActiveBorder = mixColors(themeColor, "#ffffff", 0.38);
65
+
66
+ return (
67
+ <Layout className="app-shell">
68
+ <Sider
69
+ theme="dark"
70
+ width={250}
71
+ style={{
72
+ background: `linear-gradient(180deg, ${siderStart} 0%, ${siderEnd} 100%)`,
73
+ "--portal-sider-panel": siderPanel
74
+ }}
75
+ className="portal-sider"
76
+ >
77
+ <div className="brand-block">
78
+ <div className="brand-title">{settings.portal_name}</div>
79
+ <Text className="brand-subtitle">{settings.brand_subtitle}</Text>
80
+ </div>
81
+ <div className="portal-sider-menu">
82
+ <List
83
+ dataSource={menuItems}
84
+ renderItem={(item) => (
85
+ <List.Item
86
+ onClick={() => onSelect(item.key)}
87
+ style={{
88
+ cursor: "pointer",
89
+ padding: "12px 18px",
90
+ margin: "0 10px 8px",
91
+ borderRadius: 6,
92
+ color: "#fff",
93
+ background: selectedKey === item.key ? siderActive : "transparent",
94
+ border: selectedKey === item.key ? `1px solid ${siderActiveBorder}` : "1px solid transparent"
95
+ }}
96
+ >
97
+ <Space style={{ color: "#fff" }}>
98
+ {item.icon}
99
+ {item.label}
100
+ </Space>
101
+ </List.Item>
102
+ )}
103
+ />
104
+ </div>
105
+ </Sider>
106
+ <Layout>
107
+ <Header className="portal-header">
108
+ <Space className="portal-header-actions" size={12} wrap>
109
+ <Button onClick={onChangePassword}>修改密码</Button>
110
+ <Button onClick={onLogout}>退出登录</Button>
111
+ </Space>
112
+ </Header>
113
+ <Content className="portal-content">{children}</Content>
114
+ </Layout>
115
+ </Layout>
116
+ );
117
+ }
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { ReloadOutlined } from "@ant-design/icons";
3
+ import { Space } from "antd";
4
+
5
+ export function CardTitleWithReload({ title, loading = false, onReload }) {
6
+ return (
7
+ <Space size={8}>
8
+ <span>{title}</span>
9
+ <ReloadOutlined
10
+ spin={loading}
11
+ onClick={() => {
12
+ if (!loading) {
13
+ void onReload?.();
14
+ }
15
+ }}
16
+ style={{ cursor: loading ? "default" : "pointer", color: "rgba(0, 0, 0, 0.45)" }}
17
+ />
18
+ </Space>
19
+ );
20
+ }