openxiangda-cli 2.0.0-alpha.60 → 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.
@@ -1,37 +1,31 @@
1
- import {
2
- PictureOutlined,
3
- ReloadOutlined,
4
- UploadOutlined,
5
- } from '@ant-design/icons';
6
1
  import {
7
2
  App,
8
3
  Button,
9
4
  Card,
10
- DatePicker,
11
5
  Descriptions,
12
6
  Form,
13
- Input,
14
- InputNumber,
15
- Select,
16
- Switch,
17
7
  Tag,
18
8
  Timeline,
19
9
  Typography,
20
- Upload,
21
10
  } from 'antd';
22
11
  import dayjs from 'dayjs';
23
- import { useEffect, useRef, useState } from 'react';
24
- import type { DataAuditEntry, DataFileRef } from 'openxiangda-contracts/browser';
12
+ import { useEffect, useState } from 'react';
13
+ import type { DataAuditEntry } from 'openxiangda-contracts/browser';
25
14
  import {
26
15
  AuthoritativeSelector,
27
16
  ResolvedValueText,
28
17
  } from './AuthoritativeSelector';
29
- import { AttachmentFileList } from './components/platform-fields/AttachmentFileList';
30
- import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
18
+ import {
19
+ MobileSurfaceFieldControl,
20
+ SurfaceFieldControl,
21
+ SurfaceFieldValue,
22
+ type SurfaceField,
23
+ } from './components/resource/SurfaceFields';
31
24
  import { instrumentFields, sectionTitles, type InstrumentField } from './fields';
32
25
  import type { InstrumentRecord } from './instrument';
33
26
  import { instrumentDataApi } from './platform-client';
34
27
  import { useRuntime } from './runtime';
28
+ import { instrumentSurface } from '../../../platform/data/instrument-surface.js';
35
29
 
36
30
  type Values = Record<string, unknown>;
