官网开发 0810

This commit is contained in:
eafonyang
2026-08-10 14:56:44 +08:00
parent c9a1d9c576
commit 48510739a7
20 changed files with 636 additions and 53 deletions
+11 -2
View File
@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from 'express'; import { Router, type Request, type Response } from 'express';
import { eq, and } from 'drizzle-orm'; import { eq, and, max } from 'drizzle-orm';
import { db } from '../../config/database.js'; import { db } from '../../config/database.js';
import { wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js'; import { wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js';
import logger from '../../config/logger.js'; import logger from '../../config/logger.js';
@@ -77,6 +77,15 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
return; return;
} }
// 未指定排序时,自动追加到同 page_type(及同产品)已有区块的最后,避免默认 0 导致乱序
let sortOrder = Number(b.sort_order) || 0;
if (b.sort_order === undefined || b.sort_order === null) {
const scope = [eq(wwwSectionBlocks.pageType, b.page_type)];
if (b.product_id) scope.push(eq(wwwSectionBlocks.productId, b.product_id));
const [agg] = await db.select({ max: max(wwwSectionBlocks.sortOrder) }).from(wwwSectionBlocks).where(and(...scope));
sortOrder = (agg?.max ?? -1) + 1;
}
const result = await db.insert(wwwSectionBlocks).values({ const result = await db.insert(wwwSectionBlocks).values({
pageType: b.page_type, pageType: b.page_type,
productId: b.product_id || null, productId: b.product_id || null,
@@ -108,7 +117,7 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
videoUrl: b.video_url || '', videoUrl: b.video_url || '',
config: b.config || null, config: b.config || null,
isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1, isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1,
sortOrder: b.sort_order || 0, sortOrder,
}); });
const newId = result[0].insertId; const newId = result[0].insertId;
@@ -246,7 +246,7 @@ export const generatedRoutes: GeneratedRoute[] = [
meta: { meta: {
title: 'www', title: 'www',
i18nKey: 'route.www', i18nKey: 'route.www',
icon: 'mdi:globe-model', icon: 'iconoir:www',
order: 7 order: 7
}, },
children: [ children: [
@@ -35,7 +35,7 @@ export function fetchDuplicateSection(id: number) {
// ===== 参数分组 ===== // ===== 参数分组 =====
export function fetchGetSpecGroups(sectionId: number) { export function fetchGetSpecGroups(sectionId: number) {
return request<Api.Www.SpecGroup[]>({ url: `/www/sections/${sectionId}/spec-groups`, method: 'get' }); return request<{ items: Api.Www.SpecGroup[]; total: number }>({ url: `/www/sections/${sectionId}/spec-groups`, method: 'get' });
} }
export function fetchCreateSpecGroup(sectionId: number, data: { title_zh: string; title_en: string; default_visible?: number }) { export function fetchCreateSpecGroup(sectionId: number, data: { title_zh: string; title_en: string; default_visible?: number }) {
@@ -56,7 +56,7 @@ export function fetchSortSpecGroups(sectionId: number, data: Array<{ id: number;
// ===== 参数项 ===== // ===== 参数项 =====
export function fetchGetSpecItems(groupId: number) { export function fetchGetSpecItems(groupId: number) {
return request<Api.Www.SpecItem[]>({ url: `/www/spec-groups/${groupId}/items`, method: 'get' }); return request<{ items: Api.Www.SpecItem[]; total: number }>({ url: `/www/spec-groups/${groupId}/items`, method: 'get' });
} }
export function fetchCreateSpecItem(groupId: number, data: { name_zh: string; name_en: string; value_zh: string; value_en: string }) { export function fetchCreateSpecItem(groupId: number, data: { name_zh: string; name_en: string; value_zh: string; value_en: string }) {
+2
View File
@@ -147,6 +147,8 @@ declare namespace Api {
title_en: string; title_en: string;
default_visible: number; default_visible: number;
sort_order: number; sort_order: number;
/** GET spec-groups 列表接口内联返回的参数项 */
items?: SpecItem[];
} }
interface SpecItem { interface SpecItem {
@@ -4,6 +4,7 @@ import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, t
import ColorInput from '../page/home/components/ColorInput.vue'; import ColorInput from '../page/home/components/ColorInput.vue';
import MediaSelectInput from './MediaSelectInput.vue'; import MediaSelectInput from './MediaSelectInput.vue';
import RichTextEditor from './RichTextEditor.vue'; import RichTextEditor from './RichTextEditor.vue';
import SpecManager from './SpecManager.vue';
import { import {
fetchCreateSection, fetchCreateSection,
fetchDeleteSection, fetchDeleteSection,
@@ -110,7 +111,7 @@ const formData = reactive({
media_url_en: '', media_url_en: '',
video_url: '', video_url: '',
is_visible: true, is_visible: true,
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown> 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), min_height: null } as Record<string, unknown>
}); });
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效) // 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
@@ -128,6 +129,20 @@ const containerWidthOptions = [
{ label: '全宽', value: 'full' } { label: '全宽', value: 'full' }
]; ];
// 图文分栏比例选项(值为素材栏宽占比);标签顺序随「媒体位置」联动:
// 素材在左 → 「素材 : 文案」,素材在右 → 「文案 : 素材」(与视觉左右顺序一致,底层存值不变)
const splitRatioOptions = computed(() => {
const mediaRight = formData.config.media_position === 'right';
const label = (media: number) => (mediaRight ? `${100 - media} : ${media}` : `${media} : ${100 - media}`);
return [
{ label: `${label(30)}`, value: 30 },
{ label: `${label(40)}`, value: 40 },
{ label: `${label(50)}(默认)`, value: 50 },
{ label: `${label(60)}`, value: 60 },
{ label: `${label(70)}`, value: 70 }
];
});
// ===== 马赛克拼贴(layout=mosaic):固定版式 + 槽位素材,存 config.mosaic_pattern / config.mosaic_items ===== // ===== 马赛克拼贴(layout=mosaic):固定版式 + 槽位素材,存 config.mosaic_pattern / config.mosaic_items =====
interface MosaicItemForm { interface MosaicItemForm {
type: 'image' | 'video'; type: 'image' | 'video';
@@ -247,11 +262,15 @@ const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 240, width: 290,
fixed: 'right', fixed: 'right',
render(row) { render(row) {
return h('div', { class: 'flex-y-center gap-8px' }, [ return h('div', { class: 'flex-y-center gap-8px' }, [
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }), h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
// spec_table 布局:管理参数分组与参数项
row.layout === 'spec_table'
? h(NButton, { size: 'small', type: 'info', onClick: () => handleSpecManage(row) }, { default: () => '参数' })
: null,
h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }), h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' }) h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
]); ]);
@@ -315,7 +334,7 @@ function handleAdd() {
formData.media_url_en = ''; formData.media_url_en = '';
formData.video_url = ''; formData.video_url = '';
formData.is_visible = true; formData.is_visible = true;
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), 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), min_height: null };
dialogVisible.value = true; dialogVisible.value = true;
} }
@@ -356,9 +375,11 @@ function fillFormFromRow(row: Api.Www.SectionBlock) {
container_width: ['1400', '1600', 'full'].includes(String(cfg.container_width)) ? String(cfg.container_width) : '1200', container_width: ['1400', '1600', 'full'].includes(String(cfg.container_width)) ? String(cfg.container_width) : '1200',
border_radius: typeof cfg.border_radius === 'number' && cfg.border_radius > 0 ? cfg.border_radius : 0, border_radius: typeof cfg.border_radius === 'number' && cfg.border_radius > 0 ? cfg.border_radius : 0,
padding_y: typeof cfg.padding_y === 'number' && cfg.padding_y >= 0 ? cfg.padding_y : null, padding_y: typeof cfg.padding_y === 'number' && cfg.padding_y >= 0 ? cfg.padding_y : null,
margin_y: typeof cfg.margin_y === 'number' && cfg.margin_y >= 0 ? cfg.margin_y : null,
title_font_size: typeof cfg.title_font_size === 'number' && cfg.title_font_size > 0 ? cfg.title_font_size : null, title_font_size: typeof cfg.title_font_size === 'number' && cfg.title_font_size > 0 ? cfg.title_font_size : null,
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover', bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
media_position: cfg.media_position === 'right' ? 'right' : 'left', media_position: cfg.media_position === 'right' ? 'right' : 'left',
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, 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' mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3'
}; };
@@ -404,7 +425,14 @@ function handleDelete(row: Api.Www.SectionBlock) {
}); });
} }
async function handleSubmit() { // 参数管理(spec_table 布局):唤起 SpecManager
const specManagerRef = ref<InstanceType<typeof SpecManager> | null>(null);
function handleSpecManage(row: Api.Www.SectionBlock) {
specManagerRef.value?.open(row);
}
async function handleSubmit(closeAfter = true) {
submitLoading.value = true; submitLoading.value = true;
const { id, ...payload } = formData; const { id, ...payload } = formData;
const reqData = { const reqData = {
@@ -412,13 +440,15 @@ async function handleSubmit() {
page_type: props.pageType, page_type: props.pageType,
product_id: props.productId || null product_id: props.productId || null
}; };
const { error } = id const { data, error } = id
? await fetchUpdateSection(id, reqData) ? await fetchUpdateSection(id, reqData)
: await fetchCreateSection(reqData); : await fetchCreateSection(reqData);
submitLoading.value = false; submitLoading.value = false;
if (!error) { if (!error) {
// 「应用」新建后回填新 id,后续保存走更新接口,避免重复创建
if (!id && data?.id) formData.id = data.id;
window.$message?.success(id ? '更新成功' : '创建成功'); window.$message?.success(id ? '更新成功' : '创建成功');
dialogVisible.value = false; if (closeAfter) dialogVisible.value = false;
loadData(); loadData();
} }
} }
@@ -447,6 +477,8 @@ onMounted(loadData);
:title="dialogTitle" :title="dialogTitle"
class="w-1000px" class="w-1000px"
:mask-closable="false" :mask-closable="false"
:segmented="{ content: true, footer: 'soft' }"
content-style="max-height: calc(85vh - 130px); overflow-y: auto;"
> >
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120"> <NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
<NDivider>基础配置</NDivider> <NDivider>基础配置</NDivider>
@@ -515,6 +547,23 @@ onMounted(loadData);
<span class="text-12px opacity-60">内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边</span> <span class="text-12px opacity-60">内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边</span>
</div> </div>
</NFormItem> </NFormItem>
<NFormItem label="上下外边距">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.margin_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 紧贴相邻区块"
@update:value="v => (formData.config.margin_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">区块与上下相邻区块之间的间隙露出页面背景色适合区块宽度 &lt; 100 + 圆角的卡片式区块留空/0 = 紧贴默认</span>
</div>
</NFormItem>
<NFormItem label="标题字号"> <NFormItem label="标题字号">
<div class="w-full flex flex-col gap-8px"> <div class="w-full flex flex-col gap-8px">
<NInputNumber <NInputNumber
@@ -596,10 +645,20 @@ onMounted(loadData);
</NFormItem> </NFormItem>
<NFormItem v-if="formData.layout === 'split_content'" label="媒体位置"> <NFormItem v-if="formData.layout === 'split_content'" label="媒体位置">
<NRadioGroup :value="(formData.config.media_position as string) ?? 'left'" @update:value="v => (formData.config.media_position = v)"> <NRadioGroup :value="(formData.config.media_position as string) ?? 'left'" @update:value="v => (formData.config.media_position = v)">
<NRadio value="left">图片/视频在左</NRadio> <NRadio value="left">素材文案</NRadio>
<NRadio value="right">图片/视频在右</NRadio> <NRadio value="right">文案素材</NRadio>
</NRadioGroup> </NRadioGroup>
</NFormItem> </NFormItem>
<NFormItem v-if="formData.layout === 'split_content'" label="分栏比例">
<div class="w-full flex flex-col gap-8px">
<NSelect
:value="(formData.config.split_ratio as number) ?? 50"
:options="splitRatioOptions"
@update:value="v => (formData.config.split_ratio = v)"
/>
<span class="text-12px opacity-60">按页面视觉左右顺序标注{{ formData.config.media_position === 'right' ? '文案 : 素材' : '素材 : 文案' }}默认五五等分移动端小屏始终单列堆叠不受影响</span>
</div>
</NFormItem>
<NFormItem v-if="formData.layout === 'mosaic'" label="拼贴版式"> <NFormItem v-if="formData.layout === 'mosaic'" label="拼贴版式">
<div class="w-full flex flex-col gap-8px"> <div class="w-full flex flex-col gap-8px">
<NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange"> <NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange">
@@ -700,10 +759,14 @@ onMounted(loadData);
<template #footer> <template #footer>
<NSpace justify="end"> <NSpace justify="end">
<NButton @click="dialogVisible = false">取消</NButton> <NButton @click="dialogVisible = false">取消</NButton>
<NButton type="primary" :loading="submitLoading" @click="handleSubmit">确定</NButton> <NButton type="success" secondary :loading="submitLoading" @click="handleSubmit(false)">应用</NButton>
<NButton type="primary" :loading="submitLoading" @click="handleSubmit()">确定</NButton>
</NSpace> </NSpace>
</template> </template>
</NModal> </NModal>
<!-- 参数规格管理spec_table 布局专用 -->
<SpecManager ref="specManagerRef" />
</div> </div>
</template> </template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref } from 'vue';
import { NButton, NCollapse, NCollapseItem, NEmpty, NForm, NFormItem, NInput, NInputNumber, NModal, NSpace } from 'naive-ui';
import {
fetchCreateSpecGroup,
fetchCreateSpecItem,
fetchDeleteSpecGroup,
fetchDeleteSpecItem,
fetchGetSpecGroups,
fetchSortSpecGroups,
fetchSortSpecItems,
fetchUpdateSpecGroup,
fetchUpdateSpecItem
} from '@/service/api';
/**
* 参数规格管理(spec_table 布局专用)
*
* 管理区块下的参数分组(title_zh/en、默认显示行数)与参数项(中英名称/值),
* 由 SectionManager 操作列的「参数」按钮唤起。
*/
defineOptions({ name: 'SpecManager' });
const visible = ref(false);
const loading = ref(false);
const sectionId = ref<number | null>(null);
const sectionTitle = ref('');
const groups = ref<Api.Www.SpecGroup[]>([]);
const expandedNames = ref<number[]>([]);
async function open(row: Api.Www.SectionBlock) {
sectionId.value = row.id;
sectionTitle.value = row.title_zh || '参数规格';
visible.value = true;
await loadData();
// 默认展开首个分组
expandedNames.value = groups.value.length ? [groups.value[0].id] : [];
}
async function loadData() {
if (!sectionId.value) return;
loading.value = true;
const { data, error } = await fetchGetSpecGroups(sectionId.value);
loading.value = false;
if (!error && data) {
groups.value = data.items || [];
}
}
defineExpose({ open });
// ===== 分组排序 =====
async function moveGroup(index: number, direction: -1 | 1) {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= groups.value.length || !sectionId.value) return;
const newData = [...groups.value];
[newData[index], newData[swapIndex]] = [newData[swapIndex], newData[index]];
const { error } = await fetchSortSpecGroups(
sectionId.value,
newData.map((g, i) => ({ id: g.id, sort_order: i }))
);
if (!error) {
groups.value = newData;
window.$message?.success('排序已更新');
}
}
// ===== 分组编辑 =====
const groupDialogVisible = ref(false);
const groupSubmitting = ref(false);
const groupForm = ref<{ id: number | null; title_zh: string; title_en: string; default_visible: number }>({
id: null,
title_zh: '',
title_en: '',
default_visible: 5
});
function handleAddGroup() {
groupForm.value = { id: null, title_zh: '', title_en: '', default_visible: 5 };
groupDialogVisible.value = true;
}
function handleEditGroup(group: Api.Www.SpecGroup) {
groupForm.value = {
id: group.id,
title_zh: group.title_zh,
title_en: group.title_en,
default_visible: group.default_visible || 5
};
groupDialogVisible.value = true;
}
async function handleGroupSubmit() {
if (!sectionId.value) return;
if (!groupForm.value.title_zh || !groupForm.value.title_en) {
window.$message?.warning('请填写中英文标题');
return;
}
groupSubmitting.value = true;
const payload = {
title_zh: groupForm.value.title_zh,
title_en: groupForm.value.title_en,
default_visible: groupForm.value.default_visible
};
const { error } = groupForm.value.id
? await fetchUpdateSpecGroup(sectionId.value, groupForm.value.id, payload)
: await fetchCreateSpecGroup(sectionId.value, payload);
groupSubmitting.value = false;
if (!error) {
window.$message?.success(groupForm.value.id ? '更新成功' : '创建成功');
groupDialogVisible.value = false;
await loadData();
}
}
function handleDeleteGroup(group: Api.Www.SpecGroup) {
window.$dialog?.warning({
title: '警告',
content: `确定要删除参数分组「${group.title_zh}」吗?其下所有参数项将一并删除。`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
if (!sectionId.value) return;
const { error } = await fetchDeleteSpecGroup(sectionId.value, group.id);
if (!error) {
window.$message?.success('删除成功');
await loadData();
}
}
});
}
// ===== 参数项编辑 =====
const itemDialogVisible = ref(false);
const itemSubmitting = ref(false);
const itemForm = ref<{ groupId: number; id: number | null; name_zh: string; name_en: string; value_zh: string; value_en: string }>({
groupId: 0,
id: null,
name_zh: '',
name_en: '',
value_zh: '',
value_en: ''
});
function handleAddItem(group: Api.Www.SpecGroup) {
itemForm.value = { groupId: group.id, id: null, name_zh: '', name_en: '', value_zh: '', value_en: '' };
itemDialogVisible.value = true;
}
function handleEditItem(group: Api.Www.SpecGroup, item: Api.Www.SpecItem) {
itemForm.value = {
groupId: group.id,
id: item.id,
name_zh: item.name_zh,
name_en: item.name_en,
value_zh: item.value_zh,
value_en: item.value_en
};
itemDialogVisible.value = true;
}
async function handleItemSubmit() {
const f = itemForm.value;
if (!f.name_zh || !f.name_en || !f.value_zh || !f.value_en) {
window.$message?.warning('请完整填写中英文参数名与参数值');
return;
}
itemSubmitting.value = true;
const payload = { name_zh: f.name_zh, name_en: f.name_en, value_zh: f.value_zh, value_en: f.value_en };
const { error } = f.id ? await fetchUpdateSpecItem(f.id, payload) : await fetchCreateSpecItem(f.groupId, payload);
itemSubmitting.value = false;
if (!error) {
window.$message?.success(f.id ? '更新成功' : '创建成功');
itemDialogVisible.value = false;
await loadData();
}
}
function handleDeleteItem(item: Api.Www.SpecItem) {
window.$dialog?.warning({
title: '警告',
content: `确定要删除参数「${item.name_zh}」吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchDeleteSpecItem(item.id);
if (!error) {
window.$message?.success('删除成功');
await loadData();
}
}
});
}
// ===== 参数项排序 =====
async function moveItem(group: Api.Www.SpecGroup, index: number, direction: -1 | 1) {
const items = [...(group.items || [])];
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= items.length) return;
[items[index], items[swapIndex]] = [items[swapIndex], items[index]];
const { error } = await fetchSortSpecItems(
group.id,
items.map((it, i) => ({ id: it.id, sort_order: i }))
);
if (!error) {
await loadData();
window.$message?.success('排序已更新');
}
}
</script>
<template>
<NModal v-model:show="visible" preset="card" :title="`参数管理:${sectionTitle}`" class="w-900px" :mask-closable="false">
<NSpace justify="space-between" align="center" class="mb-12px">
<span class="text-12px opacity-60">前台按分组顺序渲染参数表默认显示行数之外的参数折叠用户可自行展开</span>
<NButton size="small" type="primary" @click="handleAddGroup">新增分组</NButton>
</NSpace>
<NEmpty v-if="!loading && groups.length === 0" description="暂无参数分组,请先新增分组" class="py-40px" />
<NCollapse v-else :default-expanded-names="expandedNames" arrow-placement="left">
<NCollapseItem v-for="(group, gIdx) in groups" :key="group.id" :name="group.id">
<template #header>
<span class="font-medium">{{ group.title_zh }}</span>
<span class="ml-8px text-12px opacity-60">{{ group.title_en }} · 默认显示 {{ group.default_visible || 5 }} · {{ group.items?.length || 0 }} </span>
</template>
<template #header-extra>
<div class="flex-y-center gap-4px" @click.stop>
<NButton text size="tiny" type="primary" :disabled="gIdx === 0" @click="moveGroup(gIdx, -1)"></NButton>
<NButton text size="tiny" type="primary" :disabled="gIdx === groups.length - 1" @click="moveGroup(gIdx, 1)"></NButton>
<NButton text size="tiny" type="primary" @click="handleEditGroup(group)">编辑</NButton>
<NButton text size="tiny" type="error" @click="handleDeleteGroup(group)">删除</NButton>
</div>
</template>
<div class="flex flex-col gap-8px">
<div
v-for="(item, iIdx) in group.items || []"
:key="item.id"
class="flex items-center gap-12px rounded-8px bg-gray-50 px-12px py-8px dark:bg-black/10"
>
<span class="w-25% shrink-0 text-13px font-medium">{{ item.name_zh }}</span>
<span class="flex-1 truncate text-13px opacity-80" :title="item.value_zh">{{ item.value_zh }}</span>
<span class="hidden w-30% truncate text-12px opacity-50 md:block" :title="`${item.name_en} / ${item.value_en}`">
{{ item.name_en }} / {{ item.value_en }}
</span>
<NSpace :size="4" class="shrink-0">
<NButton text size="tiny" type="primary" :disabled="iIdx === 0" @click="moveItem(group, iIdx, -1)"></NButton>
<NButton text size="tiny" type="primary" :disabled="iIdx === (group.items?.length || 0) - 1" @click="moveItem(group, iIdx, 1)"></NButton>
<NButton text size="tiny" type="primary" @click="handleEditItem(group, item)">编辑</NButton>
<NButton text size="tiny" type="error" @click="handleDeleteItem(item)">删除</NButton>
</NSpace>
</div>
<NButton dashed size="small" @click="handleAddItem(group)">+ 新增参数项</NButton>
</div>
</NCollapseItem>
</NCollapse>
<!-- 分组编辑弹窗 -->
<NModal v-model:show="groupDialogVisible" preset="card" :title="groupForm.id ? '编辑参数分组' : '新增参数分组'" class="w-560px" :mask-closable="false">
<NForm :model="groupForm" label-placement="left" label-width="100">
<NFormItem label="标题(中)" required>
<NInput v-model:value="groupForm.title_zh" maxlength="100" placeholder="如:基本参数" />
</NFormItem>
<NFormItem label="标题(英)" required>
<NInput v-model:value="groupForm.title_en" maxlength="100" placeholder="e.g. Basic Parameters" />
</NFormItem>
<NFormItem label="默认显示行数">
<NInputNumber v-model:value="groupForm.default_visible" :min="1" :max="50" class="w-full" />
<span class="ml-8px text-12px opacity-60">前台默认显示的行数超出的折叠由用户展开</span>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="groupDialogVisible = false">取消</NButton>
<NButton type="primary" :loading="groupSubmitting" @click="handleGroupSubmit">确定</NButton>
</NSpace>
</template>
</NModal>
<!-- 参数项编辑弹窗 -->
<NModal v-model:show="itemDialogVisible" preset="card" :title="itemForm.id ? '编辑参数项' : '新增参数项'" class="w-640px" :mask-closable="false">
<NForm :model="itemForm" label-placement="left" label-width="100">
<NFormItem label="参数名(中)" required>
<NInput v-model:value="itemForm.name_zh" maxlength="100" placeholder="如:型号" />
</NFormItem>
<NFormItem label="参数名(英)" required>
<NInput v-model:value="itemForm.name_en" maxlength="100" placeholder="e.g. Model" />
</NFormItem>
<NFormItem label="参数值(中)" required>
<NInput v-model:value="itemForm.value_zh" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="如:Luxsin X8" />
</NFormItem>
<NFormItem label="参数值(英)" required>
<NInput v-model:value="itemForm.value_en" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="e.g. Luxsin X8" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="itemDialogVisible = false">取消</NButton>
<NButton type="primary" :loading="itemSubmitting" @click="handleItemSubmit">确定</NButton>
</NSpace>
</template>
</NModal>
</NModal>
</template>
@@ -0,0 +1,9 @@
-- 产品详情(product_id=1)参数区块「平衡耳机输出性能参数(4.4mm/XLR4输出)」参数项
-- 区块:www_section_blocks.id=28spec_table),分组已预建:gid=5
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(5, '高增益', 'High Gain', 'L+R≥4840mW+4840mW16Ω, THD+N1%', 'L+R≥4840mW+4840mW (16Ω, THD+N < 1%)', 0),
(5, '输出阻抗', 'Output Impedance', '', '< 1Ω', 1),
(5, '总谐波失真', 'THD+N', '<−117dB (1kHz/0dB@32Ω)', '< -117dB (1kHz / 0dB @ 32Ω)', 2),
(5, '信噪比', 'SNR', '≥123dB', '≥123dB', 3),
(5, '声道分离度', 'Crosstalk', '≥119dB (1kHz@32Ω)', '≥119dB (1kHz @ 32Ω)', 4);
+20
View File
@@ -0,0 +1,20 @@
-- 产品详情(product_id=1)参数区块「基本规格」补充参数项
-- 区块:www_section_blocks.id=28spec_table),分组:www_spec_groups.id=1(基本规格)
-- 已存在:型号 / 尺寸 / 重量(手工新增,sort_order 均为 0,此处一并规范化)
-- 1) 规范化已有 3 项排序
UPDATE www_spec_items SET sort_order = 0 WHERE id = 1;
UPDATE www_spec_items SET sort_order = 1 WHERE id = 2;
UPDATE www_spec_items SET sort_order = 2 WHERE id = 3;
-- 2) 按图片补充剩余 9 项
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(1, '电源输入', 'Input Power', 'AC 100120V/220240V~50/60Hz', 'AC 100-120V/220-240V~50/60Hz', 3),
(1, '屏幕', 'Display', '4英寸 LCD480×960)触摸屏', '4" LCD (480*960) Touchscreen', 4),
(1, '解码', 'DAC', 'Cirrus Logic CS43198 × 8', 'Cirrus Logic CS43198 × 8', 5),
(1, '内部电源', 'Power Supply', '超低底噪可调电压线性电源', 'Ultra-low-noise, voltage-adjustable Linear Power Supply', 6),
(1, 'WiFi', 'WiFi', '支持2.4G/5G双频WiFi', 'Wi-Fi 2.4GHz / 5GHz', 7),
(1, '蓝牙接收', 'Bluetooth', 'Qualcomm SXW5125 (BT 5.1)', 'Qualcomm SXW5125 (BT 5.1)', 8),
(1, '控制方式', 'Control Methods', '触控屏, 移动设备 APP (Android/iOS), 电脑浏览器, 红外遥控器(选购配件)', 'Touchscreen, Mobile APP (Android/iOS), Web Browser, IR Remote(Optional)', 9),
(1, '额定功率', 'Rated Power', '25W', '25W', 10),
(1, '包装清单', 'Packing List', '电源线×1USB-B线×1, 3.5mm转6.35mm适配器×1', 'Power Cable ×1, USB-B cable ×1, 3.5mm to 6.35mm adapter ×1', 11);
+15
View File
@@ -0,0 +1,15 @@
-- 产品详情(product_id=1)参数区块「输入 / 输出」参数项
-- 区块:www_section_blocks.id=28spec_table),分组:www_spec_groups.id=2(输入 / 输出)
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(2, 'USB-A 输入', 'USB-A Input', '用于本地固件更新', 'For local firmware update', 0),
(2, 'USB-B 输入', 'USB-B Input', '最高支持 PCM 768kHz/32bit; DSD512(Native)', 'Up to PCM 768kHz/32bit; DSD512(Native)', 1),
(2, 'USB-C 输入', 'USB-C Input', '最高支持 PCM 768kHz/32bit; DSD512(Native)', 'Up to PCM 768kHz/32bit; DSD512(Native)', 2),
(2, 'IIS 输入', 'IIS Input', '最高支持 PCM 768kHz/32bit; DSD512(Native)', 'Up to PCM 768kHz/32bit; DSD512(Native)', 3),
(2, '同轴输入', 'Coaxial Input', '最高支持 PCM 192kHz/24bit', 'Up to PCM 192kHz/24bit', 4),
(2, '光纤输入', 'Optical Input', '最高支持 PCM 192kHz/24bit', 'Up to PCM 192kHz/24bit', 5),
(2, 'TRIGGER IN', 'TRIGGER IN', '标准3.5mm耳机插座', 'Standard 3.5mm headphone jack', 6),
(2, 'TRIGGER OUT', 'TRIGGER OUT', '标准3.5mm耳机插座', 'Standard 3.5mm headphone jack', 7),
(2, '模拟输出接口', 'Analog Output', 'RCA', 'RCA', 8),
(2, '数字输出接口', 'Balanced Output', 'XLR', 'XLR', 9),
(2, '耳机输出接口', 'Headphone Output', '6.35mm(非平衡),4.4mm(平衡),4 pin XLR(平衡)', '6.35mm (Unbalanced), 4.4mm (Balanced), 4 pin XLR (Balanced)', 10);
+9
View File
@@ -0,0 +1,9 @@
-- 产品详情(product_id=1)参数区块「RCA 输出音频特性」参数项
-- 区块:www_section_blocks.id=28spec_table),分组已预建:gid=4
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(4, '信噪比', 'SNR', '≥124.5dB', '≥124.5dB', 0),
(4, '总谐波失真', 'THD+N', '<−120dB @不加权', '< -120dB @ unweighted', 1),
(4, '声道分离度', 'Crosstalk', '≥125dB (1kHz@100kΩ)', '≥125dB (1kHz@100kΩ)', 2),
(4, '底噪', 'Noise Floor', '1.3uVrms', '< 1.3uVrms', 3),
(4, '线路输出电平', 'Output Level', '2.1Vrms (1kHz@100kΩ)', '2.1Vrms (1kHz@100kΩ)', 4);
+8
View File
@@ -0,0 +1,8 @@
-- 产品详情(product_id=1)参数区块「单端耳机输出性能参数(6.35mm输出)」参数项
-- 区块:www_section_blocks.id=28spec_table),分组已预建:gid=6
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(6, '高增益', 'High Gain', 'L+R≥2900mW+2900mW16Ω,THD+N1%', 'L+R≥2900mW+2900mW (16Ω, THD+N < 1%)', 0),
(6, '输出阻抗', 'Output Impedance', '', '< 1Ω', 1),
(6, '总谐波失真', 'THD+N', '<−115dB (1kHz/0dB@32Ω)', '< -115dB (1kHz/0dB@32Ω)', 2),
(6, '信噪比', 'SNR', '≥121dB', '≥121dB', 3);
+11
View File
@@ -0,0 +1,11 @@
-- Luxsin X8product_id=1)区块排序修复
-- 背景:所有区块 sort_order 均为 0,前台按 sort_order 排序导致顺序混乱。
-- 已确认 www_section_blocks.id 为自增主键,按 id 升序(即创建顺序)重新分配 sort_order(0 起)。
-- 同时后端 POST /api/www/sections 已改为:未指定 sort_order 时取同 page_type+product_id
-- 下现有区块的 max(sort_order)+1 自动追加,不再默认 0。
SET @row := -1;
UPDATE www_section_blocks
SET sort_order = (@row := @row + 1)
WHERE page_type = 'product_detail' AND product_id = 1
ORDER BY id;
+12
View File
@@ -0,0 +1,12 @@
-- 产品详情(product_id=1)参数区块「XLR 输出音频特性」参数项
-- 区块:www_section_blocks.id=28spec_table
-- 说明:分组「XLR 输出音频特性」已预先存在(gid=3),本脚本仅补参数项;
-- 执行中发现重复建组(gid=7)已清理:参数项改挂 gid=3,重复组已删除。
-- 顺带规范了各分组 sort_order:基本规格=0、输入/输出=1、XLR=2、RCA=3、平衡耳机=4、单端耳机=5
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
(3, '信噪比', 'SNR', '≥129.5dB', '≥129.5dB', 0),
(3, '总谐波失真', 'THD+N', '<−121.8dB @不加权', '< -121.8dB @ unweighted', 1),
(3, '声道分离度', 'Crosstalk', '≥133dB (1kHz@200kΩ)', '≥133dB (1kHz@200kΩ)', 2),
(3, '底噪', 'Noise Floor', '1.1uVrms', '< 1.1uVrms', 3),
(3, '线路输出电平', 'Output Level', '4.2Vrms (1kHz@200kΩ)', '4.2Vrms (1kHz@200kΩ)', 4);
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+11
View File
@@ -4,6 +4,17 @@ html {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* UnoCSS wind3 preflight 未含 Tailwind 标准的 border 重置:浏览器默认 border-style 为 none
导致 border-b / border 等宽度工具类不可见,此处补上(宽度默认 0,不影响未显式设置边框的元素) */
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
body { body {
margin: 0; margin: 0;
font-family: font-family:
@@ -72,6 +72,13 @@ const paddingY = computed(() => {
return typeof v === 'number' && v >= 0 ? `${v}px` : ''; return typeof v === 'number' && v >= 0 ? `${v}px` : '';
}); });
// 区块上下外边距(config.margin_y):单位 px,区块与相邻区块的间隙(露出页面背景);
// 未设置或 0 = 紧贴相邻区块(默认,全宽无缝拼接设计)
const marginY = computed(() => {
const v = props.section.config?.margin_y;
return typeof v === 'number' && v > 0 ? `${v}px` : '';
});
// 标题字号(config.title_font_size):桌面端 px,未设置时不下发变量(main.css 的 section h2 规则兜底 36px); // 标题字号(config.title_font_size):桌面端 px,未设置时不下发变量(main.css 的 section h2 规则兜底 36px);
// 移动端按桌面值 62.5% 等比缩小(--sec-title-fs-m // 移动端按桌面值 62.5% 等比缩小(--sec-title-fs-m
const titleFontSize = computed(() => { const titleFontSize = computed(() => {
@@ -89,6 +96,7 @@ const sectionStyleVars = computed<CSSProperties>(() => {
'--sec-cw': containerWidth, '--sec-cw': containerWidth,
borderRadius: borderRadius.value, borderRadius: borderRadius.value,
...(paddingY.value ? { '--sec-py': paddingY.value } : {}), ...(paddingY.value ? { '--sec-py': paddingY.value } : {}),
...(marginY.value ? { marginTop: marginY.value, marginBottom: marginY.value } : {}),
...(titleFontSize.value ? { '--sec-title-fs': titleFontSize.value.desktop, '--sec-title-fs-m': titleFontSize.value.mobile } : {}) ...(titleFontSize.value ? { '--sec-title-fs': titleFontSize.value.desktop, '--sec-title-fs-m': titleFontSize.value.mobile } : {})
} as CSSProperties; } as CSSProperties;
}); });
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue';
import type { SectionBlock, SpecGroup } from '~/types/site'; import type { SectionBlock, SpecGroup } from '~/types/site';
const props = defineProps<{ section: SectionBlock }>(); const props = defineProps<{ section: SectionBlock }>();
const { overline, title, subtitle, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section); const { overline, title, subtitle, content, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
const specGroups = computed<SpecGroup[]>(() => props.section.spec_groups ?? []); const specGroups = computed<SpecGroup[]>(() => props.section.spec_groups ?? []);
// 每个分组的展开状态(默认收起,仅显示前 default_visible // 每个分组的展开状态(默认收起,仅显示前 default_visible
const expandedMap = ref<Record<number, boolean>>({}); const expandedMap = ref<Record<number, boolean>>({});
function isExpanded(group: SpecGroup): boolean { function isExpanded(group: SpecGroup): boolean {
@@ -18,10 +19,10 @@ function toggle(group: SpecGroup) {
expandedMap.value[group.id] = !expandedMap.value[group.id]; expandedMap.value[group.id] = !expandedMap.value[group.id];
} }
// 分组内可见的参数项:展开时全部,收起时前 default_visible 项 // 固定显示的参数项:始终为前 default_visible 项(默认 5 行),
function visibleItems(group: SpecGroup) { // 其余项由下方 Transition 区域渲染,避免展开时重复
function fixedItems(group: SpecGroup) {
const items = group.items ?? []; const items = group.items ?? [];
if (isExpanded(group)) return items;
return items.slice(0, group.default_visible || 5); return items.slice(0, group.default_visible || 5);
} }
@@ -29,11 +30,44 @@ function visibleItems(group: SpecGroup) {
function needToggle(group: SpecGroup): boolean { function needToggle(group: SpecGroup): boolean {
return (group.items?.length ?? 0) > (group.default_visible || 5); return (group.items?.length ?? 0) > (group.default_visible || 5);
} }
// 展开/收起高度过渡:从 0 动画到内容自然高度
// 注意:钩子必须声明并调用 done 回调(Vue 按形参个数判断是否显式接管完成时机),
// 否则 leave 结束后元素不会被移除,导致再次展开失效
const SPEC_TOGGLE_MS = 300;
function onEnter(el: Element, done: () => void) {
const html = el as HTMLElement;
html.style.overflow = 'hidden';
html.style.height = '0';
// 强制 reflow 后再切到目标高度,确保过渡生效
void html.offsetHeight;
html.style.transition = `height ${SPEC_TOGGLE_MS}ms ease`;
html.style.height = `${html.scrollHeight}px`;
setTimeout(done, SPEC_TOGGLE_MS);
}
function onAfterEnter(el: Element) {
const html = el as HTMLElement;
html.style.overflow = '';
html.style.height = '';
html.style.transition = '';
}
function onLeave(el: Element, done: () => void) {
const html = el as HTMLElement;
html.style.overflow = 'hidden';
html.style.height = `${html.scrollHeight}px`;
void html.offsetHeight;
html.style.transition = `height ${SPEC_TOGGLE_MS}ms ease`;
html.style.height = '0';
setTimeout(done, SPEC_TOGGLE_MS);
}
</script> </script>
<template> <template>
<div class="section-container py-[var(--sec-py,96px)]"> <div class="section-container py-[var(--sec-py,96px)]">
<!-- 标题 --> <!-- 块标题整个区块顶部居中 -->
<div class="text-center max-w-640px mx-auto mb-56px"> <div class="text-center max-w-640px mx-auto mb-56px">
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle"> <p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
{{ overline }} {{ overline }}
@@ -44,49 +78,84 @@ function needToggle(group: SpecGroup): boolean {
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle"> <p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle">
{{ subtitle }} {{ subtitle }}
</p> </p>
<div v-if="content" class="rich-text mt-20px text-15px leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle" v-html="content" />
</div> </div>
<!-- 参数分组卡片 --> <!-- 参数分组左分组标题 + 右参数卡片 -->
<div class="flex flex-col gap-24px max-w-960px mx-auto"> <div class="flex flex-col gap-64px">
<div <div
v-for="group in specGroups" v-for="group in specGroups"
:key="group.id" :key="group.id"
class="rounded-16px overflow-hidden" class="md:grid md:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-64px items-start"
:class="isDark ? 'bg-white/5 border border-white/10' : 'bg-white border border-gray-100 shadow-sm dark:bg-dark-800 dark:border-dark-700'"
> >
<!-- 分组标题 --> <!-- 左栏分组标题桌面端吸顶 -->
<div class="px-32px py-20px border-b" :class="isDark ? 'border-white/10' : 'border-gray-100 dark:border-dark-700'"> <div class="mb-24px md:sticky md:top-120px md:mb-0">
<h3 class="text-lg font-semibold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle"> <h3 class="text-xl md:text-24px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'">
{{ isEn ? group.title_en : group.title_zh }} {{ isEn ? group.title_en : group.title_zh }}
</h3> </h3>
</div> </div>
<!-- 参数项列表 --> <!-- 右栏参数卡片2 参数名 / 参数值 -->
<div class="px-32px"> <div
<div class="rounded-16px overflow-hidden"
v-for="item in visibleItems(group)" :class="isDark ? 'bg-white/5 border border-white/10' : 'bg-gray-100 border border-gray-200 dark:bg-dark-800 dark:border-dark-700'"
:key="item.id" >
class="flex items-start justify-between gap-24px py-14px border-b last:border-b-0" <div class="px-24px md:px-32px py-16px">
:class="isDark ? 'border-white/5' : 'border-gray-50 dark:border-dark-700/50'" <div
> v-for="(item, idx) in fixedItems(group)"
<span class="text-sm shrink-0" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'"> :key="item.id"
{{ isEn ? item.name_en : item.name_zh }} class="flex items-baseline justify-between gap-24px py-16px border-b"
</span> :class="[isDark ? 'border-white/10' : 'border-gray-300 dark:border-dark-600', idx === fixedItems(group).length - 1 && (!needToggle(group) || !isExpanded(group)) ? 'border-b-0' : '']"
<span class="text-sm text-right font-medium" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'"> >
{{ isEn ? item.value_en : item.value_zh }} <span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
</span> {{ isEn ? item.name_en : item.name_zh }}
</div> </span>
</div> <span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
{{ isEn ? item.value_en : item.value_zh }}
</span>
</div>
<!-- 展开/收起按钮 --> <!-- 收起时隐藏的参数项高度过渡动画 -->
<div v-if="needToggle(group)" class="px-32px py-16px"> <Transition @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave">
<button <div v-if="isExpanded(group)">
class="text-sm font-medium transition-colors" <div
:class="isDark ? 'text-primary-300 hover:text-primary-200' : 'text-primary hover:opacity-80'" v-for="item in group.items?.slice(group.default_visible || 5)"
@click="toggle(group)" :key="item.id"
> class="flex items-baseline justify-between gap-24px py-16px border-b"
{{ isExpanded(group) ? '收起参数 ∧' : `查看全部参数(${group.items?.length ?? 0} 项)∨` }} :class="[isDark ? 'border-white/10' : 'border-gray-300 dark:border-dark-600', item.id === group.items?.at(-1)?.id ? 'border-b-0' : '']"
</button> >
<span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
{{ isEn ? item.name_en : item.name_zh }}
</span>
<span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
{{ isEn ? item.value_en : item.value_zh }}
</span>
</div>
</div>
</Transition>
</div>
<!-- 展开/收起按钮 -->
<div v-if="needToggle(group)" class="px-24px md:px-32px py-16px flex justify-center">
<button
class="text-sm font-medium inline-flex items-center gap-8px bg-transparent cursor-pointer transition-colors"
:class="isDark ? 'text-white' : 'text-gray-600 dark:text-white'"
@click="toggle(group)"
>
<template v-if="isExpanded(group)">
{{ isEn ? 'View less' : '收起参数' }}
<span class="w-24px h-24px rounded-full flex items-center justify-center text-14px" :class="isDark ? 'bg-white/10 text-white' : 'bg-gray-200 text-gray-700'" aria-hidden="true">
<Icon icon="mingcute:up-line" />
</span>
</template>
<template v-else>
{{ isEn ? `View all (${group.items?.length ?? 0})` : `查看全部参数${group.items?.length ?? 0} ` }}
<span class="w-24px h-24px rounded-full flex items-center justify-center text-14px" :class="isDark ? 'bg-white/10 text-white' : 'bg-gray-200 text-gray-700'" aria-hidden="true">
<Icon icon="mingcute:down-line" />
</span>
</template>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -12,6 +12,19 @@ const videoUrl = computed(() => resolveAssetUrl(props.section.video_url));
// 媒体位置(config.media_position):left 图/视频在左(默认)/ right 在右;移动端始终文案在上 // 媒体位置(config.media_position):left 图/视频在左(默认)/ right 在右;移动端始终文案在上
const mediaPosition = computed(() => (props.section.config as Record<string, string> | null)?.media_position === 'right' ? 'right' : 'left'); const mediaPosition = computed(() => (props.section.config as Record<string, string> | null)?.media_position === 'right' ? 'right' : 'left');
// 分栏比例(config.split_ratio):媒体栏宽占比,档位 30/40/50/60/70,默认 50(五五等分);移动端单列不受影响
const splitRatio = computed(() => {
const v = Number((props.section.config as Record<string, unknown> | null)?.split_ratio);
return [30, 40, 50, 60, 70].includes(v) ? v : 50;
});
// 栅格轨道随媒体位置:媒体在左 = 首轨为媒体栏,媒体在右 = 次轨为媒体栏(完整字面量供 UnoCSS 提取)
const gridClass = computed(() =>
mediaPosition.value === 'right'
? 'md:grid-cols-[minmax(0,1fr)_var(--split-media,50%)]'
: 'md:grid-cols-[var(--split-media,50%)_minmax(0,1fr)]'
);
// 媒体填满所在栏(去掉 520px 上限与栏内居中),高度按原素材比例自适应 // 媒体填满所在栏(去掉 520px 上限与栏内居中),高度按原素材比例自适应
const mediaWrapClass = computed(() => const mediaWrapClass = computed(() =>
mediaPosition.value === 'right' mediaPosition.value === 'right'
@@ -27,7 +40,11 @@ const textWrapClass = computed(() =>
</script> </script>
<template> <template>
<div class="section-container py-[var(--sec-py,96px)] grid md:grid-cols-2 gap-48px items-center"> <div
class="section-container py-[var(--sec-py,96px)] grid gap-48px items-center"
:class="gridClass"
:style="{ '--split-media': `${splitRatio}%` }"
>
<!-- 媒体视频优先其次图片 --> <!-- 媒体视频优先其次图片 -->
<div v-if="videoUrl || mediaUrl" :class="mediaWrapClass"> <div v-if="videoUrl || mediaUrl" :class="mediaWrapClass">
<video <video
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "nuxt typecheck" "typecheck": "nuxt typecheck"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.1",
"@nuxtjs/i18n": "^10.5.0", "@nuxtjs/i18n": "^10.5.0",
"@pinia/nuxt": "^1.0.1", "@pinia/nuxt": "^1.0.1",
"@vueuse/nuxt": "^14.4.0", "@vueuse/nuxt": "^14.4.0",
+13
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@iconify/vue':
specifier: ^5.0.1
version: 5.0.1(vue@3.5.40(typescript@5.8.2))
'@nuxtjs/i18n': '@nuxtjs/i18n':
specifier: ^10.5.0 specifier: ^10.5.0
version: 10.5.0(@vue/compiler-dom@3.5.40)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.0.0))(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.3)(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)(supports-color@10.0.0)(typescript@5.8.2)(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)) version: 10.5.0(@vue/compiler-dom@3.5.40)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.0.0))(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.3)(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)(supports-color@10.0.0)(typescript@5.8.2)(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))
@@ -622,6 +625,11 @@ packages:
'@iconify/utils@3.1.4': '@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@iconify/vue@5.0.1':
resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==}
peerDependencies:
vue: '>=3.0.0'
'@intlify/bundle-utils@11.2.4': '@intlify/bundle-utils@11.2.4':
resolution: {integrity: sha512-eE18yR9eM9k5n8snCkHIYp2MuVTxa19aF8z9OMyxXWv0frz2HlBZDGIPFjA38pP3OJ1IlRBXC/dW5GILeLMSCQ==} resolution: {integrity: sha512-eE18yR9eM9k5n8snCkHIYp2MuVTxa19aF8z9OMyxXWv0frz2HlBZDGIPFjA38pP3OJ1IlRBXC/dW5GILeLMSCQ==}
engines: {node: '>= 22.13'} engines: {node: '>= 22.13'}
@@ -5221,6 +5229,11 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
import-meta-resolve: 4.2.0 import-meta-resolve: 4.2.0
'@iconify/vue@5.0.1(vue@3.5.40(typescript@5.8.2))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.40(typescript@5.8.2)
'@intlify/bundle-utils@11.2.4(vue-i18n@11.4.8(vue@3.5.40(typescript@5.8.2)))': '@intlify/bundle-utils@11.2.4(vue-i18n@11.4.8(vue@3.5.40(typescript@5.8.2)))':
dependencies: dependencies:
'@intlify/message-compiler': 11.4.8 '@intlify/message-compiler': 11.4.8