openxiangda-cli 2.0.0-alpha.57 → 2.0.0-alpha.59

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 (39) hide show
  1. package/dist/commands/create.d.ts.map +1 -1
  2. package/dist/commands/create.js +13 -5
  3. package/dist/commands/create.js.map +1 -1
  4. package/dist/create-workspace.d.ts +6 -1
  5. package/dist/create-workspace.d.ts.map +1 -1
  6. package/dist/create-workspace.js +12 -9
  7. package/dist/create-workspace.js.map +1 -1
  8. package/package.json +4 -4
  9. package/template/README.md +3 -1
  10. package/template/apps/server/package.json +1 -1
  11. package/template/apps/web/e2e/instruments.spec.ts +1197 -9
  12. package/template/apps/web/package.json +4 -2
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/scripts/verify-build.mjs +14 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  16. package/template/apps/web/src/CollegePage.tsx +181 -0
  17. package/template/apps/web/src/FilePreviewPage.tsx +303 -0
  18. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  19. package/template/apps/web/src/InstrumentForm.tsx +347 -94
  20. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  21. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  22. package/template/apps/web/src/Shell.tsx +229 -41
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
  24. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  25. package/template/apps/web/src/data-provider.ts +1 -1
  26. package/template/apps/web/src/fields.ts +17 -6
  27. package/template/apps/web/src/instrument.ts +1 -1
  28. package/template/apps/web/src/main.tsx +21 -1
  29. package/template/apps/web/src/platform-client.ts +248 -2
  30. package/template/apps/web/src/runtime-meta.ts +15 -0
  31. package/template/apps/web/src/runtime.tsx +4 -2
  32. package/template/apps/web/src/styles.css +1007 -28
  33. package/template/apps/web/test/contracts.test.ts +473 -10
  34. package/template/openxiangda.config.ts +24 -5
  35. package/template/package.json +3 -3
  36. package/template/packages/contracts/src/generated.ts +22 -0
  37. package/template/platform/data/colleges.ts +21 -0
  38. package/template/platform/data/instruments.ts +1 -1
  39. package/template/scripts/verify-template-budget.mjs +2 -2
@@ -15,10 +15,12 @@
15
15
  "@refinedev/core": "5.0.12",
16
16
  "antd": "6.4.3",
17
17
  "dayjs": "1.11.18",
18
- "openxiangda-contracts": "2.0.0-alpha.26",
18
+ "docx-preview": "0.3.7",
19
+ "openxiangda-contracts": "2.0.0-alpha.28",
19
20
  "react": "19.2.8",
20
21
  "react-dom": "19.2.8",
21
- "react-router-dom": "7.8.2"
22
+ "react-router-dom": "7.8.2",
23
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@playwright/test": "1.62.1",
@@ -20,9 +20,9 @@ const lines = files.reduce(
20
20
  0
21
21
  );
22
22
  if (files.length > 18) throw new Error(`WEB_SOURCE_FILE_BUDGET_EXCEEDED:${files.length}>18`);
23
- if (lines > 1_600) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>1600`);
23
+ if (lines > 4_300) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>4300`);
24
24
  const source = files.map(file => readFileSync(file, 'utf8')).join('\n');
