官网开发 0810
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 { wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js';
|
||||
import logger from '../../config/logger.js';
|
||||
@@ -77,6 +77,15 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
|
||||
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({
|
||||
pageType: b.page_type,
|
||||
productId: b.product_id || null,
|
||||
@@ -108,7 +117,7 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
|
||||
videoUrl: b.video_url || '',
|
||||
config: b.config || null,
|
||||
isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1,
|
||||
sortOrder: b.sort_order || 0,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const newId = result[0].insertId;
|
||||
|
||||
@@ -246,7 +246,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
meta: {
|
||||
title: 'www',
|
||||
i18nKey: 'route.www',
|
||||
icon: 'mdi:globe-model',
|
||||
icon: 'iconoir:www',
|
||||
order: 7
|
||||
},
|
||||
children: [
|
||||
|
||||
@@ -35,7 +35,7 @@ export function fetchDuplicateSection(id: 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 }) {
|
||||
@@ -56,7 +56,7 @@ export function fetchSortSpecGroups(sectionId: number, data: Array<{ id: 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 }) {
|
||||
|
||||
+2
@@ -147,6 +147,8 @@ declare namespace Api {
|
||||
title_en: string;
|
||||
default_visible: number;
|
||||
sort_order: number;
|
||||
/** GET spec-groups 列表接口内联返回的参数项 */
|
||||
items?: 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 MediaSelectInput from './MediaSelectInput.vue';
|
||||
import RichTextEditor from './RichTextEditor.vue';
|
||||
import SpecManager from './SpecManager.vue';
|
||||
import {
|
||||
fetchCreateSection,
|
||||
fetchDeleteSection,
|
||||
@@ -110,7 +111,7 @@ const formData = reactive({
|
||||
media_url_en: '',
|
||||
video_url: '',
|
||||
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 为图片/视频时生效)
|
||||
@@ -128,6 +129,20 @@ const containerWidthOptions = [
|
||||
{ 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 =====
|
||||
interface MosaicItemForm {
|
||||
type: 'image' | 'video';
|
||||
@@ -247,11 +262,15 @@ const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
width: 290,
|
||||
fixed: 'right',
|
||||
render(row) {
|
||||
return h('div', { class: 'flex-y-center gap-8px' }, [
|
||||
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', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
|
||||
]);
|
||||
@@ -315,7 +334,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, 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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
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,
|
||||
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,
|
||||
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
|
||||
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,
|
||||
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;
|
||||
const { id, ...payload } = formData;
|
||||
const reqData = {
|
||||
@@ -412,13 +440,15 @@ async function handleSubmit() {
|
||||
page_type: props.pageType,
|
||||
product_id: props.productId || null
|
||||
};
|
||||
const { error } = id
|
||||
const { data, error } = id
|
||||
? await fetchUpdateSection(id, reqData)
|
||||
: await fetchCreateSection(reqData);
|
||||
submitLoading.value = false;
|
||||
if (!error) {
|
||||
// 「应用」新建后回填新 id,后续保存走更新接口,避免重复创建
|
||||
if (!id && data?.id) formData.id = data.id;
|
||||
window.$message?.success(id ? '更新成功' : '创建成功');
|
||||
dialogVisible.value = false;
|
||||
if (closeAfter) dialogVisible.value = false;
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
@@ -447,6 +477,8 @@ onMounted(loadData);
|
||||
:title="dialogTitle"
|
||||
class="w-1000px"
|
||||
: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">
|
||||
<NDivider>基础配置</NDivider>
|
||||
@@ -515,6 +547,23 @@ onMounted(loadData);
|
||||
<span class="text-12px opacity-60">内容区与区块上下边缘的间距。留空 = 布局默认(多数 96px / 全幅横幅 120px / 主视觉分屏 64px);0 = 完全贴边</span>
|
||||
</div>
|
||||
</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">区块与上下相邻区块之间的间隙,露出页面背景色;适合「区块宽度 < 100 + 圆角」的卡片式区块。留空/0 = 紧贴(默认)</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem label="标题字号">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NInputNumber
|
||||
@@ -596,10 +645,20 @@ onMounted(loadData);
|
||||
</NFormItem>
|
||||
<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)">
|
||||
<NRadio value="left">图片/视频在左</NRadio>
|
||||
<NRadio value="right">图片/视频在右</NRadio>
|
||||
<NRadio value="left">素材|文案</NRadio>
|
||||
<NRadio value="right">文案|素材</NRadio>
|
||||
</NRadioGroup>
|
||||
</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="拼贴版式">
|
||||
<div class="w-full flex flex-col gap-8px">
|
||||
<NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange">
|
||||
@@ -700,10 +759,14 @@ onMounted(loadData);
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<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>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<!-- 参数规格管理(spec_table 布局专用) -->
|
||||
<SpecManager ref="specManagerRef" />
|
||||
</div>
|
||||
</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>
|
||||
Reference in New Issue
Block a user