@yss123/cage-cli 0.0.0-stage → 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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/bin/cage.js +6 -0
  4. package/bin/create-cage-app.js +6 -0
  5. package/dist/analyze.d.ts +18 -0
  6. package/dist/analyze.js +26 -0
  7. package/dist/analyze.js.map +1 -0
  8. package/dist/baseline.d.ts +5 -0
  9. package/dist/baseline.js +50 -0
  10. package/dist/baseline.js.map +1 -0
  11. package/dist/budget.d.ts +7 -0
  12. package/dist/budget.js +52 -0
  13. package/dist/budget.js.map +1 -0
  14. package/dist/config.d.ts +30 -0
  15. package/dist/config.js +47 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/create.d.ts +1 -0
  18. package/dist/create.js +47 -0
  19. package/dist/create.js.map +1 -0
  20. package/dist/database.d.ts +5 -0
  21. package/dist/database.js +70 -0
  22. package/dist/database.js.map +1 -0
  23. package/dist/derive.d.ts +14 -0
  24. package/dist/derive.js +163 -0
  25. package/dist/derive.js.map +1 -0
  26. package/dist/dev.d.ts +2 -0
  27. package/dist/dev.js +155 -0
  28. package/dist/dev.js.map +1 -0
  29. package/dist/env.d.ts +1 -0
  30. package/dist/env.js +15 -0
  31. package/dist/env.js.map +1 -0
  32. package/dist/findings.d.ts +11 -0
  33. package/dist/findings.js +13 -0
  34. package/dist/findings.js.map +1 -0
  35. package/dist/gen/contracts.d.ts +6 -0
  36. package/dist/gen/contracts.js +167 -0
  37. package/dist/gen/contracts.js.map +1 -0
  38. package/dist/gen/index.d.ts +19 -0
  39. package/dist/gen/index.js +137 -0
  40. package/dist/gen/index.js.map +1 -0
  41. package/dist/gen/zod-code.d.ts +7 -0
  42. package/dist/gen/zod-code.js +161 -0
  43. package/dist/gen/zod-code.js.map +1 -0
  44. package/dist/graph-snapshot.d.ts +13 -0
  45. package/dist/graph-snapshot.js +46 -0
  46. package/dist/graph-snapshot.js.map +1 -0
  47. package/dist/graph.d.ts +11 -0
  48. package/dist/graph.js +87 -0
  49. package/dist/graph.js.map +1 -0
  50. package/dist/impact.d.ts +28 -0
  51. package/dist/impact.js +223 -0
  52. package/dist/impact.js.map +1 -0
  53. package/dist/index.d.ts +16 -0
  54. package/dist/index.js +18 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/load.d.ts +6 -0
  57. package/dist/load.js +17 -0
  58. package/dist/load.js.map +1 -0
  59. package/dist/main.d.ts +1 -0
  60. package/dist/main.js +176 -0
  61. package/dist/main.js.map +1 -0
  62. package/dist/migrate.d.ts +7 -0
  63. package/dist/migrate.js +69 -0
  64. package/dist/migrate.js.map +1 -0
  65. package/dist/ontology.d.ts +41 -0
  66. package/dist/ontology.js +270 -0
  67. package/dist/ontology.js.map +1 -0
  68. package/dist/process.d.ts +6 -0
  69. package/dist/process.js +39 -0
  70. package/dist/process.js.map +1 -0
  71. package/dist/rules.d.ts +23 -0
  72. package/dist/rules.js +251 -0
  73. package/dist/rules.js.map +1 -0
  74. package/dist/static-check.d.ts +23 -0
  75. package/dist/static-check.js +225 -0
  76. package/dist/static-check.js.map +1 -0
  77. package/dist/sync.d.ts +5 -0
  78. package/dist/sync.js +46 -0
  79. package/dist/sync.js.map +1 -0
  80. package/dist/upgrade.d.ts +10 -0
  81. package/dist/upgrade.js +81 -0
  82. package/dist/upgrade.js.map +1 -0
  83. package/dist/verify.d.ts +7 -0
  84. package/dist/verify.js +88 -0
  85. package/dist/verify.js.map +1 -0
  86. package/dist/version.d.ts +4 -0
  87. package/dist/version.js +6 -0
  88. package/dist/version.js.map +1 -0
  89. package/graph-ui/app.js +1065 -0
  90. package/graph-ui/index.html +197 -0
  91. package/graph-ui/model.js +141 -0
  92. package/graph-ui/relations.js +125 -0
  93. package/graph-ui/structure.js +74 -0
  94. package/graph-ui/style.css +1698 -0
  95. package/managed/errors/CAGE-APPR-001.md +32 -0
  96. package/managed/errors/CAGE-APPR-002.md +32 -0
  97. package/managed/errors/CAGE-APPR-003.md +32 -0
  98. package/managed/errors/CAGE-APPR-004.md +32 -0
  99. package/managed/errors/CAGE-APPR-005.md +32 -0
  100. package/managed/errors/CAGE-APPR-006.md +32 -0
  101. package/managed/errors/CAGE-APPR-007.md +32 -0
  102. package/managed/errors/CAGE-APPR-010.md +32 -0
  103. package/managed/errors/CAGE-AUTH-001.md +32 -0
  104. package/managed/errors/CAGE-BUDGET-001.md +32 -0
  105. package/managed/errors/CAGE-BUDGET-002.md +32 -0
  106. package/managed/errors/CAGE-BUDGET-003.md +32 -0
  107. package/managed/errors/CAGE-CMD-002.md +32 -0
  108. package/managed/errors/CAGE-CMD-003.md +34 -0
  109. package/managed/errors/CAGE-CONN-001.md +32 -0
  110. package/managed/errors/CAGE-CONN-002.md +32 -0
  111. package/managed/errors/CAGE-CONN-003.md +32 -0
  112. package/managed/errors/CAGE-CONN-004.md +32 -0
  113. package/managed/errors/CAGE-CONN-005.md +32 -0
  114. package/managed/errors/CAGE-CONN-006.md +32 -0
  115. package/managed/errors/CAGE-CONN-007.md +32 -0
  116. package/managed/errors/CAGE-CONN-008.md +32 -0
  117. package/managed/errors/CAGE-CONN-009.md +32 -0
  118. package/managed/errors/CAGE-CONN-010.md +32 -0
  119. package/managed/errors/CAGE-CONN-011.md +32 -0
  120. package/managed/errors/CAGE-CONN-012.md +32 -0
  121. package/managed/errors/CAGE-CREATE-001.md +32 -0
  122. package/managed/errors/CAGE-DECL-001.md +34 -0
  123. package/managed/errors/CAGE-DECL-002.md +34 -0
  124. package/managed/errors/CAGE-DECL-003.md +34 -0
  125. package/managed/errors/CAGE-DECL-004.md +34 -0
  126. package/managed/errors/CAGE-DEP-001.md +34 -0
  127. package/managed/errors/CAGE-DEP-002.md +34 -0
  128. package/managed/errors/CAGE-DEP-003.md +34 -0
  129. package/managed/errors/CAGE-DEP-004.md +34 -0
  130. package/managed/errors/CAGE-DEP-005.md +34 -0
  131. package/managed/errors/CAGE-DLQ-001.md +32 -0
  132. package/managed/errors/CAGE-ENV-001.md +32 -0
  133. package/managed/errors/CAGE-EVENT-001.md +32 -0
  134. package/managed/errors/CAGE-EVENT-002.md +32 -0
  135. package/managed/errors/CAGE-FIELD-001.md +32 -0
  136. package/managed/errors/CAGE-FIELD-002.md +32 -0
  137. package/managed/errors/CAGE-FIELD-003.md +32 -0
  138. package/managed/errors/CAGE-FIELD-004.md +32 -0
  139. package/managed/errors/CAGE-GEN-001.md +33 -0
  140. package/managed/errors/CAGE-GEN-002.md +33 -0
  141. package/managed/errors/CAGE-GRANT-001.md +32 -0
  142. package/managed/errors/CAGE-GRANT-002.md +32 -0
  143. package/managed/errors/CAGE-GRANT-003.md +32 -0
  144. package/managed/errors/CAGE-IMPACT-001.md +32 -0
  145. package/managed/errors/CAGE-JOB-001.md +32 -0
  146. package/managed/errors/CAGE-JOB-002.md +32 -0
  147. package/managed/errors/CAGE-JOB-003.md +32 -0
  148. package/managed/errors/CAGE-LINT-001.md +32 -0
  149. package/managed/errors/CAGE-MIG-001.md +32 -0
  150. package/managed/errors/CAGE-MIG-002.md +32 -0
  151. package/managed/errors/CAGE-MIG-003.md +32 -0
  152. package/managed/errors/CAGE-MIG-004.md +32 -0
  153. package/managed/errors/CAGE-MIG-005.md +32 -0
  154. package/managed/errors/CAGE-NAME-001.md +32 -0
  155. package/managed/errors/CAGE-NAME-002.md +32 -0
  156. package/managed/errors/CAGE-NAME-003.md +32 -0
  157. package/managed/errors/CAGE-NAME-004.md +32 -0
  158. package/managed/errors/CAGE-NAME-005.md +32 -0
  159. package/managed/errors/CAGE-NAME-006.md +32 -0
  160. package/managed/errors/CAGE-ORG-001.md +32 -0
  161. package/managed/errors/CAGE-PAIR-002.md +33 -0
  162. package/managed/errors/CAGE-QUERY-001.md +32 -0
  163. package/managed/errors/CAGE-QUERY-002.md +32 -0
  164. package/managed/errors/CAGE-QUERY-003.md +32 -0
  165. package/managed/errors/CAGE-REACT-001.md +32 -0
  166. package/managed/errors/CAGE-REACT-002.md +32 -0
  167. package/managed/errors/CAGE-READS-001.md +32 -0
  168. package/managed/errors/CAGE-REG-001.md +32 -0
  169. package/managed/errors/CAGE-REG-002.md +32 -0
  170. package/managed/errors/CAGE-REG-003.md +32 -0
  171. package/managed/errors/CAGE-ROUTE-001.md +32 -0
  172. package/managed/errors/CAGE-SCOPE-001.md +32 -0
  173. package/managed/errors/CAGE-SCOPE-002.md +32 -0
  174. package/managed/errors/CAGE-SECRET-001.md +32 -0
  175. package/managed/errors/CAGE-SECRET-002.md +32 -0
  176. package/managed/errors/CAGE-SENS-001.md +32 -0
  177. package/managed/errors/CAGE-SQL-001.md +32 -0
  178. package/managed/errors/CAGE-SQL-002.md +32 -0
  179. package/managed/errors/CAGE-STATE-001.md +32 -0
  180. package/managed/errors/CAGE-STATE-002.md +32 -0
  181. package/managed/errors/CAGE-STATE-003.md +32 -0
  182. package/managed/errors/CAGE-STATE-004.md +32 -0
  183. package/managed/errors/CAGE-STATE-005.md +32 -0
  184. package/managed/errors/CAGE-STATE-010.md +32 -0
  185. package/managed/errors/CAGE-STATE-011.md +32 -0
  186. package/managed/errors/CAGE-SYNC-001.md +35 -0
  187. package/managed/errors/CAGE-TEST-001.md +33 -0
  188. package/managed/errors/CAGE-UNSAFE-001.md +32 -0
  189. package/managed/errors/CAGE-UPGRADE-001.md +32 -0
  190. package/managed/errors/README.md +99 -0
  191. package/package.json +60 -3
  192. package/templates/app/.dockerignore +10 -0
  193. package/templates/app/.env.example +17 -0
  194. package/templates/app/.github/workflows/cage.yml +44 -0
  195. package/templates/app/AGENTS.md +13 -0
  196. package/templates/app/Dockerfile +23 -0
  197. package/templates/app/README.md +58 -0
  198. package/templates/app/apps/api/package.json +16 -0
  199. package/templates/app/apps/api/src/main.ts +10 -0
  200. package/templates/app/apps/api/src/worker.ts +14 -0
  201. package/templates/app/apps/api/tsconfig.json +5 -0
  202. package/templates/app/apps/web/index.html +13 -0
  203. package/templates/app/apps/web/package.json +22 -0
  204. package/templates/app/apps/web/src/main.tsx +552 -0
  205. package/templates/app/apps/web/src/style.css +663 -0
  206. package/templates/app/apps/web/tsconfig.json +11 -0
  207. package/templates/app/apps/web/vite.config.ts +16 -0
  208. package/templates/app/cage.config.json +114 -0
  209. package/templates/app/compose.yaml +39 -0
  210. package/templates/app/db/migrations/0001_init.sql +112 -0
  211. package/templates/app/package.json +25 -0
  212. package/templates/app/packages/contracts/package.json +16 -0
  213. package/templates/app/packages/contracts/src/dept.ts +74 -0
  214. package/templates/app/packages/contracts/src/index.ts +81 -0
  215. package/templates/app/packages/contracts/src/inventory-lock.ts +52 -0
  216. package/templates/app/packages/contracts/src/inventory.ts +57 -0
  217. package/templates/app/packages/contracts/src/purchase-order.ts +72 -0
  218. package/templates/app/packages/contracts/src/queries.ts +17 -0
  219. package/templates/app/packages/contracts/src/role.ts +85 -0
  220. package/templates/app/packages/contracts/src/user.ts +89 -0
  221. package/templates/app/packages/contracts/tsconfig.json +5 -0
  222. package/templates/app/packages/domain/package.json +23 -0
  223. package/templates/app/packages/domain/src/approvals.ts +18 -0
  224. package/templates/app/packages/domain/src/erp.ts +36 -0
  225. package/templates/app/packages/domain/src/index.ts +9 -0
  226. package/templates/app/packages/domain/src/inventory.ts +43 -0
  227. package/templates/app/packages/domain/src/jobs.ts +13 -0
  228. package/templates/app/packages/domain/src/purchase-order.test.ts +137 -0
  229. package/templates/app/packages/domain/src/purchase-order.ts +122 -0
  230. package/templates/app/packages/domain/src/queries.ts +10 -0
  231. package/templates/app/packages/domain/src/reactions.ts +24 -0
  232. package/templates/app/packages/domain/tsconfig.json +5 -0
  233. package/templates/app/pnpm-workspace.yaml +10 -0
  234. package/templates/app/renovate.json +5 -0
  235. package/templates/app/tsconfig.base.json +8 -0
