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

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.
@@ -2,9 +2,6 @@ import {
2
2
  DeleteOutlined,
3
3
  EditOutlined,
4
4
  EyeOutlined,
5
- ExportOutlined,
6
- ImportOutlined,
7
- PlusOutlined,
8
5
  SearchOutlined,
9
6
  } from '@ant-design/icons';
10
7
  import { useDelete, useList } from '@refinedev/core';
@@ -12,16 +9,15 @@ import {
12
9
  App,
13
10
  Button,
14
11
  Card,
15
- DatePicker,
12
+ Empty,
16
13
  Input,
17
- InputNumber,
18
14
  Popconfirm,
19
- Result,
20
15
  Select,
21
16
  Space,
17
+ Spin,
22
18
  Table,
23
19
  Tag,
24
- Tooltip,
20
+ Typography,
25
21
  type TableColumnsType,
26
22
  } from 'antd';
27
23
  import { useMemo, useState } from 'react';
@@ -30,34 +26,62 @@ import {
30
26
  AuthoritativeSelector,
31
27
  ResolvedValueText,
32
28
  } from './AuthoritativeSelector';
33
- import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
34
- import { Shell } from './Shell';
29
+ import {
30
+ listSurfaceFields,
31
+ SurfaceFilterControl,
32
+ SurfaceFieldValue,
33
+ type SurfaceField,
34
+ } from './components/resource/SurfaceFields';
35
+ import {
36
+ MobileResourceListPage,
37
+ ResourceListPage,
38
+ } from './components/resource/StandardResourcePages';
35
39
  import {
36
40
  INSTRUMENT_CAPABILITIES,
37
41
  type InstrumentListQuery,
38
42
  type InstrumentRecord,
39
43
  } from './instrument';
44
+ import { instrumentSurface } from '../../../platform/data/instrument-surface.js';
40
45
  import { useRuntime } from './runtime';
41
46
 
42
- const statuses = [
43
- { label: '正常', value: 'normal' },
44
- { label: '维修', value: 'maintenance' },
45
- { label: '停用', value: 'disabled' },
46
- { label: '报废', value: 'scrapped' },
47
- ];
47
+ const defaultSort = instrumentSurface.list?.defaultSort || {
48
+ field: 'instrumentCode',
49
+ order: 'asc' as const,
50
+ };
51
+ const listFields = listSurfaceFields(instrumentSurface.fields);
52
+ const searchableFields = (instrumentSurface.list?.searchableFields || []).map(
53
+ key => ({ label: instrumentSurface.fields[key]?.label || key, value: key })
54
+ );
55
+ const listWidths: Record<string, number> = {
56
+ image: 84,
57
+ chineseName: 210,
58
+ specModel: 160,
59
+ instrumentCode: 140,
60
+ assetCode: 140,
61
+ collegeId: 140,
62
+ instrumentAdminIds: 130,
63
+ instrumentStatus: 100,
64
+ openToOutside: 90,
65
+ };
66
+
67
+ export function InstrumentListPage({ variant = 'desktop' }: { variant?: 'desktop' | 'mobile' }) {
68
+ return variant === 'mobile' ? <MobileInstrumentListPage /> : <DesktopInstrumentListPage />;
69
+ }
48
70
 
