Files
wordpress-theme-aksrt/assets/admin/src/pages/content/FooterTabbarSettings.vue
T

791 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="底部Tab配置" 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-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><i class="fas fa-cog"></i></span>
<strong class="aksrt-card-header-row__title">基础设置</strong>
</div>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="启用底部Tab">
<n-switch v-model:value="form.enabled" />
<span class="aksrt-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
</n-form-item>
<n-form-item label="向下滚动隐藏">
<n-switch v-model:value="form.scroll_down_hide" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
</n-form-item>
<n-form-item label="文章页专属Tab">
<n-select
v-model:value="form.footer_tabbar_single"
:options="[
{ label: '默认按钮', value: 'default' },
{ label: '文章扩展按钮(评论/收藏/分享)', value: 'extend' },
]"
style="width: 240px;"
/>
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
</n-form-item>
</n-form>
</n-card>
<!-- 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"><i class="fas fa-list-ul"></i></span>
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
</div>
<n-button type="primary" size="small" @click="addItem">
<template #icon><i class="fas fa-plus"></i></template>
添加按钮
</n-button>
</div>
</template>
<p class="section-desc">配置移动端底部导航栏显示的按钮,支持拖拽排序。建议按钮数量不超过5个。</p>
<div class="section-desc section-desc--tight">当前已启用 {{ enabledPreviewItems.length }} 个按钮,最多允许 5 个。</div>
<draggable
v-if="form.items.length"
v-model="form.items"
item-key="id"
handle=".tabbar-sort__handle"
ghost-class="tabbar-sort__ghost"
animation="180"
class="tabbar-sort"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="tabbar-sort__item">
<div class="tabbar-sort__main">
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<span class="tabbar-sort__icon">
<i v-if="element.icon" :class="element.icon"></i>
</span>
<div class="tabbar-sort__meta">
<div class="tabbar-sort__title-row">
<strong>{{ element.text || '按钮' }}</strong>
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
</div>
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
</div>
</div>
<div class="tabbar-sort__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fas fa-edit" style="font-size:12px;" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fas fa-trash-alt" style="font-size:12px;" /></template>
删除
</n-button>
</div>
</div>
</template>
</draggable>
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
</div>
</n-spin>
<!-- 编辑弹窗 -->
<n-modal
v-model:show="showModal"
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
class="aksrt-modal-card"
style="max-width: 520px;"
>
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
<n-form-item label="按钮类型">
<n-select
v-model:value="editingItem.type"
:options="typeOptions"
placeholder="选择按钮类型"
@update:value="handleTypeChange"
/>
</n-form-item>
<n-form-item label="图标 (Font Awesome)">
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
<template #feedback>
<div class="icon-preview-row">
<span>预览:</span>
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
<span v-else class="muted-text">无图标</span>
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
</a>
</div>
</template>
</n-form-item>
<n-form-item label="显示文字">
<n-input v-model:value="editingItem.text" placeholder="例如: 首页" />
</n-form-item>
<n-form-item label="徽标文本">
<n-input v-model:value="editingItem.badge" placeholder="例如: NEW, 9+,留空不显示" maxlength="4" />
</n-form-item>
<n-form-item label="徽标模式">
<n-radio-group v-model:value="editingItem.badge_mode">
<n-space>
<n-radio value="static">静态文本</n-radio>
<n-radio value="dynamic">动态徽标</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="editingItem.badge_mode === 'dynamic'" type="info" :show-icon="false" style="margin-bottom: 16px;">
<template v-if="editingItem.type === 'auth'">未登录时显示提醒徽标,登录后自动隐藏。</template>
<template v-else-if="editingItem.type === 'user'">登录用户显示评论数徽标,游客不显示。</template>
<template v-else-if="editingItem.type === 'search'">搜索结果页显示 ON 徽标,其它页面隐藏。</template>
<template v-else>当前类型不提供动态徽标,前端将回退为静态徽标。</template>
</n-alert>
<n-form-item v-if="editingItem.type === 'category'" label="指定分类">
<n-select
v-model:value="editingItem.category_id"
:options="categoryOptions"
placeholder="留空则跳转分类聚合页"
clearable
filterable
/>
</n-form-item>
<n-form-item v-if="editingItem.type === 'search'" label="默认搜索类型">
<n-radio-group v-model:value="editingItem.search_default_type">
<n-space>
<n-radio value="post">文章</n-radio>
<n-radio value="category">分类</n-radio>
<n-radio value="tag">标签</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type)" label="链接地址">
<n-input v-model:value="editingItem.link" :placeholder="editingItem.type === 'msg' ? '留空使用默认消息中心地址' : editingItem.type === 'pay_vip' ? '留空使用默认会员页地址' : editingItem.type === 'shop_cart' ? '留空使用默认购物车地址' : 'https:// 或 /path'" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'link'" label="新窗口打开">
<n-switch v-model:value="editingItem.new_window" />
</n-form-item>
<div v-if="editingItem.type === 'add'" class="add-items-editor">
<div class="add-items-editor__head">
<strong>发布菜单项</strong>
<n-button size="small" type="primary" @click="appendAddItem">
<template #icon><i class="fas fa-plus" /></template>
添加子项
</n-button>
</div>
<div class="add-items-editor__presets">
<span class="add-items-editor__presets-label">快速添加:</span>
<n-button
v-for="preset in addPresets"
:key="preset.url"
size="tiny"
secondary
:type="editingItem.add_items.some((item) => item.url === preset.url) ? 'success' : 'default'"
:disabled="editingItem.add_items.some((item) => item.url === preset.url)"
@click="appendAddPreset(preset)"
>
{{ preset.label }}
</n-button>
</div>
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
<draggable
v-model="editingItem.add_items"
item-key="id"
handle=".add-items-editor__drag"
ghost-class="add-items-editor__ghost"
animation="180"
>
<template #item="{ element, index: subIndex }">
<div class="add-items-editor__row">
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<n-input v-model:value="element.icon" placeholder="图标,例如:fas fa-pen" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fas fa-trash-alt" /></template>
</n-button>
</div>
</template>
</draggable>
</div>
<div v-else class="muted-text">当前没有子项,前端将回退为默认发布入口。</div>
</div>
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
<n-switch v-model:value="editingItem.ontop" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingItem.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="saveItem">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
import draggable from 'vuedraggable'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editingIndex = ref(-1)
const categoryOptions = ref([])
const form = reactive({
enabled: true,
scroll_hide: false,
scroll_down_hide: true,
footer_tabbar_single: 'default',
items: [],
})
const editingItem = reactive({
id: '',
type: 'link',
icon: '',
text: '',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: 0,
ontop: false,
})
const typeOptions = [
{ label: '首页', value: 'home' },
{ label: '分类页', value: 'category' },
{ label: '链接', value: 'link' },
{ label: '中央发布按钮', value: 'add' },
{ label: '用户中心', value: 'user' },
{ label: '登录/我的', value: 'auth' },
{ label: '搜索', value: 'search' },
{ label: '消息中心', value: 'msg' },
{ label: '开通会员', value: 'pay_vip' },
{ label: '购物车', value: 'shop_cart' },
]
const typeLabelMap = {
home: '首页',
category: '分类页',
link: '链接',
add: '中央发布按钮',
user: '用户中心',
auth: '登录/我的',
search: '搜索',
msg: '消息中心',
pay_vip: '开通会员',
shop_cart: '购物车',
}
const typeDefaults = {
home: { icon: 'fas fa-home', text: '首页' },
category: { icon: 'fas fa-th-large', text: '分类' },
link: { icon: 'fas fa-link', text: '链接' },
add: { icon: 'fas fa-plus', text: '发布' },
user: { icon: 'fas fa-user', text: '用户中心' },
auth: { icon: 'fas fa-user', text: '我的' },
search: { icon: 'fas fa-search', text: '搜索' },
msg: { icon: 'fas fa-bell', text: '消息' },
pay_vip: { icon: 'fas fa-crown', text: '会员' },
shop_cart: { icon: 'fas fa-shopping-cart', text: '购物车' },
}
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
function getItemSummary(item) {
if (item.type === 'home') return item.ontop ? '首页按钮,滚动后切换返回顶部' : '首页按钮'
if (item.type === 'category') {
if (item.category_id) {
const target = categoryOptions.value.find((option) => option.value === item.category_id)
return target ? `跳转到分类: ${target.label}` : `跳转到分类 ID ${item.category_id}`
}
return '跳转到分类聚合页'
}
if (item.type === 'add') {
return item.add_items?.length ? `展开 ${item.add_items.length} 个发布入口` : '点击后打开默认发布入口'
}
if (item.type === 'search') return `打开顶部搜索面板,默认搜索${item.search_default_type === 'category' ? '分类' : item.search_default_type === 'tag' ? '标签' : '文章'}`
if (item.type === 'user') return '自动跳转到用户中心或登录页'
if (item.type === 'auth') return '登录用户显示我的,游客显示登录'
if (item.type === 'msg') return '跳转到消息中心页面'
if (item.type === 'pay_vip') return item.link || '跳转到会员开通页'
if (item.type === 'shop_cart') return item.link || '跳转到购物车页面'
return item.link || '自定义链接按钮'
}
const addPresets = [
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/post-new' },
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' },
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' },
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' },
]
function appendAddPreset(preset) {
if (editingItem.add_items.some((item) => item.url === preset.url)) return
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: preset.label,
icon: preset.icon,
url: preset.url,
})
}
function appendAddItem() {
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: '',
icon: 'fas fa-pen',
url: '',
})
}
function removeAddItem(index) {
editingItem.add_items.splice(index, 1)
}
function resetSortOrder() {
form.items = form.items.map((item, index) => ({
...item,
sort_order: index,
}))
}
function applyTypeDefaults(type, force = false) {
const preset = typeDefaults[type] || typeDefaults.link
if (force || !editingItem.icon) editingItem.icon = preset.icon
if (force || !editingItem.text) editingItem.text = preset.text
if (!['link', 'msg', 'pay_vip', 'shop_cart'].includes(type)) editingItem.link = ''
if (type !== 'category') editingItem.category_id = null
if (type !== 'search') editingItem.search_default_type = 'post'
if (type !== 'add') editingItem.add_items = []
if (type !== 'home') editingItem.ontop = false
}
function handleTypeChange(value) {
applyTypeDefaults(value, false)
}
function addItem() {
if (form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
Object.assign(editingItem, {
id: `tab_${Date.now()}`,
type: 'link',
icon: 'fas fa-link',
text: '链接',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: form.items.length,
ontop: false,
})
editingIndex.value = -1
showModal.value = true
}
function editItem(index) {
const item = form.items[index]
Object.assign(editingItem, {
id: item.id,
type: item.type || 'link',
icon: item.icon || '',
text: item.text || '',
link: item.link || '',
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || null,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items.map((sub) => ({ ...sub })) : [],
enabled: item.enabled !== false,
sort_order: item.sort_order ?? index,
ontop: item.ontop || false,
})
editingIndex.value = index
showModal.value = true
}
function saveItem() {
if (editingIndex.value < 0 && form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
if (!editingItem.icon) {
message.warning('请填写图标')
return
}
if (!editingItem.text) {
message.warning('请填写显示文字')
return
}
const item = {
id: editingItem.id || `tab_${Date.now()}`,
type: editingItem.type,
icon: editingItem.icon,
text: editingItem.text,
link: ['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type) ? editingItem.link : '',
badge: (editingItem.badge || '').trim(),
badge_mode: editingItem.badge_mode,
category_id: editingItem.type === 'category' ? (editingItem.category_id || 0) : 0,
search_default_type: editingItem.type === 'search' ? editingItem.search_default_type : 'post',
new_window: editingItem.type === 'link' ? !!editingItem.new_window : false,
add_items: editingItem.type === 'add'
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
id: sub.id || `add_${Date.now()}`,
label: sub.label,
icon: sub.icon || 'fas fa-pen',
url: sub.url,
}))
: [],
enabled: editingItem.enabled,
sort_order: editingItem.sort_order,
ontop: editingItem.type === 'home' ? editingItem.ontop : false,
}
if (editingIndex.value >= 0) {
form.items[editingIndex.value] = item
} else {
form.items.push(item)
}
showModal.value = false
message.success(editingIndex.value >= 0 ? '已更新' : '已添加')
}
async function loadCategories() {
try {
const res = await api.getCategories()
if (res.code === 'success' && Array.isArray(res.data)) {
categoryOptions.value = res.data.map((item) => ({
label: item.name,
value: Number(item.id),
}))
}
} catch (e) {
console.error('[FooterTabbar] load categories failed:', e)
}
}
function deleteItem(index) {
dialog.warning({
title: '确认删除',
content: `确定删除「${form.items[index].text}」按钮吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.items.splice(index, 1)
resetSortOrder()
message.success('已删除')
},
})
}
async function loadSettings() {
loading.value = true
try {
const [res] = await Promise.all([api.getFooterTabbarSettings(), loadCategories()])
if (res.code === 'success' && res.data) {
const data = res.data
form.enabled = data.enabled !== false
form.scroll_hide = data.scroll_hide || false
form.scroll_down_hide = data.scroll_down_hide !== false
form.footer_tabbar_single = data.footer_tabbar_single || 'default'
form.items = (data.items || []).map((item, idx) => ({
...item,
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || 0,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items : [],
sort_order: item.sort_order ?? idx,
}))
}
} catch (e) {
console.error('[FooterTabbar] load failed:', e)
message.error('加载配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
if (form.items.length > 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
saving.value = true
try {
const payload = {
enabled: form.enabled,
scroll_hide: form.scroll_hide,
scroll_down_hide: form.scroll_down_hide,
footer_tabbar_single: form.footer_tabbar_single,
items: form.items.map((item, idx) => ({
...item,
sort_order: idx,
})),
}
const res = await api.saveFooterTabbarSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (e) {
console.error('[FooterTabbar] save failed:', e)
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(loadSettings)
</script>
<style scoped>
.icon-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
}
.icon-preview-row a {
color: var(--n-primary-color);
text-decoration: none;
}
.icon-preview-row a:hover {
text-decoration: underline;
}
.muted-text {
color: #999;
font-size: 12px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.section-desc {
color: var(--n-text-color-3);
font-size: 13px;
margin: 0 0 16px;
}
.section-desc--tight {
margin-top: -6px;
}
.add-items-editor {
margin-bottom: 16px;
}
.add-items-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.add-items-editor__presets {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.add-items-editor__presets-label {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
.add-items-editor__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-items-editor__row {
display: grid;
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
gap: 8px;
align-items: center;
}
.add-items-editor__drag {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.add-items-editor__ghost {
opacity: 0.5;
}
.tabbar-sort {
display: flex;
flex-direction: column;
gap: 12px;
}
.tabbar-sort__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--n-border-color);
border-radius: 14px;
background: #fff;
}
.tabbar-sort__main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.tabbar-sort__handle {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.tabbar-sort__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-size: 18px;
flex: 0 0 auto;
}
.tabbar-sort__meta {
min-width: 0;
flex: 1;
}
.tabbar-sort__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.tabbar-sort__desc {
color: var(--n-text-color-3);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbar-sort__actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
.tabbar-sort__ghost {
opacity: 0.5;
}
@media (max-width: 1023px) {
.tabbar-sort__item {
flex-direction: column;
align-items: stretch;
}
.tabbar-sort__actions {
justify-content: flex-end;
}
.add-items-editor__row {
grid-template-columns: 32px 1fr;
}
}
</style>