feat(float): rebuild right floating buttons 1:1 with reference theme

- Storage: aksrt_floating_buttons now uses reference float_btn shape
  (fixed 8 sortable types, more-buttons group); single source of
  defaults/sanitize in AKSRT\Settings\FloatButtons
- Admin SPA: CSF-parity page split into components/floating/*
- Frontend: aksrt_float_right() on wp_footer mirrors zib_float_right()
  (wp_is_mobile, PC tooltip, toggle-theme, ontop/scrollTopTo, qrcode,
  wechat/qq, pay_vip, add, more)
- CSS: floating-buttons.css rewritten from reference rules; legacy float
  rules removed from main/part/shell/overlay-primitives
- Remove dead code: reorder route/API, get_floating_buttons_items,
  floatingSchema, floatActionsId, float-theme-toggle bindings,
  common/floating.css, aksrt-runtime-win.php, .aksrt-float-btn
This commit is contained in:
2026-09-26 20:44:50 +08:00
parent 7f60071460
commit 6f2adfaef1
32 changed files with 1338 additions and 3111 deletions
@@ -0,0 +1,84 @@
<template>
<n-form label-placement="left" label-width="110" class="aksrt-form float-common-fields">
<n-form-item label="PC端显示" :show-feedback="false">
<n-switch :value="!!modelValue.pc_s" @update:value="update('pc_s', $event)" />
</n-form-item>
<n-form-item label="移动端显示" :show-feedback="false">
<n-switch :value="!!modelValue.m_s" @update:value="update('m_s', $event)" />
</n-form-item>
<n-form-item label="颜色" :show-feedback="false">
<div class="float-common-fields__colors">
<div class="float-common-fields__color">
<span>自定义图标颜色</span>
<n-color-picker
:value="modelValue.color?.color || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
clearable
@update:value="updateColor('color', $event)"
/>
</div>
<div class="float-common-fields__color">
<span>自定义背景颜色</span>
<n-color-picker
:value="modelValue.color?.bg || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
clearable
@update:value="updateColor('bg', $event)"
/>
</div>
</div>
</n-form-item>
<div class="float-common-fields__desc">自定义颜色请注意日间、夜间模式的适配</div>
</n-form>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
function update(key, value) {
emit('update:modelValue', { ...props.modelValue, [key]: value })
}
function updateColor(key, value) {
emit('update:modelValue', {
...props.modelValue,
color: { ...(props.modelValue.color || { color: '', bg: '' }), [key]: value || '' },
})
}
</script>
<style scoped>
.float-common-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.float-common-fields__colors {
display: flex;
gap: 16px;
flex-wrap: wrap;
width: 100%;
}
.float-common-fields__color {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
flex: 1;
font-size: 12px;
color: var(--n-text-color-3);
}
.float-common-fields__desc {
font-size: 12px;
color: var(--n-text-color-3);
padding-left: 110px;
}
</style>
@@ -0,0 +1,169 @@
<template>
<div class="float-btn-panel">
<div class="float-btn-panel__head" @click="expanded = !expanded">
<button type="button" class="float-btn-panel__handle" aria-label="拖拽排序" @click.stop>
<i class="fa fas fa-arrows-alt-v" />
</button>
<strong class="float-btn-panel__title">{{ meta.title }}</strong>
<template v-if="modelValue.type !== 'more'">
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
</template>
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
<i class="fa fas float-btn-panel__arrow" :class="expanded ? 'fa-angle-up' : 'fa-angle-down'" />
</div>
<div v-if="meta.warn" class="float-btn-panel__warn">
<i class="fa fas fa-info-circle" /> {{ meta.warn }}
</div>
<div v-show="expanded" class="float-btn-panel__body">
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
<FloatMoreButtonsEditor
v-if="modelValue.type === 'more'"
:model-value="modelValue.buttons"
@update:model-value="patch({ buttons: $event })"
/>
<template v-else>
<FloatBtnCommonFields :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" />
<n-form label-placement="left" label-width="110" class="aksrt-form float-btn-panel__extra">
<n-form-item v-if="modelValue.type === 'qrcode'" label="一句话简介">
<n-input :value="modelValue.desc" @update:value="patch({ desc: $event })" />
</n-form-item>
<template v-if="modelValue.type === 'add'">
<n-form-item label="选择按钮">
<div class="float-btn-panel__block">
<n-select
:value="modelValue.btns"
:options="ADD_BTN_OPTIONS"
multiple
placeholder="请选择按钮"
@update:value="patch({ btns: $event })"
/>
<div class="float-btn-panel__desc">选择并排序按钮,未开启的功能不会显示</div>
</div>
</n-form-item>
<n-form-item label="按钮图标">
<FaIconPicker v-model="addIcon" placeholder="选择图标" />
</n-form-item>
</template>
<n-form-item v-if="modelValue.type === 'service_qq'" label="QQ号码">
<n-input :value="modelValue.qq" placeholder="仅填写数字" @update:value="patch({ qq: $event })" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'service_wechat'" label="微信二维码">
<AksMediaField
:model-value="modelValue.wechat_img"
placeholder="选择二维码图片"
@update:model-value="patch({ wechat_img: $event })"
/>
</n-form-item>
</n-form>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import AksMediaField from '@/components/form/AksMediaField.vue'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
import { ADD_BTN_OPTIONS, FLOAT_BTN_META } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const expanded = ref(false)
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
const addIcon = computed({
get: () => props.modelValue.icon || '',
set: (value) => patch({ icon: value || '' }),
})
function patch(partial) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
</script>
<style scoped>
.float-btn-panel {
border: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
border-radius: 8px;
background: var(--n-card-color, #fff);
}
.float-btn-panel__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
}
.float-btn-panel__handle {
width: 26px;
height: 26px;
border: 0;
background: transparent;
color: var(--n-text-color-3, #999);
cursor: grab;
}
.float-btn-panel__handle:active {
cursor: grabbing;
}
.float-btn-panel__title {
font-size: 14px;
}
.float-btn-panel__arrow {
margin-left: auto;
color: var(--n-text-color-3, #999);
}
.float-btn-panel__warn {
padding: 0 14px 10px 48px;
font-size: 12px;
color: #f0a020;
}
.float-btn-panel__body {
padding: 12px 16px 16px;
border-top: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
}
.float-btn-panel__subtitle {
font-weight: 600;
font-size: 13px;
margin-bottom: 12px;
}
.float-btn-panel__extra {
margin-top: 12px;
}
.float-btn-panel__block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.float-btn-panel__desc {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
</style>
@@ -0,0 +1,158 @@
<template>
<div class="float-more-editor">
<n-collapse :default-expanded-names="[]" accordion>
<n-collapse-item
v-for="(btn, index) in modelValue"
:key="index"
:name="index"
>
<template #header>
<span class="float-more-editor__head">
<IconValue v-if="btn.icon" :value="btn.icon" />
<span>{{ btn.desc || `按钮 ${index + 1}` }}</span>
</span>
</template>
<template #header-extra>
<n-button
size="tiny"
quaternary
type="error"
:disabled="modelValue.length <= 1"
@click.stop="remove(index)"
>
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
</template>
<n-form label-placement="left" label-width="110" class="aksrt-form">
<n-form-item label="按钮简介">
<n-input :value="btn.desc" @update:value="patch(index, { desc: $event })" />
</n-form-item>
<n-form-item label="按钮图标">
<FaIconPicker :model-value="btn.icon" placeholder="选择图标" @update:model-value="patch(index, { icon: $event || '' })" />
</n-form-item>
<n-form-item v-if="!btn.hover" label="按钮链接">
<div class="float-more-editor__link">
<n-input
:value="btn.link.url"
placeholder="https://"
@update:value="patch(index, { link: { ...btn.link, url: $event } })"
/>
<n-checkbox
:checked="btn.link.target === '_blank'"
@update:checked="patch(index, { link: { ...btn.link, target: $event ? '_blank' : '' } })"
>
新窗口打开
</n-checkbox>
</div>
</n-form-item>
<n-form-item label="悬浮内容">
<div class="float-more-editor__block">
<div class="float-more-editor__subtitle">鼠标移到此处时显示内容</div>
<n-input
type="textarea"
:value="btn.hover"
:autosize="{ minRows: 2, maxRows: 8 }"
@update:value="patch(index, { hover: $event })"
/>
<div class="float-more-editor__desc">设置此处后,按钮链接将失效<br>支持HTML代码,请注意代码规范及标签闭合</div>
</div>
</n-form-item>
<n-form-item v-if="btn.hover" label="悬浮内容宽度">
<div class="float-more-editor__block">
<n-input-number
:value="btn.hover_width"
:min="50"
:max="500"
:step="10"
style="width: 180px;"
@update:value="patch(index, { hover_width: $event ?? 200 })"
>
<template #suffix>PX</template>
</n-input-number>
<div class="float-more-editor__desc">移动端宽度最大240px</div>
</div>
</n-form-item>
</n-form>
<FloatBtnCommonFields :model-value="btn" @update:model-value="replace(index, $event)" />
</n-collapse-item>
</n-collapse>
<div class="float-more-editor__footer">
<n-button size="small" type="primary" @click="add">
<template #icon><i class="fa fas fa-plus" /></template>
添加按钮
</n-button>
<span class="float-more-editor__desc">此处内容至少保留一个,如一个都无需显示请关闭PC端和移动端显示即可</span>
</div>
</div>
</template>
<script setup>
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import { defaultMoreButton } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Array, required: true },
})
const emit = defineEmits(['update:modelValue'])
function replace(index, value) {
const next = props.modelValue.slice()
next.splice(index, 1, value)
emit('update:modelValue', next)
}
function patch(index, partial) {
replace(index, { ...props.modelValue[index], ...partial })
}
function add() {
emit('update:modelValue', [...props.modelValue, defaultMoreButton()])
}
function remove(index) {
if (props.modelValue.length <= 1) return
const next = props.modelValue.slice()
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.float-more-editor__head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.float-more-editor__link {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.float-more-editor__block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.float-more-editor__subtitle,
.float-more-editor__desc {
font-size: 12px;
color: var(--n-text-color-3);
}
.float-more-editor__footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,91 @@
/**
* 右侧悬浮按钮:类型元数据与默认值(与 src/Settings/FloatButtons.php 保持一致)。
*/
export const emptyColor = () => ({ color: '', bg: '' })
/** 类型元数据:面板标题、折叠标题、黄色提示。 */
export const FLOAT_BTN_META = {
back_top: { title: '返回顶部', panel: '返回顶部', warn: '此按钮只能放置在最顶部或者最底部!' },
qrcode: { title: '当前页面二维码', panel: '当前页面二维码' },
add: { title: 'Add发布内容', panel: '发布投稿', warn: '此按钮尽量放在上方,否则会显示遮挡' },
theme_mode: { title: '日夜主题切换', panel: '日夜主题切换' },
pay_vip: { title: 'VIP购买', panel: 'VIP购买' },
service_qq: { title: 'QQ客服', panel: 'QQ客服' },
service_wechat: { title: '微信客服', panel: '微信客服' },
more: { title: '更多按钮', panel: '添加更多自定义按钮' },
}
/** 发布按钮可选项。 */
export const ADD_BTN_OPTIONS = [{ label: '文章投稿', value: 'post' }]
export function defaultMoreButton() {
return {
desc: '',
icon: 'fa fas fa-heart',
link: { url: '', target: '' },
hover: '',
hover_width: 200,
pc_s: false,
m_s: false,
color: { color: '', bg: 'rgba(255, 111, 6, 0.2)' },
}
}
export function defaultItems() {
return [
{ type: 'more', buttons: [defaultMoreButton()] },
{ type: 'pay_vip', pc_s: true, m_s: true, color: { color: '#f2c97d', bg: 'rgba(62,62,67,0.9)' } },
{ type: 'add', pc_s: true, m_s: false, color: emptyColor(), btns: ['post'], icon: 'fa fas fa-pencil-alt' },
{ type: 'service_qq', pc_s: false, m_s: false, color: emptyColor(), qq: '' },
{ type: 'service_wechat', pc_s: true, m_s: true, color: emptyColor(), wechat_img: '' },
{ type: 'theme_mode', pc_s: false, m_s: false, color: emptyColor() },
{ type: 'qrcode', pc_s: true, m_s: false, color: emptyColor(), desc: '在手机上浏览此页面' },
{ type: 'back_top', pc_s: true, m_s: true, color: emptyColor() },
]
}
export function defaultSettings() {
return {
style: 'round',
position: 'bottom',
filter_blur: ['m_s'],
scroll_hide: false,
scroll_down_hide: true,
items: defaultItems(),
}
}
/** 规范化颜色组:确保 color/bg 都为字符串。 */
export function normalizeColor(color) {
const c = color && typeof color === 'object' ? color : {}
return { color: c.color || '', bg: c.bg || '' }
}
/** 用默认值补齐服务端返回的按钮(按服务端顺序)。 */
export function normalizeItems(items) {
const defaults = Object.fromEntries(defaultItems().map((item) => [item.type, item]))
const source = Array.isArray(items) && items.length ? items : defaultItems()
const out = []
const seen = new Set()
for (const raw of source) {
const base = defaults[raw?.type]
if (!base || seen.has(raw.type)) continue
seen.add(raw.type)
if (raw.type === 'more') {
const buttons = (Array.isArray(raw.buttons) && raw.buttons.length ? raw.buttons : base.buttons).map((btn) => ({
...defaultMoreButton(),
...btn,
link: { url: btn?.link?.url || '', target: btn?.link?.target || '' },
color: normalizeColor(btn?.color),
}))
out.push({ type: 'more', buttons })
continue
}
out.push({ ...base, ...raw, color: normalizeColor(raw.color) })
}
for (const item of defaultItems()) {
if (!seen.has(item.type)) out.push(item)
}
return out
}