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

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 (35) 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 +912 -9
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  15. package/template/apps/web/src/CollegePage.tsx +181 -0
  16. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  17. package/template/apps/web/src/InstrumentForm.tsx +450 -88
  18. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  19. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  20. package/template/apps/web/src/Shell.tsx +229 -41
  21. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  22. package/template/apps/web/src/data-provider.ts +1 -1
  23. package/template/apps/web/src/fields.ts +17 -6
  24. package/template/apps/web/src/instrument.ts +1 -1
  25. package/template/apps/web/src/main.tsx +16 -1
  26. package/template/apps/web/src/platform-client.ts +213 -2
  27. package/template/apps/web/src/runtime.tsx +4 -2
  28. package/template/apps/web/src/styles.css +869 -29
  29. package/template/apps/web/test/contracts.test.ts +369 -10
  30. package/template/openxiangda.config.ts +24 -5
  31. package/template/package.json +3 -3
  32. package/template/packages/contracts/src/generated.ts +22 -0
  33. package/template/platform/data/colleges.ts +21 -0
  34. package/template/platform/data/instruments.ts +1 -1
  35. package/template/scripts/verify-template-budget.mjs +1 -1
@@ -1,5 +1,6 @@
1
+ import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
1
2
  import { useCreate, useOne, useUpdate } from '@refinedev/core';
2
- import { App, Result, Spin, Typography } from 'antd';
3
+ import { Alert, App, Button, Card, Result, Space, Spin, Typography } from 'antd';
3
4
  import { useNavigate, useParams } from 'react-router-dom';
4
5
  import { InstrumentForm } from './InstrumentForm';
5
6
  import { Shell } from './Shell';
@@ -23,32 +24,70 @@ export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
23
24
  mode === 'create'
24
25
  ? hasCapability(INSTRUMENT_CAPABILITIES.create)
25
26
  : hasCapability(INSTRUMENT_CAPABILITIES.update);
26
- if (!allowed)
27
+ if (!allowed) {
27
28
  return (
28
29
  <Shell>
29
30
  <Result status="403" title="当前平台用户无此操作权限" />
30
31
  </Shell>
31
32
  );
32
- if (mode === 'edit' && query.query.isLoading)
33
+ }
34
+ if (mode === 'edit' && query.query.isLoading) {
33
35
  return (
34
36
  <Shell>
35
- <Spin />
37
+ <div className="oxa-page-loading">
38
+ <Spin />
39
+ </div>
36
40
  </Shell>
37
41
  );
38
- if (mode === 'edit' && !record)
42
+ }
43
+ if (mode === 'edit' && !record) {
39
44
  return (
40
45
  <Shell>
41
46
  <Result status="404" title="仪器不存在或不在数据范围内" />
42
47
  </Shell>
43
48
  );
49
+ }
44
50
  return (
45
51
  <Shell>
46
- <Typography.Title level={3}>
47
- {mode === 'create' ? '新增仪器' : '编辑仪器'}
48
- </Typography.Title>
52
+ <Card className="oxa-edit-hero">
53
+ <div className="oxa-edit-hero-title">
54
+ <div>
55
+ <Button
56
+ icon={<ArrowLeftOutlined />}
57
+ onClick={() =>
58
+ navigate(mode === 'edit' ? `/instruments/${id}` : '/instruments')
59
+ }
60
+ type="link"
61
+ >
62
+ {mode === 'edit' ? '返回仪器详情' : '返回仪器资源'}
63
+ </Button>
64
+ <Typography.Title level={2}>
65
+ {mode === 'create' ? '新增仪器' : '编辑仪器'}
66
+ </Typography.Title>
67
+ {record && (
68
+ <Typography.Text type="secondary">
69
+ {record.chineseName} · {record.instrumentCode} · 当前版本{' '}
70
+ {record.revision}
71
+ </Typography.Text>
72
+ )}
73
+ </div>
74
+ {record && (
75
+ <Button
76
+ icon={<EyeOutlined />}
77
+ onClick={() => navigate(`/instruments/${id}`)}
78
+ >
79
+ 查看详情
80
+ </Button>
81
+ )}
82
+ </div>
83
+ <Alert
84
+ title="仅显示当前角色可编辑的字段;保存时将校验数据版本与权限范围。"
85
+ showIcon
86
+ type="info"
87
+ />
88
+ </Card>
49
89
  <InstrumentForm
50
90
  mode={mode}
51
- record={record}
52
91
  onCancel={() =>
53
92
  navigate(mode === 'edit' ? `/instruments/${id}` : '/instruments')
54
93
  }
@@ -71,6 +110,7 @@ export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
71
110
  navigate(`/instruments/${result.data.id}`);
72
111
  }
