Zibll 主线收口:统一 single 并清退 compat 资源路径

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:30:49 +08:00
co-authored by Claude
parent 0a6f97af21
commit 09f1eec909
26 changed files with 158 additions and 1325 deletions
@@ -2,7 +2,7 @@
<div class="aksrt-page-container content-page">
<PageHeader
title="首页配置"
desc="配置首页内容模型、模块编排、分类 Tab、卡片样式和交互方式。"
desc="按 Zibll 首页主线配置内容模型、Tab、主列表、卡片样式与基础交互。"
>
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
@@ -10,65 +10,39 @@
</PageHeader>
<n-tabs v-model:value="activeTab" type="line" animated>
<!-- Module Workstation Tab -->
<n-tab-pane name="modules" tab="模块编排">
<div class="aksrt-homepage__workstation">
<div class="aksrt-homepage__canvas-panel">
<ModuleCanvas
:modules="homeModules"
:selected-index="selectedModuleIndex"
@select="selectedModuleIndex = $event"
@add="handleAddModule"
@remove="handleRemoveModule"
@copy="handleCopyModule"
@toggle="handleToggleModule"
@move="handleMoveModule"
@pick-preset="showPresetPicker = true"
@export="handleExportModules"
@import="showImportDialog = true"
/>
</div>
<div class="aksrt-homepage__inspector-panel">
<ModuleInspector
:module="selectedModule"
:module-index="selectedModuleIndex"
@update="handleModuleUpdate"
/>
</div>
</div>
</n-tab-pane>
<!-- Main Stream & Tabs Tab -->
<n-tab-pane name="stream" tab="主列表 & Tab">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Content Model -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>内容模型</strong></template>
<template #header><strong>首页模型</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="首页内容模型">
<n-radio-group v-model:value="form.homepage_content_model">
<n-space vertical>
<n-radio value="mixed">
<n-text>混合模式</n-text>
<n-text depth="3" style="margin-left:8px;">文章 + 资源(默认)</n-text>
<n-text>混合内容流</n-text>
<n-text depth="3" style="margin-left:8px;">文章 + 资源,贴近 Zibll 首页主流呈现。</n-text>
</n-radio>
<n-radio value="post">
<n-text>纯文章</n-text>
<n-text depth="3" style="margin-left:8px;">仅标准文章</n-text>
<n-text>纯文章流</n-text>
<n-text depth="3" style="margin-left:8px;">仅标准文章,适合纯资讯站点。</n-text>
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="首页说明">
<n-alert type="info" :show-icon="false">
当前首页已切换为固定骨架模式,不再使用旧版自由模块画布、导入导出预设或历史模块桥接。
</n-alert>
</n-form-item>
</n-form>
</n-card>
<!-- Main Stream -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>主列表(最新发布)</strong></template>
<template #header><strong>主列表</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="启用主列表">
<n-switch v-model:value="form.home_posts_list_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后首页将不显示文章列表(仅显示模块)。</span></template>
<template #feedback><span class="aksrt-hint-text">首页主内容区保留 Zibll 式内容流,不建议关闭。</span></template>
</n-form-item>
<n-form-item label="区块标题">
<n-input v-model:value="form.home_list_title" placeholder="最新发布" />
@@ -98,7 +72,6 @@
</n-form>
</n-card>
<!-- Exclusions -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>排除规则</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
@@ -109,7 +82,7 @@
placeholder="逗号分隔的文章 ID,如 12,34,56"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表和 Tab 查询均排除这些文章。</span></template>
<template #feedback><span class="aksrt-hint-text">主列表和首页 Tab 查询均排除这些文章。</span></template>
</n-form-item>
<n-form-item label="排除分类 ID">
<n-input
@@ -118,12 +91,11 @@
placeholder="逗号分隔的分类 ID,如 5,8"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类。Tab 不能绑定已排除的分类。</span></template>
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类,首页 Tab 不能绑定已排除分类。</span></template>
</n-form-item>
</n-form>
</n-card>
<!-- Tabs -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
@@ -141,7 +113,7 @@
</n-form>
<div v-if="form.homepage_tabs.length === 0" class="homepage-settings__empty-tabs">
<n-text depth="3">暂无 Tab 配置,点击"添加 Tab"添加。</n-text>
<n-text depth="3">暂无 Tab 配置,点击“添加 Tab”创建首页分类切换。</n-text>
</div>
<div v-for="(tab, index) in form.homepage_tabs" :key="index" class="homepage-settings__tab-row">
@@ -177,8 +149,7 @@
</n-spin>
</n-tab-pane>
<!-- Cards & Sidebar Tab -->
<n-tab-pane name="cards" tab="卡片 & 侧栏">
<n-tab-pane name="cards" tab="卡片 & 交互">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>卡片样式</strong></template>
@@ -210,74 +181,36 @@
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>侧边栏</strong></template>
<template #header><strong>交互设置</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排序栏 AJAX">
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后排序链接使用整页跳转。</span></template>
</n-form-item>
<n-form-item label="Tab 吸顶">
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
</n-form-item>
<n-form-item label="迷你侧栏列表">
<n-switch v-model:value="form.homepage_sidebar_mini_enabled" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>交互设置</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排序栏 AJAX">
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后排序链接将使用整页跳转。</span></template>
</n-form-item>
<n-form-item label="Tab 吸顶">
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
</n-form-item>
</n-form>
</n-card>
<!-- Module Status -->
<n-card class="aksrt-section-card">
<template #header><strong>模块状态</strong></template>
<n-form label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="Hero 首屏">
<n-tag type="info" size="small">在 Hero 页面管理</n-tag>
<n-text depth="3" style="margin-left:8px;">首页 Hero 开关已从首页设置中移出。</n-text>
</n-form-item>
<n-form-item label="Banner 轮播">
<n-tag type="info" size="small">在 Banner 页面管理</n-tag>
<n-text depth="3" style="margin-left:8px;">首页 Banner 开关已从首页设置中移出。</n-text>
</n-form-item>
</n-form>
<template #header><strong>固定骨架说明</strong></template>
<n-space vertical>
<n-alert type="warning" :show-icon="false">
Hero 与 Banner 已作为固定首页骨架的一部分维护,不再由旧版首页模块编排驱动。
</n-alert>
<n-alert type="info" :show-icon="false">
如需调整首页首屏运营区,请前往对应 Hero / Banner 管理页,而不是返回旧模块工作台。
</n-alert>
</n-space>
</n-card>
</n-spin>
</n-tab-pane>
</n-tabs>
<!-- Preset Picker Dialog -->
<ModulePresetPicker
:show="showPresetPicker"
@close="showPresetPicker = false"
@apply="handleApplyPreset"
/>
<!-- Import Dialog -->
<n-modal
v-model:show="showImportDialog"
title="导入模块配置"
preset="dialog"
style="width: 480px;"
:show-icon="false"
>
<n-input
v-model:value="importJson"
type="textarea"
:rows="8"
placeholder="粘贴导出的 JSON 配置..."
/>
<template #action>
<n-space>
<n-button @click="showImportDialog = false">取消</n-button>
<n-button type="primary" :disabled="!importJson.trim()" @click="handleImportModules">导入</n-button>
</n-space>
</template>
</n-modal>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</div>
@@ -285,34 +218,16 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ModuleCanvas from '@/components/homepage/ModuleCanvas.vue'
import ModuleInspector from '@/components/homepage/ModuleInspector.vue'
import ModulePresetPicker from '@/components/homepage/ModulePresetPicker.vue'
import { makeDefaultModule } from '@/utils/home-module-schema'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('modules')
const activeTab = ref('stream')
const categoryOptions = ref([])
const showPresetPicker = ref(false)
const showImportDialog = ref(false)
const importJson = ref('')
// Module workstation state
const homeModules = ref([])
const selectedModuleIndex = ref(-1)
const selectedModule = computed(() => {
if (selectedModuleIndex.value >= 0 && selectedModuleIndex.value < homeModules.value.length) {
return homeModules.value[selectedModuleIndex.value]
}
return null
})
const allSortOptions = [
{ label: '最近更新', value: 'modified' },
@@ -361,116 +276,6 @@ const excludeCategoryIdsText = computed({
},
})
// Module operations
function handleAddModule(type) {
const newMod = makeDefaultModule(type)
if (newMod) {
newMod.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
newMod.enabled = true
newMod.title = ''
homeModules.value.push(newMod)
selectedModuleIndex.value = homeModules.value.length - 1
}
}
function handleRemoveModule(index) {
homeModules.value.splice(index, 1)
if (selectedModuleIndex.value >= homeModules.value.length) {
selectedModuleIndex.value = homeModules.value.length - 1
}
}
function handleCopyModule(index) {
const src = homeModules.value[index]
if (!src) return
const copy = JSON.parse(JSON.stringify(src))
copy.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
homeModules.value.splice(index + 1, 0, copy)
selectedModuleIndex.value = index + 1
}
function handleToggleModule(index, val) {
if (homeModules.value[index]) {
homeModules.value[index].enabled = val
}
}
function handleMoveModule(index, direction) {
const newIndex = index + direction
if (newIndex < 0 || newIndex >= homeModules.value.length) return
const temp = homeModules.value[index]
homeModules.value[index] = homeModules.value[newIndex]
homeModules.value[newIndex] = temp
// Trigger reactivity
homeModules.value = [...homeModules.value]
selectedModuleIndex.value = newIndex
}
function handleModuleUpdate(index, key, value) {
if (homeModules.value[index]) {
if (key === 'config') {
homeModules.value[index].config = value
} else {
homeModules.value[index][key] = value
}
// Trigger reactivity
homeModules.value = [...homeModules.value]
}
}
function handleApplyPreset(preset) {
homeModules.value = preset.modules.map((m, i) => ({
id: 'mod_' + Date.now() + '_' + i,
type: m.type,
config: JSON.parse(JSON.stringify(m.config || {})),
enabled: true,
title: '',
}))
selectedModuleIndex.value = -1
showPresetPicker.value = false
message.success(`已应用预设「${preset.label}」,共 ${preset.modules.length} 个模块`)
}
function handleExportModules() {
const json = JSON.stringify(homeModules.value, null, 2)
navigator.clipboard.writeText(json).then(() => {
message.success('模块配置已复制到剪贴板')
}).catch(() => {
// Fallback: show in a dialog
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'homepage-modules.json'
a.click()
URL.revokeObjectURL(url)
message.success('模块配置已导出')
})
}
function handleImportModules() {
try {
const parsed = JSON.parse(importJson.value)
if (!Array.isArray(parsed)) {
throw new Error('JSON 必须是数组格式')
}
homeModules.value = parsed.map((m, i) => ({
id: m.id || 'mod_' + Date.now() + '_' + i,
type: m.type,
config: m.config || {},
enabled: m.enabled !== false,
title: m.title || '',
}))
selectedModuleIndex.value = -1
showImportDialog.value = false
importJson.value = ''
message.success(`已导入 ${homeModules.value.length} 个模块`)
} catch (err) {
message.error('导入失败: ' + (err.message || 'JSON 格式错误'))
}
}
// Tab operations
function addTab() {
form.homepage_tabs.push({ term_id: 0, title: '', orderby: 'date', enabled: true })
}
@@ -483,7 +288,6 @@ function updateTab(index, field, value) {
form.homepage_tabs[index][field] = value
}
// Data loading
async function loadCategories() {
try {
const res = await api.getHomepageCategories()
@@ -492,7 +296,9 @@ async function loadCategories() {
label: `${c.name} (${c.count})`,
value: c.term_id,
}))
} catch { /* non-critical */ }
} catch {
// non-critical
}
}
async function loadSettings() {
@@ -522,15 +328,6 @@ async function loadSettings() {
form.home_exclude_category_ids = Array.isArray(d.home_exclude_category_ids) ? d.home_exclude_category_ids : []
form.homepage_tab_posts_per_page = d.homepage_tab_posts_per_page || 6
form.homepage_tab_limit = d.homepage_tab_limit || 4
// Load modules
homeModules.value = Array.isArray(d.homepage_modules) ? d.homepage_modules.map((m, i) => ({
id: m.id || 'mod_' + i,
type: m.type,
config: m.config || {},
enabled: m.enabled !== false,
title: m.title || '',
})) : []
} catch (error) {
message.error(error.message || '加载设置失败')
} finally {
@@ -543,13 +340,6 @@ async function handleSave() {
try {
const payload = {
...form,
homepage_modules: homeModules.value.map(m => ({
id: m.id,
type: m.type,
config: m.config,
enabled: m.enabled !== false,
title: m.title || '',
})),
}
const res = await api.saveHomepageSettings(payload)
@@ -572,26 +362,6 @@ onMounted(() => {
</script>
<style scoped>
.aksrt-homepage__workstation {
display: flex;
gap: 16px;
min-height: 500px;
}
.aksrt-homepage__canvas-panel {
flex: 1;
min-width: 0;
}
.aksrt-homepage__inspector-panel {
width: 360px;
flex-shrink: 0;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
background: #fafafa;
}
.homepage-settings__empty-tabs {
padding: 24px;
text-align: center;
@@ -57,7 +57,7 @@ export const orderSchema = {
icon: 'fas fa-eye',
fields: [
{ key: 'payment_mark', label: '付费标记', type: 'input', default: '付费' },
{ key: 'payment_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章模块上', value: 'box_top' }, { label: '内容顶', value: 'top' }, { label: '内容底', value: 'bottom' }, { label: '模块下', value: 'box_bottom' }] },
{ key: 'payment_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '内容顶部(正文前)', value: 'top' }, { label: '内容底部(正文后)', value: 'bottom' }] },
{ key: 'payment_free_logged_show', label: '登录用户显示免费', type: 'switch', default: false },
{ key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false },
{ key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false },
@@ -51,7 +51,7 @@ export const paidSchema = {
{ key: 'payment_currency_symbol', label: '货币符号', type: 'input', default: '¥', help: '商品价格前缀,如 ¥、$、¥' },
{ key: 'payment_paid_show_user', label: '显示购买用户', type: 'switch', default: true },
{ key: 'payment_paid_show_paycount', label: '显示购买次数', type: 'switch', default: true },
{ key: 'payment_paid_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章模块上', value: 'box_top' }, { label: '内容顶', value: 'top' }, { label: '内容底', value: 'bottom' }, { label: '模块下', value: 'box_bottom' }] },
{ key: 'payment_paid_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '内容顶部(正文前)', value: 'top' }, { label: '内容底部(正文后)', value: 'bottom' }] },
],
},
{