Files
wordpress-theme-aksrt/assets/admin/src/pages/content/FloatingButtonsSettings.vue
T
kate522 b8ee6b6a43 fix(float): repair VIP rail entry front and admin
Always open VIP modal for float pay_vip, drop forced admin link,
normalize gem icon, harden pay-vip click binding and SPA form UX.
2026-07-22 10:32:00 +08:00

1020 lines
35 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="底部悬浮按钮" 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="启用悬浮按钮">
<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-radio-group v-model:value="form.style">
<n-space>
<n-radio value="round">圆角卡片</n-radio>
<n-radio value="square">方形堆叠</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="显示位置">
<n-radio-group v-model:value="form.position">
<n-space>
<n-radio value="bottom">右下角</n-radio>
<n-radio value="center">右侧居中</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="滚动中隐藏">
<n-switch v-model:value="form.scroll_hide" />
<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="毛玻璃设备">
<n-checkbox-group v-model:value="form.filter_blur">
<n-space>
<n-checkbox value="pc">PC</n-checkbox>
<n-checkbox value="mobile">移动端</n-checkbox>
</n-space>
</n-checkbox-group>
</n-form-item>
</n-form>
</n-card>
<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-layer-group"></i></span>
<strong class="aksrt-card-header-row__title">按钮列表 ({{ 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">支持内置功能按钮、自定义链接、二维码弹层和客服入口。排序结果会直接用于前端输出顺序。</p>
<draggable
v-if="form.items.length"
v-model="form.items"
item-key="id"
handle=".float-btn-sort__handle"
ghost-class="float-btn-sort__ghost"
animation="180"
class="float-btn-sort"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="float-btn-sort__item">
<div class="float-btn-sort__main">
<button type="button" class="float-btn-sort__handle" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<span class="float-btn-sort__icon">
<i v-if="element.icon" :class="element.icon"></i>
</span>
<div class="float-btn-sort__meta">
<div class="float-btn-sort__title-row">
<strong>{{ element.text || typeLabelMap[element.type] || '按钮' }}</strong>
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
<n-tag v-if="element.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
<n-tag v-if="element.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
</div>
<div class="float-btn-sort__desc">
{{ getItemSummary(element) }}
</div>
</div>
</div>
<div class="float-btn-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="暂无悬浮按钮,请点击上方按钮添加" />
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存悬浮按钮配置</n-button>
</div>
</n-spin>
<n-modal
v-model:show="showModal"
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? '编辑悬浮按钮' : '添加悬浮按钮'"
class="aksrt-modal-card"
style="max-width: 640px;"
>
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="按钮类型">
<n-select v-model:value="editingItem.type" :options="typeOptions" placeholder="选择按钮类型" @update:value="handleTypeChange" />
</n-form-item-gi>
<n-form-item-gi v-if="editingItem.type !== 'theme_mode'" label="主图标 (Font Awesome)">
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
</n-form-item-gi>
<n-form-item-gi v-else label="主题图标">
<div class="theme-icon-preview">
<span><i class="fas fa-sun"></i> 亮色固定显示太阳</span>
<span><i class="fas fa-moon"></i> 暗色固定显示月亮</span>
</div>
</n-form-item-gi>
</n-grid>
<div class="icon-preview-row">
<span>预览:</span>
<template v-if="editingItem.type === 'theme_mode'">
<span class="theme-icon-preview-inline"><i class="fas fa-sun"></i> / <i class="fas fa-moon"></i></span>
</template>
<i v-else-if="editingItem.icon" :class="editingItem.icon" style="font-size: 18px;"></i>
<span v-else class="muted-text">未设置图标</span>
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener">
<i class="fas fa-external-link-alt"></i>
Font Awesome
</a>
</div>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="按钮标题">
<n-input v-model:value="editingItem.text" placeholder="例如: 微信, 顶部, 会员" />
</n-form-item-gi>
<n-form-item-gi v-if="showHoverDescInput" label="悬停说明">
<n-input v-model:value="editingItem.desc" placeholder="例如: 扫码添加微信" />
</n-form-item-gi>
</n-grid>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="PC 显示">
<n-switch v-model:value="editingItem.pc_s" />
</n-form-item-gi>
<n-form-item-gi label="移动端显示">
<n-switch v-model:value="editingItem.m_s" />
</n-form-item-gi>
</n-grid>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="图标颜色">
<n-color-picker v-model:value="editingItem.color" :show-alpha="true" clearable />
</n-form-item-gi>
<n-form-item-gi label="背景颜色">
<n-color-picker v-model:value="editingItem.bg" :show-alpha="true" clearable />
</n-form-item-gi>
</n-grid>
<n-form-item v-if="supportsActionMode" label="交互方式">
<n-radio-group v-model:value="editingItem.action_mode">
<n-space>
<n-radio value="link">点击跳转</n-radio>
<n-radio value="qrcode">悬浮二维码</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="needsLink" label="链接地址">
<n-input v-model:value="editingItem.link" placeholder="https://example.com 或 /vip" />
</n-form-item>
<n-alert v-if="isPayVip" type="info" :bordered="false" style="margin-bottom: 12px;">
会员入口固定打开站点「开通/升级会员」弹窗,无需填写链接;访客点击会先走登录。
</n-alert>
<n-form-item v-if="editingItem.type === 'service_qq' && editingItem.action_mode !== 'qrcode'" label="QQ 号码">
<n-input v-model:value="editingItem.qq" placeholder="仅填写数字" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'service_qq' && editingItem.action_mode === 'qrcode'" label="QQ 二维码地址">
<n-input v-model:value="editingItem.qrcode_img" placeholder="https://example.com/qq-qrcode.jpg" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'service_wechat' && editingItem.action_mode === 'qrcode'" label="微信二维码地址">
<n-input v-model:value="editingItem.wechat_img" placeholder="https://example.com/qrcode.jpg" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'qrcode'" label="二维码尺寸">
<n-input-number v-model:value="editingItem.qr_size" :min="80" :max="240" style="width: 180px;" />
</n-form-item>
<div v-if="editingItem.type === 'add'" class="add-items-editor">
<div class="add-items-editor__head">
<strong>发布菜单项</strong>
<div class="add-items-editor__head-actions">
<n-dropdown trigger="click" :options="addPresetOptions" @select="applyAddPreset">
<n-button size="small">
<template #icon><i class="fas fa-magic" /></template>
一键预设
</n-button>
</n-dropdown>
<n-button size="small" type="primary" @click="appendAddItem">
<template #icon><i class="fas fa-plus" /></template>
添加子项
</n-button>
</div>
</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"
@end="normalizeAddItems"
>
<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="链接,例如: /submit" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fas fa-trash-alt" /></template>
</n-button>
</div>
</template>
</draggable>
</div>
<n-empty v-else description="未配置子项时,发布按钮会退化为单链接模式" />
<div class="add-items-preview">
<div class="add-items-preview__title">展开预览</div>
<div class="add-items-preview__panel add-items-preview__panel--light">
<div class="add-items-preview__mode">亮色</div>
<div class="add-items-preview__trigger">
<i :class="editingItem.icon || 'fas fa-plus'"></i>
</div>
<div class="add-items-preview__menu">
<div v-if="editingItem.add_items.length" class="add-items-preview__list">
<a
v-for="subItem in editingItem.add_items"
:key="subItem.id"
href="javascript:void(0)"
class="add-items-preview__item"
>
<i :class="subItem.icon || 'fas fa-pen'"></i>
<span>{{ subItem.label || '未命名子项' }}</span>
</a>
</div>
<div v-else class="add-items-preview__empty">当前没有子菜单项,将使用单链接按钮。</div>
</div>
</div>
<div class="add-items-preview__panel add-items-preview__panel--dark">
<div class="add-items-preview__mode">暗色</div>
<div class="add-items-preview__trigger add-items-preview__trigger--dark">
<i :class="editingItem.icon || 'fas fa-plus'"></i>
</div>
<div class="add-items-preview__menu add-items-preview__menu--dark">
<div v-if="editingItem.add_items.length" class="add-items-preview__list">
<a
v-for="subItem in editingItem.add_items"
:key="`${subItem.id}-dark`"
href="javascript:void(0)"
class="add-items-preview__item add-items-preview__item--dark"
>
<i :class="subItem.icon || 'fas fa-pen'"></i>
<span>{{ subItem.label || '未命名子项' }}</span>
</a>
</div>
<div v-else class="add-items-preview__empty add-items-preview__empty--dark">当前没有子菜单项,将使用单链接按钮。</div>
</div>
</div>
</div>
</div>
<n-form-item v-if="supportsHover" label="悬浮内容 HTML">
<n-input
v-model:value="editingItem.hover"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="可填写简单 HTML 内容,填写后会优先展示弹层内容"
/>
</n-form-item>
<n-form-item v-if="supportsHover" label="悬浮层宽度">
<n-input-number v-model:value="editingItem.hover_width" :min="120" :max="480" style="width: 180px;" />
</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, h, 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 siteUrl = (window.aksrtAdminData?.siteUrl || '').replace(/\/$/, '')
const adminUrl = (window.aksrtAdminData?.adminUrl || '').replace(/\/$/, '')
const form = reactive({
enabled: true,
style: 'round',
position: 'bottom',
scroll_hide: false,
scroll_down_hide: true,
filter_blur: ['mobile'],
items: [],
})
const editingItem = reactive(createDefaultItem())
const typeOptions = [
{ label: '自定义链接', value: 'custom' },
{ label: '回到顶部', value: 'back_top' },
{ label: '主题切换', value: 'theme_mode' },
{ label: '页面二维码', value: 'qrcode' },
{ label: '发布入口', value: 'add' },
{ label: '购物车', value: 'shop_cart' },
{ label: 'QQ 客服', value: 'service_qq' },
{ label: '微信客服', value: 'service_wechat' },
{ label: '会员入口', value: 'pay_vip' },
]
const typeLabelMap = Object.fromEntries(typeOptions.map(item => [item.value, item.label]))
const addPresetOptions = computed(() => {
const presets = [
{ label: '写文章', key: 'post' },
{ label: '投稿页', key: 'submit' },
{ label: '用户中心创作', key: 'profile' },
]
return presets.map((preset) => {
const exists = isAddPresetExists(preset.key)
return {
...preset,
disabled: exists,
label: exists ? `${preset.label}(已存在)` : preset.label,
}
})
})
// pay_vip always opens the site VIP modal on the front-end — no link required.
const needsLink = computed(() => ['custom', 'add', 'shop_cart'].includes(editingItem.type) && !editingItem.hover)
// Custom hover HTML only; VIP uses modal, not free-form hover content.
const supportsHover = computed(() => editingItem.type === 'custom')
const supportsActionMode = computed(() => ['service_qq', 'service_wechat'].includes(editingItem.type))
const isPayVip = computed(() => editingItem.type === 'pay_vip')
const isServiceQrcode = computed(() =>
['service_qq', 'service_wechat'].includes(editingItem.type) && editingItem.action_mode === 'qrcode'
)
const showHoverDescInput = computed(() => !isServiceQrcode.value)
function createDefaultItem() {
return {
id: '',
type: 'custom',
icon: 'fas fa-link',
icon_dark: '',
icon_light: '',
text: '',
desc: '',
action_mode: 'link',
qq: '',
qrcode_img: '',
wechat_img: '',
link: '',
add_items: [],
hover: '',
qr_size: 120,
hover_width: 200,
pc_s: true,
m_s: true,
color: null,
bg: null,
enabled: true,
sort_order: 0,
}
}
function applyTypeDefaults(type) {
const map = {
custom: { icon: 'fas fa-link', icon_dark: '', icon_light: '', text: '链接', desc: '', link: '', hover: '', action_mode: 'link', qr_size: 120 },
back_top: { icon: 'fas fa-chevron-up', icon_dark: '', icon_light: '', text: '顶部', desc: '返回顶部', link: '', hover: '', action_mode: 'link', qr_size: 120 },
theme_mode: { icon: 'fas fa-sun', icon_dark: '', icon_light: '', text: '主题', desc: '切换主题', link: '', hover: '', action_mode: 'link', qr_size: 120 },
qrcode: { icon: 'fas fa-qrcode', icon_dark: '', icon_light: '', text: '二维码', desc: '扫码继续浏览', link: '', hover: '', action_mode: 'qrcode', qr_size: 120 },
service_qq: { icon: 'fab fa-qq', icon_dark: '', icon_light: '', text: 'QQ', desc: 'QQ 联系', link: '', hover: '', action_mode: 'link', qr_size: 120 },
service_wechat: { icon: 'fab fa-weixin', icon_dark: '', icon_light: '', text: '微信', desc: '扫码添加微信', link: '', hover: '', action_mode: 'link', qr_size: 120 },
pay_vip: { icon: 'fas fa-gem', icon_dark: '', icon_light: '', text: '会员', desc: '开通/升级会员弹窗', link: '', hover: '', action_mode: 'link', qr_size: 120 },
add: { icon: 'fas fa-plus', icon_dark: '', icon_light: '', text: '发布', desc: '发布内容', link: '', hover: '', action_mode: 'link', qr_size: 120 },
shop_cart: { icon: 'fas fa-shopping-cart', icon_dark: '', icon_light: '', text: '购物车', desc: '购物车入口', link: '', hover: '', action_mode: 'link', qr_size: 120 },
}
return map[type] || map.custom
}
function handleTypeChange(type) {
const defaults = applyTypeDefaults(type)
editingItem.icon = defaults.icon
editingItem.icon_dark = defaults.icon_dark
editingItem.icon_light = defaults.icon_light
editingItem.action_mode = defaults.action_mode
editingItem.qr_size = defaults.qr_size
if (!editingItem.text) editingItem.text = defaults.text
if (!editingItem.desc) editingItem.desc = defaults.desc
if (!supportsHover.value) editingItem.hover = ''
if (type === 'service_qq' && editingItem.action_mode === 'qrcode') editingItem.qq = ''
if (type === 'service_wechat' && editingItem.action_mode === 'qrcode') editingItem.link = ''
if (['service_qq', 'service_wechat'].includes(type) && editingItem.action_mode === 'qrcode') editingItem.desc = ''
if (!needsLink.value && !['service_qq', 'service_wechat'].includes(type)) editingItem.link = ''
// VIP float always uses modal path; never keep a stale custom link/hover.
if (type === 'pay_vip') {
editingItem.link = ''
editingItem.hover = ''
if (!editingItem.icon || editingItem.icon.includes('fa-diamond')) {
editingItem.icon = 'fas fa-gem'
}
}
}
function addItem() {
Object.assign(editingItem, createDefaultItem(), {
id: `float_${Date.now()}`,
sort_order: form.items.length,
})
editingIndex.value = -1
showModal.value = true
}
function appendAddItem() {
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: '',
icon: 'fas fa-pen',
url: '',
})
}
function normalizeAddItems() {
editingItem.add_items = editingItem.add_items.map((item, index) => ({
...item,
id: item.id || `add_${Date.now()}_${index}`,
}))
}
function applyAddPreset(key) {
if (isAddPresetExists(key)) {
message.info('该预设已存在,无需重复添加')
return
}
const presetFactories = {
post: () => ({ label: '写文章', icon: 'fas fa-pen-nib', url: `${siteUrl}/submit/` }),
submit: () => ({ label: '投稿页', icon: 'fas fa-paper-plane', url: `${siteUrl}/submit/` }),
profile: () => ({ label: '创作中心', icon: 'fas fa-user-edit', url: `${siteUrl}/user/` }),
}
const factory = presetFactories[key]
if (!factory) return
const preset = factory()
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
...preset,
})
}
function isAddPresetExists(key) {
const preset = getAddPresetValue(key)
if (!preset) return false
return editingItem.add_items.some((item) => {
const itemLabel = (item.label || '').trim()
const itemUrl = normalizeUrl(item.url || '')
return itemLabel === preset.label || itemUrl === normalizeUrl(preset.url)
})
}
function getAddPresetValue(key) {
const presetFactories = {
post: () => ({ label: '写文章', icon: 'fas fa-pen-nib', url: `${siteUrl}/submit/` }),
submit: () => ({ label: '投稿页', icon: 'fas fa-paper-plane', url: `${siteUrl}/submit/` }),
profile: () => ({ label: '创作中心', icon: 'fas fa-user-edit', url: `${siteUrl}/user/` }),
}
return presetFactories[key] ? presetFactories[key]() : null
}
function normalizeUrl(url) {
return String(url || '').replace(/\/$/, '')
}
function removeAddItem(index) {
editingItem.add_items.splice(index, 1)
}
function editItem(index) {
Object.assign(editingItem, createDefaultItem(), form.items[index])
editingIndex.value = index
showModal.value = true
}
function resetSortOrder() {
form.items.splice(0, form.items.length, ...form.items.map((item, index) => ({
...item,
sort_order: index,
})))
}
function getItemSummary(row) {
if (row.type === 'service_qq') return row.action_mode === 'qrcode' ? (row.qrcode_img || '未设置 QQ 二维码') : (row.link || row.qq || '未设置 QQ 号码')
if (row.type === 'service_wechat') return row.action_mode === 'qrcode' ? (row.wechat_img || '未设置微信二维码') : (row.link || '未设置跳转链接')
if (row.type === 'qrcode') return row.desc || '展示当前页面二维码'
if (row.type === 'pay_vip') return '点击打开会员开通弹窗(无需填写链接)'
if (row.hover) return '使用 HTML 悬浮层内容'
return row.link || row.desc || '未设置说明'
}
function saveItem() {
if (editingItem.type !== 'theme_mode' && !editingItem.icon) {
message.warning('请填写图标')
return
}
if (!editingItem.text) {
message.warning('请填写按钮标题')
return
}
if (editingItem.type === 'service_qq' && editingItem.action_mode !== 'qrcode' && !editingItem.qq && !editingItem.link) {
message.warning('请填写 QQ 号码或跳转链接')
return
}
if (editingItem.type === 'service_qq' && editingItem.action_mode === 'qrcode' && !editingItem.qrcode_img) {
message.warning('请填写 QQ 二维码地址')
return
}
if (editingItem.type === 'service_wechat' && editingItem.action_mode === 'qrcode' && !editingItem.wechat_img) {
message.warning('请填写微信二维码地址')
return
}
if (editingItem.type === 'service_wechat' && editingItem.action_mode !== 'qrcode' && !editingItem.link) {
message.warning('请填写微信跳转链接')
return
}
if (needsLink.value && !editingItem.link) {
message.warning('请填写链接地址')
return
}
const item = {
id: editingItem.id || `float_${Date.now()}`,
type: editingItem.type,
icon: editingItem.type === 'theme_mode' ? 'fas fa-sun' : (editingItem.type === 'pay_vip' ? (editingItem.icon || 'fas fa-gem') : editingItem.icon),
icon_dark: '',
icon_light: '',
text: editingItem.text,
desc: isServiceQrcode.value ? '' : editingItem.desc,
action_mode: supportsActionMode.value ? editingItem.action_mode : 'link',
qq: editingItem.type === 'service_qq' && editingItem.action_mode !== 'qrcode' ? editingItem.qq : '',
qrcode_img: editingItem.type === 'service_qq' && editingItem.action_mode === 'qrcode' ? editingItem.qrcode_img : '',
wechat_img: editingItem.type === 'service_wechat' && editingItem.action_mode === 'qrcode' ? editingItem.wechat_img : '',
// pay_vip never stores a custom link — front-end always opens the VIP modal.
link: editingItem.type === 'pay_vip'
? ''
: ((needsLink.value || (editingItem.type === 'service_wechat' && editingItem.action_mode !== 'qrcode') || (editingItem.type === 'service_qq' && editingItem.action_mode !== 'qrcode' && !!editingItem.link)) ? editingItem.link : ''),
add_items: editingItem.type === 'add' ? editingItem.add_items.filter(subItem => subItem.label || subItem.url).map((subItem, idx) => ({
id: subItem.id || `add_${idx}`,
label: subItem.label || '',
icon: subItem.icon || 'fas fa-pen',
url: subItem.url || '',
})) : [],
hover: editingItem.type === 'pay_vip' ? '' : (supportsHover.value ? (editingItem.hover || '') : ''),
qr_size: editingItem.type === 'qrcode' ? (editingItem.qr_size || 120) : 120,
hover_width: supportsHover.value ? (editingItem.hover_width || 200) : 200,
pc_s: editingItem.pc_s,
m_s: editingItem.m_s,
color: editingItem.color || '',
bg: editingItem.bg || '',
enabled: editingItem.enabled,
sort_order: editingIndex.value >= 0 ? form.items[editingIndex.value].sort_order : form.items.length,
}
if (editingIndex.value >= 0) {
form.items.splice(editingIndex.value, 1, item)
} else {
form.items.push(item)
}
resetSortOrder()
showModal.value = false
message.success(editingIndex.value >= 0 ? '已更新' : '已添加')
}
function deleteItem(index) {
dialog.warning({
title: '确认删除',
content: `确定删除「${form.items[index].text || typeLabelMap[form.items[index].type] || '按钮'}」吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.items.splice(index, 1)
resetSortOrder()
message.success('已删除')
},
})
}
async function loadSettings() {
loading.value = true
try {
const res = await api.getFloatingButtons()
if (res.code === 'success' && res.data) {
const data = res.data
form.enabled = data.enabled !== false
form.style = data.style || 'round'
form.position = data.position || 'bottom'
form.scroll_hide = !!data.scroll_hide
form.scroll_down_hide = data.scroll_down_hide !== false
form.filter_blur = Array.isArray(data.filter_blur) ? data.filter_blur : []
form.items = (data.items || []).map((item, index) => ({
...createDefaultItem(),
...item,
icon: item.type === 'theme_mode' ? 'fas fa-sun' : (item.icon || ''),
color: item.color || null,
bg: item.bg || null,
icon_dark: '',
icon_light: '',
action_mode: item.action_mode || (['service_qq', 'service_wechat'].includes(item.type) ? 'link' : 'link'),
qrcode_img: item.qrcode_img || '',
add_items: Array.isArray(item.add_items) ? item.add_items : [],
qr_size: item.qr_size || 120,
sort_order: item.sort_order ?? index,
}))
resetSortOrder()
}
} catch (error) {
console.error('[FloatingButtons] load failed:', error)
message.error('加载配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
enabled: form.enabled,
style: form.style,
position: form.position,
scroll_hide: form.scroll_hide,
scroll_down_hide: form.scroll_down_hide,
filter_blur: form.filter_blur,
items: form.items.map((item, index) => ({
...item,
icon: item.type === 'theme_mode' ? 'fas fa-sun' : (item.icon || ''),
icon_dark: '',
icon_light: '',
color: item.color || '',
bg: item.bg || '',
sort_order: index,
})),
}
const res = await api.saveFloatingButtons(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (error) {
console.error('[FloatingButtons] save failed:', error)
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(loadSettings)
</script>
<style scoped>
.icon-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
}
.theme-icon-preview {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--n-text-color-3);
font-size: 13px;
}
.theme-icon-preview span,
.theme-icon-preview-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.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;
}
.float-btn-sort {
display: flex;
flex-direction: column;
gap: 12px;
}
.float-btn-sort__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--n-border-color);
border-radius: 14px;
background: var(--n-card-color);
}
.float-btn-sort__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.float-btn-sort__handle {
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: var(--n-text-color-3);
cursor: grab;
}
.float-btn-sort__handle:active {
cursor: grabbing;
}
.float-btn-sort__icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(136, 171, 218, 0.12);
color: var(--n-text-color-2);
}
.float-btn-sort__meta {
min-width: 0;
flex: 1;
}
.float-btn-sort__title-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.float-btn-sort__desc {
font-size: 13px;
color: var(--n-text-color-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.float-btn-sort__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.float-btn-sort__ghost {
opacity: 0.45;
}
.add-items-editor {
margin-bottom: 16px;
}
.add-items-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.add-items-editor__head-actions {
display: flex;
gap: 8px;
}
.add-items-editor__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-items-editor__row {
display: grid;
grid-template-columns: auto 1fr 1fr 1.4fr auto;
gap: 10px;
align-items: center;
}
.add-items-editor__drag {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: rgba(136, 171, 218, 0.12);
color: var(--n-text-color-3);
cursor: grab;
}
.add-items-editor__drag:active {
cursor: grabbing;
}
.add-items-editor__ghost {
opacity: 0.45;
}
.add-items-preview {
margin-top: 14px;
padding: 14px;
border: 1px dashed var(--n-border-color);
border-radius: 14px;
background: rgba(136, 171, 218, 0.04);
}
.add-items-preview__title {
font-size: 13px;
color: var(--n-text-color-3);
margin-bottom: 10px;
}
.add-items-preview__panel {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.add-items-preview__panel:first-of-type {
margin-top: 0;
}
.add-items-preview__mode {
width: 40px;
flex-shrink: 0;
font-size: 12px;
color: var(--n-text-color-3);
}
.add-items-preview__trigger {
width: 42px;
height: 42px;
border-radius: 12px;
background: #fff;
border: 1px solid var(--n-border-color);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--n-text-color-2);
box-shadow: 0 8px 20px rgba(17, 24, 39, 0.08);
}
.add-items-preview__trigger--dark {
background: #232734;
border-color: rgba(255, 255, 255, 0.08);
color: #d8dee9;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
}
.add-items-preview__menu {
flex: 1;
min-width: 0;
padding: 10px;
border-radius: 14px;
background: #fff;
border: 1px solid var(--n-border-color);
}
.add-items-preview__menu--dark {
background: #232734;
border-color: rgba(255, 255, 255, 0.08);
}
.add-items-preview__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.add-items-preview__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(136, 171, 218, 0.06);
color: var(--n-text-color-2);
text-decoration: none;
}
.add-items-preview__item i {
width: 18px;
text-align: center;
color: var(--n-primary-color);
}
.add-items-preview__item--dark {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
.add-items-preview__item--dark i {
color: #9fc0ec;
}
.add-items-preview__empty {
font-size: 13px;
color: var(--n-text-color-3);
padding: 10px 12px;
}
.add-items-preview__empty--dark {
color: rgba(229, 231, 235, 0.72);
}
</style>