25
- for (const marker of ['@umijs/', 'openxiangda-' + 'admin', 'openxiangda-' + 'user', 'instrument_query', 'instrument_save', 'function.invoke', 'role' + '-session']) {
25
+ for (const marker of ['@umijs/', 'openxiangda-' + 'admin', 'openxiangda-' + 'user', 'instrument_query', 'instrument_save', 'function.invoke', 'role' + '-session', 'college-' + 'am', 'user-' + 'wang', 'dept-' + 'am-test']) {
26
26
  if (source.includes(marker)) throw new Error(`WEB_FORBIDDEN_MARKER:${marker}`);
27
27
  }
28
28
  const playwright = readFileSync(join(root, 'playwright.config.ts'), 'utf8');
@@ -13,8 +13,20 @@ const gzip = javascript.reduce(
13
13
  (bytes, file) => bytes + gzipSync(readFileSync(file), { level: 9 }).byteLength,
14
14
  0
15
15
  );
16
- if (gzip > 600_000) throw new Error(`WEB_JS_GZIP_BUDGET_EXCEEDED:${gzip}>600000`);
16
+ if (gzip > 750_000) throw new Error(`WEB_JS_GZIP_BUDGET_EXCEEDED:${gzip}>750000`);
17
17
  if (names.some(name => name.endsWith('.map'))) throw new Error('WEB_SOURCE_MAP_FORBIDDEN');
18
18
  const html = readFileSync(join(dist, 'index.html'), 'utf8');
19
19
  if (!html.includes('<div id="root"></div>')) throw new Error('WEB_ROOT_MISSING');
20
- process.stdout.write(`Verified Vite build: ${total} bytes, ${gzip} gzip bytes.\n`);
20
+ const initialNames = [
21
+ ...html.matchAll(/(?:src|href)="\.\/assets\/([^"]+\.js)"/g),
22
+ ].map(match => match[1]);
23
+ const initialGzip = [...new Set(initialNames)].reduce((bytes, name) => {
24
+ const file = join(assets, name);
25
+ return bytes + gzipSync(readFileSync(file), { level: 9 }).byteLength;
26
+ }, 0);
27
+ if (initialGzip > 600_000) {
28
+ throw new Error(`WEB_INITIAL_JS_GZIP_BUDGET_EXCEEDED:${initialGzip}>600000`);
29
+ }
30
+ process.stdout.write(
31
+ `Verified Vite build: ${total} bytes, ${gzip} total gzip bytes, ${initialGzip} initial gzip bytes.\n`
32
+ );
@@ -0,0 +1,371 @@
1
+ import {
2
+ ApartmentOutlined,
3
+ BankOutlined,
4
+ UserOutlined,
5
+ } from '@ant-design/icons';
6
+ import { Avatar, Select, Space, Spin, Tag, Typography } from 'antd';
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ import {
9
+ resolveCollegeScope,
10
+ resolveDirectory,
11
+ searchCollegeScope,
12
+ searchDirectory,
13
+ type DirectoryKind,
14
+ } from './platform-client';
15
+
16
+ type Source = DirectoryKind | 'college';
17
+ type Option = {
18
+ value: string;
19
+ label: string;
20
+ selectable: boolean;
21
+ description?: string;
22
+ path?: string[];
23
+ source: Source;
24
+ };
25
+
26
+ function mergeOptions(current: Option[], incoming: Option[]) {
27
+ const merged = new Map(current.map(option => [option.value, option]));
28
+ for (const option of incoming) merged.set(option.value, option);
29
+ return [...merged.values()];
30
+ }
31
+
32
+ function directoryOption(
33
+ source: DirectoryKind,
34
+ item: {
35
+ id: string;
36
+ label: string;
37
+ selectable: boolean;
38
+ description?: string;
39
+ path?: Array<{ label: string }>;
40
+ }
41
+ ): Option {
42
+ return {
43
+ source,
44
+ value: item.id,
45
+ label: item.label,
46
+ selectable: item.selectable,
47
+ ...(item.description ? { description: item.description } : {}),
48
+ ...(item.path?.length
49
+ ? { path: item.path.map(entry => entry.label) }
50
+ : {}),
51
+ };
52
+ }
53
+
54
+ async function searchSource(
55
+ source: Source,
56
+ operation: 'create' | 'update',
57
+ keyword: string,
58
+ cursor?: string
59
+ ) {
60
+ if (source === 'college') {
61
+ const page = await searchCollegeScope(operation, { keyword, cursor });
62
+ return {
63
+ items: page.items.map(item => ({ ...item, source }) as Option),
64
+ nextCursor: page.nextCursor,
65
+ };
66
+ }
67
+ const page = await searchDirectory(source, { keyword, cursor });
68
+ return {
69
+ items: page.items.map(item => directoryOption(source, item)),
70
+ nextCursor: page.nextCursor,
71
+ };
72
+ }
73
+
74
+ async function resolveSource(
75
+ source: Source,
76
+ operation: 'create' | 'update',
77
+ values: string[]
78
+ ) {
79
+ if (source === 'college') {
80
+ return (await resolveCollegeScope(operation, values)).items.map(
81
+ item => ({ ...item, source }) as Option
82
+ );
83
+ }
84
+ return (await resolveDirectory(source, values)).items.map(item =>
85
+ directoryOption(source, item)
86
+ );
87
+ }
88
+
89
+ function optionIcon(option: Pick<Option, 'source' | 'label'>) {
90
+ if (option.source === 'user') {
91
+ return (
92
+ <Avatar className="oxa-selector-avatar" size={28}>
93
+ {option.label.slice(0, 1) || <UserOutlined />}
94
+ </Avatar>
95
+ );
96
+ }
97
+ return option.source === 'college' ? (
98
+ <BankOutlined className="oxa-selector-icon" />
99
+ ) : (
100
+ <ApartmentOutlined className="oxa-selector-icon" />
101
+ );
102
+ }
103
+
104
+ function optionDescription(option: Option) {
105
+ if (option.path?.length) return option.path.join(' / ');
106
+ return option.description || '';
107
+ }
108
+
109
+ export function AuthoritativeSelector({
110
+ value,
111
+ onChange,
112
+ source,
113
+ operation,
114
+ multiple = false,
115
+ disabled = false,
116
+ id,
117
+ placeholder,
118
+ }: {
119
+ value?: string | string[];
120
+ onChange?: (value: string | string[] | undefined) => void;
121
+ source: Source;
122
+ operation: 'create' | 'update';
123
+ multiple?: boolean;
124
+ disabled?: boolean;
125
+ id?: string;
126
+ placeholder: string;
127
+ }) {
128
+ const [options, setOptions] = useState<Option[]>([]);
129
+ const [keyword, setKeyword] = useState('');
130
+ const [cursor, setCursor] = useState<string | null>(null);
131
+ const [loading, setLoading] = useState(false);
132
+ const [error, setError] = useState('');
133
+ const [open, setOpen] = useState(false);
134
+ const requestSequence = useRef(0);
135
+ const values = useMemo(
136
+ () => (Array.isArray(value) ? value : value ? [value] : []),
137
+ [value]
138
+ );
139
+
140
+ useEffect(() => {
141
+ const missing = values.filter(
142
+ selected => !options.some(option => option.value === selected)
143
+ );
144
+ if (missing.length === 0) return;
145
+ let active = true;
146
+ setLoading(true);
147
+ void resolveSource(source, operation, missing)
148
+ .then(items => {
149
+ if (active) setOptions(current => mergeOptions(current, items));
150
+ })
151
+ .catch(reason => {
152
+ if (active)
153
+ setError(reason instanceof Error ? reason.message : String(reason));
154
+ })
155
+ .finally(() => {
156
+ if (active) setLoading(false);
157
+ });
158
+ return () => {
159
+ active = false;
160
+ };
161
+ }, [operation, options, source, values]);
162
+
163
+ useEffect(() => {
164
+ if (disabled) return;
165
+ if (source !== 'college' && keyword.trim().length < 2) {
166
+ setCursor(null);
167
+ return;
168
+ }
169
+ const sequence = ++requestSequence.current;
170
+ const timer = window.setTimeout(() => {
171
+ setLoading(true);
172
+ setError('');
173
+ void searchSource(source, operation, keyword)
174
+ .then(page => {
175
+ if (sequence !== requestSequence.current) return;
176
+ setOptions(current => {
177
+ const selected = current.filter(option =>
178
+ values.includes(option.value)
179
+ );
180
+ return mergeOptions(selected, page.items);
181
+ });
182
+ setCursor(page.nextCursor);
183
+ })
184
+ .catch(reason => {
185
+ if (sequence === requestSequence.current)
186
+ setError(
187
+ reason instanceof Error ? reason.message : String(reason)
188
+ );
189
+ })
190
+ .finally(() => {
191
+ if (sequence === requestSequence.current) setLoading(false);
192
+ });
193
+ }, 300);
194
+ return () => window.clearTimeout(timer);
195
+ }, [disabled, keyword, operation, source, values]);
196
+
197
+ const loadNext = () => {
198
+ if (!cursor || loading) return;
199
+ const nextCursor = cursor;
200
+ setLoading(true);
201
+ setError('');
202
+ void searchSource(source, operation, keyword, nextCursor)
203
+ .then(page => {
204
+ setOptions(current => mergeOptions(current, page.items));
205
+ setCursor(page.nextCursor);
206
+ })
207
+ .catch(reason =>
208
+ setError(reason instanceof Error ? reason.message : String(reason))
209
+ )
210
+ .finally(() => setLoading(false));
211
+ };
212
+
213
+ return (
214
+ <Select
215
+ allowClear
216
+ className={`oxa-authoritative-selector oxa-selector-${source}`}
217
+ disabled={disabled}
218
+ id={id}
219
+ labelRender={({ label, value: selectedValue }) => {
220
+ const selected = options.find(
221
+ option => option.value === String(selectedValue)
222
+ );
223
+ return selected?.label || label || '读取中…';
224
+ }}
225
+ loading={loading}
226
+ maxTagCount="responsive"
227
+ mode={multiple ? 'multiple' : undefined}
228
+ notFoundContent={
229
+ loading ? (
230
+ <Spin size="small" />
231
+ ) : error ? (
232
+ <Typography.Text type="danger">{error}</Typography.Text>
233
+ ) : source !== 'college' && keyword.trim().length < 2 ? (
234
+ '请输入至少 2 个字符'
235
+ ) : (
236
+ '无可选数据'
237
+ )
238
+ }
239
+ onChange={next => {
240
+ onChange?.(next || undefined);
241
+ if (!multiple) setOpen(false);
242
+ }}
243
+ onOpenChange={setOpen}
244
+ onPopupScroll={event => {
245
+ const element = event.currentTarget;
246
+ if (
247
+ element.scrollTop + element.clientHeight >=
248
+ element.scrollHeight - 16
249
+ )
250
+ loadNext();
251
+ }}
252
+ open={open}
253
+ optionRender={option => {
254
+ const item = options.find(
255
+ current => current.value === String(option.value)
256
+ );
257
+ if (!item) return option.label;
258
+ const description = optionDescription(item);
259
+ return (
260
+ <Space className="oxa-selector-option" size={10}>
261
+ {optionIcon(item)}
262
+ <span className="oxa-selector-option-copy">
263
+ <strong>{item.label}</strong>
264
+ {description && <small>{description}</small>}
265
+ </span>
266
+ </Space>
267
+ );
268
+ }}
269
+ options={options.map(option => ({
270
+ label: option.label,
271
+ value: option.value,
272
+ disabled: !option.selectable,
273
+ }))}
274
+ placeholder={placeholder}
275
+ prefix={
276
+ source === 'user' ? (
277
+ <UserOutlined />
278
+ ) : source === 'college' ? (
279
+ <BankOutlined />
280
+ ) : (
281
+ <ApartmentOutlined />
282
+ )
283
+ }
284
+ showSearch={{
285
+ filterOption: false,
286
+ onSearch: next => {
287
+ setKeyword(next);
288
+ setOpen(true);
289
+ },
290
+ }}
291
+ status={error ? 'error' : undefined}
292
+ tagRender={({ label, value: selectedValue, closable, onClose }) => {
293
+ const selected = options.find(
294
+ option => option.value === String(selectedValue)
295
+ );
296
+ return (
297
+ <Tag
298
+ className="oxa-selector-tag"
299
+ closable={closable}
300
+ onClose={onClose}
301
+ onMouseDown={event => {
302
+ event.preventDefault();
303
+ event.stopPropagation();
304
+ }}
305
+ >
306
+ {selected && optionIcon(selected)}
307
+ <span>{selected?.label || label || '读取中…'}</span>
308
+ {selected?.description && <small>{selected.description}</small>}
309
+ </Tag>
310
+ );
311
+ }}
312
+ value={value}
313
+ />
314
+ );
315
+ }
316
+
317
+ export function ResolvedValueText({
318
+ source,
319
+ value,
320
+ }: {
321
+ source: Source;
322
+ value?: string | string[];
323
+ }) {
324
+ const values = useMemo(
325
+ () => (Array.isArray(value) ? value : value ? [value] : []),
326
+ [value]
327
+ );
328
+ const [items, setItems] = useState<Option[]>([]);
329
+ const [failed, setFailed] = useState(false);
330
+ useEffect(() => {
331
+ if (values.length === 0) {
332
+ setItems([]);
333
+ return;
334
+ }
335
+ let active = true;
336
+ setFailed(false);
337
+ void resolveSource(source, 'update', values)
338
+ .then(next => {
339
+ if (active) setItems(next);
340
+ })
341
+ .catch(() => {
342
+ if (active) setFailed(true);
343
+ });
344
+ return () => {
345
+ active = false;
346
+ };
347
+ }, [source, values]);
348
+ if (values.length === 0) return <>-</>;
349
+ if (failed) return <Typography.Text type="danger">目录解析失败</Typography.Text>;
350
+ if (!items.length) return <>读取中…</>;
351
+ const ordered = values.map(value => items.find(item => item.value === value));
352
+ return (
353
+ <span className={`oxa-resolved-values oxa-resolved-${source}`}>
354
+ {ordered.map((item, index) =>
355
+ item ? (
356
+ <span className="oxa-resolved-item" key={item.value}>
357
+ {optionIcon(item)}
358
+ <span>
359
+ <strong>{item.label}</strong>
360
+ {optionDescription(item) && <small>{optionDescription(item)}</small>}
361
+ </span>
362
+ </span>
363
+ ) : (
364
+ <Typography.Text key={values[index]} type="secondary">
365
+ 目录项不可用
366
+ </Typography.Text>
367
+ )
368
+ )}
369
+ </span>
370
+ );
371
+ }
@@ -0,0 +1,181 @@
1
+ import { EditOutlined, PlusOutlined } from '@ant-design/icons';
2
+ import {
3
+ App,
4
+ Button,
5
+ Card,
6
+ Drawer,
7
+ Form,
8
+ Input,
9
+ Result,
10
+ Space,
11
+ Switch,
12
+ Table,
13
+ Tag,
14
+ } from 'antd';
15
+ import { useEffect, useState } from 'react';
16
+ import { Shell } from './Shell';
17
+ import {
18
+ collegeDataApi,
19
+ type CollegeRecord,
20
+ } from './platform-client';
21
+ import { useRuntime } from './runtime';
22
+
23
+ const capabilities = {
24
+ read: 'app:instrument-center:data:colleges:read',
25
+ create: 'app:instrument-center:data:colleges:create',
26
+ update: 'app:instrument-center:data:colleges:update',
27
+ } as const;
28
+
29
+ export function CollegePage() {
30
+ const { hasCapability } = useRuntime();
31
+ const { message } = App.useApp();
32
+ const [form] = Form.useForm<{ name: string; enabled: boolean }>();
33
+ const [rows, setRows] = useState<CollegeRecord[]>([]);
34
+ const [loading, setLoading] = useState(false);
35
+ const [editing, setEditing] = useState<CollegeRecord>();
36
+ const [open, setOpen] = useState(false);
37
+ const [loadError, setLoadError] = useState('');
38
+ const refresh = async () => {
39
+ setLoading(true);
40
+ setLoadError('');
41
+ try {
42
+ setRows((await collegeDataApi.list()).rows);
43
+ } catch (error) {
44
+ const reason = error instanceof Error ? error.message : String(error);
45
+ setLoadError(reason);
46
+ message.error(reason);
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ };
51
+ useEffect(() => {
52
+ if (hasCapability(capabilities.read)) void refresh();
53
+ }, []);
54
+ if (!hasCapability(capabilities.read)) {
55
+ return (
56
+ <Shell>
57
+ <Result status="403" title="当前平台用户无学院字典权限" />
58
+ </Shell>
59
+ );
60
+ }
61
+ const submit = async () => {
62
+ const values = await form.validateFields();
63
+ if (editing) {
64
+ await collegeDataApi.update(editing.id, editing.revision, values);
65
+ } else {
66
+ await collegeDataApi.create(values);
67
+ }
68
+ setOpen(false);
69
+ setEditing(undefined);
70
+ form.resetFields();
71
+ message.success(editing ? '学院已更新' : '学院已创建');
72
+ await refresh();
73
+ };
74
+ return (
75
+ <Shell>
76
+ <Card>
77
+ <div className="oxa-page-heading">
78
+ <div>
79
+ <h1 className="oxa-title">学院字典</h1>
80
+ <div className="oxa-muted">
81
+ 仪器归属保存学院记录 UUID;停用不会改写已有仪器
82
+ </div>
83
+ </div>
84
+ {hasCapability(capabilities.create) && (
85
+ <Button
86
+ icon={<PlusOutlined />}
87
+ onClick={() => {
88
+ setEditing(undefined);
89
+ form.setFieldsValue({ name: '', enabled: true });
90
+ setOpen(true);
91
+ }}
92
+ type="primary"
93
+ >
94
+ 新增学院
95
+ </Button>
96
+ )}
97
+ </div>
98
+ {loadError ? (
99
+ <Result
100
+ extra={<Button onClick={() => void refresh()}>重新加载</Button>}
101
+ status="error"
102
+ subTitle={loadError}
103
+ title="学院字典暂不可用"
104
+ />
105
+ ) : (
106
+ <Table<CollegeRecord>
107
+ dataSource={rows}
108
+ loading={loading}
109
+ pagination={false}
110
+ rowKey="id"
111
+ columns={[
112
+ { title: '学院名称', dataIndex: 'name' },
113
+ {
114
+ title: '状态',
115
+ dataIndex: 'enabled',
116
+ render: value => (
117
+ <Tag color={value ? 'green' : 'default'}>
118
+ {value ? '启用' : '停用'}
119
+ </Tag>
120
+ ),
121
+ },
122
+ {
123
+ title: '操作',
124
+ render: (_, record) =>
125
+ hasCapability(capabilities.update) ? (
126
+ <Button
127
+ icon={<EditOutlined />}
128
+ onClick={() => {
129
+ setEditing(record);
130
+ form.setFieldsValue({
131
+ name: record.name,
132
+ enabled: record.enabled,
133
+ });
134
+ setOpen(true);
135
+ }}
136
+ >
137
+ 编辑
138
+ </Button>
139
+ ) : null,
140
+ },
141
+ ]}
142
+ />
143
+ )}
144
+ </Card>
145
+ <Drawer
146
+ className="oxa-dictionary-drawer"
147
+ destroyOnHidden
148
+ extra={
149
+ <Space>
150
+ <Button onClick={() => setOpen(false)}>取消</Button>
151
+ <Button
152
+ onClick={() =>
153
+ void submit().catch(error => message.error(error.message))
154
+ }
155
+ type="primary"
156
+ >
157
+ 保存
158
+ </Button>
159
+ </Space>
160
+ }
161
+ onClose={() => setOpen(false)}
162
+ open={open}
163
+ size={520}
164
+ title={editing ? '编辑学院' : '新增学院'}
165
+ >
166
+ <Form form={form} layout="vertical">
167
+ <Form.Item
168
+ label="学院名称"
169
+ name="name"
170
+ rules={[{ required: true, message: '请输入学院名称' }]}
171
+ >
172
+ <Input />
173
+ </Form.Item>
174
+ <Form.Item label="启用" name="enabled" valuePropName="checked">
175
+ <Switch />
176
+ </Form.Item>
177
+ </Form>
178
+ </Drawer>
179
+ </Shell>
180
+ );
181
+ }