519 lines
15 KiB
Vue
519 lines
15 KiB
Vue
<template>
|
||
<div class="aksrt-page-container content-page">
|
||
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 幻灯片内容(图片/视频、文案、图层、跳转)。标题、副标题可留空。" />
|
||
|
||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||
<template #header>
|
||
<div class="aksrt-card-header-row">
|
||
<strong>显示范围</strong>
|
||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(页面类型/搜索/卡片)</n-button>
|
||
</div>
|
||
</template>
|
||
<p class="aksrt-inline-setting__hint" style="margin:0;">
|
||
幻灯片内容在此维护。页面类型启用、设备规则、搜索覆盖层与图标卡片请到「顶部多功能组件」配置;全不选页面类型即为关闭。
|
||
</p>
|
||
</n-card>
|
||
|
||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||
<n-card class="aksrt-section-card">
|
||
<template #header>
|
||
<div class="aksrt-card-header-row">
|
||
<div class="aksrt-card-header-row__left">
|
||
<span class="header-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
|
||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||
<circle cx="9" cy="9" r="2"/>
|
||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||
</svg>
|
||
</span>
|
||
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
|
||
</div>
|
||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
|
||
</div>
|
||
</template>
|
||
|
||
<n-data-table
|
||
v-if="slides.length"
|
||
:columns="columns"
|
||
:data="slides"
|
||
:row-key="(row) => row.id"
|
||
:bordered="false"
|
||
size="small"
|
||
/>
|
||
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
|
||
</n-card>
|
||
</n-spin>
|
||
|
||
<!-- 编辑/新增 弹窗 -->
|
||
<n-modal
|
||
v-model:show="modalShow"
|
||
:mask-closable="false"
|
||
preset="card"
|
||
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
|
||
class="aksrt-modal-card"
|
||
>
|
||
<n-form :model="editing" label-placement="top" class="aksrt-form">
|
||
<n-form-item label="标题">
|
||
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="副标题">
|
||
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="背景图 URL">
|
||
<n-input-group>
|
||
<n-input v-model:value="editing.image_url" placeholder="https://..." />
|
||
<n-button secondary @click="pickImage">媒体库</n-button>
|
||
</n-input-group>
|
||
</n-form-item>
|
||
|
||
<div v-if="editing.image_url" class="aksrt-modal-preview">
|
||
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
|
||
</div>
|
||
|
||
<n-form-item label="跳转链接">
|
||
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="描述文字">
|
||
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="视频背景 URL">
|
||
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
|
||
</n-form-item>
|
||
|
||
<div class="aksrt-modal-grid">
|
||
<n-form-item label="链接打开方式">
|
||
<n-radio-group v-model:value="editing.link_target">
|
||
<n-radio-button value="_self">当前窗口</n-radio-button>
|
||
<n-radio-button value="_blank">新窗口</n-radio-button>
|
||
</n-radio-group>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="文字对齐">
|
||
<n-radio-group v-model:value="editing.text_align">
|
||
<n-radio-button value="left">左</n-radio-button>
|
||
<n-radio-button value="center">居中</n-radio-button>
|
||
<n-radio-button value="right">右</n-radio-button>
|
||
</n-radio-group>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="设备可见性">
|
||
<n-radio-group v-model:value="editing.device_visibility">
|
||
<n-radio-button value="all">全部</n-radio-button>
|
||
<n-radio-button value="pc">仅电脑</n-radio-button>
|
||
<n-radio-button value="mobile">仅移动</n-radio-button>
|
||
</n-radio-group>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="文字视差">
|
||
<n-switch v-model:value="editing.text_parallax" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="文字大小(PC)">
|
||
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="文字大小(移动)">
|
||
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
|
||
</n-form-item>
|
||
</div>
|
||
|
||
<div class="aksrt-modal-grid">
|
||
<n-form-item label="显示文字">
|
||
<n-switch v-model:value="editing.show_text" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="排序">
|
||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="启用">
|
||
<n-switch v-model:value="editing.enabled" />
|
||
</n-form-item>
|
||
|
||
<n-form-item label="显示元信息">
|
||
<n-switch v-model:value="editing.show_meta" />
|
||
</n-form-item>
|
||
</div>
|
||
|
||
<!-- 图片图层 -->
|
||
<n-divider title-placement="left" style="margin:16px 0 12px;">
|
||
图片图层(视差叠加)
|
||
</n-divider>
|
||
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
|
||
<div class="aksrt-layer-item__header">
|
||
<strong>图层 {{ idx + 1 }}</strong>
|
||
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
|
||
</div>
|
||
<n-form-item label="图片">
|
||
<n-input-group>
|
||
<n-input v-model:value="layer.image" placeholder="图片 URL" />
|
||
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
|
||
</n-input-group>
|
||
</n-form-item>
|
||
<div class="aksrt-modal-grid">
|
||
<n-form-item label="原始尺寸">
|
||
<n-switch v-model:value="layer.free_size" />
|
||
</n-form-item>
|
||
<n-form-item label="对齐方式">
|
||
<n-select v-model:value="layer.align" :options="[
|
||
{ label: '居中', value: 'center' },
|
||
{ label: '左上', value: 'left top' },
|
||
{ label: '右上', value: 'right top' },
|
||
{ label: '左下', value: 'left bottom' },
|
||
{ label: '右下', value: 'right bottom' },
|
||
]" />
|
||
</n-form-item>
|
||
<n-form-item label="视差">
|
||
<n-switch v-model:value="layer.parallax" />
|
||
</n-form-item>
|
||
<n-form-item label="视差强度" v-if="layer.parallax">
|
||
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
|
||
</n-form-item>
|
||
<n-form-item label="视差透明度" v-if="layer.parallax">
|
||
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
|
||
</n-form-item>
|
||
</div>
|
||
</div>
|
||
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
|
||
</n-form>
|
||
|
||
<template #footer>
|
||
<div class="aksrt-modal-footer">
|
||
<n-button @click="modalShow = false">取消</n-button>
|
||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { h, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
|
||
import api from '@/api'
|
||
import { openMediaPicker } from '@/utils/media'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
|
||
const router = useRouter()
|
||
const message = useMessage()
|
||
const dialog = useDialog()
|
||
|
||
function goHeaderComponents() {
|
||
router.push('/page-display/header-components')
|
||
}
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
const slides = ref([])
|
||
|
||
const modalShow = ref(false)
|
||
const editing = ref(emptyForm())
|
||
|
||
function emptyForm() {
|
||
return {
|
||
id: 0,
|
||
title: '',
|
||
subtitle: '',
|
||
description: '',
|
||
image_url: '',
|
||
video_url: '',
|
||
link_url: '',
|
||
link_target: '_self',
|
||
text_align: 'left',
|
||
device_visibility: 'all',
|
||
enabled: true,
|
||
show_text: true,
|
||
show_meta: false,
|
||
sort_order: 0,
|
||
text_size_pc: '',
|
||
text_size_m: '',
|
||
text_parallax: false,
|
||
layers: [],
|
||
}
|
||
}
|
||
|
||
const columns = [
|
||
{
|
||
title: '预览',
|
||
key: 'image_url',
|
||
width: 90,
|
||
render(row) {
|
||
if (!row.image_url) {
|
||
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
|
||
}
|
||
return h(NImage, {
|
||
src: row.image_url,
|
||
width: 64,
|
||
height: 36,
|
||
objectFit: 'cover',
|
||
previewSrc: row.image_url,
|
||
style: 'border-radius:4px;display:block;',
|
||
})
|
||
},
|
||
},
|
||
{
|
||
title: '标题',
|
||
key: 'title',
|
||
ellipsis: { tooltip: true },
|
||
render(row) {
|
||
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
|
||
},
|
||
},
|
||
{
|
||
title: '文字',
|
||
key: 'show_text',
|
||
width: 70,
|
||
className: 'aksrt-table-mobile-hide',
|
||
render(row) {
|
||
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
|
||
default: () => (row.show_text ? '显示' : '隐藏'),
|
||
})
|
||
},
|
||
},
|
||
{
|
||
title: '元信息',
|
||
key: 'show_meta',
|
||
width: 78,
|
||
className: 'aksrt-table-mobile-hide',
|
||
render(row) {
|
||
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
|
||
default: () => (row.show_meta ? '显示' : '隐藏'),
|
||
})
|
||
},
|
||
},
|
||
{
|
||
title: '跳转',
|
||
key: 'link_url',
|
||
ellipsis: { tooltip: true },
|
||
className: 'aksrt-table-mobile-hide',
|
||
render(row) {
|
||
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
|
||
},
|
||
},
|
||
{
|
||
title: '排序',
|
||
key: 'sort_order',
|
||
width: 64,
|
||
className: 'aksrt-table-mobile-hide',
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'enabled',
|
||
width: 84,
|
||
render(row) {
|
||
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
|
||
default: () => (row.enabled ? '启用' : '禁用'),
|
||
})
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 130,
|
||
render(row) {
|
||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
|
||
])
|
||
},
|
||
},
|
||
]
|
||
|
||
async function loadSlides() {
|
||
try {
|
||
loading.value = true
|
||
const res = await api.getHeroes()
|
||
if (res.code === 'success') {
|
||
slides.value = res.data || []
|
||
}
|
||
} catch (e) {
|
||
message.error(e.message || '加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function openCreate() {
|
||
editing.value = emptyForm()
|
||
modalShow.value = true
|
||
}
|
||
|
||
function openEdit(row) {
|
||
editing.value = {
|
||
id: row.id,
|
||
title: row.title || '',
|
||
subtitle: row.subtitle || '',
|
||
description: row.description || '',
|
||
image_url: row.image_url || '',
|
||
video_url: row.video_url || '',
|
||
link_url: row.link_url || '',
|
||
link_target: row.link_target || '_self',
|
||
text_align: row.text_align || 'left',
|
||
device_visibility: row.device_visibility || 'all',
|
||
show_text: row.show_text !== false,
|
||
enabled: !!row.enabled,
|
||
show_meta: row.show_meta === true,
|
||
sort_order: Number(row.sort_order) || 0,
|
||
text_size_pc: row.text_size_pc || '',
|
||
text_size_m: row.text_size_m || '',
|
||
text_parallax: !!row.text_parallax,
|
||
layers: Array.isArray(row.layers) ? row.layers : [],
|
||
}
|
||
modalShow.value = true
|
||
}
|
||
|
||
async function pickImage() {
|
||
try {
|
||
const media = await openMediaPicker({
|
||
title: '选择 Hero 背景图',
|
||
buttonText: '使用这张 Hero 图',
|
||
})
|
||
if (media?.url) {
|
||
editing.value.image_url = media.url
|
||
}
|
||
} catch (e) {
|
||
message.error(e.message || '打开媒体库失败')
|
||
}
|
||
}
|
||
|
||
function addLayer() {
|
||
editing.value.layers.push({
|
||
image: '',
|
||
free_size: false,
|
||
align: 'center',
|
||
parallax: false,
|
||
parallax_scale: 0.3,
|
||
parallax_opacity: 0.6,
|
||
})
|
||
}
|
||
|
||
function removeLayer(idx) {
|
||
editing.value.layers.splice(idx, 1)
|
||
}
|
||
|
||
async function pickLayerImage(idx) {
|
||
try {
|
||
const media = await openMediaPicker({
|
||
title: '选择图层图片',
|
||
buttonText: '使用这张图',
|
||
})
|
||
if (media?.url) {
|
||
editing.value.layers[idx].image = media.url
|
||
}
|
||
} catch (e) {
|
||
message.error(e.message || '打开媒体库失败')
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!editing.value.image_url) {
|
||
message.warning('请填写 Hero 背景图')
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
if (editing.value.id) {
|
||
await api.updateHero(editing.value.id, editing.value)
|
||
message.success('已保存')
|
||
} else {
|
||
await api.createHero(editing.value)
|
||
message.success('已添加')
|
||
}
|
||
modalShow.value = false
|
||
await loadSlides()
|
||
} catch (e) {
|
||
message.error(e.message || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(row) {
|
||
dialog.warning({
|
||
title: '确认删除',
|
||
content: `确定删除 Hero「${row.title || '未命名'}」?`,
|
||
positiveText: '删除',
|
||
negativeText: '取消',
|
||
onPositiveClick: async () => {
|
||
try {
|
||
await api.deleteHero(row.id)
|
||
message.success('已删除')
|
||
await loadSlides()
|
||
} catch (e) {
|
||
message.error(e.message || '删除失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadSlides()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.aksrt-inline-setting {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.aksrt-inline-setting__hint {
|
||
color: #7a7f8a;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.aksrt-inline-setting__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.aksrt-modal-preview {
|
||
margin: -8px 0 18px;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: 1px solid #eef1f6;
|
||
}
|
||
|
||
.aksrt-modal-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.aksrt-layer-item {
|
||
border: 1px solid #eef1f6;
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
margin-bottom: 10px;
|
||
background: #fafbfc;
|
||
}
|
||
|
||
.aksrt-layer-item__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.aksrt-modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.aksrt-modal-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|