49
- export function InstrumentListPage() {
71
+ function DesktopInstrumentListPage() {
50
72
  const navigate = useNavigate();
51
73
  const { hasCapability } = useRuntime();
52
74
  const { message } = App.useApp();
53
75
  const [page, setPage] = useState(1);
54
- const [pageSize, setPageSize] = useState(20);
76
+ const [pageSize, setPageSize] = useState(
77
+ instrumentSurface.list?.defaultPageSize || 20
78
+ );
55
79
  const [draft, setDraft] = useState<Partial<InstrumentListQuery>>({});
56
80
  const [query, setQuery] = useState<Partial<InstrumentListQuery>>({});
57
81
  const [sort, setSort] = useState<{
58
82
  field: string;
59
83
  order: 'asc' | 'desc';
60
- }>({ field: 'instrumentCode', order: 'asc' });
84
+ }>({ field: defaultSort.field, order: defaultSort.order || 'asc' });
61
85
  const list = useList<InstrumentRecord>({
62
86
  resource: 'instruments',
63
87
  pagination: { currentPage: page, pageSize },
@@ -67,70 +91,20 @@ export function InstrumentListPage() {
67
91
  const remove = useDelete();
68
92
  const data = (list.result.data || []) as InstrumentRecord[];
69
93
  const total = list.result.total || 0;
70
- const canCreate = hasCapability(INSTRUMENT_CAPABILITIES.create);
71
94
  const canUpdate = hasCapability(INSTRUMENT_CAPABILITIES.update);
72
95
  const canDelete = hasCapability(INSTRUMENT_CAPABILITIES.delete);
73
- const columns = useMemo<TableColumnsType<InstrumentRecord>>(
74
- () => [
75
- {
76
- title: '图片',
77
- dataIndex: 'image',
78
- width: 84,
79
- render: value =>
80
- value?.name ? <Tag>{String(value.name)}</Tag> : <span className="oxa-muted">无图</span>,
81
- },
82
- {
83
- title: '仪器名称',
84
- dataIndex: 'chineseName',
85
- width: 210,
86
- sorter: true,
87
- render: (value, record) => (
88
- <a onClick={() => navigate(`/instruments/${record.id}`)}>{value}</a>
89
- ),
90
- },
91
- { title: '规格型号', dataIndex: 'specModel', width: 160 },
92
- { title: '仪器编码', dataIndex: 'instrumentCode', width: 140 },
93
- { title: '资产编码', dataIndex: 'assetCode', width: 140 },
94
- {
95
- title: '所属学院',
96
- dataIndex: 'collegeId',
97
- width: 140,
98
- render: value => <ResolvedValueText source="college" value={value} />,
99
- },
100
- {
101
- title: '仪器管理员',
102
- dataIndex: 'instrumentAdminIds',
103
- width: 130,
104
- render: values => (
105
- <ResolvedValueText source="user" value={values as string[]} />
106
- ),
107
- },
108
- {
109
- title: '仪器状态',
110
- dataIndex: 'instrumentStatus',
111
- width: 100,
112
- render: value => (
113
- <Tag
114
- color={
115
- value === 'normal'
116
- ? 'green'
117
- : value === 'maintenance'
118
- ? 'orange'
119
- : 'default'
120
- }
121
- >
122
- {statuses.find(item => item.value === value)?.label || value}
123
- </Tag>
124
- ),
125
- },
126
- {
127
- title: '对外开放',
128
- dataIndex: 'openToOutside',
129
- width: 90,
130
- render: value => (
131
- <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>
132
- ),
133
- },
96
+ const columns = useMemo<TableColumnsType<InstrumentRecord>>(() => {
97
+ const standardColumns = listFields.map(field => ({
98
+ title: field.label,
99
+ dataIndex: field.key,
100
+ key: field.key,
101
+ width: listWidths[field.key] || 140,
102
+ sorter: Boolean(field.sortable),
103
+ render: (value: unknown, record: InstrumentRecord) =>
104
+ renderListValue(field, value, record, navigate),
105
+ }));
106
+ return [
107
+ ...standardColumns,
134
108
  {
135
109
  title: '更新时间',
136
110
  dataIndex: 'updated_at',
@@ -188,53 +162,30 @@ export function InstrumentListPage() {
188
162
  </Space>
189
163
  ),
190
164
  },
191
- ],
192
- [canDelete, canUpdate, list.query, message, navigate, remove]
193
- );
194
- if (!hasCapability(INSTRUMENT_CAPABILITIES.read)) {
195
- return (
196
- <Shell>
197
- <Result status="403" title="当前平台用户无仪器页面权限" />
198
- </Shell>
199
- );
200
- }
165
+ ];
166
+ }, [canDelete, canUpdate, list.query, message, navigate, remove]);
201
167
  return (
202
- <Shell>
203
- <Card className="oxa-list-card">
204
- <Space orientation="vertical" size={16} style={{ width: '100%' }}>
205
- <div className="oxa-page-heading">
206
- <div>
207
- <h2 className="oxa-title">
208
- 仪器资源
209
- </h2>
210
- <div className="oxa-muted">
211
- 统一管理仪器档案、归属、管理员与开放状态
212
- </div>
213
- </div>
214
- <Space>
215
- <Tooltip title="批量事务合同落地后启用,不用自定义接口绕过 Data API">
216
- <Button disabled icon={<ImportOutlined />}>
217
- 导入
218
- </Button>
219
- </Tooltip>
220
- <Button
221
- icon={<ExportOutlined />}
222
- onClick={() => message.info('导出将使用当前 Data API 查询条件')}
223
- >
224
- 导出
225
- </Button>
226
- {canCreate && (
227
- <Button
228
- type="primary"
229
- icon={<PlusOutlined />}
230
- onClick={() => navigate('/instruments/new')}
231
- >
232
- 新增仪器
233
- </Button>
234
- )}
235
- </Space>
236
- </div>
168
+ <ResourceListPage
169
+ createCapability={INSTRUMENT_CAPABILITIES.create}
170
+ createPath="/instruments/new"
171
+ description="统一管理仪器档案、归属、管理员与开放状态"
172
+ importDisabledReason="批量事务合同落地后启用,不用自定义接口绕过 Data API"
173
+ onExport={() => message.info('导出将使用当前 Data API 查询条件')}
174
+ readCapability={INSTRUMENT_CAPABILITIES.read}
175
+ resource="instruments"
176
+ surface={instrumentSurface}
177
+ title="仪器资源"
178
+ >
237
179
  <div className="oxa-filter-grid">
180
+ <Select
181
+ allowClear
182
+ options={searchableFields}
183
+ placeholder="搜索字段"
184
+ value={draft.searchField}
185
+ onChange={value =>
186
+ setDraft(item => ({ ...item, searchField: value }))
187
+ }
188
+ />
238
189
  <Input
239
190
  allowClear
240
191
  prefix={<SearchOutlined />}
@@ -244,92 +195,67 @@ export function InstrumentListPage() {
244
195
  setDraft(value => ({ ...value, keyword: event.target.value }))
245
196
  }
246
197
  />
247
- <AuthoritativeSelector
248
- operation="update"
249
- placeholder="所属学院"
250
- source="college"
251
- value={draft.collegeId}
198
+ <SurfaceFilterControl
199
+ field={surfaceField('collegeId')}
200
+ renderers={{
201
+ renderFilter: context => (
202
+ <AuthoritativeSelector
203
+ operation="update"
204
+ placeholder="所属学院"
205
+ source="college"
206
+ value={context.value as string | undefined}
207
+ onChange={value => context.onChange(typeof value === 'string' ? value : undefined)}
208
+ />
209
+ ),
210
+ }}
252
211
  onChange={value =>
253
212
  setDraft(item => ({
254
213
  ...item,
255
214
  collegeId: typeof value === 'string' ? value : undefined,
256
215
  }))
257
216
  }
217
+ value={draft.collegeId}
258
218
  />
259
- <PlatformDirectoryPicker
260
- kind="user"
261
- placeholder="仪器管理员"
262
- value={draft.instrumentAdminId}
219
+ <SurfaceFilterControl
220
+ field={surfaceField('instrumentAdminIds')}
263
221
  onChange={value =>
264
222
  setDraft(item => ({
265
223
  ...item,
266
- instrumentAdminId:
267
- typeof value === 'string' ? value : undefined,
224
+ instrumentAdminId: typeof value === 'string' ? value : undefined,
268
225
  }))
269
226
  }
227
+ value={draft.instrumentAdminId}
270
228
  />
271
- <Select
272
- allowClear
273
- options={statuses}
274
- placeholder="仪器状态"
275
- value={draft.instrumentStatus}
229
+ <SurfaceFilterControl
230
+ field={surfaceField('instrumentStatus')}
276
231
  onChange={value =>
277
- setDraft(item => ({ ...item, instrumentStatus: value }))
232
+ setDraft(item => ({ ...item, instrumentStatus: value as string | undefined }))
278
233
  }
234
+ value={draft.instrumentStatus}
279
235
  />
280
- <Select
281
- allowClear
282
- options={[
283
- { label: '对外开放', value: true },
284
- { label: '未开放', value: false },
285
- ]}
286
- placeholder="是否对外开放"
236
+ <SurfaceFilterControl
237
+ field={surfaceField('openToOutside')}
238
+ onChange={value =>
239
+ setDraft(item => ({ ...item, openToOutside: value as boolean | undefined }))
240
+ }
287
241
  value={draft.openToOutside}
242
+ />
243
+ <SurfaceFilterControl
244
+ field={surfaceField('enabledDate')}
288
245
  onChange={value =>
289
- setDraft(item => ({ ...item, openToOutside: value }))
246
+ setDraft(item => ({ ...item, enabledDate: value as [string, string] | undefined }))
290
247
  }
248
+ range
249
+ value={draft.enabledDate}
291
250
  />
292
- <DatePicker.RangePicker
293
- onChange={(_, values) =>
294
- setDraft(item => ({
295
- ...item,
296
- enabledDate:
297
- values[0] && values[1]
298
- ? [values[0], values[1]]
299
- : undefined,
300
- }))
251
+ <SurfaceFilterControl
252
+ field={surfaceField('assetValue')}
253
+ onChange={value =>
254
+ setDraft(item => ({ ...item, assetValue: value as [number | undefined, number | undefined] | undefined }))
301
255
  }
256
+ range
257
+ value={draft.assetValue}
302
258
  />
303
- <Space.Compact>
304
- <InputNumber
305
- min={0}
306
- placeholder="最低资产价值"
307
- value={draft.assetValue?.[0]}
308
- onChange={value =>
309
- setDraft(item => ({
310
- ...item,
311
- assetValue: [
312
- value === null ? undefined : Number(value),
313
- item.assetValue?.[1],
314
- ],
315
- }))
316
- }
317
- />
318
- <InputNumber
319
- min={0}
320
- placeholder="最高资产价值"
321
- value={draft.assetValue?.[1]}
322
- onChange={value =>
323
- setDraft(item => ({
324
- ...item,
325
- assetValue: [
326
- item.assetValue?.[0],
327
- value === null ? undefined : Number(value),
328
- ],
329
- }))
330
- }
331
- />
332
- </Space.Compact>
333
259
  <Space>
334
260
  <Button
335
261
  type="primary"
@@ -378,8 +304,154 @@ export function InstrumentListPage() {
378
304
  },
379
305
  }}
380
306
  />
381
- </Space>
382
- </Card>
383
- </Shell>
307
+ </ResourceListPage>
384
308
  );
385
309
  }
310
+
311
+ function MobileInstrumentListPage() {
312
+ const navigate = useNavigate();
313
+ const { hasCapability } = useRuntime();
314
+ const { message } = App.useApp();
315
+ const [page, setPage] = useState(1);
316
+ const [keyword, setKeyword] = useState('');
317
+ const [query, setQuery] = useState<Partial<InstrumentListQuery>>({});
318
+ const list = useList<InstrumentRecord>({
319
+ resource: 'instruments',
320
+ pagination: { currentPage: page, pageSize: instrumentSurface.list?.defaultPageSize || 20 },
321
+ sorters: [{ field: defaultSort.field, order: defaultSort.order || 'asc' }],
322
+ meta: { query },
323
+ });
324
+ const records = (list.result.data || []) as unknown as InstrumentRecord[];
325
+ const canCreate = hasCapability(INSTRUMENT_CAPABILITIES.create);
326
+ const canUpdate = hasCapability(INSTRUMENT_CAPABILITIES.update);
327
+ const listFieldsForCard = listFields.filter(field =>
328
+ ['instrumentCode', 'specModel', 'instrumentStatus', 'collegeId'].includes(field.key)
329
+ );
330
+ return (
331
+ <MobileResourceListPage
332
+ createCapability={INSTRUMENT_CAPABILITIES.create}
333
+ createPath="/m/instruments/new"
334
+ description="仪器档案、归属与开放状态"
335
+ onExport={() => message.info('导出将使用当前 Data API 查询条件')}
336
+ readCapability={INSTRUMENT_CAPABILITIES.read}
337
+ resource="instruments"
338
+ surface={instrumentSurface}
339
+ title="仪器资源"
340
+ >
341
+ <div className="oxa-mobile-filter">
342
+ <Select
343
+ options={searchableFields}
344
+ value={query.searchField || searchableFields[0]?.value}
345
+ onChange={value => setQuery(item => ({ ...item, searchField: value }))}
346
+ />
347
+ <Input
348
+ allowClear
349
+ placeholder="搜索仪器名称、编号或资产编号"
350
+ value={keyword}
351
+ onChange={event => setKeyword(event.target.value)}
352
+ onPressEnter={() => {
353
+ setPage(1);
354
+ setQuery(item => ({ ...item, keyword: keyword || undefined }));
355
+ }}
356
+ />
357
+ <Button
358
+ icon={<SearchOutlined />}
359
+ onClick={() => {
360
+ setPage(1);
361
+ setQuery(item => ({ ...item, keyword: keyword || undefined }));
362
+ }}
363
+ type="primary"
364
+ >
365
+ 查询
366
+ </Button>
367
+ </div>
368
+ {list.query.isLoading ? (
369
+ <div className="oxa-page-loading"><Spin /></div>
370
+ ) : list.query.isError ? (
371
+ <Card><Typography.Text type="danger">{list.query.error?.message || '列表读取失败'}</Typography.Text></Card>
372
+ ) : records.length ? (
373
+ <div className="oxa-mobile-record-list">
374
+ {records.map(record => (
375
+ <Card className="oxa-mobile-record-card" key={record.id} size="small">
376
+ <div className="oxa-mobile-record-heading">
377
+ <a onClick={() => navigate(`/m/instruments/${record.id}`)}>{record.chineseName || '-'}</a>
378
+ <SurfaceFieldValue
379
+ field={surfaceField('instrumentStatus')}
380
+ value={record.instrumentStatus}
381
+ renderers={{
382
+ renderValue: ({ field, value }) => <Tag color={value === 'normal' ? 'green' : 'default'}>{field.options?.find(option => option.value === value)?.label || String(value)}</Tag>,
383
+ }}
384
+ />
385
+ </div>
386
+ <div className="oxa-mobile-record-fields">
387
+ {listFieldsForCard.map(field => (
388
+ <div key={field.key}>
389
+ <Typography.Text type="secondary">{field.label}</Typography.Text>
390
+ <div><SurfaceFieldValue field={field} value={record[field.key]} /></div>
391
+ </div>
392
+ ))}
393
+ </div>
394
+ <Space>
395
+ <Button onClick={() => navigate(`/m/instruments/${record.id}`)} size="small">查看</Button>
396
+ {canUpdate && <Button onClick={() => navigate(`/m/instruments/${record.id}/edit`)} size="small">编辑</Button>}
397
+ </Space>
398
+ </Card>
399
+ ))}
400
+ </div>
401
+ ) : (
402
+ <Card><Empty description="暂无仪器资源" /></Card>
403
+ )}
404
+ <div className="oxa-mobile-pagination">
405
+ <Button disabled={page <= 1} onClick={() => setPage(current => current - 1)}>上一页</Button>
406
+ <Typography.Text>第 {page} 页</Typography.Text>
407
+ <Button disabled={records.length < (instrumentSurface.list?.defaultPageSize || 20)} onClick={() => setPage(current => current + 1)}>下一页</Button>
408
+ </div>
409
+ {!canCreate && <Typography.Text type="secondary">当前角色无新增权限</Typography.Text>}
410
+ </MobileResourceListPage>
411
+ );
412
+ }
413
+
414
+ function renderListValue(
415
+ field: SurfaceField,
416
+ value: unknown,
417
+ record: InstrumentRecord,
418
+ navigate: ReturnType<typeof useNavigate>
419
+ ) {
420
+ if (field.key === 'image') {
421
+ return value && typeof value === 'object' && 'name' in value ? (
422
+ <Tag>{String((value as { name?: unknown }).name)}</Tag>
423
+ ) : (
424
+ <span className="oxa-muted">无图</span>
425
+ );
426
+ }
427
+ if (field.key === 'chineseName') {
428
+ return <a onClick={() => navigate(`/instruments/${record.id}`)}>{String(value || '-')}</a>;
429
+ }
430
+ return (
431
+ <SurfaceFieldValue
432
+ field={field}
433
+ renderers={{
434
+ renderValue: ({ field: current, value: currentValue }) => {
435
+ if (current.key === 'collegeId') {
436
+ return <ResolvedValueText source="college" value={String(currentValue)} />;
437
+ }
438
+ if (current.key === 'instrumentStatus') {
439
+ const label = current.options?.find(option => option.value === currentValue)?.label || String(currentValue);
440
+ return (
441
+ <Tag color={currentValue === 'normal' ? 'green' : currentValue === 'maintenance' ? 'orange' : 'default'}>
442
+ {label}
443
+ </Tag>
444
+ );
445
+ }
446
+ return undefined;
447
+ },
448
+ }}
449
+ value={value}
450
+ />
451
+ );
452
+ }
453
+
454
+ function surfaceField(key: string): SurfaceField {
455
+ const field = instrumentSurface.fields[key];
456
+ return field ? { key, ...field } : { key, label: key, widget: 'text' };
457
+ }