@us_momo/dsh-erp-analysis 0.0.0-stage → 1.3.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/README.md +18 -2
- package/client.mjs +265 -0
- package/company-public.json +1 -0
- package/package.json +44 -3
- package/plugin/client.js +185 -0
- package/plugin/cordis.patch.yml +3 -0
- package/plugin/icon.svg +1 -0
- package/plugin/index.mjs +27 -0
- package/plugin/locale/en.json +1 -0
- package/plugin/locale/zh.json +1 -0
- package/plugin/runtime.mjs +97 -0
- package/release.json +1 -0
- package/release.mjs +403 -0
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 公司 ERP 分析插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
首版使用 Windows DSH,最低版本 `0.2.1-rc.3`。模型由使用者自行选择。
|
|
4
|
+
|
|
5
|
+
1. 在 DSH 添加插件时选择 npm 官方源,包名为 `@us_momo/dsh-erp-analysis`;也可保存公司发放的同版 `.tgz` 文件,不要解压。
|
|
6
|
+
2. 在 DSH 打开“插件 → 添加插件”,在“包名或地址”填写上述包名或该文件的完整本机路径,点击“安装”。
|
|
7
|
+
3. 安装成功后点击“立即启用”,按提示重启 DSH。
|
|
8
|
+
4. 点击左侧“公司 ERP 分析 → 登录 ERP”,打开显示的 ERP 链接,核对自己的账号并确认登录。浏览器中账号不对时点击“换个账号登录”。
|
|
9
|
+
5. 返回公司入口刷新状态,点击“开始公司分析”。工作区自动准备,直接提问即可。
|
|
10
|
+
|
|
11
|
+
ERP 账号须由管理员开启“允许 DSH 接入”。登录记住 30 天,同一账号可在多台电脑登录,电脑名只用于识别登录来源。原有旧 ERP 连接应先在 DSH 停用。
|
|
12
|
+
|
|
13
|
+
面板显示当前账号。换人使用时点击“切换账号”,插件会自动退出当前电脑的旧账号,再提供新登录链接;不必到设备列表手动解绑。只想结束使用时点击“退出登录”。退出和切换不影响其他电脑,也不替你退出浏览器中的 ERP;确认页可选择“换个账号登录”。
|
|
14
|
+
|
|
15
|
+
切换成功后新建公司分析对话。旧对话停止 ERP 调用,待共享报告保留原作者,不以新账号上传;原有聊天和工作文件保留在本机。
|
|
16
|
+
|
|
17
|
+
先完成正在运行的分析,在 DSH Market 检查并更新,或更换同版 .tgz,重启后新建公司分析对话。旧 @company 包首次迁移须卸载后安装 @us_momo 包。
|
|
18
|
+
设备授权与工作资料保存在当前 Windows 用户的 `%LOCALAPPDATA%/CompanyDSH`,与插件安装目录分开,更新和重新安装保留。
|
|
19
|
+
正式报告自动集中共享,所有已启用成员可查看完整报告,包括成本和利润。聊天、草稿和临时附件各机保存。公司知识与共享报告从 ERP 中读取,内容变化无需重新安装插件。
|
package/client.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { generateKeyPairSync, createHash, randomUUID, sign } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, rename } from "node:fs/promises";
|
|
3
|
+
import { join, basename, dirname } from "node:path";
|
|
4
|
+
import { hostname, userInfo } from "node:os";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { trustedErpUrl, readJson, atomicJson } from "./release.mjs";
|
|
7
|
+
export async function secureDirectory(path) {
|
|
8
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
const output = execFileSync(
|
|
11
|
+
"C:/Windows/System32/whoami.exe",
|
|
12
|
+
["/user", "/fo", "csv", "/nh"],
|
|
13
|
+
{ encoding: "utf8", windowsHide: true },
|
|
14
|
+
);
|
|
15
|
+
const sid = output.match(/S-1-5-[0-9-]+/);
|
|
16
|
+
if (!sid) throw new Error("无法确认当前 Windows 用户。");
|
|
17
|
+
execFileSync(
|
|
18
|
+
"C:/Windows/System32/icacls.exe",
|
|
19
|
+
[path, "/inheritance:r", "/grant:r", `*${sid[0]}:(OI)(CI)F`],
|
|
20
|
+
{ windowsHide: true, stdio: "pipe" },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class TeamClient {
|
|
25
|
+
constructor(root, config) {
|
|
26
|
+
this.root = root;
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.state = join(root, "private");
|
|
29
|
+
trustedErpUrl(config.server);
|
|
30
|
+
this.base = new URL("/api/v1/dsh/", config.server);
|
|
31
|
+
}
|
|
32
|
+
async key() {
|
|
33
|
+
await secureDirectory(this.state);
|
|
34
|
+
const file = join(this.state, "device-key.pem");
|
|
35
|
+
let privateKey;
|
|
36
|
+
try {
|
|
37
|
+
privateKey = await readFile(file, "utf8");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.code !== "ENOENT") throw e;
|
|
40
|
+
privateKey = generateKeyPairSync("ed25519", {
|
|
41
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
42
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
43
|
+
}).privateKey;
|
|
44
|
+
await writeFile(file, privateKey, { flag: "wx", mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
return privateKey;
|
|
47
|
+
}
|
|
48
|
+
checkIdentity(device, expectedDeviceId) {
|
|
49
|
+
if ((expectedDeviceId && device.deviceId !== expectedDeviceId) ||
|
|
50
|
+
(this.config.pinIdentity && this.boundDeviceId && device.deviceId !== this.boundDeviceId))
|
|
51
|
+
throw new Error("ERP 账号已切换,请新建公司分析对话。原账号的待共享报告已保留,不会以当前账号上传。");
|
|
52
|
+
if (this.config.pinIdentity) this.boundDeviceId ||= device.deviceId;
|
|
53
|
+
}
|
|
54
|
+
async request(path, { method = "GET", body, signed = true, expectedDeviceId } = {}) {
|
|
55
|
+
const url = new URL(path, this.base);
|
|
56
|
+
if (
|
|
57
|
+
url.origin !== this.base.origin ||
|
|
58
|
+
!url.pathname.startsWith(this.base.pathname)
|
|
59
|
+
)
|
|
60
|
+
throw new Error("无效的公司接口。");
|
|
61
|
+
const raw =
|
|
62
|
+
body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body));
|
|
63
|
+
const headers = { "Content-Type": "application/json" };
|
|
64
|
+
if (signed) {
|
|
65
|
+
const device = await readJson(join(this.state, "device.json"), null);
|
|
66
|
+
if (!device) throw new Error("请先关联本人 ERP 账号,再重试本次操作。");
|
|
67
|
+
this.checkIdentity(device, expectedDeviceId);
|
|
68
|
+
const time = String(Math.floor(Date.now() / 1000)),
|
|
69
|
+
nonce = randomUUID();
|
|
70
|
+
const canonical = [
|
|
71
|
+
time,
|
|
72
|
+
nonce,
|
|
73
|
+
method,
|
|
74
|
+
url.pathname + url.search,
|
|
75
|
+
createHash("sha256").update(raw).digest("hex"),
|
|
76
|
+
].join("\n");
|
|
77
|
+
headers["X-DSH-Device"] = device.deviceId;
|
|
78
|
+
headers["X-DSH-Time"] = time;
|
|
79
|
+
headers["X-DSH-Nonce"] = nonce;
|
|
80
|
+
headers["X-DSH-Signature"] = sign(
|
|
81
|
+
null,
|
|
82
|
+
Buffer.from(canonical),
|
|
83
|
+
await this.key(),
|
|
84
|
+
).toString("base64");
|
|
85
|
+
}
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetch(url, {
|
|
89
|
+
method,
|
|
90
|
+
headers,
|
|
91
|
+
body: method === "GET" ? undefined : raw,
|
|
92
|
+
redirect: "error",
|
|
93
|
+
signal: AbortSignal.timeout(120000),
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error("公司服务暂时无法连接,请保留当前操作后重试。");
|
|
97
|
+
}
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let size = 0;
|
|
100
|
+
for await (const chunk of response.body) {
|
|
101
|
+
size += chunk.length;
|
|
102
|
+
if (size > 32 * 1024 * 1024) throw new Error("服务响应超过限制。");
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
}
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error("公司服务返回了无效响应。");
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const code = result.error?.code || `HTTP_${response.status}`;
|
|
113
|
+
const requestId = typeof result.error?.requestId === "string" &&
|
|
114
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.error.requestId)
|
|
115
|
+
? result.error.requestId : undefined;
|
|
116
|
+
const message = response.status === 503 || code === "SERVICE_UNAVAILABLE"
|
|
117
|
+
? "公司服务繁忙,请稍后重试。"
|
|
118
|
+
: `公司服务拒绝本次操作 (${code})`;
|
|
119
|
+
const fieldErrors = Array.isArray(result.error?.fieldErrors)
|
|
120
|
+
? result.error.fieldErrors.slice(0, 20).filter(item =>
|
|
121
|
+
typeof item.field === "string" && /^\$(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*$/.test(item.field) &&
|
|
122
|
+
typeof item.message === "string" && item.message.length <= 300)
|
|
123
|
+
.map(item => ({ field: item.field, reason: item.message })) : [];
|
|
124
|
+
const e = new Error(message + (requestId ? `(请求编号:${requestId})` : "") +
|
|
125
|
+
(fieldErrors.length ? ` details=${JSON.stringify(fieldErrors)}` : ""));
|
|
126
|
+
e.code = code;
|
|
127
|
+
e.status = response.status;
|
|
128
|
+
e.requestId = requestId;
|
|
129
|
+
e.details = fieldErrors;
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
async connect(deviceName = hostname()) {
|
|
135
|
+
const previous = await readJson(join(this.state, "device.json"), null);
|
|
136
|
+
if (previous) {
|
|
137
|
+
const status = await this.status();
|
|
138
|
+
if (status.status === "active" || status.canRenew)
|
|
139
|
+
throw new Error("本机已登录 ERP,请使用续期或点击“切换账号”。");
|
|
140
|
+
if (status.status === "pending")
|
|
141
|
+
return {deviceId:previous.deviceId, expiresAt:status.expiresAt,
|
|
142
|
+
verificationUrl:new URL(`connect/${encodeURIComponent(previous.deviceId)}`,this.base).href};
|
|
143
|
+
if (status.status !== "revoked" || status.linked && !status.revoked)
|
|
144
|
+
throw new Error("尚未确认旧关联已解除,请先核对状态。");
|
|
145
|
+
}
|
|
146
|
+
const { createPublicKey } = await import("node:crypto");
|
|
147
|
+
const publicKey = createPublicKey(await this.key()).export({
|
|
148
|
+
type: "spki",
|
|
149
|
+
format: "pem",
|
|
150
|
+
});
|
|
151
|
+
const result = await this.request("devices/request", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
signed: false,
|
|
154
|
+
body: { publicKey, deviceName },
|
|
155
|
+
});
|
|
156
|
+
await atomicJson(join(this.state, "device.json"), {
|
|
157
|
+
deviceId: result.deviceId,
|
|
158
|
+
});
|
|
159
|
+
const verification = trustedErpUrl(result.verificationUrl);
|
|
160
|
+
if (verification.origin !== this.base.origin)
|
|
161
|
+
throw new Error("确认地址与公司 ERP 不一致。");
|
|
162
|
+
return {
|
|
163
|
+
deviceId: result.deviceId,
|
|
164
|
+
verificationUrl: verification.href,
|
|
165
|
+
expiresAt: result.expiresAt,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async status() {
|
|
169
|
+
const device = await readJson(join(this.state, "device.json"), null);
|
|
170
|
+
if (!device) return { status: "unpaired" };
|
|
171
|
+
return this.request(
|
|
172
|
+
`devices/${encodeURIComponent(device.deviceId)}/status`,
|
|
173
|
+
{ method: "POST", body: {} },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
async logout() {
|
|
177
|
+
const device = await readJson(join(this.state, "device.json"), null);
|
|
178
|
+
if (device) {
|
|
179
|
+
const result = await this.request(`devices/${encodeURIComponent(device.deviceId)}/revoke`, {
|
|
180
|
+
method: "POST", body: {}, expectedDeviceId: device.deviceId,
|
|
181
|
+
});
|
|
182
|
+
if (result.status !== "revoked" || result.deviceId !== device.deviceId)
|
|
183
|
+
throw new Error("尚未确认当前账号已退出,请刷新状态后重试。");
|
|
184
|
+
await atomicJson(join(this.state, "device.json"), null);
|
|
185
|
+
}
|
|
186
|
+
return { status: "unpaired" };
|
|
187
|
+
}
|
|
188
|
+
async switchAccount() {
|
|
189
|
+
await this.logout();
|
|
190
|
+
return this.connect();
|
|
191
|
+
}
|
|
192
|
+
async tools() {
|
|
193
|
+
return this.request("tools");
|
|
194
|
+
}
|
|
195
|
+
async call(name, args = {}, { expectedDeviceId } = {}) {
|
|
196
|
+
return this.request("tools/call", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: { name, arguments: args },
|
|
199
|
+
expectedDeviceId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async saveReport(report) {
|
|
203
|
+
await secureDirectory(this.state);
|
|
204
|
+
const device = await readJson(join(this.state, "device.json"), null);
|
|
205
|
+
if (!device) throw new Error("请先关联本人 ERP 账号,再保存共享报告。");
|
|
206
|
+
this.checkIdentity(device);
|
|
207
|
+
const queue = join(this.state, "reports", device.deviceId);
|
|
208
|
+
await mkdir(queue, { recursive: true, mode: 0o700 });
|
|
209
|
+
const operationId = report.operationId || randomUUID();
|
|
210
|
+
if (
|
|
211
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
212
|
+
operationId,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
throw new Error("报告操作编号必须是 UUID。");
|
|
216
|
+
const path = join(queue, operationId + ".json");
|
|
217
|
+
const pending = await readJson(path, null);
|
|
218
|
+
const original = pending || await readJson(path + ".shared", null);
|
|
219
|
+
const request = { ...report, operationId };
|
|
220
|
+
const generatedAt = original ? original.source?.generatedAt : new Date().toISOString();
|
|
221
|
+
if (generatedAt && report.source?.generatedAt === undefined)
|
|
222
|
+
request.source = { ...report.source, generatedAt };
|
|
223
|
+
if (original && JSON.stringify(original) !== JSON.stringify(request))
|
|
224
|
+
throw new Error("同一报告操作编号不能修改内容。");
|
|
225
|
+
if (!pending) await atomicJson(path, request);
|
|
226
|
+
return this.sendQueued(path, request);
|
|
227
|
+
}
|
|
228
|
+
async sendQueued(path, request) {
|
|
229
|
+
try {
|
|
230
|
+
const receipt = await this.call("company_reports_save", { request }, { expectedDeviceId: basename(dirname(path)) });
|
|
231
|
+
if (receipt.status !== "shared")
|
|
232
|
+
throw new Error("服务器尚未确认报告共享。");
|
|
233
|
+
await rename(path, path + ".shared");
|
|
234
|
+
return { ...receipt, operationId: request.operationId };
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return {
|
|
237
|
+
status: "pending",
|
|
238
|
+
operationId: request.operationId,
|
|
239
|
+
message: e.message,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async flushReports() {
|
|
244
|
+
const device = await readJson(join(this.state, "device.json"), null);
|
|
245
|
+
if (!device) throw new Error("请先关联本人 ERP 账号,再保存共享报告。");
|
|
246
|
+
this.checkIdentity(device);
|
|
247
|
+
const queue = join(this.state, "reports", device.deviceId);
|
|
248
|
+
await mkdir(queue, { recursive: true });
|
|
249
|
+
const results = [];
|
|
250
|
+
for (const owner of await readdir(join(this.state, "reports"), { withFileTypes: true })) {
|
|
251
|
+
if (!owner.isDirectory() || owner.name === device.deviceId) continue;
|
|
252
|
+
const files = await readdir(join(this.state, "reports", owner.name));
|
|
253
|
+
const count = files.filter(name => name.endsWith(".json")).length;
|
|
254
|
+
if (count) results.push({status:"pending", reason:"different_device", count,
|
|
255
|
+
message:"原关联的待共享报告已保留,未以当前用户上传。"});
|
|
256
|
+
}
|
|
257
|
+
for (const name of await readdir(queue)) {
|
|
258
|
+
if (name.endsWith(".json")) {
|
|
259
|
+
const path = join(queue, name);
|
|
260
|
+
results.push(await this.sendQueued(path, await readJson(path)));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return results;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"server": "http://211.97.68.49:10020"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@us_momo/dsh-erp-analysis",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "公司 ERP 分析:选品研究、个人账号关联、统一方法、共享知识与报告。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"client.mjs",
|
|
8
|
+
"company-public.json",
|
|
9
|
+
"plugin/",
|
|
10
|
+
"release.json",
|
|
11
|
+
"release.mjs"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"registry": "https://registry.npmjs.org/"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"dsh": ">=0.2.1-rc.3 <0.3.0"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./plugin/index.mjs",
|
|
22
|
+
"./client": "./plugin/client.js",
|
|
23
|
+
"./locale/*.json": "./plugin/locale/*.json",
|
|
24
|
+
"./icon": "./plugin/icon.svg"
|
|
25
|
+
},
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./plugin/cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"immediately": true,
|
|
33
|
+
"inject": [
|
|
34
|
+
"@deepseek-ai/dsh-client-connection",
|
|
35
|
+
"@deepseek-ai/dsh-client-locale",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/Huazhufather/DSH_USEC.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/Huazhufather/DSH_USEC#readme"
|
|
6
47
|
}
|
package/plugin/client.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@us_momo/dsh-erp-analysis",
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require("react"), h = React.createElement;
|
|
5
|
+
const words = {
|
|
6
|
+
zh: {
|
|
7
|
+
title: "公司 ERP 分析", intro: "经营复盘、SKU、备货和财务分析,自动选用公司方法。",
|
|
8
|
+
refresh: "刷新状态", connect: "登录 ERP", continue: "继续登录", renew: "续期登录",
|
|
9
|
+
switchAccount: "切换账号", logout: "退出登录", signedOut: "已退出当前电脑的 ERP 登录。", accountChanged: "请在 ERP 确认新账号;登录后新建公司分析对话。",
|
|
10
|
+
authorize: "打开 ERP 登录并确认", awaiting: "在 ERP 完成确认后,返回这里刷新状态。",
|
|
11
|
+
unpaired: "先登录本人的 ERP 账号,再开始公司分析。", active: "当前账号:", expired: "登录已到期或权限已变更,请续期或切换账号。",
|
|
12
|
+
revoked: "当前登录已退出,可以重新登录。", pending: "等待本人在 ERP 页面确认登录。",
|
|
13
|
+
unavailable: "公司服务暂时无法连接,请稍后刷新。", loading: "正在读取公司接入状态…",
|
|
14
|
+
start: "开始公司分析", account: "个人 ERP 账号", devices: "我的设备", revoke: "解除此设备",
|
|
15
|
+
revokedDevice: "已解除", expires: "有效期至", version: "公司方法版本", needReload: "新版已准备好,完成当前任务后重启 DSH。",
|
|
16
|
+
methods: "使用统一公司方法;模型由你在 DSH 中自行选择。",
|
|
17
|
+
reports: "共享正式报告", loadReports: "读取共享报告", retry: "恢复待共享报告", queue: "份报告待共享",
|
|
18
|
+
sharing: "生成或保存正式报告后自动共享,所有已启用成员均可查看完整内容,包括成本和利润。聊天、草稿和临时附件仅保存在本机。",
|
|
19
|
+
noReports: "暂无共享报告。", reportTitle: "报告", author: "作者", reportVersion: "版本", time: "生成时间", download: "下载",
|
|
20
|
+
previousPage: "上一页", nextPage: "下一页", page: "页码",
|
|
21
|
+
retryDone: "已恢复共享报告", local: "本机公司资料目录", update: "安装更新",
|
|
22
|
+
updateHelp: "完成当前分析后,在 DSH Market 的已安装插件中刷新检查,发现新版时点击“更新”,按提示重启 DSH 并新建公司分析对话。旧 GitHub 或本地安装包首次迁移时,先卸载公司插件,再用 @us_momo/dsh-erp-analysis 安装并启用;ERP 登录和本机资料保留。",
|
|
23
|
+
failed: "操作未完成,请重试。", mismatch: "报告文件校验失败,请重新下载。", refreshing: "正在办理…",
|
|
24
|
+
},
|
|
25
|
+
en: {
|
|
26
|
+
title: "Company ERP Analysis", intro: "Business reviews, SKU, supply and finance analysis using company methods.",
|
|
27
|
+
refresh: "Refresh", connect: "Sign in to ERP", continue: "Continue sign-in", renew: "Renew sign-in",
|
|
28
|
+
switchAccount: "Switch account", logout: "Sign out", signedOut: "Signed out of ERP on this computer.", accountChanged: "Confirm the new ERP account, then start a new company analysis conversation.",
|
|
29
|
+
authorize: "Sign in and confirm in ERP", awaiting: "Confirm in ERP, then return here and refresh.",
|
|
30
|
+
unpaired: "Sign in with your own ERP account to begin.", active: "Current account:", expired: "Sign-in expired or permissions changed. Renew or switch accounts.",
|
|
31
|
+
revoked: "Signed out. You may sign in again.", pending: "Waiting for your ERP sign-in confirmation.",
|
|
32
|
+
unavailable: "The company service is unavailable. Refresh later.", loading: "Loading connection status…",
|
|
33
|
+
start: "Start company analysis", account: "Your ERP account", devices: "My devices", revoke: "Revoke device",
|
|
34
|
+
revokedDevice: "Revoked", expires: "Valid until", version: "Company method version", needReload: "An update is ready. Finish your work, then restart DSH.",
|
|
35
|
+
methods: "Company methods are managed centrally. Choose your own model in DSH.",
|
|
36
|
+
reports: "Shared formal reports", loadReports: "Load shared reports", retry: "Retry pending reports", queue: "reports pending upload",
|
|
37
|
+
sharing: "Formal reports are shared automatically with every enabled member, including costs and profit. Chats, drafts and temporary attachments stay on this computer.",
|
|
38
|
+
noReports: "No shared reports yet.", reportTitle: "Report", author: "Author", reportVersion: "Version", time: "Created", download: "Download",
|
|
39
|
+
previousPage: "Previous page", nextPage: "Next page", page: "Page",
|
|
40
|
+
retryDone: "Reports shared", local: "Local company workspace", update: "Install updates",
|
|
41
|
+
updateHelp: "Finish your analysis, refresh installed plugins in DSH Market, and select Update when a newer release is available. Restart DSH as prompted and start a new company analysis conversation. To migrate a GitHub or local archive installation, uninstall the company plugin once, then install and enable @us_momo/dsh-erp-analysis. ERP sign-in and local files are retained.",
|
|
42
|
+
failed: "The operation did not finish. Retry.", mismatch: "Report verification failed. Download again.", refreshing: "Working…",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const css = `.company-erp-panel{height:100%;overflow:auto;padding:36px clamp(20px,4vw,56px);color:var(--dsw-alias-label-primary,CanvasText);font:inherit;box-sizing:border-box}.company-erp-panel>div{max-width:1000px;margin:auto}.company-erp-panel h1{font-size:26px;line-height:1.3;margin:0 0 10px;font-weight:600}.company-erp-panel h2{font-size:18px;margin:0 0 12px;font-weight:600}.company-erp-panel p{line-height:1.7;max-width:72ch;margin:10px 0}.company-erp-panel header,.company-erp-panel .company-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.company-erp-panel header{justify-content:space-between;align-items:flex-start}.company-erp-panel section{padding:28px 0;border-bottom:1px solid color-mix(in srgb,currentColor 18%,transparent)}.company-erp-panel button,.company-erp-panel a.company-button{font:inherit;min-height:38px;padding:8px 14px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:6px;background:transparent;color:inherit;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;box-sizing:border-box}.company-erp-panel button:hover,.company-erp-panel a.company-button:hover{background:color-mix(in srgb,currentColor 7%,transparent)}.company-erp-panel button.primary{background:#205eaa;border-color:#205eaa;color:#fff}.company-erp-panel button:disabled{opacity:.5;cursor:default}.company-erp-panel button:focus-visible,.company-erp-panel a:focus-visible{outline:2px solid #3e8ce0;outline-offset:3px}.company-erp-panel .company-message{min-height:28px;margin-top:16px;line-height:1.6}.company-erp-panel .company-error{color:light-dark(#a52420,#ffaaa4)}.company-erp-panel table{border-collapse:collapse;width:100%;font-size:14px}.company-erp-panel th,.company-erp-panel td{text-align:left;padding:12px 10px;border-bottom:1px solid color-mix(in srgb,currentColor 15%,transparent);overflow-wrap:anywhere}.company-erp-panel th{font-weight:600}.company-erp-panel .company-table{overflow-x:auto}.company-erp-panel small{font-size:13px}.company-erp-panel .company-path{overflow-wrap:anywhere}.company-erp-panel ::selection{background:#b7d6fa;color:#122b48}@media(max-width:700px){.company-erp-panel{padding:24px 18px}.company-erp-panel h1{font-size:23px}.company-erp-panel section{padding:22px 0}}`;
|
|
46
|
+
function Icon() {
|
|
47
|
+
return h("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, "aria-hidden": true },
|
|
48
|
+
h("path", { d: "M4 20V4h12v16M2 20h20M8 8h4M8 12h4M8 16h2M18 8h3v12" }));
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
inject: ["connection", "slots", "locale", "layout", "uiWorkspace", "workspaces"],
|
|
52
|
+
apply(ctx) {
|
|
53
|
+
ctx.effect(() => ctx.locale.register("company-erp", words));
|
|
54
|
+
const t = ctx.locale.bind("company-erp");
|
|
55
|
+
async function api(action, body = {}) {
|
|
56
|
+
const response = await ctx.connection.rpc.call("/api", "company-erp/control", { action, body });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const error = new Error(response.error?.message || t("failed"));
|
|
59
|
+
error.code = response.error?.code; error.status = response.error?.details?.status;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return response.value;
|
|
63
|
+
}
|
|
64
|
+
function CompanyPanel() {
|
|
65
|
+
const [state, setState] = React.useState(null), [busy, setBusy] = React.useState(false);
|
|
66
|
+
const [message, setMessage] = React.useState(""), [error, setError] = React.useState(false);
|
|
67
|
+
const [verification, setVerification] = React.useState(null), [devices, setDevices] = React.useState(null);
|
|
68
|
+
const [reports, setReports] = React.useState(null);
|
|
69
|
+
const [reportPage, setReportPage] = React.useState(1), [hasMoreReports, setHasMoreReports] = React.useState(false);
|
|
70
|
+
const alive = React.useRef(true), accountEpoch = React.useRef(0);
|
|
71
|
+
const refresh = async () => {
|
|
72
|
+
const epoch = accountEpoch.current;
|
|
73
|
+
const next = await api("status");
|
|
74
|
+
if (alive.current && epoch === accountEpoch.current) {
|
|
75
|
+
setState(next);
|
|
76
|
+
if (next.connection.status !== "active") { setReports(null); setDevices(null); }
|
|
77
|
+
}
|
|
78
|
+
return next;
|
|
79
|
+
};
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
alive.current = true;
|
|
82
|
+
refresh().catch((e) => { if (alive.current) { setMessage(e.message); setError(true); } });
|
|
83
|
+
return () => { alive.current = false; };
|
|
84
|
+
}, []);
|
|
85
|
+
async function action(fn) {
|
|
86
|
+
setBusy(true); setError(false); setMessage(t("refreshing"));
|
|
87
|
+
try { await fn(); }
|
|
88
|
+
catch (e) { if (alive.current) {
|
|
89
|
+
if (e.status === 401 || e.status === 403) {
|
|
90
|
+
setReports(null); setDevices(null); setVerification(null);
|
|
91
|
+
setState((previous) => previous && { ...previous, connection: { status: "unavailable", message: e.message } });
|
|
92
|
+
}
|
|
93
|
+
setError(true); setMessage(e.message);
|
|
94
|
+
} }
|
|
95
|
+
finally { if (alive.current) setBusy(false); }
|
|
96
|
+
}
|
|
97
|
+
const connection = state?.connection, active = connection?.status === "active";
|
|
98
|
+
const canRenew = connection?.canRenew;
|
|
99
|
+
const user = connection?.user?.login && connection.user.login !== connection.user.displayName
|
|
100
|
+
? `${connection.user.displayName} (${connection.user.login})`
|
|
101
|
+
: connection?.user?.login || connection?.user?.displayName || "";
|
|
102
|
+
const status = !connection ? t("loading") : active
|
|
103
|
+
? `${t("active")} ${user}${connection.expiresAt ? ` · ${t("expires")} ${new Date(connection.expiresAt).toLocaleString()}` : ""}`
|
|
104
|
+
: connection.message || t(canRenew ? "expired" : connection.status) || t("unavailable");
|
|
105
|
+
function button(label, fn, disabled = false, primary = false) {
|
|
106
|
+
return h("button", { type: "button", disabled: busy || disabled, className: primary ? "primary" : undefined,
|
|
107
|
+
onClick: () => action(fn) }, label);
|
|
108
|
+
}
|
|
109
|
+
async function link(renew) {
|
|
110
|
+
const result = await api(renew ? "renew" : "connect");
|
|
111
|
+
setVerification(result.verificationUrl); setMessage(t("awaiting")); await refresh();
|
|
112
|
+
}
|
|
113
|
+
async function changeAccount(switching) {
|
|
114
|
+
accountEpoch.current++;
|
|
115
|
+
setReports(null); setDevices(null); setVerification(null); setState(null);
|
|
116
|
+
setReportPage(1); setHasMoreReports(false);
|
|
117
|
+
try {
|
|
118
|
+
const result = await api(switching ? "switch" : "logout");
|
|
119
|
+
setVerification(result.verificationUrl || null);
|
|
120
|
+
setMessage(t(switching ? "accountChanged" : "signedOut"));
|
|
121
|
+
} finally { await refresh(); }
|
|
122
|
+
}
|
|
123
|
+
async function loadReports(page = 1) {
|
|
124
|
+
const result = await api("reports", { page });
|
|
125
|
+
setReports(result.items || []); setReportPage(result.page);
|
|
126
|
+
setHasMoreReports(result.hasMore); setMessage("");
|
|
127
|
+
}
|
|
128
|
+
async function download(report) {
|
|
129
|
+
const file = await api("report", { reportId: report.reportId || report.id, version: report.version });
|
|
130
|
+
const bytes = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
|
|
131
|
+
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
132
|
+
if (digest !== file.sha256) throw new Error(t("mismatch"));
|
|
133
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
|
|
134
|
+
const link = document.createElement("a"); link.href = url; link.download = file.filename.split(/[\\/]/).pop(); link.click();
|
|
135
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("");
|
|
136
|
+
}
|
|
137
|
+
return h("main", { className: "company-erp-panel" }, h("style", null, css), h("div", null,
|
|
138
|
+
h("header", null, h("div", null, h("h1", null, t("title")), h("p", null, t("intro"))),
|
|
139
|
+
button(t("refresh"), async () => { await refresh(); setMessage(""); })),
|
|
140
|
+
h("div", { role: error ? "alert" : "status", "aria-live": "polite", className: `company-message${error ? " company-error" : ""}` }, message),
|
|
141
|
+
h("section", null, h("h2", null, t("account")), h("p", null, status),
|
|
142
|
+
h("div", { className: "company-row" },
|
|
143
|
+
button(connection?.status === "pending" ? t("continue") : t("connect"), () => link(false), active || canRenew || !state || connection?.status === "unavailable"),
|
|
144
|
+
button(t("renew"), () => link(true), !active && !canRenew),
|
|
145
|
+
button(t("switchAccount"), () => changeAccount(true), !connection?.deviceId),
|
|
146
|
+
button(t("logout"), () => changeAccount(false), !connection?.deviceId),
|
|
147
|
+
button(t("start"), async () => {
|
|
148
|
+
const workspace = await ctx.workspaces.create({ path: state.workspace });
|
|
149
|
+
if (workspace.title === "workspace") await ctx.workspaces.rename(workspace.workspaceId, t("title"));
|
|
150
|
+
await ctx.uiWorkspace.openWorkspace(workspace.workspaceId, undefined, { agentPreset: "company-erp-analysis" });
|
|
151
|
+
setMessage("");
|
|
152
|
+
}, !active || state?.pendingReload, true)),
|
|
153
|
+
verification ? h("p", null, h("a", { className: "company-button", href: verification, target: "_blank", rel: "noreferrer noopener" }, t("authorize"))) : null,
|
|
154
|
+
h("p", null, h("small", null, `${t("version")} ${state?.version || "—"} · ${t("methods")}`)),
|
|
155
|
+
state?.pendingReload ? h("p", { role: "status" }, t("needReload")) : null),
|
|
156
|
+
h("section", null, h("h2", null, t("reports")), h("p", null, t("sharing")),
|
|
157
|
+
h("div", { className: "company-row" },
|
|
158
|
+
button(t("loadReports"), () => loadReports(), !active),
|
|
159
|
+
button(t("retry"), async () => { const r = await api("retry"); await refresh(); setMessage(`${t("retryDone")}: ${r.receipts.filter((x) => x.status === "shared").length}`); }, !active),
|
|
160
|
+
state?.pendingReports ? h("span", null, `${state.pendingReports} ${t("queue")}`) : null),
|
|
161
|
+
reports ? reports.length ? h("div", { className: "company-table" }, h("table", null,
|
|
162
|
+
h("thead", null, h("tr", null, ...["reportTitle", "author", "reportVersion", "time", "download"].map((key) => h("th", { key, scope: "col" }, t(key))))),
|
|
163
|
+
h("tbody", null, ...reports.map((report) => h("tr", { key: report.reportId || report.id },
|
|
164
|
+
h("td", null, report.title), h("td", null, report.authorName || report.author?.name || "—"),
|
|
165
|
+
h("td", null, report.version || report.currentVersion || "—"),
|
|
166
|
+
h("td", null, report.generatedAt ? new Date(report.generatedAt).toLocaleString() : "—"),
|
|
167
|
+
h("td", null, button(t("download"), () => download(report)))))))) : h("p", null, t("noReports")) : null,
|
|
168
|
+
reports && h("div", { className: "company-row" },
|
|
169
|
+
button(t("previousPage"), () => loadReports(reportPage - 1), reportPage <= 1 || !active),
|
|
170
|
+
h("span", { "aria-live": "polite" }, `${t("page")} ${reportPage}`),
|
|
171
|
+
button(t("nextPage"), () => loadReports(reportPage + 1), !hasMoreReports || !active))),
|
|
172
|
+
h("section", null, h("h2", null, t("devices")),
|
|
173
|
+
button(t("devices"), async () => { const r = await api("devices"); setDevices(r.items || r.devices || []); setMessage(""); }, !active),
|
|
174
|
+
devices?.map((device) => h("div", { className: "company-row", key: device.deviceId || device.id },
|
|
175
|
+
h("p", null, device.deviceName || device.name, " · ", device.revoked ? t("revokedDevice") : `${t("expires")} ${new Date(device.expiresAt).toLocaleString()}`),
|
|
176
|
+
button(t("revoke"), async () => { await api("revoke", { deviceId: device.deviceId || device.id }); await refresh(); setDevices(null); setReports(null); setVerification(null); setMessage(""); }, device.revoked)))),
|
|
177
|
+
h("section", null, h("h2", null, t("update")), h("p", null, t("updateHelp")),
|
|
178
|
+
h("p", null, h("small", null, t("local")), h("br"), h("span", { className: "company-path" }, state?.workspace || "—")))));
|
|
179
|
+
}
|
|
180
|
+
ctx.slots.inject("main", () => ctx.slots.register({ name: "main", key: "company-erp-analysis" }, CompanyPanel));
|
|
181
|
+
ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({ name: "sidebar.panellist", id: "company-erp-analysis", order: 5, label: () => t("title") }, Icon));
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
package/plugin/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="#2765b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 25V7h14v18M4 25h24M10 11h6M10 15h6M10 19h3M21 12h5v13"/><path d="m17 22 4-4 3 2 4-6"/></svg>
|
package/plugin/index.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { prepareCompany, companyPreset, companyActions } from "./runtime.mjs";
|
|
2
|
+
export const inject = ["agentPresets", "connection"];
|
|
3
|
+
export async function apply(ctx) {
|
|
4
|
+
const runtime = await prepareCompany();
|
|
5
|
+
const dispose = await ctx.agentPresets.register(await companyPreset(runtime));
|
|
6
|
+
ctx.effect(() => dispose);
|
|
7
|
+
const action = companyActions(runtime);
|
|
8
|
+
// Connection owns browser authentication, request trust, owner access and Desktop IPC.
|
|
9
|
+
ctx.connection.fetch.register({
|
|
10
|
+
path: "/api/company-erp/control", methods: ["POST"], requestBody: "buffered",
|
|
11
|
+
async fetch(request) {
|
|
12
|
+
const message = await request.json();
|
|
13
|
+
if (message?.type !== "client-request" || message.method !== "company-erp/control" ||
|
|
14
|
+
typeof message.rpcId !== "string" || typeof message.payload?.action !== "string")
|
|
15
|
+
return new Response("Invalid company request", { status: 400 });
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
request.signal.throwIfAborted();
|
|
19
|
+
result = { ok: true, value: await action(message.payload.action, message.payload.body) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
result = { ok: false, error: { code: error.code || "company/action-failed", message: error.message,
|
|
22
|
+
details: Number.isInteger(error.status) ? { status: error.status } : {} } };
|
|
23
|
+
}
|
|
24
|
+
return Response.json({ type: "server-response", rpcId: message.rpcId, result });
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"Company ERP Analysis","description":"Connect your ERP identity to company methods, knowledge and shared reports."}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"公司 ERP 分析","description":"关联本人 ERP,使用统一专家方法、公司知识和共享正式报告。"}}
|