修复 bug,优化界面

This commit is contained in:
eafonyang
2026-08-13 16:48:05 +08:00
parent d01b0c8f47
commit f99f622977
15 changed files with 205 additions and 28 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ services:
- "host.docker.internal:host-gateway"
volumes:
- /data/projects/source:/data/projects/source
# 素材库上传目录:容器内 /app/uploads ↔ 宿主机 /data/project/dashboard/backend/uploads
# 素材库上传目录:容器内 /app/uploads ↔ 宿主机 /data/project/dashboard/frontend/dist/uploads
# (与 frontend nginx 静态直出同一目录;若该目录被删除重建,需 force-recreate backend 重新挂载)
- /data/project/dashboard/frontend/dist/uploads:/app/uploads
frontend:
+41
View File
@@ -0,0 +1,41 @@
server {
listen 80;
server_name _;
# 管理后台前端构建产物(dist 挂载到 /usr/share/nginx/html
root /usr/share/nginx/html;
index index.html;
# 与后端 multer 50MB 上传限制对齐,避免 /api 上传被 nginx 413 拦截
client_max_body_size 50m;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
# 带内容指纹的构建资源:长缓存(正则含 {},必须加引号避免被当作块定界符)
location ~* "\.[0-9a-f]{8}\.(js|css|png|jpg|jpeg|gif|webp|svg|woff2?)$" {
expires 365d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# 素材库静态文件:backend 上传目录挂载在 dist/uploads,前端直接静态直出
location /uploads/ {
expires 7d;
try_files $uri =404;
}
# SPA history 回退
location / {
try_files $uri $uri/ /index.html;
}
# API 反代到 backend 容器(compose 网络内服务名解析)
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -261,6 +261,7 @@ const local: App.I18n.Schema = {
www_product: 'Products',
www_product_series: 'Product Series',
www_product_list: 'Product List',
www_product_detail: 'Product Detail',
www_news: 'News',
www_news_articles: 'Articles',
www_media: 'Media Library',
@@ -258,6 +258,7 @@ const local: App.I18n.Schema = {
www_product: '产品管理',
www_product_series: '产品系列',
www_product_list: '产品列表',
www_product_detail: '产品详情页',
www_news: '新闻管理',
www_news_articles: '新闻文章',
www_media: '素材库',
@@ -46,6 +46,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"www_page_news-list": () => import("@/views/www/page/news-list/index.vue"),
"www_page_product-list": () => import("@/views/www/page/product-list/index.vue"),
www_page_support: () => import("@/views/www/page/support/index.vue"),
www_product_detail: () => import("@/views/www/product/detail/index.vue"),
www_product_list: () => import("@/views/www/product/list/index.vue"),
www_product_series: () => import("@/views/www/product/series/index.vue"),
};
@@ -423,7 +423,7 @@ export const generatedRoutes: GeneratedRoute[] = [
meta: {
title: 'www_news_articles',
i18nKey: 'route.www_news_articles',
icon: 'mdi:article-outline',
icon: 'fluent-mdl2:articles',
order: 1,
keepAlive: true
}
@@ -524,6 +524,18 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 4
},
children: [
{
name: 'www_product_detail',
path: '/www/product/detail',
component: 'view.www_product_detail',
meta: {
title: 'www_product_detail',
i18nKey: 'route.www_product_detail',
icon: 'mdi:file-document-edit-outline',
order: 3,
keepAlive: true
}
},
{
name: 'www_product_list',
path: '/www/product/list',
@@ -206,6 +206,7 @@ const routeMap: RouteMap = {
"www_page_support": "/www/page/support",
"www_page-seo": "/www/page-seo",
"www_product": "/www/product",
"www_product_detail": "/www/product/detail",
"www_product_list": "/www/product/list",
"www_product_series": "/www/product/series"
};
+2
View File
@@ -60,6 +60,7 @@ declare module "@elegant-router/types" {
"www_page_support": "/www/page/support";
"www_page-seo": "/www/page-seo";
"www_product": "/www/product";
"www_product_detail": "/www/product/detail";
"www_product_list": "/www/product/list";
"www_product_series": "/www/product/series";
};
@@ -152,6 +153,7 @@ declare module "@elegant-router/types" {
| "www_page_news-list"
| "www_page_product-list"
| "www_page_support"
| "www_product_detail"
| "www_product_list"
| "www_product_series"
>;
@@ -678,7 +678,7 @@ onMounted(loadData);
<ColorInput v-model:value="formData.body_color" />
</NFormItem>
</div>
<NFormItem v-if="formData.bg_type === 'image' || formData.bg_type === 'video'" label="背景显示模式">
<NFormItem v-if="formData.layout !== 'split_content' && (formData.bg_type === 'image' || formData.bg_type === 'video')" label="背景显示模式">
<NRadioGroup :value="(formData.config.bg_fit as string) ?? 'cover'" @update:value="v => (formData.config.bg_fit = v)">
<NRadio v-for="opt in bgFitOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
@@ -699,22 +699,36 @@ onMounted(loadData);
<span v-if="formData.layout === 'title_media'" class="text-12px opacity-60">标题+媒体布局仅水平方向生效左对齐/居中/右对齐垂直位置忽略</span>
</div>
</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>
</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>
<!-- 图文分栏背景显示模式仅图片/视频背景/ 媒体位置 / 分栏比例 同一行 -->
<div
v-if="formData.layout === 'split_content'"
class="grid gap-x-16px"
:class="formData.bg_type === 'image' || formData.bg_type === 'video' ? 'grid-cols-3' : 'grid-cols-2'"
>
<NFormItem v-if="formData.bg_type === 'image' || formData.bg_type === 'video'" label="背景显示模式" label-placement="top">
<NRadioGroup :value="(formData.config.bg_fit as string) ?? 'cover'" @update:value="v => (formData.config.bg_fit = v)">
<NRadio v-for="opt in bgFitOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="媒体位置" label-placement="top">
<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>
</NRadioGroup>
</NFormItem>
<NFormItem label="分栏比例" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NSelect
:value="(formData.config.split_ratio as number) ?? 50"
:options="splitRatioOptions"
@update:value="v => (formData.config.split_ratio = v)"
/>
</template>
按页面视觉左右顺序标注{{ formData.config.media_position === 'right' ? '文案 : 素材' : '素材 : 文案' }}默认五五等分移动端小屏始终单列堆叠不受影响
</NTooltip>
</NFormItem>
</div>
<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">
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue';
import SectionManager from '../../components/SectionManager.vue';
import { fetchGetProducts, fetchGetProductSeries } from '@/service/api';
/**
* 产品详情页区块管理
*
* 统一入口:选择产品后直接管理该产品详情页(page_type='product_detail')的所有区块,
* 复用 SectionManager(与产品列表操作列的「区块」按钮同一组件)。
*/
defineOptions({ name: 'WwwProductDetail' });
const products = ref<Api.Www.Product[]>([]);
const seriesList = ref<Api.Www.ProductSeries[]>([]);
const selectedId = ref<number | null>(null);
// 产品下拉按系列分组,标签为「中文名(英文名)」,与视觉列表一致
// 分组必须用 type: 'group'(不可选、用 key 标识),否则系列父项会被当作普通选项,
// 其 value 与产品 id 碰撞导致误选中,且子项标签无法回显
const productOptions = ref<
Array<{ type: 'group'; label: string; key: string; children: Array<{ label: string; value: number }> }>
>([]);
async function loadAll() {
const [seriesRes, productRes] = await Promise.all([
fetchGetProductSeries(),
fetchGetProducts({ skip: 0, limit: 500 })
]);
if (seriesRes.error || productRes.error || !productRes.data) return;
seriesList.value = seriesRes.data?.items || [];
products.value = productRes.data.items || [];
const groups: typeof productOptions.value = [];
for (const s of seriesList.value) {
const children = products.value.filter(p => p.series_id === s.id);
if (children.length === 0) continue;
groups.push({
type: 'group',
key: `series-${s.id}`,
label: `${s.name_zh}${s.name_en}`,
children: children.map(p => ({ label: `${p.name_zh}${p.name_en}`, value: p.id }))
});
}
// 不属于任何已有系列的产品兜底归入「未分组」
const grouped = new Set(groups.flatMap(g => g.children.map(c => c.value)));
const orphans = products.value.filter(p => !grouped.has(p.id));
if (orphans.length > 0) {
groups.push({
type: 'group',
key: 'series-none',
label: '未分组',
children: orphans.map(p => ({ label: `${p.name_zh}${p.name_en}`, value: p.id }))
});
}
productOptions.value = groups;
// 默认选中第一个产品;已有选中且仍存在则保持
if (selectedId.value === null || !products.value.some(p => p.id === selectedId.value)) {
selectedId.value = products.value[0]?.id ?? null;
}
}
onActivated(loadAll);
onMounted(loadAll);
</script>
<template>
<div class="flex-col-stretch gap-16px">
<NCard :bordered="false" class="card-wrapper" size="small">
<div class="flex items-center gap-12px">
<span class="shrink-0 font-medium">选择产品</span>
<NSelect
v-model:value="selectedId"
:options="productOptions"
placeholder="请选择要管理详情页区块的产品"
filterable
class="w-360px"
/>
<NButton @click="loadAll">
<template #icon>
<SvgIcon icon="mdi:refresh" />
</template>
刷新
</NButton>
</div>
</NCard>
<!-- 切换产品时通过 :key 销毁重建 SectionManager其数据加载在 onMounted不监听 productId 变化 -->
<SectionManager
v-if="selectedId"
:key="selectedId"
page-type="product_detail"
:product-id="selectedId"
title="详情页区块"
/>
<NCard v-else :bordered="false" class="card-wrapper" size="small">
<NEmpty description="暂无产品,请先在「产品列表」中创建产品" />
</NCard>
</div>
</template>