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.
- package/.env.json +21 -0
- package/README.md +257 -0
- package/data/management-console.db +0 -0
- package/data/management-console.db-shm +0 -0
- package/data/management-console.db-wal +0 -0
- package/dist/assets/index-CV_wjCAG.js +464 -0
- package/dist/assets/index-DfMPB0eV.css +1 -0
- package/dist/index.html +13 -0
- package/docs/spec.md +199 -0
- package/index.html +12 -0
- package/package.json +37 -0
- package/scripts/reset-kernel.js +59 -0
- package/scripts/reset-password.js +22 -0
- package/server/fakes.js +57 -0
- package/server/index.js +21 -0
- package/server/src/api/middleware/auth.js +29 -0
- package/server/src/api/middleware/internal.js +44 -0
- package/server/src/api/routes/index.js +677 -0
- package/server/src/app.js +90 -0
- package/server/src/background/index.js +106 -0
- package/server/src/background/protocol.js +15 -0
- package/server/src/config/env.js +90 -0
- package/server/src/db/index.js +501 -0
- package/server/src/infra/mqtt/management-rpc-client.js +213 -0
- package/server/src/infra/providers/hzg-provider-client.js +39 -0
- package/server/src/infra/s3/object-storage.js +97 -0
- package/server/src/services/agent-quota.js +54 -0
- package/server/src/services/agent-service.js +696 -0
- package/server/src/services/agent-status-sync-service.js +132 -0
- package/server/src/services/audit-log-service.js +39 -0
- package/server/src/services/auth-service.js +153 -0
- package/server/src/services/catalog-sync-service.js +712 -0
- package/server/src/services/external-service.js +308 -0
- package/server/src/services/kernel-reset-service.js +86 -0
- package/server/src/services/portal-service.js +555 -0
- package/server/src/services/system-service.js +580 -0
- package/server/src/services/topic-ping-service.js +282 -0
- package/server/src/utils/errors.js +36 -0
- package/server/src/utils/security.js +22 -0
- package/server/test/agent-service-alignment.test.js +316 -0
- package/server/test/agent-service-create.test.js +662 -0
- package/server/test/agent-status-sync-service.test.js +167 -0
- package/server/test/agent-update-audit.test.js +63 -0
- package/server/test/auth-middleware.test.js +71 -0
- package/server/test/background-services.test.js +160 -0
- package/server/test/catalog-sync-service.test.js +920 -0
- package/server/test/db-reset-migration.test.js +123 -0
- package/server/test/env-config.test.js +68 -0
- package/server/test/external-service.test.js +380 -0
- package/server/test/hzg-provider-client.test.js +50 -0
- package/server/test/internal-auth-middleware.test.js +66 -0
- package/server/test/kernel-reset-service.test.js +112 -0
- package/server/test/management-rpc-client.test.js +105 -0
- package/server/test/portal-service-access-tokens.test.js +121 -0
- package/server/test/portal-service-alignment.test.js +318 -0
- package/server/test/portal-service-management-logs.test.js +114 -0
- package/server/test/reset-kernel-cli.test.js +23 -0
- package/server/test/service-api-auth-middleware.test.js +59 -0
- package/server/test/system-service-alignment.test.js +265 -0
- package/server/test/topic-ping-service.test.js +182 -0
- package/server/test/usage-refresh-audit-route.test.js +82 -0
- package/src/App.jsx +1 -0
- package/src/api.js +1 -0
- package/src/app/App.jsx +346 -0
- package/src/app/api-client.js +112 -0
- package/src/components/AppShell.jsx +117 -0
- package/src/components/CardTitleWithReload.jsx +20 -0
- package/src/components/DeleteActionButton.jsx +31 -0
- package/src/main.jsx +14 -0
- package/src/pages/AgentsPage.jsx +647 -0
- package/src/pages/AiosUsersPage.jsx +151 -0
- package/src/pages/DashboardPage.jsx +72 -0
- package/src/pages/LoginPage.jsx +41 -0
- package/src/pages/SettingsPage.jsx +431 -0
- package/src/pages/SkillsPage.jsx +175 -0
- package/src/pages/SystemLogsPage.jsx +349 -0
- package/src/pages/SystemsPage.jsx +498 -0
- package/src/pages/TemplatesPage.jsx +207 -0
- package/src/pages/UserManagementPage.jsx +25 -0
- package/src/pages/UsersPage.jsx +192 -0
- package/src/pages/system-logs/SystemLogsTabs.jsx +362 -0
- package/src/styles.css +222 -0
- package/src/utils/format.js +63 -0
- package/test/.reports/fast-2026-05-25T08-32-39-420Z.json +299 -0
- package/test/integration/common.js +208 -0
- package/test/integration/fast.js +135 -0
- package/test/integration/full.js +306 -0
- package/test/run-browser-e2e.js +212 -0
- package/test/run-jasmine.js +21 -0
- package/test/setup.js +1 -0
- 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";
|
package/src/app/App.jsx
ADDED
|
@@ -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
|
+
}
|