fix(admin): restore Vite admin source files
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader
|
||||
title="Banner 管理"
|
||||
desc="管理首页内容流 Banner 幻灯片(图片、文案、跳转、启用状态)。"
|
||||
/>
|
||||
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<strong>Banner 显示开关</strong>
|
||||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(轮播参数)</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-form label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="显示首页 Banner">
|
||||
<div class="aksrt-inline-setting">
|
||||
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
|
||||
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="aksrt-inline-setting__actions">
|
||||
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
|
||||
</div>
|
||||
</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="14" x="3" y="5" rx="2"/>
|
||||
<path d="M3 10h18"/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="banners.length"
|
||||
:columns="columns"
|
||||
:data="banners"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
|
||||
</n-card>
|
||||
</n-spin>
|
||||
|
||||
<n-modal
|
||||
v-model:show="modalShow"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
|
||||
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="Banner 图片">
|
||||
<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>
|
||||
|
||||
<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-switch v-model:value="editing.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示元信息">
|
||||
<n-switch v-model:value="editing.show_meta" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</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 settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
const banners = ref([])
|
||||
const bannerEnabled = ref(true)
|
||||
|
||||
const modalShow = ref(false)
|
||||
const editing = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
image_url: '',
|
||||
link_url: '',
|
||||
show_text: true,
|
||||
enabled: true,
|
||||
show_meta: false,
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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: 'sort_order',
|
||||
width: 64,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
},
|
||||
{
|
||||
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: '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 loadBanners() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await api.getHomepageBanners()
|
||||
if (res.code === 'success') {
|
||||
banners.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannerSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const res = await api.getHomepageSettings()
|
||||
const data = res?.data || {}
|
||||
bannerEnabled.value = data.homepage_module_banner_enabled !== false
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载 Banner 开关失败')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBannerSettings() {
|
||||
settingsSaving.value = true
|
||||
try {
|
||||
const res = await api.saveHomepageSettings({
|
||||
homepage_module_banner_enabled: bannerEnabled.value,
|
||||
})
|
||||
if (res?.code === 'success') {
|
||||
message.success('Banner 开关已保存')
|
||||
return
|
||||
}
|
||||
throw new Error(res?.message || '保存 Banner 开关失败')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存 Banner 开关失败')
|
||||
} finally {
|
||||
settingsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = emptyForm()
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editing.value = {
|
||||
id: row.id,
|
||||
title: row.title || '',
|
||||
subtitle: row.subtitle || '',
|
||||
image_url: row.image_url || '',
|
||||
link_url: row.link_url || '',
|
||||
show_text: row.show_text !== false,
|
||||
enabled: !!row.enabled,
|
||||
show_meta: row.show_meta === true,
|
||||
sort_order: Number(row.sort_order) || 0,
|
||||
}
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
try {
|
||||
const media = await openMediaPicker({
|
||||
title: '选择 Banner 图片',
|
||||
buttonText: '使用这张 Banner 图',
|
||||
})
|
||||
if (media?.url) {
|
||||
editing.value.image_url = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '打开媒体库失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing.value.image_url) {
|
||||
message.warning('请填写 Banner 图片')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value.id) {
|
||||
await api.updateHomepageBanner(editing.value.id, editing.value)
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await api.createHomepageBanner(editing.value)
|
||||
message.success('已添加')
|
||||
}
|
||||
modalShow.value = false
|
||||
await loadBanners()
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除 Banner「${row.title || '未命名'}」?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.deleteHomepageBanner(row.id)
|
||||
message.success('已删除')
|
||||
await loadBanners()
|
||||
} catch (e) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBanners()
|
||||
loadBannerSettings()
|
||||
})
|
||||
</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 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-modal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,986 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="页脚配置" desc="管理页脚区域的联系方式、快速链接、更多链接、友情链接和版权信息。">
|
||||
<template #actions>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-tabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
class="aksrt-tabs"
|
||||
>
|
||||
<!-- ============== 品牌与Logo ============== -->
|
||||
<n-tab-pane name="brand" tab="品牌与Logo">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
|
||||
<h3>品牌列(第一列)</h3>
|
||||
</div>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_t1_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="页脚 Logo(日间)">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
|
||||
</n-form-item>
|
||||
<n-form-item label="页脚 Logo(夜间)">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
|
||||
</n-form-item>
|
||||
<n-form-item label="品牌标题">
|
||||
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="品牌列自定义代码">
|
||||
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 联系方式 ============== -->
|
||||
<n-tab-pane name="contacts" tab="联系方式">
|
||||
<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" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚中的社交平台或联系入口。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.contacts.length"
|
||||
:columns="contactColumns"
|
||||
:data="form.contacts"
|
||||
:row-key="(row) => row._uid"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无联系方式图标" />
|
||||
|
||||
<n-divider>标准联系字段</n-divider>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_contact_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="微信二维码">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
|
||||
</n-form-item>
|
||||
<n-form-item label="QQ">
|
||||
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
|
||||
</n-form-item>
|
||||
<n-form-item label="微博">
|
||||
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱">
|
||||
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接 HTML(fcode_t2_code_1)">
|
||||
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="3" placeholder="推荐直接写 <a href="/">首页</a> 等链接 HTML;为空时回退快速链接/友链/热门分类" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接下方自定义代码">
|
||||
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-divider>更多联系项</n-divider>
|
||||
<n-card class="aksrt-section-card" embedded>
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
|
||||
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
|
||||
<n-data-table
|
||||
v-if="form.footer_contact_more.length"
|
||||
:columns="contactMoreColumns"
|
||||
:data="form.footer_contact_more"
|
||||
:row-key="(_, i) => i"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无扩展联系项" />
|
||||
</n-card>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 快速链接 ============== -->
|
||||
<n-tab-pane name="quick" tab="快速链接">
|
||||
<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" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">快速链接</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚第二列的常用导航链接。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.quick_links.length"
|
||||
:columns="createLinkColumns('quick_links')"
|
||||
:data="form.quick_links"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无快速链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 更多链接 ============== -->
|
||||
<n-tab-pane name="more" tab="更多链接">
|
||||
<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" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">更多链接</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚补充链接区域。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.more_links.length"
|
||||
:columns="createLinkColumns('more_links')"
|
||||
:data="form.more_links"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无更多链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 友情链接 ============== -->
|
||||
<n-tab-pane name="friends" tab="友情链接">
|
||||
<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" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="friendLinks.length"
|
||||
:columns="friendColumns"
|
||||
:data="friendLinks"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无友情链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 二维码图片组 ============== -->
|
||||
<n-tab-pane name="mini_qr" tab="二维码图片组">
|
||||
<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" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
|
||||
<n-space align="center" :size="12" style="width: 100%;">
|
||||
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
|
||||
<div class="aksrt-media-row" style="flex: 1;">
|
||||
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
|
||||
</div>
|
||||
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
|
||||
</n-space>
|
||||
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
|
||||
</div>
|
||||
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 自定义代码 ============== -->
|
||||
<n-tab-pane name="custom_code" tab="自定义代码">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
|
||||
<h3>底部自定义代码</h3>
|
||||
</div>
|
||||
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
|
||||
|
||||
<n-form :model="form" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="fcode_customize_code">
|
||||
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 版权与杂项 ============== -->
|
||||
<n-tab-pane name="misc" tab="版权与杂项">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<h3>其他设置</h3>
|
||||
</div>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="页脚布局模板">
|
||||
<n-radio-group v-model:value="form.layout_template">
|
||||
<n-radio-button value="template_1">模板一(Zibll)</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="热门分类数量">
|
||||
<n-space align="center" :size="10">
|
||||
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
|
||||
<span class="aksrt-hint-text">范围 1 - 20</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义版权文案">
|
||||
<n-input
|
||||
v-model:value="form.copyright_text"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="留空则使用默认版权信息"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<!-- ===== 联系方式 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showContactModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="编辑联系方式图标"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="图标类型">
|
||||
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingContact.url" placeholder="https://..." />
|
||||
</n-form-item>
|
||||
<n-form-item label="标题提示">
|
||||
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用状态">
|
||||
<n-switch v-model:value="editingContact.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showContactModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveContact">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- ===== 链接 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showLinkModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="链接名称">
|
||||
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用状态">
|
||||
<n-switch v-model:value="editingLink.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showLinkModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveLink">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- ===== 友情链接 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showFriendModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="添加友情链接"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="站点名称">
|
||||
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
|
||||
</n-form-item>
|
||||
<n-form-item label="站点 URL">
|
||||
<n-input v-model:value="editingFriend.url" placeholder="https://" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Logo 图片 URL">
|
||||
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showFriendModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
<!-- ===== 扩展联系项 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showContactMoreModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="编辑扩展联系项"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图标类名">
|
||||
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
|
||||
</n-form-item>
|
||||
<n-form-item label="Hover 图片">
|
||||
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showContactMoreModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveContactMore">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { openMediaPicker } from '@/utils/media'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const activeTab = ref('contacts')
|
||||
|
||||
const form = reactive({
|
||||
contacts: [],
|
||||
quick_links: [],
|
||||
more_links: [],
|
||||
hot_categories: 5,
|
||||
copyright_text: '',
|
||||
layout_template: 'template_1',
|
||||
// §2.3 三列结构化字段
|
||||
footer_t1_m_s: ['pc', 'mobile'],
|
||||
footer_t1_img: '',
|
||||
footer_t1_img_dark: '',
|
||||
footer_t1_t: '',
|
||||
fcode_t1_code: '',
|
||||
footer_contact_wechat_img: '',
|
||||
footer_contact_qq: '',
|
||||
footer_contact_weibo: '',
|
||||
footer_contact_email: '',
|
||||
footer_contact_m_s: ['pc', 'mobile'],
|
||||
footer_contact_more: [],
|
||||
fcode_t2_code_1: '',
|
||||
fcode_t2_code_2: '',
|
||||
footer_mini_img_m_s: ['pc', 'mobile'],
|
||||
footer_mini_img: [],
|
||||
fcode_customize_code: '',
|
||||
})
|
||||
|
||||
// ============== 联系方式 ==============
|
||||
const showContactModal = ref(false)
|
||||
const editingContactIndex = ref(-1)
|
||||
const editingContact = reactive({
|
||||
icon: '',
|
||||
url: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
let contactUid = 0
|
||||
|
||||
const iconOptions = [
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'X (Twitter)', value: 'x' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: 'QQ', value: 'qq' },
|
||||
{ label: '微博', value: 'weibo' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Discord', value: 'discord' },
|
||||
{ label: 'Bilibili', value: 'bilibili' },
|
||||
{ label: 'LinkedIn', value: 'linkedin' },
|
||||
{ label: 'Facebook', value: 'facebook' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
{ label: 'Instagram', value: 'instagram' },
|
||||
{ label: 'RSS / 网站', value: 'web' },
|
||||
]
|
||||
|
||||
function addContact() {
|
||||
Object.assign(editingContact, {
|
||||
icon: '',
|
||||
url: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
})
|
||||
editingContactIndex.value = -1
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
function editContact(row, index) {
|
||||
Object.assign(editingContact, {
|
||||
icon: row.icon,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
enabled: row.enabled,
|
||||
})
|
||||
editingContactIndex.value = index
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
function saveContact() {
|
||||
if (!editingContact.icon || !editingContact.url) {
|
||||
message.warning('请填写图标类型和链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const item = {
|
||||
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
|
||||
icon: editingContact.icon,
|
||||
url: editingContact.url,
|
||||
title: editingContact.title,
|
||||
enabled: editingContact.enabled,
|
||||
}
|
||||
|
||||
if (editingContactIndex.value >= 0) {
|
||||
form.contacts[editingContactIndex.value] = item
|
||||
} else {
|
||||
form.contacts.push(item)
|
||||
}
|
||||
|
||||
showContactModal.value = false
|
||||
}
|
||||
|
||||
function deleteContact(index) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定删除这个联系方式图标吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
form.contacts.splice(index, 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const contactColumns = [
|
||||
{
|
||||
title: '平台',
|
||||
key: 'icon',
|
||||
width: 120,
|
||||
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
|
||||
},
|
||||
{
|
||||
title: '链接',
|
||||
key: 'url',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: '提示',
|
||||
key: 'title',
|
||||
ellipsis: { tooltip: true },
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
// ============== 快速 / 更多链接 ==============
|
||||
const showLinkModal = ref(false)
|
||||
const editingLinkType = ref('quick_links')
|
||||
const editingLinkIndex = ref(-1)
|
||||
const editingLink = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
function openLinkEditor(type, row = null, index = -1) {
|
||||
editingLinkType.value = type
|
||||
editingLinkIndex.value = index
|
||||
|
||||
Object.assign(editingLink, row || {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
|
||||
})
|
||||
|
||||
showLinkModal.value = true
|
||||
}
|
||||
|
||||
function saveLink() {
|
||||
if (!editingLink.name || !editingLink.url) {
|
||||
message.warning('请填写链接名称和链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const target = form[editingLinkType.value]
|
||||
const item = {
|
||||
id: editingLink.id || `link_${Date.now()}`,
|
||||
name: editingLink.name,
|
||||
url: editingLink.url,
|
||||
enabled: editingLink.enabled,
|
||||
sort_order: editingLink.sort_order ?? target.length,
|
||||
}
|
||||
|
||||
if (editingLinkIndex.value >= 0) {
|
||||
target[editingLinkIndex.value] = item
|
||||
} else {
|
||||
target.push(item)
|
||||
}
|
||||
|
||||
showLinkModal.value = false
|
||||
}
|
||||
|
||||
function deleteLink(type, index) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定删除这个链接吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
form[type].splice(index, 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createLinkColumns(type) {
|
||||
return [
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
key: 'url',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ============== 友情链接 ==============
|
||||
const friendLinks = ref([])
|
||||
const showFriendModal = ref(false)
|
||||
const addingFriend = ref(false)
|
||||
const editingFriend = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
logo: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const friendColumns = [
|
||||
{
|
||||
title: 'Logo',
|
||||
key: 'logo',
|
||||
width: 60,
|
||||
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
|
||||
},
|
||||
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
|
||||
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
|
||||
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (row) => h(NButton, {
|
||||
quaternary: true,
|
||||
type: 'error',
|
||||
size: 'tiny',
|
||||
onClick: () => deleteFriend(row),
|
||||
}, { default: () => '删除' }),
|
||||
},
|
||||
]
|
||||
|
||||
function openFriendEditor() {
|
||||
Object.assign(editingFriend, {
|
||||
name: '',
|
||||
url: '',
|
||||
logo: '',
|
||||
description: '',
|
||||
})
|
||||
showFriendModal.value = true
|
||||
}
|
||||
|
||||
async function loadFriendLinks() {
|
||||
try {
|
||||
const res = await api.getFriendLinks()
|
||||
if (res.code === 'success') friendLinks.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('[FriendLinks] load failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFriend() {
|
||||
if (!editingFriend.name || !editingFriend.url) {
|
||||
message.warning('请填写站点名称和 URL')
|
||||
return
|
||||
}
|
||||
addingFriend.value = true
|
||||
try {
|
||||
const res = await api.addFriendLink({
|
||||
name: editingFriend.name,
|
||||
url: editingFriend.url,
|
||||
logo: editingFriend.logo,
|
||||
description: editingFriend.description,
|
||||
sort_order: friendLinks.value.length,
|
||||
})
|
||||
if (res.code === 'success') {
|
||||
message.success(res.message || '已添加')
|
||||
showFriendModal.value = false
|
||||
await loadFriendLinks()
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '添加失败')
|
||||
} finally {
|
||||
addingFriend.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFriend(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除友链「${row.name}」?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.deleteFriendLink(row.id)
|
||||
message.success('已删除')
|
||||
await loadFriendLinks()
|
||||
} catch (e) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============== 加载 / 保存 ==============
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [footerRes] = await Promise.all([
|
||||
api.getFooterSettings(),
|
||||
loadFriendLinks(),
|
||||
])
|
||||
if (footerRes.code === 'success' && footerRes.data) {
|
||||
const data = footerRes.data
|
||||
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
|
||||
form.quick_links = data.quick_links || []
|
||||
form.more_links = data.more_links || []
|
||||
form.hot_categories = data.hot_categories ?? 5
|
||||
form.copyright_text = data.copyright_text || ''
|
||||
form.layout_template = 'template_1'
|
||||
// §2.3 三列结构化字段
|
||||
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
|
||||
form.footer_t1_img = data.footer_t1_img || ''
|
||||
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
|
||||
form.footer_t1_t = data.footer_t1_t || ''
|
||||
form.fcode_t1_code = data.fcode_t1_code || ''
|
||||
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
|
||||
form.footer_contact_qq = data.footer_contact_qq || ''
|
||||
form.footer_contact_weibo = data.footer_contact_weibo || ''
|
||||
form.footer_contact_email = data.footer_contact_email || ''
|
||||
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
|
||||
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
|
||||
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
|
||||
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
|
||||
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
|
||||
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
|
||||
form.fcode_customize_code = data.fcode_customize_code || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Footer] load failed:', error)
|
||||
message.error(error?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
|
||||
quick_links: form.quick_links,
|
||||
more_links: form.more_links,
|
||||
hot_categories: form.hot_categories,
|
||||
copyright_text: form.copyright_text,
|
||||
layout_template: form.layout_template,
|
||||
// §2.3 三列结构化字段
|
||||
footer_t1_m_s: form.footer_t1_m_s,
|
||||
footer_t1_img: form.footer_t1_img,
|
||||
footer_t1_img_dark: form.footer_t1_img_dark,
|
||||
footer_t1_t: form.footer_t1_t,
|
||||
fcode_t1_code: form.fcode_t1_code,
|
||||
footer_contact_wechat_img: form.footer_contact_wechat_img,
|
||||
footer_contact_qq: form.footer_contact_qq,
|
||||
footer_contact_weibo: form.footer_contact_weibo,
|
||||
footer_contact_email: form.footer_contact_email,
|
||||
footer_contact_m_s: form.footer_contact_m_s,
|
||||
footer_contact_more: form.footer_contact_more,
|
||||
fcode_t2_code_1: form.fcode_t2_code_1,
|
||||
fcode_t2_code_2: form.fcode_t2_code_2,
|
||||
footer_mini_img_m_s: form.footer_mini_img_m_s,
|
||||
footer_mini_img: form.footer_mini_img,
|
||||
fcode_customize_code: form.fcode_customize_code,
|
||||
}
|
||||
const res = await api.saveFooterSettings(payload)
|
||||
if (res.code === 'success') {
|
||||
message.success(res.message || '保存成功')
|
||||
} else {
|
||||
message.error(res.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Footer] save failed:', error)
|
||||
message.error(error?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 媒体选择 ==============
|
||||
async function pickMedia(field) {
|
||||
try {
|
||||
const media = await openMediaPicker({ title: '选择图片' })
|
||||
if (media?.url) {
|
||||
form[field] = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message) message.warning(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 扩展联系项 ==============
|
||||
function addContactMore() {
|
||||
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
|
||||
}
|
||||
|
||||
function removeContactMore(index) {
|
||||
form.footer_contact_more.splice(index, 1)
|
||||
}
|
||||
|
||||
const showContactMoreModal = ref(false)
|
||||
const editingContactMoreIndex = ref(-1)
|
||||
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
|
||||
|
||||
function editContactMore(row, index) {
|
||||
Object.assign(editingContactMore, row)
|
||||
editingContactMoreIndex.value = index
|
||||
showContactMoreModal.value = true
|
||||
}
|
||||
|
||||
function saveContactMore() {
|
||||
const item = { ...editingContactMore }
|
||||
if (editingContactMoreIndex.value >= 0) {
|
||||
form.footer_contact_more[editingContactMoreIndex.value] = item
|
||||
}
|
||||
showContactMoreModal.value = false
|
||||
}
|
||||
|
||||
const contactMoreColumns = [
|
||||
{ title: '描述', key: 'desc', width: 120 },
|
||||
{ title: '图标', key: 'icon', width: 100 },
|
||||
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
|
||||
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
// ============== 二维码图片组 ==============
|
||||
function addMiniImg() {
|
||||
if (form.footer_mini_img.length < 4) {
|
||||
form.footer_mini_img.push({ text: '', img: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function removeMiniImg(idx) {
|
||||
form.footer_mini_img.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function pickMiniImg(idx) {
|
||||
try {
|
||||
const media = await openMediaPicker({ title: '选择二维码图片' })
|
||||
if (media?.url) {
|
||||
form.footer_mini_img[idx].img = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message) message.warning(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.aksrt-media-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.aksrt-media-preview {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
max-height: 80px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
object-fit: contain;
|
||||
}
|
||||
.aksrt-media-preview--sm {
|
||||
max-width: 64px;
|
||||
max-height: 64px;
|
||||
}
|
||||
.aksrt-mini-img-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
|
||||
}
|
||||
.aksrt-mini-img-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,518 @@
|
||||
<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>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button :loading="cleaning" @click="runCleanup">立即清理</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveSettings">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-card title="通知内容开关" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="评论回复通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_reply" />
|
||||
</n-form-item>
|
||||
<n-form-item label="评论审核通过通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_approved" />
|
||||
</n-form-item>
|
||||
<n-form-item label="资源下载就绪通知">
|
||||
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分增加通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_added" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分扣减通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱待验证提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
||||
</n-form-item>
|
||||
<n-form-item label="待审核评论提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.pending_comments" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图片审核通知">
|
||||
<n-switch v-model:value="settings.enabled_types.upload_moderation" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
|
||||
<n-grid-item span="24 l:10">
|
||||
<n-card title="user 徽标规则" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="启用 user 动态徽标">
|
||||
<n-switch v-model:value="settings.user_badge_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="徽标包含状态提醒">
|
||||
<n-switch v-model:value="settings.badge_include_stateful" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="24 l:14">
|
||||
<n-card title="通知清理策略" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="已读通知保留天数">
|
||||
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="未读通知保留天数">
|
||||
<n-input-number v-model:value="settings.unread_retention_days" :min="7" :max="3650" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="每用户最大保留数">
|
||||
<n-input-number v-model:value="settings.max_per_user" :min="20" :max="5000" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const cleaning = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
enabled_types: {
|
||||
comment_reply: true,
|
||||
comment_approved: true,
|
||||
download_ready: true,
|
||||
credits_added: true,
|
||||
credits_deducted: true,
|
||||
verify_email: true,
|
||||
pending_comments: true,
|
||||
upload_moderation: true,
|
||||
},
|
||||
user_badge_enabled: true,
|
||||
badge_include_stateful: true,
|
||||
read_retention_days: 30,
|
||||
unread_retention_days: 180,
|
||||
max_per_user: 200,
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.getNotificationSettings()
|
||||
if (res.code === 'success' && res.data) {
|
||||
Object.assign(settings, res.data)
|
||||
if (res.data.enabled_types) {
|
||||
settings.enabled_types = { ...settings.enabled_types, ...res.data.enabled_types }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.saveNotificationSettings({
|
||||
enabled_types: settings.enabled_types,
|
||||
user_badge_enabled: settings.user_badge_enabled,
|
||||
badge_include_stateful: settings.badge_include_stateful,
|
||||
read_retention_days: settings.read_retention_days,
|
||||
unread_retention_days: settings.unread_retention_days,
|
||||
max_per_user: settings.max_per_user,
|
||||
})
|
||||
if (res.code === 'success') message.success(res.message || '保存成功')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
cleaning.value = true
|
||||
try {
|
||||
const res = await api.runNotificationCleanup()
|
||||
if (res.code === 'success') message.success(res.message || '清理完成')
|
||||
} catch (e) {
|
||||
message.error(e.message || '清理失败')
|
||||
} finally {
|
||||
cleaning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
Reference in New Issue
Block a user