Files
wordpress-theme-aksrt/assets/admin/src/pages/content/FooterTabbarSettings.vue
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

669 lines
23 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="jio-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="jio-section-card">
<template #header>
<div class="jio-card-header-row">
<div class="jio-card-header-row__left">
<span class="header-icon"><i class="fa fas fa-cog"></i></span>
<strong class="jio-card-header-row__title">基础设置</strong>
</div>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="140" class="jio-form">
<n-form-item label="启用底部Tab">
<n-switch v-model:value="form.enabled" />
<span class="jio-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="jio-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="jio-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
</n-form-item>
</n-form>
</n-card>
<!-- Tab按钮配置 -->
<n-card class="jio-section-card">
<template #header>
<div class="jio-card-header-row">
<div class="jio-card-header-row__left">
<span class="header-icon"><i class="fa fas fa-list-ul"></i></span>
<strong class="jio-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
</div>
<n-button type="primary" size="small" @click="addItem">
<template #icon><i class="fa 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"
v-bind="SORTABLE_LIST_ATTRS"
item-key="id"
class="jio-sortable"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="jio-sortable__item">
<div class="jio-sortable__head">
<JioSortHandle />
<span class="jio-sortable__icon">
<IconValue v-if="element.icon" :value="element.icon" />
</span>
<div class="jio-sortable__main">
<div class="jio-sortable__title-row">
<span class="jio-sortable__title">{{ element.text || '按钮' }}</span>
<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="jio-sortable__desc">{{ getItemSummary(element) }}</div>
</div>
<div class="jio-sortable__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
删除
</n-button>
</div>
</div>
</div>
</template>
</draggable>
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
</n-card>
<div class="jio-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="jio-modal-card"
style="max-width: 520px;"
>
<n-form :model="editingItem" label-placement="top" class="jio-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 / 内置 SVG)">
<FaIconPicker v-model="editingItem.icon" placeholder="选择图标" />
<template #feedback>
<div class="icon-preview-row">
<span>预览:</span>
<IconValue v-if="editingItem.icon" :value="editingItem.icon" style="font-size: 20px;" />
<span v-else class="muted-text">无图标</span>
</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'].includes(editingItem.type)" label="链接地址">
<n-input v-model:value="editingItem.link" :placeholder="editingItem.type === 'msg' ? '留空使用默认消息中心地址' : editingItem.type === 'pay_vip' ? '留空使用默认会员页地址' : '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="fa 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"
v-bind="SORTABLE_LIST_ATTRS"
item-key="id"
class="jio-sortable jio-sortable--compact"
>
<template #item="{ element, index: subIndex }">
<div class="jio-sortable__item">
<div class="jio-sortable__head add-items-editor__row">
<JioSortHandle />
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" aria-label="删除子项" @click="removeAddItem(subIndex)">
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
</div>
</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="jio-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="jio-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'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
import JioSortHandle from '@/components/form/JioSortHandle.vue'
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
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' },
]
const typeLabelMap = {
home: '首页',
category: '分类页',
link: '链接',
add: '中央发布按钮',
user: '用户中心',
auth: '登录/我的',
search: '搜索',
msg: '消息中心',
pay_vip: '开通会员',
}
const typeDefaults = {
home: { icon: 'fa fas fa-home', text: '首页' },
category: { icon: 'fa fas fa-th-large', text: '分类' },
link: { icon: 'fa fas fa-link', text: '链接' },
add: { icon: 'fa fas fa-plus', text: '发布' },
user: { icon: 'fa fas fa-user', text: '用户中心' },
auth: { icon: 'fa fas fa-user', text: '我的' },
search: { icon: 'fa fas fa-search', text: '搜索' },
msg: { icon: 'fa fas fa-bell', text: '消息' },
pay_vip: { icon: 'fa fas fa-gem', 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 || '跳转到会员开通页'
return item.link || '自定义链接按钮'
}
const addPresets = [
{ label: '写文章', icon: 'fa fas fa-pen', url: '/submit' },
{ label: '投稿页', icon: 'fa fas fa-file-alt', url: '/submit' },
{ label: '发布资源', icon: 'fa fas fa-cloud-upload-alt', url: '/resource-new' },
{ label: '创作中心', icon: 'fa fas fa-paint-brush', 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: 'fa 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'].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: 'fa 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'].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 || 'fa 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(--jio-color-primary, #18A058);
text-decoration: none;
}
.icon-preview-row a:hover {
text-decoration: underline;
}
.muted-text {
color: var(--jio-color-muted, #6F7A72);
font-size: 12px;
}
.section-desc {
color: var(--jio-color-muted, #6F7A72);
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(--jio-color-muted, #6F7A72);
}
.add-items-editor__row {
display: grid;
grid-template-columns: 28px 1fr 1fr 1.2fr 34px;
gap: 8px;
align-items: center;
}
@media (max-width: 1023px) {
.add-items-editor__row {
grid-template-columns: 28px 1fr;
}
}
</style>