导入 AKSRT 主题开发源码

This commit is contained in:
Claude Code committed 2026-07-03 22:50:34 +08:00
1 parent 9681dd3d48
commit 1268a55dd0
595 files changed
+127705

No files matched your search

@@ -0,0 +1,790 @@
<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>