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

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/create-workspace.d.ts.map +1 -1
  2. package/dist/create-workspace.js +1 -0
  3. package/dist/create-workspace.js.map +1 -1
  4. package/package.json +4 -4
  5. package/template/AGENTS.md +1 -1
  6. package/template/README.md +7 -7
  7. package/template/apps/server/Dockerfile +2 -1
  8. package/template/apps/server/package.json +1 -1
  9. package/template/apps/server/src/app.module.ts +1 -3
  10. package/template/apps/server/test/smoke.test.ts +6 -23
  11. package/template/apps/web/e2e/resources.spec.ts +20 -0
  12. package/template/apps/web/index.html +1 -1
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/src/AuthoritativeSelector.tsx +298 -41
  15. package/template/apps/web/src/Shell.tsx +39 -211
  16. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
  17. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +229 -16
  18. package/template/apps/web/src/components/resource/SurfaceFields.tsx +142 -2
  19. package/template/apps/web/src/data-provider.ts +23 -102
  20. package/template/apps/web/src/main.tsx +35 -83
  21. package/template/apps/web/src/platform-client.ts +112 -424
  22. package/template/apps/web/src/runtime-meta.ts +1 -1
  23. package/template/apps/web/src/styles.css +285 -3
  24. package/template/apps/web/test/contracts.test.ts +43 -769
  25. package/template/openxiangda.config.ts +14 -81
  26. package/template/package.json +3 -3
  27. package/template/packages/contracts/src/generated.ts +7 -804
  28. package/template/apps/server/src/instrument-context.controller.ts +0 -25
  29. package/template/apps/web/e2e/instruments.spec.ts +0 -1338
  30. package/template/apps/web/src/CollegePage.tsx +0 -181
  31. package/template/apps/web/src/InstrumentDetailPage.tsx +0 -176
  32. package/template/apps/web/src/InstrumentForm.tsx +0 -380
  33. package/template/apps/web/src/InstrumentFormPage.tsx +0 -82
  34. package/template/apps/web/src/InstrumentListPage.tsx +0 -457
  35. package/template/apps/web/src/fields.ts +0 -18
  36. package/template/apps/web/src/instrument.ts +0 -56
  37. package/template/platform/data/colleges.ts +0 -21
  38. package/template/platform/data/instrument-surface.ts +0 -125
  39. package/template/platform/data/instruments.ts +0 -83
@@ -1,19 +1,19 @@
1
1
  import {
2
2
  ApartmentOutlined,
3
- BankOutlined,
3
+ DatabaseOutlined,
4
4
  UserOutlined,
5
5
  } from '@ant-design/icons';
6
6
  import { Avatar, Select, Space, Spin, Tag, Typography } from 'antd';
7
7
  import { useEffect, useMemo, useRef, useState } from 'react';
8
8
  import {
9
- resolveCollegeScope,
10
9
  resolveDirectory,
11
- searchCollegeScope,
10
+ resolveResource,
12
11
  searchDirectory,
12
+ searchResource,
13
13
  type DirectoryKind,
14
14
  } from './platform-client';
15
15
 