@@ -0,0 +1,552 @@
1
+ // 前端仅依赖生成契约。按钮显隐用于交互,真正的授权仍由后端命令流水线执行。
2
+ import { useEffect, useState, type FormEvent } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import {
6
+ can,
7
+ client,
8
+ currentUser,
9
+ loadMe,
10
+ purchaseOrder,
11
+ inventory,
12
+ usePurchaseOrderList,
13
+ useInventoryList,
14
+ type CageResult,
15
+ type PurchaseOrder,
16
+ } from '@app/contracts';
17
+ import './style.css';
18
+
19
+ const queryClient = new QueryClient();
20
+ const statusLabels: Record<string, string> = {
21
+ draft: '草稿',
22
+ pending_approval: '待审批',
23
+ approved: '已通过',
24
+ received: '已收货',
25
+ rejected: '已驳回',
26
+ cancelled: '已取消',
27
+ };
28
+ const currency = (value: string) =>
29
+ new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(Number(value));
30
+
31
+ /** 将框架错误码转换成操作提示,保留 requestId 供用户向维护人员反馈。 */
32
+ function errorMessage(r: Extract<CageResult<unknown, string>, { ok: false }>): string {
33
+ const labels: Record<string, string> = {
34
+ FORBIDDEN: '你没有执行此操作的权限',
35
+ INVALID_INPUT: '请检查填写内容',
36
+ CONFLICT: '单据已更新,请刷新后重试',
37
+ UNAUTHENTICATED: '登录已过期',
38
+ CONSTRAINT_VIOLATED: '未满足审批条件,请检查审批人或额度',
39
+ STOCK_SHORT: '库存不足',
40
+ NETWORK: '连接失败,请稍后重试',
41
+ };
42
+ return `${labels[r.domainCode ?? r.code] ?? r.domainCode ?? r.code}${r.requestId ? ` · 请求 ${r.requestId.slice(0, 8)}` : ''}`;
43
+ }
44
+
45
+ function Login({ onLogin }: { onLogin: () => void }) {
46
+ const [error, setError] = useState('');
47
+ const [busy, setBusy] = useState(false);
48
+ async function submit(event: FormEvent<HTMLFormElement>) {
49
+ event.preventDefault();
50
+ setBusy(true);
51
+ setError('');
52
+ const data = new FormData(event.currentTarget);
53
+ const result = await client.login(String(data.get('username')), String(data.get('password')));
54
+ setBusy(false);
55
+ if (result.ok) onLogin();
56
+ else setError(errorMessage(result));
57
+ }
58
+ return (
59
+ <main className="login">
60
+ <div className="login-intro">
61
+ <span className="eyebrow">CAGE / OPERATIONS</span>
62
+ <h1>
63
+ 每一笔采购,
64
+ <br />
65
+ 有据可循。
66
+ </h1>
67
+ <p>
68
+ 从需求到审批,再到入库。
69
+ <br />
70
+ 让团队在同一张工作台上协作。
71
+ </p>
72
+ <span className="edition">采购管理 · 示例工作台 / 01</span>
73
+ </div>
74
+ <form onSubmit={submit} className="login-form">
75
+ <span className="eyebrow">WELCOME BACK</span>
76
+ <h2>登录工作台</h2>
77
+ <p className="muted">使用管理员分配的账号继续。</p>
78
+ <label>
79
+ 账号
80
+ <input name="username" autoComplete="username" required autoFocus />
81
+ </label>
82
+ <label>
83
+ 密码
84
+ <input name="password" type="password" autoComplete="current-password" required />
85
+ </label>
86
+ {error && (
87
+ <p className="error" role="alert">
88
+ {error}
89
+ </p>
90
+ )}
91
+ <button className="primary" disabled={busy}>
92
+ {busy ? '正在登录…' : '登录 →'}
93
+ </button>
94
+ <small>首次启动的管理员账号显示在 API 终端。</small>
95
+ </form>
96
+ </main>
97
+ );
98
+ }
99
+
100
+ function OrderForm({
101
+ close,
102
+ run,
103
+ }: {
104
+ close: () => void;
105
+ run: (action: () => Promise<CageResult<unknown, string>>, success: string) => Promise<boolean>;
106
+ }) {
107
+ const [busy, setBusy] = useState(false);
108
+ async function submit(event: FormEvent<HTMLFormElement>) {
109
+ event.preventDefault();
110
+ setBusy(true);
111
+ const data = new FormData(event.currentTarget);
112
+ const ok = await run(
113
+ () =>
114
+ purchaseOrder.create({
115
+ amount: String(data.get('amount')),
116
+ supplierPhone: String(data.get('phone')) || undefined,
117
+ items: [{ sku: String(data.get('sku')), qty: Number(data.get('qty')) }],
118
+ }),
119
+ '采购单已保存为草稿',
120
+ );
121
+ setBusy(false);
122
+ if (ok) close();
123
+ }
124
+ return (
125
+ <div className="modal-backdrop">
126
+ <section role="dialog" aria-modal="true" aria-labelledby="new-order-title" className="modal">
127
+ <div className="section-head">
128
+ <h2 id="new-order-title">新建采购单</h2>
129
+ <button onClick={close} aria-label="关闭">
130
+ ×
131
+ </button>
132
+ </div>
133
+ <form onSubmit={submit}>
134
+ <label>
135
+ 物料编码
136
+ <input name="sku" placeholder="例如 A-01" required autoFocus maxLength={64} />
137
+ </label>
138
+ <div className="form-row">
139
+ <label>
140
+ 数量
141
+ <input name="qty" type="number" min="1" step="1" defaultValue="1" required />
142
+ </label>
143
+ <label>
144
+ 采购金额(元)
145
+ <input name="amount" type="number" min="0.01" step="0.01" required />
146
+ </label>
147
+ </div>
148
+ <label>
149
+ 供应商电话
150
+ <input name="phone" type="tel" maxLength={32} />
151
+ </label>
152
+ <p className="muted">保存后可在列表提交审批,创建人和部门会自动填写。</p>
153
+ <button className="primary" disabled={busy}>
154
+ {busy ? '保存中…' : '保存草稿'}
155
+ </button>
156
+ </form>
157
+ </section>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ type Task = { id: string; businessKey: string; title: string; createdAt: string };
163
+
164
+ function Workspace({ logout }: { logout: () => void }) {
165
+ const [tab, setTab] = useState<'orders' | 'tasks' | 'stock'>('orders');
166
+ const [status, setStatus] = useState('');
167
+ const [page, setPage] = useState(1);
168
+ const [creating, setCreating] = useState(false);
169
+ const [message, setMessage] = useState('');
170
+ const [busy, setBusy] = useState(false);
171
+ const cache = useQueryClient();
172
+ // 列表与统计均来自服务端返回值;不向用户展示写死的演示数字。
173
+ const orders = usePurchaseOrderList({ status, page, pageSize: 10, sort: '-createdAt' });
174
+ const stock = useInventoryList({ pageSize: 100 });
175
+ const tasks = useQuery({
176
+ queryKey: ['tasks'],
177
+ queryFn: async () => {
178
+ const result = await client.get<Task[]>('/api/approval/tasks');
179
+ if (!result.ok) throw new Error(errorMessage(result));
180
+ return result.data;
181
+ },
182
+ });
183
+ async function run(action: () => Promise<CageResult<unknown, string>>, success: string) {
184
+ setBusy(true);
185
+ setMessage('');
186
+ const result = await action();
187
+ setBusy(false);
188
+ setMessage(result.ok ? success : errorMessage(result));
189
+ if (result.ok) await cache.invalidateQueries();
190
+ return result.ok;
191
+ }
192
+ async function exportCsv() {
193
+ const result = await purchaseOrder.export({ status });
194
+ if (!result.ok) {
195
+ setMessage(errorMessage(result));
196
+ return;
197
+ }
198
+ const url = URL.createObjectURL(new Blob(['\uFEFF', result.data], { type: 'text/csv;charset=utf-8' }));
199
+ const link = document.createElement('a');
200
+ link.href = url;
201
+ link.download = '采购单.csv';
202
+ link.click();
203
+ URL.revokeObjectURL(url);
204
+ }
205
+ return (
206
+ <div className="workspace">
207
+ <aside>
208
+ <a className="brand" href="/">
209
+ cage<span>采购工作台</span>
210
+ </a>
211
+ <span className="nav-caption">工作空间</span>
212
+ <nav>
213
+ {(
214
+ [
215
+ ['orders', '01', '采购单'],
216
+ ['tasks', '02', '审批待办'],
217
+ ['stock', '03', '库存台账'],
218
+ ] as const
219
+ ).map(([key, no, label]) => (
220
+ <button key={key} className={tab === key ? 'selected' : ''} onClick={() => setTab(key)}>
221
+ <span>{no}</span>
222
+ {label}
223
+ {key === 'tasks' && <b>{tasks.data?.length ?? 0}</b>}
224
+ </button>
225
+ ))}
226
+ </nav>
227
+ <div className="aside-bottom">
228
+ <a href="/admin/">系统管理 ↗</a>
229
+ <div className="user">
230
+ <span className="avatar">{currentUser()?.name?.slice(0, 1) ?? '用'}</span>
231
+ <div>
232
+ {currentUser()?.name}
233
+ <small>已登录</small>
234
+ </div>
235
+ </div>
236
+ <button
237
+ className="logout"
238
+ onClick={async () => {
239
+ await client.logout();
240
+ cache.clear();
241
+ logout();
242
+ }}
243
+ >
244
+ 退出登录
245
+ </button>
246
+ </div>
247
+ </aside>
248
+ <main className="content">
249
+ <header>
250
+ <span>采购管理 / {tab === 'orders' ? '采购单' : tab === 'tasks' ? '审批待办' : '库存台账'}</span>
251
+ <span className="live-dot">团队工作空间</span>
252
+ </header>
253
+ <div className="page-title">
254
+ <div>
255
+ <span className="eyebrow">
256
+ {tab === 'orders' ? 'PURCHASE ORDERS' : tab === 'tasks' ? 'APPROVAL INBOX' : 'INVENTORY'}
257
+ </span>
258
+ <h1>
259
+ {tab === 'orders' ? '采购单' : tab === 'tasks' ? '审批待办' : '库存台账'}
260
+ <span className="title-dot">.</span>
261
+ </h1>
262
+ <p className="muted">
263
+ {tab === 'orders'
264
+ ? '每一张单据的进展,清晰可见。'
265
+ : tab === 'tasks'
266
+ ? '处理分配给你的审批任务。'
267
+ : '查看物料与当前登记数量。'}
268
+ </p>
269
+ </div>
270
+ {tab === 'orders' && can('PurchaseOrder.create') && (
271
+ <button className="primary" onClick={() => setCreating(true)}>
272
+ + 新建采购单
273
+ </button>
274
+ )}
275
+ </div>
276
+ {message && (
277
+ <div role="status" className="notice">
278
+ {message}
279
+ <button onClick={() => setMessage('')} aria-label="关闭提示">
280
+ ×
281
+ </button>
282
+ </div>
283
+ )}
284
+ {tab === 'orders' && (
285
+ <>
286
+ <div className="summary">
287
+ <div>
288
+ <small>当前筛选单据</small>
289
+ <strong>
290
+ {orders.data?.total ?? '—'}
291
+ <span> 单</span>
292
+ </strong>
293
+ </div>
294
+ <div>
295
+ <small>我的待办</small>
296
+ <strong>
297
+ {tasks.data?.length ?? '—'}
298
+ <span> 项</span>
299
+ </strong>
300
+ </div>
301
+ <div className="summary-note">
302
+ <span>完整记录 · 每步可追溯</span>
303
+ <p>
304
+ 提交、审批和同步进展
305
+ <br />
306
+ 统一记录在单据中。
307
+ </p>
308
+ </div>
309
+ </div>
310
+ <section className="panel">
311
+ <div className="toolbar">
312
+ <div className="filters">
313
+ {[
314
+ ['', '全部'],
315
+ ['draft', '草稿'],
316
+ ['pending_approval', '待审批'],
317
+ ['approved', '已通过'],
318
+ ['received', '已收货'],
319
+ ].map(([value, label]) => (
320
+ <button
321
+ className={status === value ? 'active' : ''}
322
+ key={value}
323
+ onClick={() => {
324
+ setStatus(value);
325
+ setPage(1);
326
+ }}
327
+ >
328
+ {label}
329
+ </button>
330
+ ))}
331
+ </div>
332
+ {can('PurchaseOrder.export') && <button onClick={exportCsv}>导出 CSV ↗</button>}
333
+ </div>
334
+ {orders.isPending ? (
335
+ <p className="empty">正在读取采购单…</p>
336
+ ) : orders.isError ? (
337
+ <p role="alert" className="error">
338
+ 读取失败:{orders.error.message}
339
+ </p>
340
+ ) : (
341
+ <>
342
+ <div className="table-wrap">
343
+ <table>
344
+ <thead>
345
+ <tr>
346
+ <th>单据编号 / 创建时间</th>
347
+ <th>物料</th>
348
+ <th>采购金额</th>
349
+ <th>状态</th>
350
+ <th>ERP 单号</th>
351
+ <th>操作</th>
352
+ </tr>
353
+ </thead>
354
+ <tbody>
355
+ {orders.data.items.map((order) => (
356
+ <tr key={order.id}>
357
+ <td>
358
+ <strong className="mono">PO-{order.id.slice(0, 8).toUpperCase()}</strong>
359
+ <small>{new Date(order.createdAt).toLocaleString('zh-CN')}</small>
360
+ </td>
361
+ <td>{order.items.map((i) => `${i.sku} × ${i.qty}`).join('、')}</td>
362
+ <td className="amount">{currency(order.amount)}</td>
363
+ <td>
364
+ <span className={`badge ${order.status}`}>{statusLabels[order.status]}</span>
365
+ </td>
366
+ <td className="mono muted">{order.erpNo || '—'}</td>
367
+ <td>
368
+ <div className="actions">
369
+ {order.status === 'draft' && can('PurchaseOrder.submit') && (
370
+ <button
371
+ disabled={busy}
372
+ onClick={() =>
373
+ run(() => purchaseOrder.submit(order.id, order.version), '已提交审批')
374
+ }
375
+ >
376
+ 提交审批
377
+ </button>
378
+ )}
379
+ {['draft', 'pending_approval', 'approved'].includes(order.status) &&
380
+ can('PurchaseOrder.cancel') && (
381
+ <button
382
+ disabled={busy}
383
+ onClick={() =>
384
+ run(
385
+ () => purchaseOrder.cancel(order.id, order.version),
386
+ '采购单已取消',
387
+ )
388
+ }
389
+ >
390
+ 取消
391
+ </button>
392
+ )}
393
+ </div>
394
+ </td>
395
+ </tr>
396
+ ))}
397
+ </tbody>
398
+ </table>
399
+ </div>
400
+ {!orders.data.items.length && (
401
+ <div className="empty">
402
+ <span className="empty-symbol">▤</span>
403
+ <h3>暂无采购单</h3>
404
+ <p>新建一张采购单,开始记录你的采购需求。</p>
405
+ </div>
406
+ )}
407
+ <footer className="pagination">
408
+ <span>
409
+ 共 {orders.data.total} 单 · 第 {page} 页
410
+ </span>
411
+ <div>
412
+ <button disabled={page === 1} onClick={() => setPage(page - 1)}>
413
+ 上一页
414
+ </button>
415
+ <button disabled={page * 10 >= orders.data.total} onClick={() => setPage(page + 1)}>
416
+ 下一页
417
+ </button>
418
+ </div>
419
+ </footer>
420
+ </>
421
+ )}
422
+ </section>
423
+ </>
424
+ )}
425
+ {tab === 'tasks' && (
426
+ <section className="panel">
427
+ <div className="section-head">
428
+ <h2>待你处理</h2>
429
+ <button onClick={() => void tasks.refetch()}>刷新</button>
430
+ </div>
431
+ {tasks.isError && <p className="error">{tasks.error.message}</p>}
432
+ {tasks.isPending && <p className="empty">正在读取待办…</p>}
433
+ {tasks.data?.map((task) => (
434
+ <div className="task" key={task.id}>
435
+ <div>
436
+ <h3>{task.title}</h3>
437
+ <p className="mono muted">PO-{task.businessKey.slice(0, 8).toUpperCase()}</p>
438
+ </div>
439
+ <div className="actions">
440
+ <button
441
+ disabled={busy}
442
+ onClick={() => run(() => client.post(`/api/approval/tasks/${task.id}/reject`), '已驳回')}
443
+ >
444
+ 驳回
445
+ </button>
446
+ <button
447
+ disabled={busy}
448
+ className="primary"
449
+ onClick={() =>
450
+ run(() => client.post(`/api/approval/tasks/${task.id}/approve`), '已通过审批')
451
+ }
452
+ >
453
+ 通过审批
454
+ </button>
455
+ </div>
456
+ </div>
457
+ ))}
458
+ {tasks.data?.length === 0 && (
459
+ <div className="empty">
460
+ <h3>待办已清空</h3>
461
+ <p>新任务分配给你后会显示在这里。</p>
462
+ </div>
463
+ )}
464
+ </section>
465
+ )}
466
+ {tab === 'stock' && (
467
+ <section className="panel">
468
+ <div className="section-head">
469
+ <h2>物料台账</h2>
470
+ </div>
471
+ {can('Inventory.create') && (
472
+ <form
473
+ className="stock-form"
474
+ onSubmit={async (event) => {
475
+ event.preventDefault();
476
+ const form = event.currentTarget;
477
+ const data = new FormData(form);
478
+ if (
479
+ await run(
480
+ () =>
481
+ inventory.create({
482
+ sku: String(data.get('sku')),
483
+ available: Number(data.get('available')),
484
+ }),
485
+ '物料已登记',
486
+ )
487
+ )
488
+ form.reset();
489
+ }}
490
+ >
491
+ <label>
492
+ 物料编码
493
+ <input name="sku" required maxLength={64} />
494
+ </label>
495
+ <label>
496
+ 登记数量
497
+ <input name="available" type="number" min="0" step="1" required />
498
+ </label>
499
+ <button className="primary" disabled={busy}>
500
+ 登记物料
501
+ </button>
502
+ </form>
503
+ )}
504
+ {stock.isError && <p className="error">{stock.error.message}</p>}
505
+ <table>
506
+ <thead>
507
+ <tr>
508
+ <th>物料编码</th>
509
+ <th>登记数量</th>
510
+ </tr>
511
+ </thead>
512
+ <tbody>
513
+ {stock.data?.items.map((item) => (
514
+ <tr key={item.id}>
515
+ <td className="mono">{item.sku}</td>
516
+ <td>{item.available}</td>
517
+ </tr>
518
+ ))}
519
+ </tbody>
520
+ </table>
521
+ {stock.data?.items.length === 0 && (
522
+ <p className="empty">尚未登记物料。请先登记采购单使用的物料编码。</p>
523
+ )}
524
+ </section>
525
+ )}
526
+ <div className="page-footer">
527
+ CAGE / PROCUREMENT WORKSPACE<span>业务操作均受权限与数据范围约束</span>
528
+ </div>
529
+ </main>
530
+ {creating && <OrderForm close={() => setCreating(false)} run={run} />}
531
+ </div>
532
+ );
533
+ }
534
+
535
+ function App() {
536
+ const [session, setSession] = useState<'loading' | 'guest' | 'ready'>('loading');
537
+ useEffect(() => {
538
+ void loadMe().then((r) => setSession(r.ok ? 'ready' : 'guest'));
539
+ }, []);
540
+ if (session === 'loading') return <div className="loading">正在连接工作台…</div>;
541
+ return session === 'guest' ? (
542
+ <Login onLogin={() => setSession('ready')} />
543
+ ) : (
544
+ <Workspace logout={() => setSession('guest')} />
545
+ );
546
+ }
547
+
548
+ createRoot(document.getElementById('root')!).render(
549
+ <QueryClientProvider client={queryClient}>
550
+ <App />
551
+ </QueryClientProvider>,
552
+ );