73
112
  }}
113
+ record={record}
74
114
  />
75
115
  </Shell>
76
116
  );
@@ -26,6 +26,11 @@ import {
26
26
  } from 'antd';
27
27
  import { useMemo, useState } from 'react';
28
28
  import { useNavigate } from 'react-router-dom';
29
+ import {
30
+ AuthoritativeSelector,
31
+ ResolvedValueText,
32
+ } from './AuthoritativeSelector';
33
+ import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
29
34
  import { Shell } from './Shell';
30
35
  import {
31
36
  INSTRUMENT_CAPABILITIES,
@@ -34,16 +39,6 @@ import {
34
39
  } from './instrument';
35
40
  import { useRuntime } from './runtime';
36
41
 
37
- const colleges = [
38
- { label: '先进材料学院', value: 'college-am' },
39
- { label: '生命健康学院', value: 'college-lh' },
40
- { label: '公共测试中心', value: 'college-pt' },
41
- ];
42
- const admins = [
43
- { label: '王老师', value: 'user-wang' },
44
- { label: '李老师', value: 'user-li' },
45
- { label: '陈老师', value: 'user-chen' },
46
- ];
47
42
  const statuses = [
48
43
  { label: '正常', value: 'normal' },
49
44
  { label: '维修', value: 'maintenance' },
@@ -62,7 +57,7 @@ export function InstrumentListPage() {
62
57
  const [sort, setSort] = useState<{
63
58
  field: string;
64
59
  order: 'asc' | 'desc';
65
- }>({ field: 'updatedAt', order: 'desc' });
60
+ }>({ field: 'instrumentCode', order: 'asc' });
66
61
  const list = useList<InstrumentRecord>({
67
62
  resource: 'instruments',
68
63
  pagination: { currentPage: page, pageSize },
@@ -100,20 +95,15 @@ export function InstrumentListPage() {
100
95
  title: '所属学院',
101
96
  dataIndex: 'collegeId',
102
97
  width: 140,
103
- render: value =>
104
- colleges.find(item => item.value === value)?.label || value,
98
+ render: value => <ResolvedValueText source="college" value={value} />,
105
99
  },
106
100
  {
107
101
  title: '仪器管理员',
108
102
  dataIndex: 'instrumentAdminIds',
109
103
  width: 130,
110
- render: values =>
111
- (values as string[])
112
- .map(
113
- value =>
114
- admins.find(item => item.value === value)?.label || value
115
- )
116
- .join('、'),
104
+ render: values => (
105
+ <ResolvedValueText source="user" value={values as string[]} />
106
+ ),
117
107
  },
118
108
  {
119
109
  title: '仪器状态',
@@ -143,9 +133,8 @@ export function InstrumentListPage() {
143
133
  },
144
134
  {
145
135
  title: '更新时间',
146
- dataIndex: 'updatedAt',
136
+ dataIndex: 'updated_at',
147
137
  width: 160,
148
- sorter: true,
149
138
  render: value => (value ? new Date(value).toLocaleString() : '-'),
150
139
  },
151
140
  {
@@ -211,15 +200,15 @@ export function InstrumentListPage() {
211
200
  }
212
201
  return (
213
202
  <Shell>
214
- <Card>
215
- <Space direction="vertical" size={16} style={{ width: '100%' }}>
203
+ <Card className="oxa-list-card">
204
+ <Space orientation="vertical" size={16} style={{ width: '100%' }}>
216
205
  <div className="oxa-page-heading">
217
206
  <div>
218
- <h1 className="oxa-title" data-testid="instrument-title">
207
+ <h2 className="oxa-title">
219
208
  仪器资源
220
- </h1>
209
+ </h2>
221
210
  <div className="oxa-muted">
222
- Refine 管理查询状态;CRUD 只经过平台 Data API adapter
211
+ 统一管理仪器档案、归属、管理员与开放状态
223
212
  </div>
224
213
  </div>
225
214
  <Space>
@@ -255,22 +244,28 @@ export function InstrumentListPage() {
255
244
  setDraft(value => ({ ...value, keyword: event.target.value }))
256
245
  }
257
246
  />
258
- <Select
259
- allowClear
260
- options={colleges}
247
+ <AuthoritativeSelector
248
+ operation="update"
261
249
  placeholder="所属学院"
250
+ source="college"
262
251
  value={draft.collegeId}
263
252
  onChange={value =>
264
- setDraft(item => ({ ...item, collegeId: value }))
253
+ setDraft(item => ({
254
+ ...item,
255
+ collegeId: typeof value === 'string' ? value : undefined,
256
+ }))
265
257
  }
266
258
  />
267
- <Select
268
- allowClear
269
- options={admins}
259
+ <PlatformDirectoryPicker
260
+ kind="user"
270
261
  placeholder="仪器管理员"
271
262
  value={draft.instrumentAdminId}
272
263
  onChange={value =>
273
- setDraft(item => ({ ...item, instrumentAdminId: value }))
264
+ setDraft(item => ({
265
+ ...item,
266
+ instrumentAdminId:
267
+ typeof value === 'string' ? value : undefined,
268
+ }))
274
269
  }
275
270
  />
276
271
  <Select
@@ -1,52 +1,240 @@
1
- import { DatabaseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
2
- import { App, Button, Space, Tag } from 'antd';
1
+ import {
2
+ ApartmentOutlined,
3
+ AppstoreOutlined,
4
+ BookOutlined,
5
+ DownOutlined,
6
+ ExperimentOutlined,
7
+ HomeOutlined,
8
+ LeftOutlined,
9
+ MenuUnfoldOutlined,
10
+ SettingOutlined,
11
+ TeamOutlined,
12
+ } from '@ant-design/icons';
13
+ import {
14
+ Avatar,
15
+ Breadcrumb,
16
+ Button,
17
+ Layout,
18
+ Menu,
19
+ Space,
20
+ Typography,
21
+ type MenuProps,
22
+ } from 'antd';
3
23
  import type { ReactNode } from 'react';
24
+ import { useEffect, useMemo, useState } from 'react';
25
+ import { useLocation, useNavigate } from 'react-router-dom';
26
+ import { resolveDirectory } from './platform-client';
4
27
  import { useRuntime } from './runtime';
5
- import { applicationApiPath } from './runtime-meta';
6
28
 
7
- export function Shell({ children }: { children: ReactNode }) {
8
- const { identity } = useRuntime();
9
- const { message } = App.useApp();
10
- const inspectContext = async () => {
11
- const response = await fetch(applicationApiPath('api/instruments/context'), {
12
- credentials: 'include',
13
- headers: { accept: 'application/json' },
14
- });
15
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
16
- const body = (await response.json()) as {
17
- userId?: string;
18
- data?: { userId?: string };
29
+ const { Content, Header, Sider } = Layout;
30
+
31
+ type PageMeta = { title: string; breadcrumbs: string[]; selectedKey: string };
32
+
33
+ function pageMeta(pathname: string): PageMeta {
34
+ if (pathname === '/colleges') {
35
+ return {
36
+ title: '学院字典',
37
+ breadcrumbs: ['首页', '基础数据', '学院字典'],
38
+ selectedKey: '/colleges',
39
+ };
40
+ }
41
+ if (pathname.endsWith('/new')) {
42
+ return {
43
+ title: '新增仪器',
44
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '新增仪器'],
45
+ selectedKey: '/instruments',
19
46
  };
20
- message.success(
21
- `本地 Nest 已读取平台用户:${body.data?.userId || body.userId || identity.userId}`
22
- );
47
+ }
48
+ if (pathname.endsWith('/edit')) {
49
+ return {
50
+ title: '编辑仪器',
51
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '编辑仪器'],
52
+ selectedKey: '/instruments',
53
+ };
54
+ }
55
+ if (/^\/instruments\/[^/]+$/.test(pathname)) {
56
+ return {
57
+ title: '仪器详情',
58
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '仪器详情'],
59
+ selectedKey: '/instruments',
60
+ };
61
+ }
62
+ return {
63
+ title: '仪器资源',
64
+ breadcrumbs: ['首页', '仪器管理', '仪器资源'],
65
+ selectedKey: '/instruments',
23
66
  };
67
+ }
68
+
69
+ export function Shell({ children }: { children: ReactNode }) {
70
+ const { identity, hasCapability } = useRuntime();
71
+ const location = useLocation();
72
+ const navigate = useNavigate();
73
+ const [collapsed, setCollapsed] = useState(false);
74
+ const [user, setUser] = useState({ label: '当前用户', description: '平台用户' });
75
+ const meta = pageMeta(location.pathname);
76
+
77
+ useEffect(() => {
78
+ let active = true;
79
+ void resolveDirectory('user', [identity.userId])
80
+ .then(page => {
81
+ const entry = page.items[0];
82
+ if (active && entry) {
83
+ setUser({
84
+ label: entry.label || '当前用户',
85
+ description: entry.description || '平台用户',
86
+ });
87
+ }
88
+ })
89
+ .catch(() => {
90
+ if (active) setUser({ label: '当前用户', description: '平台用户' });
91
+ });
92
+ return () => {
93
+ active = false;
94
+ };
95
+ }, [identity.userId]);
96
+
97
+ const menuItems = useMemo<MenuProps['items']>(
98
+ () => [
99
+ {
100
+ key: 'overview',
101
+ icon: <AppstoreOutlined />,
102
+ label: '总览',
103
+ children: [
104
+ { key: 'workbench', icon: <HomeOutlined />, label: '工作台' },
105
+ ],
106
+ },
107
+ {
108
+ key: 'instrument-management',
109
+ icon: <ExperimentOutlined />,
110
+ label: '仪器管理',
111
+ children: [
112
+ {
113
+ key: '/instruments',
114
+ icon: <ExperimentOutlined />,
115
+ label: '仪器资源',
116
+ },
117
+ {
118
+ key: 'open-records',
119
+ icon: <BookOutlined />,
120
+ label: '开放记录',
121
+ disabled: true,
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ key: 'base-data',
127
+ icon: <ApartmentOutlined />,
128
+ label: '基础数据',
129
+ children: hasCapability('app:instrument-center:data:colleges:read')
130
+ ? [
131
+ {
132
+ key: '/colleges',
133
+ icon: <ApartmentOutlined />,
134
+ label: '学院字典',
135
+ },
136
+ ]
137
+ : [],
138
+ },
139
+ {
140
+ key: 'system-settings',
141
+ icon: <SettingOutlined />,
142
+ label: '系统设置',
143
+ children: [
144
+ {
145
+ key: 'application-admins',
146
+ icon: <TeamOutlined />,
147
+ label: '应用管理员',
148
+ disabled: true,
149
+ },
150
+ ],
151
+ },
152
+ ],
153
+ [hasCapability]
154
+ );
155
+
24
156
  return (
25
- <div className="oxa-shell">
26
- <header className="oxa-header">
27
- <Space>
28
- <DatabaseOutlined />
29
- <strong>仪器资源管理</strong>
30
- <Tag color="blue">{identity.environment.key}</Tag>
31
- </Space>
32
- <Space wrap>
33
- <span>{identity.userId}</span>
34
- <span className="oxa-header-roles">
35
- {identity.roleCodes.join('、') || '无应用角色'}
36
- </span>
37
- <Button
38
- ghost
39
- size="small"
40
- icon={<SafetyCertificateOutlined />}
41
- onClick={() =>
42
- void inspectContext().catch(error => message.error(error.message))
157
+ <Layout className="oxa-app-layout">
158
+ <Sider
159
+ className="oxa-sider"
160
+ breakpoint="lg"
161
+ collapsed={collapsed}
162
+ collapsedWidth={76}
163
+ collapsible
164
+ onBreakpoint={setCollapsed}
165
+ theme="light"
166
+ trigger={null}
167
+ width={240}
168
+ >
169
+ <div className="oxa-brand">
170
+ <div aria-hidden className="oxa-brand-mark">
171
+ <span />
172
+ </div>
173
+ {!collapsed && (
174
+ <div className="oxa-brand-copy">
175
+ <strong>仪器资源管理</strong>
176
+ <span>仪器、学院与开放管理</span>
177
+ </div>
178
+ )}
179
+ </div>
180
+ <Menu
181
+ className="oxa-menu"
182
+ defaultOpenKeys={[
183
+ 'overview',
184
+ 'instrument-management',
185
+ 'base-data',
186
+ 'system-settings',
187
+ ]}
188
+ inlineCollapsed={collapsed}
189
+ items={menuItems}
190
+ mode="inline"
191
+ onClick={({ key }) => {
192
+ if (key === 'workbench' || key === '/instruments') {
193
+ navigate('/instruments');
194
+ } else if (key === '/colleges') {
195
+ navigate('/colleges');
43
196
  }
197
+ }}
198
+ selectedKeys={[meta.selectedKey]}
199
+ />
200
+ <div className="oxa-sider-footer">
201
+ <Button
202
+ aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
203
+ block
204
+ icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />}
205
+ onClick={() => setCollapsed(value => !value)}
206
+ type="text"
44
207
  >
45
- 验证本地 Nest
208
+ {!collapsed && '收起侧边栏'}
46
209
  </Button>
47
- </Space>
48
- </header>
49
- <main className="oxa-main">{children}</main>
50
- </div>
210
+ </div>
211
+ </Sider>
212
+ <Layout className="oxa-workspace">
213
+ <Header className="oxa-topbar">
214
+ <div className="oxa-topbar-page">
215
+ <Breadcrumb items={meta.breadcrumbs.map(title => ({ title }))} />
216
+ <Typography.Title
217
+ data-testid={meta.title === '仪器资源' ? 'instrument-title' : undefined}
218
+ level={3}
219
+ >
220
+ {meta.title}
221
+ </Typography.Title>
222
+ </div>
223
+ <Space className="oxa-current-user" size={12}>
224
+ <Avatar className="oxa-current-user-avatar" size={42}>
225
+ {user.label.slice(0, 1)}
226
+ </Avatar>
227
+ <div className="oxa-current-user-copy">
228
+ <strong>{user.label}</strong>
229
+ <span>{user.description}</span>
230
+ </div>
231
+ <DownOutlined className="oxa-current-user-chevron" />
232
+ </Space>
233
+ </Header>
234
+ <Content className="oxa-content">
235
+ <main className="oxa-main">{children}</main>
236
+ </Content>
237
+ </Layout>
238
+ </Layout>
51
239
  );
52
240
  }