16
- type Source = DirectoryKind | 'college';
16
+ type Source = DirectoryKind | 'resource';
17
17
  type Option = {
18
18
  value: string;
19
19
  label: string;
@@ -55,14 +55,14 @@ async function searchSource(
55
55
  source: Source,
56
56
  operation: 'create' | 'update',
57
57
  keyword: string,
58
- cursor?: string
58
+ cursor?: string,
59
+ resourceCode?: string,
60
+ labelField?: string,
59
61
  ) {
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
- };
62
+ if (source === 'resource') {
63
+ if (!resourceCode || !labelField) throw new Error('OPENXIANGDA_RESOURCE_REFERENCE_CONFIG_INVALID');
64
+ const page = await searchResource(resourceCode, labelField, { keyword, cursor });
65
+ return { items: page.items.map(item => ({ ...item, value: item.id, source }) as Option), nextCursor: page.nextCursor };
66
66
  }
67
67
  const page = await searchDirectory(source, { keyword, cursor });
68
68
  return {
@@ -74,12 +74,13 @@ async function searchSource(
74
74
  async function resolveSource(
75
75
  source: Source,
76
76
  operation: 'create' | 'update',
77
- values: string[]
77
+ values: string[],
78
+ resourceCode?: string,
79
+ labelField?: string,
78
80
  ) {
79
- if (source === 'college') {
80
- return (await resolveCollegeScope(operation, values)).items.map(
81
- item => ({ ...item, source }) as Option
82
- );
81
+ if (source === 'resource') {
82
+ if (!resourceCode || !labelField) throw new Error('OPENXIANGDA_RESOURCE_REFERENCE_CONFIG_INVALID');
83
+ return (await resolveResource(resourceCode, values, labelField)).items.map(item => ({ ...item, value: item.id, source }) as Option);
83
84
  }
84
85
  return (await resolveDirectory(source, values)).items.map(item =>
85
86
  directoryOption(source, item)
@@ -94,8 +95,8 @@ function optionIcon(option: Pick<Option, 'source' | 'label'>) {
94
95
  </Avatar>
95
96
  );
96
97
  }
97
- return option.source === 'college' ? (
98
- <BankOutlined className="oxa-selector-icon" />
98
+ return option.source === 'resource' ? (
99
+ <DatabaseOutlined className="oxa-selector-icon" />
99
100
  ) : (
100
101
  <ApartmentOutlined className="oxa-selector-icon" />
101
102
  );
@@ -106,6 +107,19 @@ function optionDescription(option: Option) {
106
107
  return option.description || '';
107
108
  }
108
109
 
110
+ export type AuthoritativeSelectorProps = {
111
+ value?: string | string[];
112
+ onChange?: (value: string | string[] | undefined) => void;
113
+ source: Source;
114
+ operation: 'create' | 'update';
115
+ multiple?: boolean;
116
+ disabled?: boolean;
117
+ id?: string;
118
+ placeholder: string;
119
+ resourceCode?: string;
120
+ labelField?: string;
121
+ };
122
+
109
123
  export function AuthoritativeSelector({
110
124
  value,
111
125
  onChange,
@@ -115,16 +129,9 @@ export function AuthoritativeSelector({
115
129
  disabled = false,
116
130
  id,
117
131
  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
- }) {
132
+ resourceCode,
133
+ labelField,
134
+ }: AuthoritativeSelectorProps) {
128
135
  const [options, setOptions] = useState<Option[]>([]);
129
136
  const [keyword, setKeyword] = useState('');
130
137
  const [cursor, setCursor] = useState<string | null>(null);
@@ -144,7 +151,7 @@ export function AuthoritativeSelector({
144
151
  if (missing.length === 0) return;
145
152
  let active = true;
146
153
  setLoading(true);
147
- void resolveSource(source, operation, missing)
154
+ void resolveSource(source, operation, missing, resourceCode, labelField)
148
155
  .then(items => {
149
156
  if (active) setOptions(current => mergeOptions(current, items));
150
157
  })
@@ -158,11 +165,11 @@ export function AuthoritativeSelector({
158
165
  return () => {
159
166
  active = false;
160
167
  };
161
- }, [operation, options, source, values]);
168
+ }, [labelField, operation, options, resourceCode, source, values]);
162
169
 
163
170
  useEffect(() => {
164
171
  if (disabled) return;
165
- if (source !== 'college' && keyword.trim().length < 2) {
172
+ if (keyword.trim().length < 2) {
166
173
  setCursor(null);
167
174
  return;
168
175
  }
@@ -170,7 +177,7 @@ export function AuthoritativeSelector({
170
177
  const timer = window.setTimeout(() => {
171
178
  setLoading(true);
172
179
  setError('');
173
- void searchSource(source, operation, keyword)
180
+ void searchSource(source, operation, keyword, undefined, resourceCode, labelField)
174
181
  .then(page => {
175
182
  if (sequence !== requestSequence.current) return;
176
183
  setOptions(current => {
@@ -192,14 +199,38 @@ export function AuthoritativeSelector({
192
199
  });
193
200
  }, 300);
194
201
  return () => window.clearTimeout(timer);
195
- }, [disabled, keyword, operation, source, values]);
202
+ }, [disabled, keyword, labelField, operation, resourceCode, source, values]);
203
+
204
+ useEffect(() => {
205
+ if (disabled || !open || keyword.trim().length >= 2) return;
206
+ const sequence = ++requestSequence.current;
207
+ setLoading(true);
208
+ setError('');
209
+ void searchSource(source, operation, '', undefined, resourceCode, labelField)
210
+ .then(page => {
211
+ if (sequence !== requestSequence.current) return;
212
+ setOptions(current => {
213
+ const selected = current.filter(option => values.includes(option.value));
214
+ return mergeOptions(selected, page.items);
215
+ });
216
+ setCursor(page.nextCursor);
217
+ })
218
+ .catch(reason => {
219
+ if (sequence === requestSequence.current) {
220
+ setError(reason instanceof Error ? reason.message : String(reason));
221
+ }
222
+ })
223
+ .finally(() => {
224
+ if (sequence === requestSequence.current) setLoading(false);
225
+ });
226
+ }, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
196
227
 
197
228
  const loadNext = () => {
198
229
  if (!cursor || loading) return;
199
230
  const nextCursor = cursor;
200
231
  setLoading(true);
201
232
  setError('');
202
- void searchSource(source, operation, keyword, nextCursor)
233
+ void searchSource(source, operation, keyword, nextCursor, resourceCode, labelField)
203
234
  .then(page => {
204
235
  setOptions(current => mergeOptions(current, page.items));
205
236
  setCursor(page.nextCursor);
@@ -230,8 +261,8 @@ export function AuthoritativeSelector({
230
261
  <Spin size="small" />
231
262
  ) : error ? (
232
263
  <Typography.Text type="danger">{error}</Typography.Text>
233
- ) : source !== 'college' && keyword.trim().length < 2 ? (
234
- '请输入至少 2 个字符'
264
+ ) : keyword.trim().length < 2 ? (
265
+ '暂无可选数据,可输入至少 2 个字符搜索'
235
266
  ) : (
236
267
  '无可选数据'
237
268
  )
@@ -240,7 +271,10 @@ export function AuthoritativeSelector({
240
271
  onChange?.(next || undefined);
241
272
  if (!multiple) setOpen(false);
242
273
  }}
243
- onOpenChange={setOpen}
274
+ onOpenChange={next => {
275
+ setOpen(next);
276
+ if (!next) setKeyword('');
277
+ }}
244
278
  onPopupScroll={event => {
245
279
  const element = event.currentTarget;
246
280
  if (
@@ -275,8 +309,8 @@ export function AuthoritativeSelector({
275
309
  prefix={
276
310
  source === 'user' ? (
277
311
  <UserOutlined />
278
- ) : source === 'college' ? (
279
- <BankOutlined />
312
+ ) : source === 'resource' ? (
313
+ <DatabaseOutlined />
280
314
  ) : (
281
315
  <ApartmentOutlined />
282
316
  )
@@ -314,12 +348,235 @@ export function AuthoritativeSelector({
314
348
  );
315
349
  }
316
350
 
351
+ /**
352
+ * Mobile reference picker. It deliberately avoids Ant Design Select so that
353
+ * resource references have a touch-friendly, full-width interaction on
354
+ * phones. The search and resolve calls are the same authoritative platform
355
+ * APIs used by the desktop selector.
356
+ */
357
+ export function MobileAuthoritativeSelector({
358
+ value,
359
+ onChange,
360
+ source,
361
+ operation,
362
+ multiple = false,
363
+ disabled = false,
364
+ id,
365
+ placeholder,
366
+ resourceCode,
367
+ labelField,
368
+ }: AuthoritativeSelectorProps) {
369
+ const [options, setOptions] = useState<Option[]>([]);
370
+ const [keyword, setKeyword] = useState('');
371
+ const [cursor, setCursor] = useState<string | null>(null);
372
+ const [loading, setLoading] = useState(false);
373
+ const [error, setError] = useState('');
374
+ const [open, setOpen] = useState(false);
375
+ const [draftValues, setDraftValues] = useState<string[]>([]);
376
+ const requestSequence = useRef(0);
377
+ const values = useMemo(
378
+ () => (Array.isArray(value) ? value : value ? [value] : []),
379
+ [value]
380
+ );
381
+
382
+ useEffect(() => {
383
+ setDraftValues(values);
384
+ }, [open, values]);
385
+
386
+ useEffect(() => {
387
+ const missing = values.filter(
388
+ selected => !options.some(option => option.value === selected)
389
+ );
390
+ if (missing.length === 0) return;
391
+ let active = true;
392
+ setLoading(true);
393
+ void resolveSource(source, operation, missing, resourceCode, labelField)
394
+ .then(items => {
395
+ if (active) setOptions(current => mergeOptions(current, items));
396
+ })
397
+ .catch(reason => {
398
+ if (active) setError(reason instanceof Error ? reason.message : String(reason));
399
+ })
400
+ .finally(() => {
401
+ if (active) setLoading(false);
402
+ });
403
+ return () => {
404
+ active = false;
405
+ };
406
+ }, [labelField, operation, options, resourceCode, source, values]);
407
+
408
+ useEffect(() => {
409
+ if (disabled || !open) return;
410
+ const trimmed = keyword.trim();
411
+ if (trimmed.length > 0 && trimmed.length < 2) return;
412
+ const sequence = ++requestSequence.current;
413
+ const timer = window.setTimeout(() => {
414
+ setLoading(true);
415
+ setError('');
416
+ void searchSource(source, operation, trimmed, undefined, resourceCode, labelField)
417
+ .then(page => {
418
+ if (sequence !== requestSequence.current) return;
419
+ setOptions(current => {
420
+ const selected = current.filter(option => values.includes(option.value));
421
+ return mergeOptions(selected, page.items);
422
+ });
423
+ setCursor(page.nextCursor);
424
+ })
425
+ .catch(reason => {
426
+ if (sequence === requestSequence.current) {
427
+ setError(reason instanceof Error ? reason.message : String(reason));
428
+ }
429
+ })
430
+ .finally(() => {
431
+ if (sequence === requestSequence.current) setLoading(false);
432
+ });
433
+ }, trimmed ? 300 : 0);
434
+ return () => window.clearTimeout(timer);
435
+ }, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
436
+
437
+ const loadNext = () => {
438
+ if (!cursor || loading) return;
439
+ const nextCursor = cursor;
440
+ setLoading(true);
441
+ setError('');
442
+ void searchSource(source, operation, keyword.trim(), nextCursor, resourceCode, labelField)
443
+ .then(page => {
444
+ setOptions(current => mergeOptions(current, page.items));
445
+ setCursor(page.nextCursor);
446
+ })
447
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
448
+ .finally(() => setLoading(false));
449
+ };
450
+
451
+ const selectedItems = values
452
+ .map(selected => options.find(option => option.value === selected))
453
+ .filter((item): item is Option => Boolean(item));
454
+ const selectedLabel = selectedItems.length
455
+ ? selectedItems.map(item => item.label).join('、')
456
+ : values.length
457
+ ? (loading ? '读取中…' : values.join('、'))
458
+ : `请选择${placeholder}`;
459
+
460
+ const toggleValue = (nextValue: string) => {
461
+ const item = options.find(option => option.value === nextValue);
462
+ if (!item?.selectable) return;
463
+ if (!multiple) {
464
+ onChange?.(nextValue);
465
+ setOpen(false);
466
+ return;
467
+ }
468
+ setDraftValues(current =>
469
+ current.includes(nextValue)
470
+ ? current.filter(itemValue => itemValue !== nextValue)
471
+ : [...current, nextValue]
472
+ );
473
+ };
474
+
475
+ return (
476
+ <>
477
+ <button
478
+ aria-haspopup="dialog"
479
+ className="oxa-mobile-reference-trigger"
480
+ disabled={disabled}
481
+ id={id}
482
+ onClick={() => {
483
+ setDraftValues(values);
484
+ setKeyword('');
485
+ setOpen(true);
486
+ }}
487
+ type="button"
488
+ >
489
+ <span className={values.length ? '' : 'oxa-mobile-reference-placeholder'}>
490
+ {selectedLabel}
491
+ </span>
492
+ <span aria-hidden="true">⌄</span>
493
+ </button>
494
+ {open && (
495
+ <div
496
+ aria-label={placeholder}
497
+ aria-modal="true"
498
+ className="oxa-mobile-reference-backdrop"
499
+ onClick={() => setOpen(false)}
500
+ role="dialog"
501
+ >
502
+ <div className="oxa-mobile-reference-sheet" onClick={event => event.stopPropagation()}>
503
+ <div className="oxa-mobile-reference-header">
504
+ <strong>{placeholder}</strong>
505
+ <button onClick={() => setOpen(false)} type="button">关闭</button>
506
+ </div>
507
+ <input
508
+ autoFocus
509
+ className="oxa-mobile-input oxa-mobile-reference-search"
510
+ onChange={event => setKeyword(event.target.value)}
511
+ placeholder={`搜索${placeholder}`}
512
+ type="search"
513
+ value={keyword}
514
+ />
515
+ {error && <div className="oxa-mobile-reference-error">{error}</div>}
516
+ {loading && <div className="oxa-mobile-reference-loading">加载中…</div>}
517
+ {!loading && !error && options.length === 0 && (
518
+ <div className="oxa-mobile-reference-empty">
519
+ {keyword.trim().length < 2 ? '请输入至少 2 个字符搜索' : '暂无可选数据'}
520
+ </div>
521
+ )}
522
+ <div aria-multiselectable={multiple} className="oxa-mobile-reference-options" role="listbox">
523
+ {options.map(option => {
524
+ const selected = (multiple ? draftValues : values).includes(option.value);
525
+ const description = optionDescription(option);
526
+ return (
527
+ <button
528
+ aria-selected={selected}
529
+ className={`oxa-mobile-reference-option${selected ? ' is-selected' : ''}`}
530
+ disabled={!option.selectable}
531
+ key={option.value}
532
+ onClick={() => toggleValue(option.value)}
533
+ role="option"
534
+ type="button"
535
+ >
536
+ {optionIcon(option)}
537
+ <span className="oxa-mobile-reference-option-copy">
538
+ <strong>{option.label}</strong>
539
+ {description && <small>{description}</small>}
540
+ </span>
541
+ {selected && <span aria-hidden="true">✓</span>}
542
+ </button>
543
+ );
544
+ })}
545
+ </div>
546
+ {cursor && (
547
+ <button className="oxa-mobile-reference-more" onClick={loadNext} type="button">
548
+ {loading ? '加载中…' : '加载更多'}
549
+ </button>
550
+ )}
551
+ {multiple && (
552
+ <button
553
+ className="oxa-mobile-reference-confirm"
554
+ onClick={() => {
555
+ onChange?.(draftValues.length ? draftValues : undefined);
556
+ setOpen(false);
557
+ }}
558
+ type="button"
559
+ >
560
+ 确定({draftValues.length})
561
+ </button>
562
+ )}
563
+ </div>
564
+ </div>
565
+ )}
566
+ </>
567
+ );
568
+ }
569
+
317
570
  export function ResolvedValueText({
318
571
  source,
319
572
  value,
573
+ resourceCode,
574
+ labelField,
320
575
  }: {
321
576
  source: Source;
322
577
  value?: string | string[];
578
+ resourceCode?: string;
579
+ labelField?: string;
323
580
  }) {
324
581
  const values = useMemo(
325
582
  () => (Array.isArray(value) ? value : value ? [value] : []),
@@ -334,7 +591,7 @@ export function ResolvedValueText({
334
591
  }
335
592
  let active = true;
336
593
  setFailed(false);
337
- void resolveSource(source, 'update', values)
594
+ void resolveSource(source, 'update', values, resourceCode, labelField)
338
595
  .then(next => {
339
596
  if (active) setItems(next);
340
597
  })
@@ -344,7 +601,7 @@ export function ResolvedValueText({
344
601
  return () => {
345
602
  active = false;
346
603
  };
347
- }, [source, values]);
604
+ }, [labelField, resourceCode, source, values]);
348
605
  if (values.length === 0) return <>-</>;
349
606
  if (failed) return <Typography.Text type="danger">目录解析失败</Typography.Text>;
350
607
  if (!items.length) return <>读取中…</>;