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
@@ -0,0 +1,303 @@
1
+ import {
2
+ CloseOutlined,
3
+ DownloadOutlined,
4
+ FileExcelOutlined,
5
+ FileImageOutlined,
6
+ FilePdfOutlined,
7
+ FileWordOutlined,
8
+ PaperClipOutlined,
9
+ } from '@ant-design/icons';
10
+ import { Alert, Button, Empty, Result, Segmented, Spin, Typography } from 'antd';
11
+ import type { DataFilePreview } from 'openxiangda-contracts/browser';
12
+ import { useEffect, useMemo, useRef, useState } from 'react';
13
+ import { useParams } from 'react-router-dom';
14
+ import {
15
+ dataFileContentUrl,
16
+ fetchDataFileBlob,
17
+ loadDataFilePreview,
18
+ } from './platform-client';
19
+ import { formatManagedFileSize } from './components/platform-fields/AttachmentFileList';
20
+
21
+ const MAX_SPREADSHEET_ROWS = 5_000;
22
+ const MAX_SPREADSHEET_COLUMNS = 200;
23
+
24
+ function DocxDocument({ blob }: { blob: Blob }) {
25
+ const styleRef = useRef<HTMLDivElement>(null);
26
+ const bodyRef = useRef<HTMLDivElement>(null);
27
+ const [error, setError] = useState('');
28
+ useEffect(() => {
29
+ let active = true;
30
+ const body = bodyRef.current;
31
+ const style = styleRef.current;
32
+ if (!body || !style) return;
33
+ body.replaceChildren();
34
+ style.replaceChildren();
35
+ setError('');
36
+ void import('docx-preview')
37
+ .then(({ renderAsync }) =>
38
+ renderAsync(blob, body, style, {
39
+ className: 'oxa-docx-document',
40
+ inWrapper: true,
41
+ ignoreFonts: false,
42
+ ignoreHeight: false,
43
+ ignoreWidth: false,
44
+ renderChanges: false,
45
+ renderHeaders: true,
46
+ renderFooters: true,
47
+ })
48
+ )
49
+ .catch(reason => {
50
+ if (active) {
51
+ setError(reason instanceof Error ? reason.message : String(reason));
52
+ }
53
+ });
54
+ return () => {
55
+ active = false;
56
+ body.replaceChildren();
57
+ style.replaceChildren();
58
+ };
59
+ }, [blob]);
60
+ if (error) {
61
+ return <Alert message="Word 文档解析失败" description={error} type="error" showIcon />;
62
+ }
63
+ return (
64
+ <div className="oxa-docx-preview">
65
+ <div ref={styleRef} />
66
+ <div ref={bodyRef} />
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function SpreadsheetDocument({ blob }: { blob: Blob }) {
72
+ const [sheetName, setSheetName] = useState('');
73
+ const [sheets, setSheets] = useState<Record<string, unknown[][]>>({});
74
+ const [error, setError] = useState('');
75
+ useEffect(() => {
76
+ let active = true;
77
+ setError('');
78
+ setSheets({});
79
+ void Promise.all([blob.arrayBuffer(), import('xlsx')])
80
+ .then(([buffer, XLSX]) => {
81
+ const workbook = XLSX.read(buffer, {
82
+ type: 'array',
83
+ cellFormula: false,
84
+ cellHTML: false,
85
+ cellText: true,
86
+ });
87
+ if (!active) return;
88
+ setSheets(
89
+ Object.fromEntries(
90
+ workbook.SheetNames.slice(0, 50).map(name => [
91
+ name,
92
+ (
93
+ XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets[name]!, {
94
+ header: 1,
95
+ raw: false,
96
+ blankrows: false,
97
+ defval: '',
98
+ }) as unknown[][]
99
+ )
100
+ .slice(0, MAX_SPREADSHEET_ROWS)
101
+ .map(row => row.slice(0, MAX_SPREADSHEET_COLUMNS)),
102
+ ])
103
+ )
104
+ );
105
+ setSheetName(workbook.SheetNames[0] || '');
106
+ })
107
+ .catch(reason => {
108
+ if (active) {
109
+ setError(reason instanceof Error ? reason.message : String(reason));
110
+ }
111
+ });
112
+ return () => {
113
+ active = false;
114
+ };
115
+ }, [blob]);
116
+ const rows = sheets[sheetName] || [];
117
+ if (error) {
118
+ return <Alert message="Excel 文件解析失败" description={error} type="error" showIcon />;
119
+ }
120
+ const sheetNames = Object.keys(sheets);
121
+ if (!sheetNames.length) return <Spin description="正在解析 Excel 文件" />;
122
+ return (
123
+ <div className="oxa-spreadsheet-preview">
124
+ {sheetNames.length > 1 && (
125
+ <Segmented
126
+ block
127
+ onChange={value => setSheetName(String(value))}
128
+ options={sheetNames}
129
+ value={sheetName}
130
+ />
131
+ )}
132
+ <div className="oxa-spreadsheet-scroll">
133
+ {rows.length ? (
134
+ <table>
135
+ <tbody>
136
+ {rows.map((row, rowIndex) => (
137
+ <tr key={rowIndex}>
138
+ <th>{rowIndex + 1}</th>
139
+ {row.map((cell, columnIndex) => (
140
+ <td key={columnIndex}>{String(cell ?? '')}</td>
141
+ ))}
142
+ </tr>
143
+ ))}
144
+ </tbody>
145
+ </table>
146
+ ) : (
147
+ <Empty description="当前工作表没有可显示的数据" />
148
+ )}
149
+ </div>
150
+ {(rows.length >= MAX_SPREADSHEET_ROWS ||
151
+ rows.some(row => row.length >= MAX_SPREADSHEET_COLUMNS)) && (
152
+ <Alert
153
+ banner
154
+ message={`为保证浏览器稳定,仅展示前 ${MAX_SPREADSHEET_ROWS} 行、${MAX_SPREADSHEET_COLUMNS} 列`}
155
+ type="warning"
156
+ />
157
+ )}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function PreviewBody({ preview, blob }: { preview: DataFilePreview; blob?: Blob }) {
163
+ const objectUrl = useMemo(() => (blob ? URL.createObjectURL(blob) : ''), [blob]);
164
+ useEffect(
165
+ () => () => {
166
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
167
+ },
168
+ [objectUrl]
169
+ );
170
+ if (!preview.canPreview || preview.renderMode === 'download') {
171
+ return (
172
+ <Empty
173
+ description={
174
+ <div>
175
+ <Typography.Text strong>当前文件无法在线预览</Typography.Text>
176
+ <br />
177
+ <Typography.Text type="secondary">
178
+ {preview.unsupportedReason || '请下载后使用本地应用打开'}
179
+ </Typography.Text>
180
+ </div>
181
+ }
182
+ />
183
+ );
184
+ }
185
+ if (!blob) return <Spin description="正在读取文件内容" />;
186
+ if (preview.previewType === 'image') {
187
+ return <img alt={preview.file.name} className="oxa-preview-image" src={objectUrl} />;
188
+ }
189
+ if (preview.renderMode === 'pdfjs') {
190
+ return (
191
+ <iframe
192
+ className="oxa-preview-frame"
193
+ src={objectUrl}
194
+ title={`${preview.file.name} PDF 预览`}
195
+ />
196
+ );
197
+ }
198
+ if (preview.renderMode === 'docx-html') return <DocxDocument blob={blob} />;
199
+ if (
200
+ preview.renderMode === 'excel-client' ||
201
+ preview.renderMode === 'excel-basic'
202
+ ) {
203
+ return <SpreadsheetDocument blob={blob} />;
204
+ }
205
+ return (
206
+ <Empty description={preview.unsupportedReason || '当前文件没有可用的预览器'} />
207
+ );
208
+ }
209
+
210
+ function previewIcon(preview?: DataFilePreview) {
211
+ if (preview?.previewType === 'image') return <FileImageOutlined />;
212
+ if (preview?.previewType === 'pdf') return <FilePdfOutlined />;
213
+ if (preview?.previewType === 'spreadsheet') return <FileExcelOutlined />;
214
+ if (preview?.previewType === 'office') return <FileWordOutlined />;
215
+ return <PaperClipOutlined />;
216
+ }
217
+
218
+ export function FilePreviewPage() {
219
+ const { resourceCode = '', fileId = '' } = useParams();
220
+ const [preview, setPreview] = useState<DataFilePreview>();
221
+ const [blob, setBlob] = useState<Blob>();
222
+ const [error, setError] = useState('');
223
+ useEffect(() => {
224
+ let active = true;
225
+ setPreview(undefined);
226
+ setBlob(undefined);
227
+ setError('');
228
+ if (!resourceCode || !fileId) {
229
+ setError('OPENXIANGDA_NATIVE_DATA_FILE_PREVIEW_PATH_INVALID');
230
+ return () => {
231
+ active = false;
232
+ };
233
+ }
234
+ void loadDataFilePreview(resourceCode, fileId)
235
+ .then(async metadata => {
236
+ if (!active) return;
237
+ setPreview(metadata);
238
+ document.title = `${metadata.file.name} - 文件预览`;
239
+ if (metadata.canPreview && metadata.renderMode !== 'download') {
240
+ const content = await fetchDataFileBlob(resourceCode, fileId);
241
+ if (active) setBlob(content);
242
+ }
243
+ })
244
+ .catch(reason => {
245
+ if (active) setError(reason instanceof Error ? reason.message : String(reason));
246
+ });
247
+ return () => {
248
+ active = false;
249
+ };
250
+ }, [fileId, resourceCode]);
251
+
252
+ const downloadUrl =
253
+ resourceCode && fileId
254
+ ? dataFileContentUrl(resourceCode, fileId, 'attachment')
255
+ : '';
256
+ return (
257
+ <div className="oxa-file-preview-page">
258
+ <header className="oxa-file-preview-toolbar">
259
+ <div className="oxa-file-preview-title">
260
+ {previewIcon(preview)}
261
+ <div>
262
+ <strong>{preview?.file.name || '文件预览'}</strong>
263
+ <span>
264
+ {preview
265
+ ? `${preview.extension.toUpperCase() || '文件'} · ${formatManagedFileSize(preview.file.size)} · 只读预览`
266
+ : '正在加载预览信息'}
267
+ </span>
268
+ </div>
269
+ </div>
270
+ <div>
271
+ {downloadUrl && (
272
+ <Button href={downloadUrl} icon={<DownloadOutlined />}>
273
+ 下载文件
274
+ </Button>
275
+ )}
276
+ <Button icon={<CloseOutlined />} onClick={() => window.close()}>
277
+ 关闭
278
+ </Button>
279
+ </div>
280
+ </header>
281
+ <main className="oxa-file-preview-body">
282
+ {error ? (
283
+ <Result
284
+ status="error"
285
+ title="文件预览加载失败"
286
+ subTitle={error}
287
+ extra={
288
+ downloadUrl ? (
289
+ <Button href={downloadUrl} icon={<DownloadOutlined />} type="primary">
290
+ 下载文件
291
+ </Button>
292
+ ) : undefined
293
+ }
294
+ />
295
+ ) : preview ? (
296
+ <PreviewBody blob={blob} preview={preview} />
297
+ ) : (
298
+ <Spin description="正在确认文件权限与预览能力" />
299
+ )}
300
+ </main>
301
+ </div>
302
+ );
303
+ }
@@ -1,60 +1,134 @@
1
- import { useOne } from '@refinedev/core';
2
- import { Button, Result, Space, Spin, Typography } from 'antd';
1
+ import {
2
+ ArrowLeftOutlined,
3
+ EditOutlined,
4
+ HistoryOutlined,
5
+ } from '@ant-design/icons';
6
+ import { Button, Card, Result, Space, Spin, Tag, Typography } from 'antd';
7
+ import { useEffect, useState } from 'react';
8
+ import type { DataAuditEntry } from 'openxiangda-contracts/browser';
3
9
  import { useNavigate, useParams } from 'react-router-dom';
4
10
  import { InstrumentForm } from './InstrumentForm';
5
11
  import { Shell } from './Shell';
6
12
  import { INSTRUMENT_CAPABILITIES, type InstrumentRecord } from './instrument';
13
+ import { instrumentDataApi } from './platform-client';
7
14
  import { useRuntime } from './runtime';
15
+ import { useOne } from '@refinedev/core';
8
16
 
9
17
  export function InstrumentDetailPage() {
10
18
  const { id = '' } = useParams();
11
19
  const navigate = useNavigate();
12
20
  const { hasCapability } = useRuntime();
21
+ const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
22
+ const [auditError, setAuditError] = useState('');
13
23
  const query = useOne<InstrumentRecord>({
14
24
  resource: 'instruments',
15
25
  id,
16
26
  queryOptions: { retry: false },
17
27
  });
18
28
  const record = query.result;
19
- if (query.query.isLoading)
29
+ useEffect(() => {
30
+ if (!record?.id) return;
31
+ let active = true;
32
+ setAuditError('');
33
+ void instrumentDataApi.audit(record.id).then(
34
+ page => {
35
+ if (active) setAuditEntries(page.items || []);
36
+ },
37
+ error => {
38
+ if (active)
39
+ setAuditError(error instanceof Error ? error.message : String(error));
40
+ }
41
+ );
42
+ return () => {
43
+ active = false;
44
+ };
45
+ }, [record?.id, record?.revision]);
46
+ if (query.query.isLoading) {
20
47
  return (
21
48
  <Shell>
22
- <Spin />
49
+ <div className="oxa-page-loading">
50
+ <Spin />
51
+ </div>
23
52
  </Shell>
24
53
  );
25
- if (query.query.isError || !record)
54
+ }
55
+ if (query.query.isError || !record) {
26
56
  return (
27
57
  <Shell>
28
58
  <Result
29
59
  status="403"
30
- title="无法访问"
31
60
  subTitle={query.query.error?.message || '记录不存在'}
61
+ title="无法访问"
32
62
  />
33
63
  </Shell>
34
64
  );
65
+ }
35
66
  return (
36
67
  <Shell>
37
- <Space className="oxa-page-heading">
38
- <Typography.Title level={3} style={{ margin: 0 }}>
39
- 仪器详情
40
- </Typography.Title>
41
- <Space>
42
- <Button onClick={() => navigate('/instruments')}>返回列表</Button>
43
- {hasCapability(INSTRUMENT_CAPABILITIES.update) && (
68
+ <Card className="oxa-detail-hero">
69
+ <div className="oxa-detail-hero-top">
70
+ <Button
71
+ aria-label="返回列表"
72
+ icon={<ArrowLeftOutlined />}
73
+ onClick={() => navigate('/instruments')}
74
+ type="link"
75
+ >
76
+ 返回仪器资源
77
+ </Button>
78
+ <Space>
44
79
  <Button
45
- type="primary"
46
- onClick={() => navigate(`/instruments/${id}/edit`)}
80
+ icon={<HistoryOutlined />}
81
+ onClick={() =>
82
+ document
83
+ .getElementById('instrument-audit')
84
+ ?.scrollIntoView({ behavior: 'smooth', block: 'center' })
85
+ }
47
86
  >
48
- 编辑
87
+ 操作记录
49
88
  </Button>
50
- )}
51
- </Space>
52
- </Space>
89
+ {hasCapability(INSTRUMENT_CAPABILITIES.update) && (
90
+ <Button
91
+ icon={<EditOutlined />}
92
+ onClick={() => navigate(`/instruments/${id}/edit`)}
93
+ type="primary"
94
+ >
95
+ 编辑
96
+ </Button>
97
+ )}
98
+ </Space>
99
+ </div>
100
+ <div className="oxa-detail-hero-main">
101
+ <div>
102
+ <Space align="center" size={10}>
103
+ <Typography.Title level={2}>仪器详情</Typography.Title>
104
+ <Tag color={record.instrumentStatus === 'normal' ? 'green' : 'default'}>
105
+ {record.instrumentStatus === 'normal' ? '启用' : '非正常'}
106
+ </Tag>
107
+ </Space>
108
+ <Typography.Title className="oxa-instrument-name" level={3}>
109
+ {record.chineseName}
110
+ </Typography.Title>
111
+ <Typography.Text type="secondary">
112
+ {record.instrumentCode} · {record.specModel}
113
+ </Typography.Text>
114
+ </div>
115
+ <Space className="oxa-record-meta" separator={<span>·</span>}>
116
+ <span>最近更新 {formatDateTime(record.updated_at)}</span>
117
+ <span>版本 {record.revision}</span>
118
+ </Space>
119
+ </div>
120
+ </Card>
53
121
  <InstrumentForm
122
+ auditEntries={auditEntries}
123
+ auditError={auditError}
54
124
  mode="detail"
55
- record={record}
56
125
  onCancel={() => navigate('/instruments')}
126
+ record={record}
57
127
  />
58
128
  </Shell>
59
129
  );
60
130
  }
131
+
132
+ function formatDateTime(value?: string) {
133
+ return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
134
+ }