openxiangda-cli 2.0.0-alpha.58 → 2.0.0-alpha.60
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.
- package/package.json +4 -4
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/instruments.spec.ts +285 -0
- package/template/apps/web/package.json +4 -2
- package/template/apps/web/scripts/check.mjs +1 -1
- package/template/apps/web/scripts/verify-build.mjs +14 -2
- package/template/apps/web/src/FilePreviewPage.tsx +303 -0
- package/template/apps/web/src/InstrumentForm.tsx +21 -130
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
- package/template/apps/web/src/main.tsx +5 -0
- package/template/apps/web/src/platform-client.ts +38 -3
- package/template/apps/web/src/runtime-meta.ts +15 -0
- package/template/apps/web/src/styles.css +163 -24
- package/template/apps/web/test/contracts.test.ts +104 -0
- package/template/package.json +3 -3
- package/template/scripts/verify-template-budget.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openxiangda-cli",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.60",
|
|
4
4
|
"description": "Thin application-level CLI for OpenXiangda 2.0.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@oclif/core": "4.13.3",
|
|
26
|
-
"openxiangda-contracts": "2.0.0-alpha.
|
|
27
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
28
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
26
|
+
"openxiangda-contracts": "2.0.0-alpha.28",
|
|
27
|
+
"openxiangda-devkit-core": "2.0.0-alpha.36",
|
|
28
|
+
"openxiangda-mcp": "2.0.0-alpha.36"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsx": "4.23.12",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
instrumentFields,
|
|
10
10
|
} from '../src/fields';
|
|
11
|
+
import * as XLSX from 'xlsx';
|
|
11
12
|
|
|
12
13
|
const declaredInstrumentFields = new Set(
|
|
13
14
|
instrumentFields.filter(field => !field.system).map(field => field.key)
|
|
@@ -15,6 +16,28 @@ const declaredInstrumentFields = new Set(
|
|
|
15
16
|
const collegeId = '11111111-1111-4111-8111-111111111111';
|
|
16
17
|
const userId = '22222222-2222-4222-8222-222222222222';
|
|
17
18
|
const departmentId = '33333333-3333-4333-8333-333333333333';
|
|
19
|
+
const minimalDocx = Buffer.from(
|
|
20
|
+
'UEsDBAoAAAAIABJvFV15bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH1QyU7DMBD9FWuuKHHggBCK0wPLETiUDxjZk8SqN3nc0v49Tlt6QIXjzFv1+tXeO7GjzDYGBbdtB4KCjsaGScHn+rV5AMEFg0EXAyk4EMNq6NeHRCyqNrCCuZT0KCXrmTxyGxOFiowxeyz1zJNMqDc4kbzrunupYygUSlMWDxj6Zxpx64p42df3qUcmxyCeTsQlSwGm5KzGUnG5C+ZXSnNOaKvyyOHZJr6pBJBXExbk74Cz7r0Ok60h8YG5vKGvLPkVs5Em6q2vyvZ/mys94zhaTRf94pZy1MRcF/euvSAebfjpL49zD99QSwMECgAAAAAAEm8VXQAAAAAAAAAAAAAAAAYAAABfcmVscy9QSwMECgAAAAgAEm8VXZv9N+qtAAAAKQEAAAsAAABfcmVscy8ucmVsc43POw7CMAwG4KtE3mlaBoRQ0y4IqSsqB7ASN61oHkrCo7cnAwNFDIy2f3+W6/ZpZnanECdnBVRFCYysdGqyWsClP232wGJCq3B2lgQsFKFt6jPNmPJKHCcfWTZsFDCm5A+cRzmSwVg4TzZPBhcMplwGzT3KK2ri27Lc8fBpwNpknRIQOlUB6xdP/9huGCZJRydvhmz6ceIrkWUMmpKAhwuKq3e7yCzwpuarF5sXUEsDBAoAAAAAABJvFV0AAAAAAAAAAAAAAAAFAAAAd29yZC9QSwMECgAAAAgAEm8VXZ1FWubkAAAAWwEAABEAAAB3b3JkL2RvY3VtZW50LnhtbEVQwW7DIAz9FcR9JY26qYqS9NbbtErbtF1pcBOkgBF4Zd3Xz6SKenn2s/2eDe3h183iCjFZ9J3cbiopwA9orB87+flxfNpLkUh7o2f00MkbJHno29wYHH4ceBJs4FOTOzkRhUapNEzgdNpgAM+9C0aniWkcVcZoQsQBUmJ/N6u6ql6U09bLYnlGcysxFIgFqH9jl2+r/Wi0+GK5OEW4WsitKt2CccFFk2Cg0yIM4/ufyOWqbV3v+FG5mTh/3nOu7gOvOnKVMHB9dx+JdpzoQc9IhO7BZ7isXbUsXfep9Xb1+Jf+H1BLAQIUAAoAAAAIABJvFV15bjPX6AAAAK0BAAATAAAAAAAAAAAAAAAAAAAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQACgAAAAAAEm8VXQAAAAAAAAAAAAAAAAYAAAAAAAAAAAAQAAAAGQEAAF9yZWxzL1BLAQIUAAoAAAAIABJvFV2b/TfqrQAAACkBAAALAAAAAAAAAAAAAAAAAD0BAABfcmVscy8ucmVsc1BLAQIUAAoAAAAAABJvFV0AAAAAAAAAAAAAAAAFAAAAAAAAAAAAEAAAABMCAAB3b3JkL1BLAQIUAAoAAAAIABJvFV2dRVrm5AAAAFsBAAARAAAAAAAAAAAAAAAAADYCAAB3b3JkL2RvY3VtZW50LnhtbFBLBQYAAAAABQAFACABAABJAwAAAAA=',
|
|
21
|
+
'base64'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function previewWorkbook() {
|
|
25
|
+
const workbook = XLSX.utils.book_new();
|
|
26
|
+
XLSX.utils.book_append_sheet(
|
|
27
|
+
workbook,
|
|
28
|
+
XLSX.utils.aoa_to_sheet([
|
|
29
|
+
['仪器编码', '仪器名称'],
|
|
30
|
+
['INS-PREVIEW-001', '透射电子显微镜'],
|
|
31
|
+
]),
|
|
32
|
+
'仪器清单'
|
|
33
|
+
);
|
|
34
|
+
XLSX.utils.book_append_sheet(
|
|
35
|
+
workbook,
|
|
36
|
+
XLSX.utils.aoa_to_sheet([['第二个工作表'], ['预览正常']]),
|
|
37
|
+
'维护记录'
|
|
38
|
+
);
|
|
39
|
+
return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
|
40
|
+
}
|
|
18
41
|
|
|
19
42
|
function expectDeclaredInstrumentQuery(request: Request) {
|
|
20
43
|
const body = request.postDataJSON() as {
|
|
@@ -728,6 +751,268 @@ test('keeps the production warning visible when current-user fails closed', asyn
|
|
|
728
751
|
await expect(page.getByText('无法读取平台当前用户')).toBeVisible();
|
|
729
752
|
});
|
|
730
753
|
|
|
754
|
+
test('previews images in a lightbox and documents in focused new windows', async ({
|
|
755
|
+
page,
|
|
756
|
+
context,
|
|
757
|
+
}) => {
|
|
758
|
+
test.setTimeout(60_000);
|
|
759
|
+
const image = Buffer.from(
|
|
760
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
761
|
+
'base64'
|
|
762
|
+
);
|
|
763
|
+
const spreadsheet = previewWorkbook();
|
|
764
|
+
const pdf = Buffer.from(
|
|
765
|
+
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 200]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
|
|
766
|
+
);
|
|
767
|
+
const files = new Map([
|
|
768
|
+
[
|
|
769
|
+
'preview-image',
|
|
770
|
+
{
|
|
771
|
+
name: '显微镜照片.png',
|
|
772
|
+
type: 'image/png',
|
|
773
|
+
body: image,
|
|
774
|
+
previewType: 'image',
|
|
775
|
+
renderMode: 'inline',
|
|
776
|
+
canPreview: true,
|
|
777
|
+
unsupportedReason: '',
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
[
|
|
781
|
+
'preview-docx',
|
|
782
|
+
{
|
|
783
|
+
name: '设备说明书.docx',
|
|
784
|
+
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
785
|
+
body: minimalDocx,
|
|
786
|
+
previewType: 'office',
|
|
787
|
+
renderMode: 'docx-html',
|
|
788
|
+
canPreview: true,
|
|
789
|
+
unsupportedReason: '',
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
[
|
|
793
|
+
'preview-xlsx',
|
|
794
|
+
{
|
|
795
|
+
name: '仪器清单.xlsx',
|
|
796
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
797
|
+
body: spreadsheet,
|
|
798
|
+
previewType: 'spreadsheet',
|
|
799
|
+
renderMode: 'excel-client',
|
|
800
|
+
canPreview: true,
|
|
801
|
+
unsupportedReason: '',
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
[
|
|
805
|
+
'preview-pdf',
|
|
806
|
+
{
|
|
807
|
+
name: '验收报告.pdf',
|
|
808
|
+
type: 'application/pdf',
|
|
809
|
+
body: pdf,
|
|
810
|
+
previewType: 'pdf',
|
|
811
|
+
renderMode: 'pdfjs',
|
|
812
|
+
canPreview: true,
|
|
813
|
+
unsupportedReason: '',
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
'preview-doc',
|
|
818
|
+
{
|
|
819
|
+
name: '旧版说明书.doc',
|
|
820
|
+
type: 'application/msword',
|
|
821
|
+
body: Buffer.from('legacy-word-file'),
|
|
822
|
+
previewType: 'download',
|
|
823
|
+
renderMode: 'download',
|
|
824
|
+
canPreview: false,
|
|
825
|
+
unsupportedReason: '该旧版 Office 格式暂不支持在线预览,请下载后打开',
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
]);
|
|
829
|
+
const byName = new Map([...files].map(([id, file]) => [file.name, id]));
|
|
830
|
+
const inlineReads: string[] = [];
|
|
831
|
+
await context.route('**/mock-upload/**', route =>
|
|
832
|
+
route.fulfill({ status: 200, body: '' })
|
|
833
|
+
);
|
|
834
|
+
await context.route('**/service/**', async route => {
|
|
835
|
+
const request = route.request();
|
|
836
|
+
const url = new URL(request.url());
|
|
837
|
+
if (url.pathname.endsWith('/dev-sessions/current')) {
|
|
838
|
+
await route.fulfill({
|
|
839
|
+
contentType: 'application/json',
|
|
840
|
+
body: JSON.stringify({
|
|
841
|
+
code: 200,
|
|
842
|
+
data: {
|
|
843
|
+
environment: { id: 'test-id', key: 'preproduction' },
|
|
844
|
+
principal: {
|
|
845
|
+
userId,
|
|
846
|
+
roleCodes: ['school_admin'],
|
|
847
|
+
capabilityCodes: [],
|
|
848
|
+
isAppSuperAdmin: true,
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
}),
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (url.pathname.endsWith('/files/uploads/initiate')) {
|
|
856
|
+
const input = request.postDataJSON() as {
|
|
857
|
+
fileName: string;
|
|
858
|
+
fileSize: number;
|
|
859
|
+
contentType: string;
|
|
860
|
+
};
|
|
861
|
+
const id = byName.get(input.fileName)!;
|
|
862
|
+
await route.fulfill({
|
|
863
|
+
contentType: 'application/json',
|
|
864
|
+
body: JSON.stringify({
|
|
865
|
+
code: 200,
|
|
866
|
+
data: {
|
|
867
|
+
schemaVersion: 'openxiangda.data-file-upload-plan/v2',
|
|
868
|
+
resourceCode: 'instruments',
|
|
869
|
+
fieldCode: input.contentType.startsWith('image/') ? 'image' : 'attachments',
|
|
870
|
+
file: {
|
|
871
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
872
|
+
id,
|
|
873
|
+
name: input.fileName,
|
|
874
|
+
size: input.fileSize,
|
|
875
|
+
contentType: input.contentType,
|
|
876
|
+
},
|
|
877
|
+
uploadMethod: 'PUT',
|
|
878
|
+
uploadUrl: `${url.origin}/mock-upload/${id}`,
|
|
879
|
+
headers: {},
|
|
880
|
+
expiresAt: '2026-08-21T18:00:00.000Z',
|
|
881
|
+
},
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const complete = url.pathname.match(/\/files\/(preview-[^/]+)\/complete$/);
|
|
887
|
+
if (complete) {
|
|
888
|
+
const id = complete[1]!;
|
|
889
|
+
const file = files.get(id)!;
|
|
890
|
+
await route.fulfill({
|
|
891
|
+
contentType: 'application/json',
|
|
892
|
+
body: JSON.stringify({
|
|
893
|
+
code: 200,
|
|
894
|
+
data: {
|
|
895
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
896
|
+
id,
|
|
897
|
+
name: file.name,
|
|
898
|
+
size: file.body.length,
|
|
899
|
+
contentType: file.type,
|
|
900
|
+
},
|
|
901
|
+
}),
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const preview = url.pathname.match(/\/files\/(preview-[^/]+)\/preview$/);
|
|
906
|
+
if (preview) {
|
|
907
|
+
const id = preview[1]!;
|
|
908
|
+
const file = files.get(id)!;
|
|
909
|
+
await route.fulfill({
|
|
910
|
+
contentType: 'application/json',
|
|
911
|
+
body: JSON.stringify({
|
|
912
|
+
code: 200,
|
|
913
|
+
data: {
|
|
914
|
+
schemaVersion: 'openxiangda.data-file-preview/v2',
|
|
915
|
+
resourceCode: 'instruments',
|
|
916
|
+
file: {
|
|
917
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
918
|
+
id,
|
|
919
|
+
name: file.name,
|
|
920
|
+
size: file.body.length,
|
|
921
|
+
contentType: file.type,
|
|
922
|
+
},
|
|
923
|
+
extension: file.name.split('.').pop(),
|
|
924
|
+
previewType: file.previewType,
|
|
925
|
+
renderMode: file.renderMode,
|
|
926
|
+
previewSurface: file.previewType === 'image' ? 'media' : 'document',
|
|
927
|
+
previewProvider: 'browser',
|
|
928
|
+
canPreview: file.canPreview,
|
|
929
|
+
canDownload: true,
|
|
930
|
+
...(file.unsupportedReason
|
|
931
|
+
? { unsupportedReason: file.unsupportedReason }
|
|
932
|
+
: {}),
|
|
933
|
+
},
|
|
934
|
+
}),
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const content = url.pathname.match(/\/files\/(preview-[^/]+)\/content$/);
|
|
939
|
+
if (content) {
|
|
940
|
+
const id = content[1]!;
|
|
941
|
+
const file = files.get(id)!;
|
|
942
|
+
if (url.searchParams.get('disposition') === 'inline') inlineReads.push(id);
|
|
943
|
+
await route.fulfill({
|
|
944
|
+
status: 200,
|
|
945
|
+
contentType: file.type,
|
|
946
|
+
body: file.body,
|
|
947
|
+
});
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
await page.goto('/instruments/new');
|
|
954
|
+
const fileFields = page.locator('.oxa-file-field');
|
|
955
|
+
const imageInput = fileFields.nth(0).locator('input[type="file"]').first();
|
|
956
|
+
const attachmentInput = fileFields.nth(1).locator('input[type="file"]').first();
|
|
957
|
+
await imageInput.setInputFiles({
|
|
958
|
+
name: '显微镜照片.png',
|
|
959
|
+
mimeType: 'image/png',
|
|
960
|
+
buffer: image,
|
|
961
|
+
});
|
|
962
|
+
for (const [name, mimeType, buffer] of [
|
|
963
|
+
['设备说明书.docx', files.get('preview-docx')!.type, minimalDocx],
|
|
964
|
+
['仪器清单.xlsx', files.get('preview-xlsx')!.type, spreadsheet],
|
|
965
|
+
['验收报告.pdf', 'application/pdf', pdf],
|
|
966
|
+
['旧版说明书.doc', 'application/msword', files.get('preview-doc')!.body],
|
|
967
|
+
] as const) {
|
|
968
|
+
await attachmentInput.setInputFiles({ name, mimeType, buffer });
|
|
969
|
+
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
await page.getByRole('button', { name: '预览显微镜照片.png' }).click();
|
|
973
|
+
await expect(page.locator('.ant-image-preview-img')).toBeVisible();
|
|
974
|
+
await page.keyboard.press('Escape');
|
|
975
|
+
|
|
976
|
+
const docxPagePromise = context.waitForEvent('page');
|
|
977
|
+
await page.getByRole('button', { name: '预览设备说明书.docx' }).click();
|
|
978
|
+
const docxPage = await docxPagePromise;
|
|
979
|
+
await expect(docxPage.getByText('OpenXiangda Word Preview')).toBeVisible();
|
|
980
|
+
await docxPage.close();
|
|
981
|
+
|
|
982
|
+
const xlsxPagePromise = context.waitForEvent('page');
|
|
983
|
+
await page.getByRole('button', { name: '预览仪器清单.xlsx' }).click();
|
|
984
|
+
const xlsxPage = await xlsxPagePromise;
|
|
985
|
+
await expect(xlsxPage.getByText('透射电子显微镜')).toBeVisible();
|
|
986
|
+
await xlsxPage.getByText('维护记录', { exact: true }).click();
|
|
987
|
+
await expect(xlsxPage.getByText('预览正常')).toBeVisible();
|
|
988
|
+
await xlsxPage.close();
|
|
989
|
+
|
|
990
|
+
const pdfPagePromise = context.waitForEvent('page');
|
|
991
|
+
await page.getByRole('button', { name: '预览验收报告.pdf' }).click();
|
|
992
|
+
const pdfPage = await pdfPagePromise;
|
|
993
|
+
await expect(pdfPage.locator('iframe[title="验收报告.pdf PDF 预览"]')).toBeVisible();
|
|
994
|
+
await pdfPage.close();
|
|
995
|
+
|
|
996
|
+
const legacyPagePromise = context.waitForEvent('page');
|
|
997
|
+
await page.getByRole('button', { name: '预览旧版说明书.doc' }).click();
|
|
998
|
+
const legacyPage = await legacyPagePromise;
|
|
999
|
+
await expect(
|
|
1000
|
+
legacyPage.getByText('该旧版 Office 格式暂不支持在线预览,请下载后打开')
|
|
1001
|
+
).toBeVisible();
|
|
1002
|
+
await expect(legacyPage.getByRole('link', { name: '下载文件' })).toBeVisible();
|
|
1003
|
+
await legacyPage.close();
|
|
1004
|
+
|
|
1005
|
+
expect(inlineReads).toEqual(
|
|
1006
|
+
expect.arrayContaining([
|
|
1007
|
+
'preview-image',
|
|
1008
|
+
'preview-docx',
|
|
1009
|
+
'preview-xlsx',
|
|
1010
|
+
'preview-pdf',
|
|
1011
|
+
])
|
|
1012
|
+
);
|
|
1013
|
+
expect(inlineReads).not.toContain('preview-doc');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
731
1016
|
async function installProductionRuntimeMeta(page: Page) {
|
|
732
1017
|
await page.route('**/view/instrument-center/**', async route => {
|
|
733
1018
|
if (route.request().resourceType() !== 'document') {
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"@refinedev/core": "5.0.12",
|
|
16
16
|
"antd": "6.4.3",
|
|
17
17
|
"dayjs": "1.11.18",
|
|
18
|
-
"
|
|
18
|
+
"docx-preview": "0.3.7",
|
|
19
|
+
"openxiangda-contracts": "2.0.0-alpha.28",
|
|
19
20
|
"react": "19.2.8",
|
|
20
21
|
"react-dom": "19.2.8",
|
|
21
|
-
"react-router-dom": "7.8.2"
|
|
22
|
+
"react-router-dom": "7.8.2",
|
|
23
|
+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"@playwright/test": "1.62.1",
|
|
@@ -20,7 +20,7 @@ const lines = files.reduce(
|
|
|
20
20
|
0
|
|
21
21
|
);
|
|
22
22
|
if (files.length > 18) throw new Error(`WEB_SOURCE_FILE_BUDGET_EXCEEDED:${files.length}>18`);
|
|
23
|
-
if (lines >
|
|
23
|
+
if (lines > 4_300) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>4300`);
|
|
24
24
|
const source = files.map(file => readFileSync(file, 'utf8')).join('\n');
|
|
25
25
|
for (const marker of ['@umijs/', 'openxiangda-' + 'admin', 'openxiangda-' + 'user', 'instrument_query', 'instrument_save', 'function.invoke', 'role' + '-session', 'college-' + 'am', 'user-' + 'wang', 'dept-' + 'am-test']) {
|
|
26
26
|
if (source.includes(marker)) throw new Error(`WEB_FORBIDDEN_MARKER:${marker}`);
|
|
@@ -13,8 +13,20 @@ const gzip = javascript.reduce(
|
|
|
13
13
|
(bytes, file) => bytes + gzipSync(readFileSync(file), { level: 9 }).byteLength,
|
|
14
14
|
0
|
|
15
15
|
);
|
|
16
|
-
if (gzip >
|
|
16
|
+
if (gzip > 750_000) throw new Error(`WEB_JS_GZIP_BUDGET_EXCEEDED:${gzip}>750000`);
|
|
17
17
|
if (names.some(name => name.endsWith('.map'))) throw new Error('WEB_SOURCE_MAP_FORBIDDEN');
|
|
18
18
|
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
|
19
19
|
if (!html.includes('<div id="root"></div>')) throw new Error('WEB_ROOT_MISSING');
|
|
20
|
-
|
|
20
|
+
const initialNames = [
|
|
21
|
+
...html.matchAll(/(?:src|href)="\.\/assets\/([^"]+\.js)"/g),
|
|
22
|
+
].map(match => match[1]);
|
|
23
|
+
const initialGzip = [...new Set(initialNames)].reduce((bytes, name) => {
|
|
24
|
+
const file = join(assets, name);
|
|
25
|
+
return bytes + gzipSync(readFileSync(file), { level: 9 }).byteLength;
|
|
26
|
+
}, 0);
|
|
27
|
+
if (initialGzip > 600_000) {
|
|
28
|
+
throw new Error(`WEB_INITIAL_JS_GZIP_BUDGET_EXCEEDED:${initialGzip}>600000`);
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
`Verified Vite build: ${total} bytes, ${gzip} total gzip bytes, ${initialGzip} initial gzip bytes.\n`
|
|
32
|
+
);
|
|
@@ -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
|
+
}
|