优化项目结构

This commit is contained in:
eafonyang
2026-08-13 14:23:01 +08:00
parent e40d9d6b27
commit d01b0c8f47
29 changed files with 620 additions and 872 deletions
-4
View File
@@ -83,8 +83,6 @@ declare namespace Api {
body_color: string;
bg_type: string;
bg_value: string;
overlay_enabled: boolean;
overlay_opacity: number;
overline_zh: string;
overline_en: string;
title_zh: string;
@@ -119,8 +117,6 @@ declare namespace Api {
body_color?: string;
bg_type?: string;
bg_value?: string;
overlay_enabled?: boolean;
overlay_opacity?: number;
overline_zh?: string;
overline_en?: string;
title_zh?: string;
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { NButton, NColorPicker, NInputNumber, NRadioButton, NRadioGroup, NSlider } from 'naive-ui';
defineOptions({ name: 'GradientInput' });
/**
* 渐变背景编辑器
*
* 对外仍是单条 CSS 渐变字符串(v-model:value,写入 section.bg_value),
* 内部以「类型 + 角度 + 色标」结构化编辑,并提供精选预设一键套用。
* 所有编辑仅在本地预览,点击「确认」后才写回 bg_value,
* 避免切换背景类型时误覆盖原有值(如视频/图片路径)。
* 传入值无法解析为受支持格式时,以默认渐变作为本地编辑起点。
*/
const props = defineProps<{ value: string }>();
const emit = defineEmits<{ 'update:value': [value: string] }>();
interface GradientStop {
color: string;
position: number;
}
interface GradientState {
type: 'linear' | 'radial';
angle: number;
stops: GradientStop[];
}
interface Preset extends GradientState {
name: string;
}
const MAX_STOPS = 6;
const MIN_STOPS = 2;
function defaultGradient(): GradientState {
return {
type: 'linear',
angle: 135,
stops: [
{ color: '#667eea', position: 0 },
{ color: '#764ba2', position: 100 }
]
};
}
// 精选预设(点击即用)
const PRESETS: Preset[] = [
{ name: '梦幻紫', type: 'linear', angle: 135, stops: [{ color: '#667eea', position: 0 }, { color: '#764ba2', position: 100 }] },
{ name: '蔚蓝', type: 'linear', angle: 135, stops: [{ color: '#4facfe', position: 0 }, { color: '#00f2fe', position: 100 }] },
{ name: '翠绿', type: 'linear', angle: 135, stops: [{ color: '#43e97b', position: 0 }, { color: '#38f9d7', position: 100 }] },
{ name: '樱粉', type: 'linear', angle: 135, stops: [{ color: '#f5576c', position: 0 }, { color: '#f093fb', position: 100 }] },
{ name: '暖阳', type: 'linear', angle: 135, stops: [{ color: '#f6d365', position: 0 }, { color: '#fda085', position: 100 }] },
{ name: '深海', type: 'linear', angle: 135, stops: [{ color: '#30cfd0', position: 0 }, { color: '#330867', position: 100 }] },
{ name: '马卡龙', type: 'linear', angle: 135, stops: [{ color: '#a8edea', position: 0 }, { color: '#fed6e3', position: 100 }] },
{ name: '石墨', type: 'linear', angle: 135, stops: [{ color: '#232526', position: 0 }, { color: '#414345', position: 100 }] },
{ name: '暗夜', type: 'linear', angle: 200, stops: [{ color: '#0f2027', position: 0 }, { color: '#203a43', position: 50 }, { color: '#2c5364', position: 100 }] }
];
const state = reactive<GradientState>(defaultGradient());
function buildCssFrom(s: GradientState): string {
const stops = [...s.stops].sort((a, b) => a.position - b.position);
const stopsStr = stops.map(st => `${st.color} ${st.position}%`).join(', ');
return s.type === 'radial' ? `radial-gradient(circle, ${stopsStr})` : `linear-gradient(${s.angle}deg, ${stopsStr})`;
}
const previewCss = computed(() => buildCssFrom(state));
function commit() {
emit('update:value', buildCssFrom(state));
window.$message?.success('已应用到背景值');
}
function applyState(s: GradientState) {
state.type = s.type;
state.angle = s.angle;
state.stops = s.stops.map(st => ({ ...st }));
}
// 解析 CSS 渐变字符串;仅支持本组件生成的受控格式(hex/rgb 色 + 百分比位置)
function parseGradient(v: string): GradientState | null {
const linear = v.match(/^linear-gradient\(\s*(\d+(?:\.\d+)?)deg\s*,\s*(.+)\)\s*$/i);
const radial = v.match(/^radial-gradient\(\s*circle\s*,\s*(.+)\)\s*$/i);
const stopsStr = linear ? linear[2] : radial ? radial[1] : null;
if (!stopsStr) return null;
const stopRe = /(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[a-zA-Z]+)\s+(\d+(?:\.\d+)?)%/g;
const stops: GradientStop[] = [];
let m: RegExpExecArray | null;
while ((m = stopRe.exec(stopsStr)) !== null) {
stops.push({ color: m[1], position: Number.parseFloat(m[2]) });
}
if (stops.length < MIN_STOPS) return null;
return { type: linear ? 'linear' : 'radial', angle: linear ? Number.parseFloat(linear[1]) : 135, stops };
}
function init() {
const v = (props.value || '').trim();
const parsed = parseGradient(v);
// 能解析则回填现有渐变;否则以默认渐变作为本地编辑起点,
// 不写回 bg_value,避免切换类型时误覆盖原值(如视频/图片路径)
applyState(parsed || defaultGradient());
}
function applyPreset(p: Preset) {
applyState(p);
}
function addStop() {
if (state.stops.length >= MAX_STOPS) return;
const last = state.stops[state.stops.length - 1];
const position = Math.min(Math.round((last.position + 100) / 2), 100);
state.stops.push({ color: last.color, position });
}
function removeStop(idx: number) {
if (state.stops.length <= MIN_STOPS) return;
state.stops.splice(idx, 1);
}
init();
</script>
<template>
<div class="w-full flex flex-col gap-10px">
<!-- 实时预览 -->
<div class="h-44px w-full rounded-6px border border-solid border-[var(--n-border-color)]" :style="{ background: previewCss }" />
<!-- 可视化编辑 -->
<!-- 预设渐变 -->
<div>
<p class="mb-4px text-12px opacity-60">预设渐变</p>
<div class="grid grid-cols-9 gap-6px">
<button
v-for="p in PRESETS"
:key="p.name"
type="button"
class="h-30px cursor-pointer rounded-4px border border-solid border-transparent transition-all hover:border-primary"
:title="p.name"
:style="{ background: buildCssFrom(p) }"
@click="applyPreset(p)"
/>
</div>
</div>
<!-- 类型 + 角度 -->
<div class="flex items-center gap-8px">
<NRadioGroup v-model:value="state.type" size="small">
<NRadioButton value="linear">线性</NRadioButton>
<NRadioButton value="radial">径向</NRadioButton>
</NRadioGroup>
<template v-if="state.type === 'linear'">
<NSlider v-model:value="state.angle" :min="0" :max="360" :step="1" class="flex-1" />
<NInputNumber v-model:value="state.angle" :min="0" :max="360" size="small" class="w-84px">
<template #suffix>°</template>
</NInputNumber>
</template>
</div>
<!-- 色标列表 -->
<div class="flex flex-col gap-6px">
<div v-for="(s, i) in state.stops" :key="i" class="flex items-center gap-6px">
<NColorPicker v-model:value="s.color" :show-alpha="false" :modes="['hex']" size="small" class="w-120px" />
<NInputNumber v-model:value="s.position" :min="0" :max="100" size="small" class="w-96px">
<template #suffix>%</template>
</NInputNumber>
<NButton v-if="state.stops.length > MIN_STOPS" size="tiny" quaternary type="error" @click="removeStop(i)">删除</NButton>
</div>
</div>
<div class="flex items-center justify-between">
<NButton size="small" :disabled="state.stops.length >= MAX_STOPS" @click="addStop">+ 添加色标{{ state.stops.length }}/{{ MAX_STOPS }}</NButton>
<NButton size="small" type="primary" @click="commit">确认应用到背景值</NButton>
</div>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed, h, onMounted, reactive, ref } from 'vue';
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, type SelectOption, type SelectRenderOption } from 'naive-ui';
import ColorInput from '../page/home/components/ColorInput.vue';
import GradientInput from './GradientInput.vue';
import MediaSelectInput from './MediaSelectInput.vue';
import RichTextEditor from './RichTextEditor.vue';
import SpecManager from './SpecManager.vue';
@@ -89,8 +90,6 @@ const formData = reactive({
body_color: '',
bg_type: 'color',
bg_value: '',
overlay_enabled: false,
overlay_opacity: 0,
overline_zh: '',
overline_en: '',
title_zh: '',
@@ -349,8 +348,6 @@ function handleAdd() {
formData.body_color = '';
formData.bg_type = 'color';
formData.bg_value = '';
formData.overlay_enabled = false;
formData.overlay_opacity = 0;
formData.overline_zh = '';
formData.overline_en = '';
formData.title_zh = '';
@@ -382,8 +379,6 @@ function fillFormFromRow(row: Api.Www.SectionBlock) {
formData.body_color = row.body_color || '';
formData.bg_type = row.bg_type;
formData.bg_value = row.bg_value;
formData.overlay_enabled = Boolean(row.overlay_enabled);
formData.overlay_opacity = Number(row.overlay_opacity);
formData.overline_zh = row.overline_zh;
formData.overline_en = row.overline_en;
formData.title_zh = row.title_zh;
@@ -519,146 +514,170 @@ onMounted(loadData);
>
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
<NDivider>基础配置</NDivider>
<NFormItem label="布局类型">
<NSelect v-model:value="formData.layout" :options="layoutOptions" :render-label="renderLayoutLabel" :render-option="renderLayoutOption" />
</NFormItem>
<NFormItem label="主题">
<NRadioGroup v-model:value="formData.theme">
<NRadio v-for="opt in themeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="区块宽度">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.width_percent as number) ?? 100"
:min="30"
:max="100"
:step="5"
class="w-full"
@update:value="v => (formData.config.width_percent = v ?? 100)"
>
<template #suffix>%</template>
</NInputNumber>
<span class="text-12px opacity-60">占视口宽度百分比居中显示100 = 全宽移动端小屏始终全宽</span>
</div>
</NFormItem>
<NFormItem label="内容区宽度">
<div class="w-full flex flex-col gap-8px">
<NSelect
:value="(formData.config.container_width as string) ?? '1200'"
:options="containerWidthOptions"
@update:value="v => (formData.config.container_width = v)"
/>
<span class="text-12px opacity-60">区块内容容器的最大宽度居中于区块内全宽 = 内容占满整个区块</span>
</div>
</NFormItem>
<NFormItem label="区块圆角">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.border_radius as number) ?? 0"
:min="0"
:max="60"
:step="4"
class="w-full"
@update:value="v => (formData.config.border_radius = v ?? 0)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">区块四角圆角大小0 = 直角默认建议与区块宽度小于 100 搭配使用全宽时圆角不可见</span>
</div>
</NFormItem>
<NFormItem label="上下内边距">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.padding_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 布局默认值"
@update:value="v => (formData.config.padding_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边</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">区块与上下相邻区块之间的间隙露出页面背景色适合区块宽度 &lt; 100 + 圆角的卡片式区块留空/0 = 紧贴默认</span>
</div>
</NFormItem>
<NFormItem label="标题字号">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.title_font_size as number | null) ?? null"
:min="16"
:max="96"
:step="2"
class="w-full"
clearable
placeholder="留空 = 36px(兜底)"
@update:value="v => (formData.config.title_font_size = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">桌面端标题字号移动端自动按约 62.5% 缩小如填 48 移动端 30px留空 = 兜底 36px引言布局无标题字段不受影响</span>
</div>
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.min_height as number | null) ?? null"
:min="0"
:max="1200"
:step="20"
class="w-full"
clearable
:placeholder="formData.layout === 'mosaic' ? '留空 = 默认 16:9 宽高比' : '留空 = 首屏高度(默认)'"
@update:value="v => (formData.config.min_height = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">
<div class="grid grid-cols-2 gap-x-16px">
<NFormItem label="布局类型" label-placement="top">
<NSelect v-model:value="formData.layout" :options="layoutOptions" :render-label="renderLayoutLabel" :render-option="renderLayoutOption" />
</NFormItem>
<NFormItem label="主题" label-placement="top">
<NRadioGroup v-model:value="formData.theme">
<NRadio v-for="opt in themeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
</div>
<div class="grid grid-cols-3 gap-x-16px">
<NFormItem label="区块宽度" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.width_percent as number) ?? 100"
:min="30"
:max="100"
:step="5"
class="w-full"
@update:value="v => (formData.config.width_percent = v ?? 100)"
>
<template #suffix>%</template>
</NInputNumber>
</template>
占视口宽度百分比居中显示100 = 全宽移动端小屏始终全宽
</NTooltip>
</NFormItem>
<NFormItem label="内容区宽度" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NSelect
:value="(formData.config.container_width as string) ?? '1200'"
:options="containerWidthOptions"
@update:value="v => (formData.config.container_width = v)"
/>
</template>
区块内容容器的最大宽度居中于区块内全宽 = 内容占满整个区块
</NTooltip>
</NFormItem>
<NFormItem label="区块圆角" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.border_radius as number) ?? 0"
:min="0"
:max="60"
:step="4"
class="w-full"
@update:value="v => (formData.config.border_radius = v ?? 0)"
>
<template #suffix>px</template>
</NInputNumber>
</template>
区块四角圆角大小0 = 直角默认建议与区块宽度小于 100 搭配使用全宽时圆角不可见
</NTooltip>
</NFormItem>
</div>
<div class="grid grid-cols-3 gap-x-16px">
<NFormItem label="上下内边距" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.padding_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 布局默认值"
@update:value="v => (formData.config.padding_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
</template>
内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边
</NTooltip>
</NFormItem>
<NFormItem label="上下外边距" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<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>
</template>
区块与上下相邻区块之间的间隙露出页面背景色适合区块宽度 &lt; 100 + 圆角的卡片式区块留空/0 = 紧贴默认
</NTooltip>
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split' || formData.layout === 'mosaic'" :label="formData.layout === 'mosaic' ? '网格高度' : '最小高度'" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.min_height as number | null) ?? null"
:min="0"
:max="1200"
:step="20"
class="w-full"
clearable
:placeholder="formData.layout === 'mosaic' ? '留空 = 默认 16:9 宽高比' : '留空 = 首屏高度(默认)'"
@update:value="v => (formData.config.min_height = v)"
>
<template #suffix>px</template>
</NInputNumber>
</template>
<template v-if="formData.layout === 'mosaic'">仅桌面端生效留空 = 默认 16:9 宽高比填数值 = 网格固定高度px素材自动裁剪填充可调低区块高度移动端保持单列堆叠不变</template>
<template v-else>仅主视觉分屏布局生效留空 = 沿用首屏高度至少 680px 约一屏0 = 完全自适应内容去上下内边距区块高度严格等于内容高度填数值 = 自定义最小高度</template>
</span>
</div>
</NFormItem>
<NDivider>文案颜色留空=跟随主题默认色</NDivider>
<NFormItem label="Overline 颜色">
<ColorInput v-model:value="formData.overline_color" />
</NFormItem>
<NFormItem label="标题颜色">
<ColorInput v-model:value="formData.title_color" />
</NFormItem>
<NFormItem label="副标题/正文颜色">
<ColorInput v-model:value="formData.body_color" />
</NFormItem>
<NFormItem label="背景类型">
<NRadioGroup v-model:value="formData.bg_type">
<NRadioButton value="color">纯色</NRadioButton>
<NRadioButton value="image">图片</NRadioButton>
<NRadioButton value="gradient">渐变</NRadioButton>
<NRadioButton value="video">视频</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem label="背景值">
<MediaSelectInput v-model:value="formData.bg_value" placeholder="颜色值或图片/视频路径(支持 /uploads/... 相对路径)" />
</NFormItem>
</NTooltip>
</NFormItem>
</div>
<div class="flex items-start gap-x-16px">
<NFormItem label="背景类型" label-placement="top" class="shrink-0">
<NRadioGroup v-model:value="formData.bg_type">
<NRadioButton value="color">纯色</NRadioButton>
<NRadioButton value="gradient">渐变</NRadioButton>
<NRadioButton value="image">图片</NRadioButton>
<NRadioButton value="video">视频</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem label="背景值" label-placement="top" class="min-w-0 flex-1">
<ColorInput v-if="formData.bg_type === 'color'" v-model:value="formData.bg_value" placeholder="留空则无背景色" />
<GradientInput v-else-if="formData.bg_type === 'gradient'" v-model:value="formData.bg_value" />
<MediaSelectInput v-else v-model:value="formData.bg_value" placeholder="图片/视频路径(支持 /uploads/... 相对路径)" />
</NFormItem>
</div>
<NDivider>文案样式</NDivider>
<div class="grid grid-cols-4 gap-x-16px">
<NFormItem label="标题字号" label-placement="top">
<NTooltip trigger="focus" placement="top">
<template #trigger>
<NInputNumber
:value="(formData.config.title_font_size as number | null) ?? null"
:min="16"
:max="96"
:step="2"
class="w-full"
clearable
placeholder="留空 = 36px(兜底)"
@update:value="v => (formData.config.title_font_size = v)"
>
<template #suffix>px</template>
</NInputNumber>
</template>
桌面端标题字号移动端自动按约 62.5% 缩小如填 48 移动端 30px留空 = 兜底 36px引言布局无标题字段不受影响
</NTooltip>
</NFormItem>
<NFormItem label="Overline 颜色" label-placement="top">
<ColorInput v-model:value="formData.overline_color" />
</NFormItem>
<NFormItem label="标题颜色" label-placement="top">
<ColorInput v-model:value="formData.title_color" />
</NFormItem>
<NFormItem label="副标题/正文颜色" label-placement="top">
<ColorInput v-model:value="formData.body_color" />
</NFormItem>
</div>
<NFormItem v-if="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>
@@ -704,14 +723,6 @@ onMounted(loadData);
<span class="text-12px opacity-60">固定版式切换版式会同步下方槽位数量已填素材按顺序保留</span>
</div>
</NFormItem>
<NFormItem label="遮罩">
<NSpace align="center">
<NSwitch v-model:value="formData.overlay_enabled" />
<span>透明度</span>
<NSlider v-model:value="formData.overlay_opacity" :min="0" :max="1" :step="0.05" class="w-200px" />
<span>{{ formData.overlay_opacity.toFixed(2) }}</span>
</NSpace>
</NFormItem>
<NDivider>文案内容</NDivider>
<NFormItem label="Overline(中)">
@@ -4,7 +4,7 @@ import { NColorPicker, NInput } from 'naive-ui';
defineOptions({ name: 'ColorInput' });
const props = defineProps<{ value: string }>();
const props = withDefaults(defineProps<{ value: string; placeholder?: string }>(), { placeholder: '留空则跟随主题默认色' });
const emit = defineEmits<{ (e: 'update:value', value: string): void }>();
// 空值 = 跟随主题默认色
@@ -19,23 +19,24 @@ const showPicker = ref(false);
</script>
<template>
<div class="w-full flex flex-col gap-8px">
<div class="relative w-full">
<NInput
v-model:value="model"
placeholder="留空则跟随主题默认色"
:placeholder="placeholder"
clearable
@focus="showPicker = true"
/>
<!-- 空值时取色器内部以白色兜底展示选中任意颜色即写回 -->
<NColorPicker
v-if="showPicker"
:value="model || '#FFFFFF'"
:show-alpha="false"
:modes="['hex']"
default-show
class="w-full"
@update:value="v => (model = v)"
@update:show="v => !v && (showPicker = false)"
/>
<!-- 取色器用普通 div 绝对定位悬浮在输入框下方不占文档流避免展开时擑高布局导致同行字段位移 -->
<div v-if="showPicker" class="absolute left-0 top-full z-30 mt-4px w-full">
<NColorPicker
:value="model || '#FFFFFF'"
:show-alpha="false"
:modes="['hex']"
default-show
class="w-full"
@update:value="v => (model = v)"
@update:show="v => !v && (showPicker = false)"
/>
</div>
</div>
</template>