37
31
  const createDefaults = {
@@ -69,6 +63,7 @@ export function sanitizeInstrumentValues(
69
63
  export function InstrumentForm({
70
64
  mode,
71
65
  record,
66
+ variant = 'desktop',
72
67
  onCancel,
73
68
  onSubmit,
74
69
  auditEntries = [],
@@ -76,6 +71,7 @@ export function InstrumentForm({
76
71
  }: {
77
72
  mode: 'create' | 'edit' | 'detail';
78
73
  record?: InstrumentRecord;
74
+ variant?: 'desktop' | 'mobile';
79
75
  onCancel: () => void;
80
76
  onSubmit?: (values: Values) => Promise<void>;
81
77
  auditEntries?: DataAuditEntry[];
@@ -89,7 +85,11 @@ export function InstrumentForm({
89
85
  if (record) {
90
86
  form.setFieldsValue({
91
87
  ...record,
92
- enabledDate: record.enabledDate ? dayjs(record.enabledDate) : undefined,
88
+ enabledDate: record.enabledDate
89
+ ? variant === 'mobile'
90
+ ? record.enabledDate
91
+ : dayjs(record.enabledDate)
92
+ : undefined,
93
93
  });
94
94
  }
95
95
  }, [form, record]);
@@ -99,6 +99,7 @@ export function InstrumentForm({
99
99
  auditEntries={auditEntries}
100
100
  auditError={auditError}
101
101
  record={record}
102
+ variant={variant}
102
103
  />
103
104
  );
104
105
  }
@@ -136,9 +137,11 @@ export function InstrumentForm({
136
137
  .filter(field => field.section === key && !field.system)
137
138
  .map(field => field.key),
138
139
  }));
140
+ const FieldControl =
141
+ variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
139
142
  return (
140
143
  <Form
141
- className="oxa-instrument-form oxa-form-full"
144
+ className={`oxa-instrument-form oxa-form-full${variant === 'mobile' ? ' oxa-mobile-form' : ''}`}
142
145
  form={form}
143
146
  initialValues={isCreate ? createDefaults : undefined}
144
147
  layout="vertical"
@@ -153,14 +156,38 @@ export function InstrumentForm({
153
156
  .map(key => instrumentFields.find(field => field.key === key))
154
157
  .filter((field): field is InstrumentField => Boolean(field))
155
158
  .map(field => (
156
- <FieldControl
159
+ <SurfaceFieldControl
157
160
  disabled={
158
161
  !instrumentFieldAllowed(field, writeMode, hasCapability)
159
162
  }
160
- field={field}
163
+ field={surfaceField(field.key)}
161
164
  key={field.key}
162
165
  operation={isCreate ? 'create' : 'update'}
163
166
  recordId={record?.id}
167
+ renderers={{
168
+ renderExtra: context =>
169
+ context.disabled
170
+ ? '当前角色不可修改此字段'
171
+ : context.field.widget === 'scope'
172
+ ? '仅显示当前角色可管理的学院'
173
+ : context.field.key === 'instrumentAdminIds'
174
+ ? '管理员选择结果将参与仪器数据权限判断'
175
+ : undefined,
176
+ renderScope: context => (
177
+ <AuthoritativeSelector
178
+ disabled={context.disabled}
179
+ operation={context.operation}
180
+ placeholder={`请选择${context.field.label}`}
181
+ source="college"
182
+ />
183
+ ),
184
+ upload: (field, file, recordId) =>
185
+ instrumentDataApi.upload(
186
+ field.key as 'image' | 'attachments',
187
+ file,
188
+ recordId
189
+ ),
190
+ }}
164
191
  />
165
192
  ))}
166
193
  </div>
@@ -184,253 +211,16 @@ export function InstrumentForm({
184
211
  );
185
212
  }
186
213
 
187
- function FieldControl({
188
- field,
189
- disabled,
190
- operation,
191
- recordId,
192
- }: {
193
- field: InstrumentField;
194
- disabled: boolean;
195
- operation: 'create' | 'update';
196
- recordId?: string;
197
- }) {
198
- const rules = [
199
- ...(field.required
200
- ? [{ required: true, message: `请填写或选择${field.label}` }]
201
- : []),
202
- ...(field.kind === 'email'
203
- ? [{ type: 'email' as const, message: '邮箱格式不正确' }]
204
- : []),
205
- ...(field.kind === 'phone'
206
- ? [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }]
207
- : []),
208
- ];
209
- const common = { disabled, placeholder: `请输入${field.label}` };
210
- let control = <Input {...common} />;
211
- if (field.kind === 'textarea') {
212
- control = <Input.TextArea {...common} rows={3} />;
213
- }
214
- if (field.kind === 'number') {
215
- control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
216
- }
217
- if (field.kind === 'date') {
218
- control = <DatePicker disabled={disabled} style={{ width: '100%' }} />;
219
- }
220
- if (field.kind === 'boolean') {
221
- control = (
222
- <Switch
223
- checkedChildren="是"
224
- disabled={disabled}
225
- unCheckedChildren="否"
226
- />
227
- );
228
- }
229
- if (field.kind === 'select') {
230
- control = (
231
- <Select
232
- disabled={disabled}
233
- options={field.options}
234
- placeholder={`请选择${field.label}`}
235
- />
236
- );
237
- }
238
- if (field.kind === 'multi') {
239
- control = (
240
- <Select
241
- disabled={disabled}
242
- mode="multiple"
243
- options={field.options}
244
- placeholder={`请选择${field.label}`}
245
- />
246
- );
247
- }
248
- if (field.kind === 'scope') {
249
- control = (
250
- <AuthoritativeSelector
251
- disabled={disabled}
252
- operation={operation}
253
- placeholder={`请选择${field.label}`}
254
- source="college"
255
- />
256
- );
257
- }
258
- if (
259
- field.kind === 'directory-user' ||
260
- field.kind === 'directory-department'
261
- ) {
262
- control = (
263
- <PlatformDirectoryPicker
264
- disabled={disabled}
265
- kind={field.kind === 'directory-user' ? 'user' : 'department'}
266
- multiple={field.multiple}
267
- placeholder={`搜索并选择${field.label}`}
268
- />
269
- );
270
- }
271
- if (field.kind === 'file') {
272
- control = (
273
- <FileValue
274
- accept={field.accept}
275
- disabled={disabled}
276
- fieldCode={field.key as 'image' | 'attachments'}
277
- maxCount={field.maxCount}
278
- multiple={field.multiple}
279
- recordId={recordId}
280
- />
281
- );
282
- }
283
- const extra = disabled
284
- ? '当前角色不可修改此字段'
285
- : field.kind === 'scope'
286
- ? '仅显示当前角色可管理的学院'
287
- : field.key === 'instrumentAdminIds'
288
- ? '管理员选择结果将参与仪器数据权限判断'
289
- : undefined;
290
- return (
291
- <Form.Item
292
- className={
293
- field.kind === 'textarea' || field.kind === 'file'
294
- ? 'oxa-field-wide'
295
- : undefined
296
- }
297
- extra={extra}
298
- label={field.label}
299
- name={field.key}
300
- rules={rules}
301
- valuePropName={field.kind === 'boolean' ? 'checked' : 'value'}
302
- >
303
- {control}
304
- </Form.Item>
305
- );
306
- }
307
-
308
- function FileValue({
309
- value,
310
- onChange,
311
- fieldCode,
312
- recordId,
313
- disabled,
314
- multiple,
315
- maxCount = 1,
316
- accept,
317
- }: {
318
- value?: DataFileRef | DataFileRef[];
319
- onChange?: (value: DataFileRef | DataFileRef[] | undefined) => void;
320
- fieldCode: 'image' | 'attachments';
321
- recordId?: string;
322
- disabled?: boolean;
323
- multiple?: boolean;
324
- maxCount?: number;
325
- accept?: string;
326
- }) {
327
- const [uploading, setUploading] = useState(false);
328
- const [uploadError, setUploadError] = useState('');
329
- const refs = Array.isArray(value) ? value : value ? [value] : [];
330
- const refsRef = useRef(refs);
331
- useEffect(() => {
332
- refsRef.current = refs;
333
- }, [refs]);
334
- const uploadProps = {
335
- accept,
336
- disabled,
337
- maxCount,
338
- multiple,
339
- customRequest: async (options: {
340
- file: string | Blob;
341
- onError?: (error: Error) => void;
342
- onProgress?: (event: { percent: number }) => void;
343
- onSuccess?: (body: unknown) => void;
344
- }) => {
345
- setUploading(true);
346
- setUploadError('');
347
- try {
348
- options.onProgress?.({ percent: 15 });
349
- const uploaded = await instrumentDataApi.upload(
350
- fieldCode,
351
- options.file as File,
352
- recordId
353
- );
354
- options.onProgress?.({ percent: 100 });
355
- const next = multiple ? [...refsRef.current, uploaded] : uploaded;
356
- refsRef.current = Array.isArray(next) ? next : [next];
357
- onChange?.(next);
358
- options.onSuccess?.(uploaded);
359
- } catch (error) {
360
- const failure =
361
- error instanceof Error ? error : new Error(String(error));
362
- setUploadError(failure.message);
363
- options.onError?.(failure);
364
- } finally {
365
- setUploading(false);
366
- }
367
- },
368
- showUploadList: false,
369
- };
370
- return (
371
- <div className="oxa-file-field">
372
- <Upload {...uploadProps} pastable>
373
- <Button
374
- disabled={disabled || uploading || refs.length >= maxCount}
375
- icon={fieldCode === 'image' ? <PictureOutlined /> : <UploadOutlined />}
376
- loading={uploading}
377
- >
378
- {fieldCode === 'image' ? '上传图片' : '上传文件'}
379
- </Button>
380
- </Upload>
381
- <Upload.Dragger
382
- {...uploadProps}
383
- className="oxa-file-dropzone"
384
- openFileDialogOnClick={false}
385
- pastable
386
- >
387
- <span>
388
- 可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴
389
- </span>
390
- <small>
391
- {multiple
392
- ? `最多 ${maxCount} 个文件,单个不超过 50MB`
393
- : '仅保留一个文件,新上传会替换当前文件'}
394
- </small>
395
- </Upload.Dragger>
396
- {uploadError && (
397
- <div className="oxa-file-error">
398
- <span>{uploadError}</span>
399
- <Button
400
- icon={<ReloadOutlined />}
401
- onClick={() => setUploadError('')}
402
- size="small"
403
- type="text"
404
- >
405
- 关闭
406
- </Button>
407
- </div>
408
- )}
409
- {refs.length ? (
410
- <AttachmentFileList
411
- files={refs}
412
- onRemove={file => {
413
- const next = refs.filter(item => item.id !== file.id);
414
- refsRef.current = next;
415
- onChange?.(multiple ? next : undefined);
416
- }}
417
- removable={!disabled}
418
- />
419
- ) : (
420
- <div className="oxa-file-empty">尚未上传文件</div>
421
- )}
422
- </div>
423
- );
424
- }
425
-
426
214
  function Detail({
427
215
  record,
428
216
  auditEntries,
429
217
  auditError,
218
+ variant = 'desktop',
430
219
  }: {
431
220
  record: InstrumentRecord;
432
221
  auditEntries: DataAuditEntry[];
433
222
  auditError?: string;
223
+ variant?: 'desktop' | 'mobile';
434
224
  }) {
435
225
  const groups = Object.entries(sectionTitles)
436
226
  .map(([section, title]) => ({
@@ -445,7 +235,7 @@ function Detail({
445
235
  }))
446
236
  .filter(group => group.fields.length > 0);
447
237
  return (
448
- <div className="oxa-detail-layout">
238
+ <div className={`oxa-detail-layout${variant === 'mobile' ? ' oxa-mobile-detail' : ''}`}>
449
239
  <div className="oxa-detail-main">
450
240
  {groups.map(group => (
451
241
  <Card className="oxa-section-card" key={group.section} title={group.title}>
@@ -455,8 +245,49 @@ function Detail({
455
245
  key: field.key,
456
246
  label: field.label,
457
247
  span:
458
- field.kind === 'textarea' || field.kind === 'file' ? 3 : 1,
459
- children: displayValue(field, record[field.key]),
248
+ surfaceField(field.key).widget === 'textarea' ||
249
+ surfaceField(field.key).widget === 'file'
250
+ ? 3
251
+ : 1,
252
+ children: (
253
+ <SurfaceFieldValue
254
+ field={surfaceField(field.key)}
255
+ renderers={{
256
+ renderValue: ({ field: surface, value }) => {
257
+ if (surface.widget === 'scope') {
258
+ return (
259
+ <ResolvedValueText
260
+ source="college"
261
+ value={String(value)}
262
+ />
263
+ );
264
+ }
265
+ if (
266
+ surface.key === 'instrumentStatus' ||
267
+ surface.key === 'usageStatus'
268
+ ) {
269
+ const label =
270
+ surface.options?.find(
271
+ option => option.value === value
272
+ )?.label || String(value);
273
+ return (
274
+ <Tag
275
+ color={
276
+ value === 'normal' || value === 'active'
277
+ ? 'green'
278
+ : 'default'
279
+ }
280
+ >
281
+ {label}
282
+ </Tag>
283
+ );
284
+ }
285
+ return undefined;
286
+ },
287
+ }}
288
+ value={record[field.key]}
289
+ />
290
+ ),
460
291
  }))}
461
292
  />
462
293
  </Card>
@@ -476,11 +307,30 @@ function Detail({
476
307
  {
477
308
  key: 'status',
478
309
  label: '仪器状态',
479
- children: displayValue(
480
- instrumentFields.find(
481
- field => field.key === 'instrumentStatus'
482
- )!,
483
- record.instrumentStatus
310
+ children: (
311
+ <SurfaceFieldValue
312
+ field={surfaceField('instrumentStatus')}
313
+ renderers={{
314
+ renderValue: ({ field, value }) => {
315
+ const label =
316
+ field.options?.find(
317
+ option => option.value === value
318
+ )?.label || String(value);
319
+ return (
320
+ <Tag
321
+ color={
322
+ value === 'normal' || value === 'active'
323
+ ? 'green'
324
+ : 'default'
325
+ }
326
+ >
327
+ {label}
328
+ </Tag>
329
+ );
330
+ },
331
+ }}
332
+ value={record.instrumentStatus}
333
+ />
484
334
  ),
485
335
  },
486
336
  ]}
@@ -512,54 +362,9 @@ function Detail({
512
362
  );
513
363
  }
514
364
 
515
- function AttachmentReadOnly({ values }: { values: DataFileRef[] }) {
516
- if (!values.length) return <>-</>;
517
- return <AttachmentFileList files={values} />;
518
- }
519
-
520
- function displayValue(field: InstrumentField, value: unknown) {
521
- if (value === undefined || value === null || value === '') return '-';
522
- if (field.kind === 'scope') {
523
- return <ResolvedValueText source="college" value={String(value)} />;
524
- }
525
- if (field.kind === 'directory-user') {
526
- return (
527
- <ResolvedValueText
528
- source="user"
529
- value={Array.isArray(value) ? value.map(String) : String(value)}
530
- />
531
- );
532
- }
533
- if (field.kind === 'directory-department') {
534
- return <ResolvedValueText source="department" value={String(value)} />;
535
- }
536
- if (field.kind === 'boolean') {
537
- return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
538
- }
539
- if (field.kind === 'file') {
540
- const values = (Array.isArray(value) ? value : [value]).filter(
541
- (item): item is DataFileRef =>
542
- Boolean(item && typeof item === 'object' && 'id' in item)
543
- );
544
- return <AttachmentReadOnly values={values} />;
545
- }
546
- if (Array.isArray(value)) {
547
- return (
548
- value
549
- .map(
550
- item =>
551
- field.options?.find(option => option.value === item)?.label ||
552
- String(item)
553
- )
554
- .join('、') || '-'
555
- );
556
- }
557
- const label =
558
- field.options?.find(option => option.value === value)?.label || String(value);
559
- if (field.key === 'instrumentStatus' || field.key === 'usageStatus') {
560
- return <Tag color={value === 'normal' || value === 'active' ? 'green' : 'default'}>{label}</Tag>;
561
- }
562
- return label;
365
+ function surfaceField(key: string): SurfaceField {
366
+ const field = instrumentSurface.fields[key];
367
+ return field ? { key, ...field } : { key, label: key, widget: 'text' };
563
368
  }
564
369
 
565
370
  function auditLabel(operation: DataAuditEntry['operation']) {
@@ -1,16 +1,23 @@
1
- import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
2
1
  import { useCreate, useOne, useUpdate } from '@refinedev/core';
3
- import { Alert, App, Button, Card, Result, Space, Spin, Typography } from 'antd';
2
+ import { App } from 'antd';
4
3
  import { useNavigate, useParams } from 'react-router-dom';
4
+ import {
5
+ MobileResourceFormPage,
6
+ ResourceFormPage,
7
+ } from './components/resource/StandardResourcePages';
5
8
  import { InstrumentForm } from './InstrumentForm';
6
- import { Shell } from './Shell';
7
9
  import { INSTRUMENT_CAPABILITIES, type InstrumentRecord } from './instrument';
8
- import { useRuntime } from './runtime';
10
+ import { instrumentSurface } from '../../../platform/data/instrument-surface.js';
9
11
 
10
- export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
12
+ export function InstrumentFormPage({
13
+ mode,
14
+ variant = 'desktop',
15
+ }: {
16
+ mode: 'create' | 'edit';
17
+ variant?: 'desktop' | 'mobile';
18
+ }) {
11
19
  const { id = '' } = useParams();
12
20
  const navigate = useNavigate();
13
- const { hasCapability } = useRuntime();
14
21
  const { message } = App.useApp();
15
22
  const create = useCreate();
16
23
  const update = useUpdate();
@@ -20,76 +27,34 @@ export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
20
27
  queryOptions: { enabled: mode === 'edit' },
21
28
  });
22
29
  const record = query.result;
23
- const allowed =
24
- mode === 'create'
25
- ? hasCapability(INSTRUMENT_CAPABILITIES.create)
26
- : hasCapability(INSTRUMENT_CAPABILITIES.update);
27
- if (!allowed) {
28
- return (
29
- <Shell>
30
- <Result status="403" title="当前平台用户无此操作权限" />
31
- </Shell>
32
- );
33
- }
34
- if (mode === 'edit' && query.query.isLoading) {
35
- return (
36
- <Shell>
37
- <div className="oxa-page-loading">
38
- <Spin />
39
- </div>
40
- </Shell>
41
- );
42
- }
43
- if (mode === 'edit' && !record) {
44
- return (
45
- <Shell>
46
- <Result status="404" title="仪器不存在或不在数据范围内" />
47
- </Shell>
48
- );
49
- }
30
+ const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
31
+ const listPath = variant === 'mobile' ? '/m/instruments' : '/instruments';
32
+ const detailPath = variant === 'mobile' ? `/m/instruments/${id}` : `/instruments/${id}`;
50
33
  return (
51
- <Shell>
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>
34
+ <Page
35
+ backLabel={mode === 'edit' ? '返回仪器详情' : '返回仪器资源'}
36
+ backPath={mode === 'edit' ? detailPath : listPath}
37
+ capability={
38
+ mode === 'create'
39
+ ? INSTRUMENT_CAPABILITIES.create
40
+ : INSTRUMENT_CAPABILITIES.update
41
+ }
42
+ detailPath={record ? detailPath : undefined}
43
+ loading={mode === 'edit' && query.query.isLoading}
44
+ mode={mode}
45
+ notFound={mode === 'edit' && !record}
46
+ recordSummary={
47
+ record && `${record.chineseName} · ${record.instrumentCode} · 当前版本 ${record.revision}`
48
+ }
49
+ resource="instruments"
50
+ surface={instrumentSurface}
51
+ title="仪器"
52
+ >
89
53
  <InstrumentForm
90
54
  mode={mode}
55
+ variant={variant}
91
56
  onCancel={() =>
92
- navigate(mode === 'edit' ? `/instruments/${id}` : '/instruments')
57
+ navigate(mode === 'edit' ? detailPath : listPath)
93
58
  }
94
59
  onSubmit={async values => {
95
60
  if (mode === 'create') {
@@ -98,7 +63,7 @@ export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
98
63
  values,
99
64
  });
100
65
  message.success('仪器已创建');
101
- navigate('/instruments');
66
+ navigate(listPath);
102
67
  } else {
103
68
  const result = await update.mutateAsync({
104
69
  resource: 'instruments',
@@ -107,11 +72,11 @@ export function InstrumentFormPage({ mode }: { mode: 'create' | 'edit' }) {
107
72
  meta: { expectedRevision: record!.revision },
108
73
  });
109
74
  message.success('修改已保存');
110
- navigate(`/instruments/${result.data.id}`);
75
+ navigate(variant === 'mobile' ? `/m/instruments/${result.data.id}` : `/instruments/${result.data.id}`);
111
76
  }
112
77
  }}
113
78
  record={record}
114
79
  />
115
- </Shell>
80
+ </Page>
116
81
  );
117
82
  }