Always open VIP modal for float pay_vip, drop forced admin link, normalize gem icon, harden pay-vip click binding and SPA form UX.
1020 lines
35 KiB
Vue
1020 lines
35 KiB
Vue
<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>
|