diff --git a/dashboard/frontend/src/views/www/components/SectionManager.vue b/dashboard/frontend/src/views/www/components/SectionManager.vue index d0222f9..7e65fd9 100644 --- a/dashboard/frontend/src/views/www/components/SectionManager.vue +++ b/dashboard/frontend/src/views/www/components/SectionManager.vue @@ -51,7 +51,8 @@ const layoutOptions: LayoutOption[] = [ { label: '标题+媒体', value: 'title_media', desc: '顶部标题文案 + 下方图片/视频,对齐方向可配置' }, { label: '马赛克拼贴', value: 'mosaic', desc: '一大带多小的不规则网格,每个槽位支持图片/视频、中英双素材' }, { label: '跑马灯', value: 'marquee', desc: '图片/视频轮播,可自定义切换间隔,最多 5 个素材' }, - { label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' } + { label: '参数规格表', value: 'spec_table', desc: '产品技术参数规格表格' }, + { label: '3D 模型展示', value: 'model3d', desc: 'three.js 交互式 3D 模型(素材库上传 .glb),支持拖拽旋转/缩放/自动旋转' } ]; function layoutLabel(value: string) { @@ -366,7 +367,7 @@ function handleAdd() { formData.media_url_en = ''; formData.video_url = ''; formData.is_visible = true; - formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null }; + formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), marquee_items: emptyMarqueeItems(1), marquee_interval: 3, min_height: null, model_url: '', model_height: 480, auto_rotate: true }; dialogVisible.value = true; } @@ -412,7 +413,10 @@ function fillFormFromRow(row: Api.Www.SectionBlock) { split_ratio: [30, 40, 50, 60, 70].includes(Number(cfg.split_ratio)) ? Number(cfg.split_ratio) : 50, min_height: typeof cfg.min_height === 'number' && cfg.min_height >= 0 ? cfg.min_height : null, mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3', - marquee_interval: typeof cfg.marquee_interval === 'number' && cfg.marquee_interval >= 1 ? Math.min(cfg.marquee_interval, 60) : 3 + marquee_interval: typeof cfg.marquee_interval === 'number' && cfg.marquee_interval >= 1 ? Math.min(cfg.marquee_interval, 60) : 3, + model_url: typeof cfg.model_url === 'string' ? cfg.model_url : '', + model_height: typeof cfg.model_height === 'number' && cfg.model_height >= 200 ? cfg.model_height : 480, + auto_rotate: cfg.auto_rotate !== false }; formData.config.mosaic_items = normalizeMosaicItems(cfg.mosaic_items, String(formData.config.mosaic_pattern)); formData.config.marquee_items = normalizeMarqueeItems(cfg.marquee_items); @@ -835,6 +839,28 @@ onMounted(loadData); + 添加素材({{ marqueeItems.length }}/5) + + + + + + + (formData.config.model_height = v ?? 480)" + > + px + + + + (formData.config.auto_rotate = v)" /> + + + diff --git a/www/app/components/section/SectionRenderer.vue b/www/app/components/section/SectionRenderer.vue index 5b8147d..9414c7f 100644 --- a/www/app/components/section/SectionRenderer.vue +++ b/www/app/components/section/SectionRenderer.vue @@ -12,6 +12,7 @@ import LayoutTitleMedia from './layouts/LayoutTitleMedia.vue'; import LayoutMosaic from './layouts/LayoutMosaic.vue'; import LayoutMarquee from './layouts/LayoutMarquee.vue'; import LayoutSpecTable from './layouts/LayoutSpecTable.vue'; +import LayoutModel3d from './layouts/LayoutModel3d.vue'; const props = defineProps<{ section: SectionBlock }>(); @@ -26,7 +27,8 @@ const layoutComponents: Record = { title_media: LayoutTitleMedia, mosaic: LayoutMosaic, marquee: LayoutMarquee, - spec_table: LayoutSpecTable + spec_table: LayoutSpecTable, + model3d: LayoutModel3d }; const layoutComponent = computed(() => layoutComponents[props.section.layout] ?? null); diff --git a/www/app/components/section/layouts/LayoutModel3d.vue b/www/app/components/section/layouts/LayoutModel3d.vue new file mode 100644 index 0000000..2e62374 --- /dev/null +++ b/www/app/components/section/layouts/LayoutModel3d.vue @@ -0,0 +1,198 @@ + + + + + + + + {{ overline }} + + + {{ title }} + + + {{ subtitle }} + + + + + + + + + 3D 模型加载失败 + + + 未配置 3D 模型 + + + + 模型加载中… + + + + + + 拖拽旋转 · 滚轮缩放 + + + + diff --git a/www/app/types/site.ts b/www/app/types/site.ts index 509dfc1..1d19a42 100644 --- a/www/app/types/site.ts +++ b/www/app/types/site.ts @@ -86,7 +86,8 @@ export type SectionLayout = | 'title_media' | 'mosaic' | 'marquee' - | 'spec_table'; + | 'spec_table' + | 'model3d'; export interface SpecItem { id: number; @@ -163,6 +164,12 @@ export interface SectionConfig { marquee_items?: MarqueeItem[]; /** 跑马灯切换间隔(秒) */ marquee_interval?: number; + /** 3D 模型文件路径(model3d 布局,素材库上传的 .glb/.gltf) */ + model_url?: string; + /** 3D 展示区高度 px(默认 480) */ + model_height?: number; + /** 3D 模型是否自动旋转(默认开) */ + auto_rotate?: boolean; [key: string]: unknown; } diff --git a/www/package.json b/www/package.json index 3927be6..160b566 100644 --- a/www/package.json +++ b/www/package.json @@ -20,10 +20,12 @@ "@vueuse/nuxt": "^14.4.0", "nuxt": "^4.5.1", "pinia": "^4.0.2", + "three": "^0.185.1", "vue": "^3.5.0", "vue-router": "^5.2.0" }, "devDependencies": { + "@types/three": "^0.185.4", "@unocss/nuxt": "^66.0.0", "typescript": "^5.8.0", "unocss": "^66.0.0", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 12553f5..af8c3fb 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: pinia: specifier: ^4.0.2 version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.8.2)(vue@3.5.40(typescript@5.8.2)) + three: + specifier: ^0.185.1 + version: 0.185.1 vue: specifier: ^3.5.0 version: 3.5.40(typescript@5.8.2) @@ -42,6 +45,9 @@ importers: specifier: ^5.2.0 version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.8.2)(vue@3.5.40(typescript@5.8.2)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.2))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23)) devDependencies: + '@types/three': + specifier: ^0.185.4 + version: 0.185.4 '@unocss/nuxt': specifier: ^66.0.0 version: 66.7.5(esbuild@0.28.1)(magic-string@1.1.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23)) @@ -242,6 +248,9 @@ packages: '@devframes/hub': optional: true + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dxup/nuxt@0.5.5': resolution: {integrity: sha512-7GIUr0aD4klOyLjGMzLBNjsZiIbU6EPqO5de1Q1kTPdZXWMqamr5FqRCZycA/9n9lxsXRLFxENL7tcRAClMTrw==} @@ -1748,6 +1757,9 @@ packages: '@speed-highlight/core@1.2.17': resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1769,9 +1781,18 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.4': + resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/project-service@8.65.0': resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2802,6 +2823,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3296,6 +3320,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4173,6 +4200,9 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4971,6 +5001,8 @@ snapshots: optionalDependencies: '@devframes/hub': 0.7.14(devframe@0.7.14(srvx@0.11.22)(typescript@5.8.2)) + '@dimforge/rapier3d-compat@0.12.0': {} + '@dxup/nuxt@0.5.5(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23))': dependencies: '@dxup/unimport': 0.1.2 @@ -6372,6 +6404,8 @@ snapshots: '@speed-highlight/core@1.2.17': {} + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -6391,8 +6425,21 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.4': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/project-service@8.65.0(supports-color@10.0.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.8.2) @@ -7691,6 +7738,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.0 @@ -8165,6 +8214,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -9318,6 +9369,8 @@ snapshots: transitivePeerDependencies: - react-native-b4a + three@0.185.1: {} + tiny-invariant@1.3.3: {} tinyclip@0.1.15: {}
+ {{ overline }} +
+ {{ subtitle }} +