导入 AKSRT 主题开发源码

This commit is contained in:
Claude Code committed 2026-07-03 22:50:34 +08:00
1 parent 9681dd3d48
commit 1268a55dd0
595 files changed
+127705

No files matched your search

@@ -0,0 +1,617 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="首页配置"
desc="配置首页内容模型、模块编排、分类 Tab、卡片样式和交互方式。"
>
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</template>
</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>
<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-radio>
<n-radio value="post">
<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>
</n-card>
<!-- Main Stream -->
<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="启用主列表">
<n-switch v-model:value="form.home_posts_list_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后首页将不显示文章列表(仅显示模块)。</span></template>
</n-form-item>
<n-form-item label="区块标题">
<n-input v-model:value="form.home_list_title" placeholder="最新发布" />
</n-form-item>
<n-form-item label="默认排序">
<n-select v-model:value="form.homepage_default_sort" :options="allSortOptions" />
</n-form-item>
<n-form-item label="启用排序栏">
<n-switch v-model:value="form.home_list_sort_enabled" />
</n-form-item>
<n-form-item v-if="form.home_list_sort_enabled" label="排序栏样式">
<n-radio-group v-model:value="form.home_list_sort_dropdown">
<n-space size="large">
<n-radio-button :value="false">内联链接</n-radio-button>
<n-radio-button :value="true">下拉菜单</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="form.home_list_sort_enabled" label="可用排序选项">
<n-select
v-model:value="form.home_list_sort_options"
:options="allSortOptions"
multiple
placeholder="选择排序选项"
/>
</n-form-item>
</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">
<n-form-item label="排除文章 ID">
<n-input
v-model:value="excludePostIdsText"
type="textarea"
placeholder="逗号分隔的文章 ID,如 12,34,56"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表和 Tab 查询均排除这些文章。</span></template>
</n-form-item>
<n-form-item label="排除分类 ID">
<n-input
v-model:value="excludeCategoryIdsText"
type="textarea"
placeholder="逗号分隔的分类 ID,如 5,8"
:rows="2"
/>
<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;">
<strong>首页 Tab</strong>
<n-button size="small" @click="addTab">+ 添加 Tab</n-button>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form" style="margin-bottom:16px;">
<n-form-item label="Tab 数量上限">
<n-input-number v-model:value="form.homepage_tab_limit" :min="1" :max="12" />
</n-form-item>
<n-form-item label="每 Tab 文章数">
<n-input-number v-model:value="form.homepage_tab_posts_per_page" :min="1" :max="20" />
</n-form-item>
</n-form>
<div v-if="form.homepage_tabs.length === 0" class="homepage-settings__empty-tabs">
<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">
<div class="homepage-settings__tab-fields">
<n-select
:value="tab.term_id"
:options="categoryOptions"
placeholder="分类"
filterable
style="flex:2;"
@update:value="(val) => updateTab(index, 'term_id', val)"
/>
<n-input
:value="tab.title"
placeholder="自定义标题"
style="flex:1;"
@update:value="(val) => updateTab(index, 'title', val)"
/>
<n-select
:value="tab.orderby"
:options="allSortOptions"
style="flex:1;"
@update:value="(val) => updateTab(index, 'orderby', val)"
/>
<n-switch
:value="tab.enabled"
@update:value="(val) => updateTab(index, 'enabled', val)"
/>
<n-button size="small" quaternary type="error" @click="removeTab(index)">删除</n-button>
</div>
</div>
</n-card>
</n-spin>
</n-tab-pane>
<!-- Cards & Sidebar 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>
<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_card_style">
<n-space size="large">
<n-radio-button value="default">默认</n-radio-button>
<n-radio-button value="style2">风格 2</n-radio-button>
<n-radio-button value="style3">风格 3</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="多图卡片">
<n-switch v-model:value="form.homepage_multi_thumb_enabled" />
</n-form-item>
<n-form-item v-if="form.homepage_multi_thumb_enabled" label="最少图片数">
<n-input-number v-model:value="form.homepage_multi_thumb_min_images" :min="2" :max="4" />
</n-form-item>
<n-form-item label="默认视图">
<n-radio-group v-model:value="form.homepage_default_view">
<n-space size="large">
<n-radio-button value="list">列表</n-radio-button>
<n-radio-button value="card">卡片</n-radio-button>
</n-space>
</n-radio-group>
</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="迷你侧栏列表">
<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>
</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>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } 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 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' },
{ label: '发布时间', value: 'date' },
{ label: '浏览量', value: 'views' },
{ label: '点赞数', value: 'likes' },
{ label: '评论数', value: 'comments' },
{ label: '收藏数', value: 'favorites' },
{ label: '下载量', value: 'downloads' },
{ label: '随机', value: 'rand' },
]
const form = reactive({
homepage_content_model: 'mixed',
homepage_tabs: [],
homepage_card_style: 'default',
homepage_multi_thumb_enabled: true,
homepage_multi_thumb_min_images: 3,
homepage_sidebar_mini_enabled: true,
homepage_default_sort: 'modified',
homepage_default_view: 'list',
homepage_sort_ajax_enabled: true,
homepage_sticky_tabs_enabled: true,
home_posts_list_enabled: true,
home_list_title: '最新发布',
home_list_sort_enabled: true,
home_list_sort_options: ['modified', 'date', 'views', 'likes', 'comments'],
home_list_sort_dropdown: false,
home_exclude_post_ids: [],
home_exclude_category_ids: [],
homepage_tab_posts_per_page: 6,
homepage_tab_limit: 4,
})
const excludePostIdsText = computed({
get: () => (form.home_exclude_post_ids || []).join(','),
set: (val) => {
form.home_exclude_post_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
const excludeCategoryIdsText = computed({
get: () => (form.home_exclude_category_ids || []).join(','),
set: (val) => {
form.home_exclude_category_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
// 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 })
}
function removeTab(index) {
form.homepage_tabs.splice(index, 1)
}
function updateTab(index, field, value) {
form.homepage_tabs[index][field] = value
}
// Data loading
async function loadCategories() {
try {
const res = await api.getHomepageCategories()
const cats = res?.data || []
categoryOptions.value = cats.map(c => ({
label: `${c.name} (${c.count})`,
value: c.term_id,
}))
} catch { /* non-critical */ }
}
async function loadSettings() {
loading.value = true
try {
const res = await api.getHomepageSettings()
const d = res?.data || {}
form.homepage_content_model = d.homepage_content_model || 'mixed'
form.homepage_tabs = Array.isArray(d.homepage_tabs) ? d.homepage_tabs : []
form.homepage_card_style = d.homepage_card_style || 'default'
form.homepage_multi_thumb_enabled = d.homepage_multi_thumb_enabled !== false
form.homepage_multi_thumb_min_images = d.homepage_multi_thumb_min_images || 3
form.homepage_sidebar_mini_enabled = d.homepage_sidebar_mini_enabled !== false
form.homepage_default_sort = d.homepage_default_sort || 'modified'
form.homepage_default_view = d.homepage_default_view || 'list'
form.homepage_sort_ajax_enabled = d.homepage_sort_ajax_enabled !== false
form.homepage_sticky_tabs_enabled = d.homepage_sticky_tabs_enabled !== false
form.home_posts_list_enabled = d.home_posts_list_enabled !== false
form.home_list_title = d.home_list_title || '最新发布'
form.home_list_sort_enabled = d.home_list_sort_enabled !== false
form.home_list_sort_options = Array.isArray(d.home_list_sort_options) && d.home_list_sort_options.length
? d.home_list_sort_options
: ['modified', 'date', 'views', 'likes', 'comments']
form.home_list_sort_dropdown = !!d.home_list_sort_dropdown
form.home_exclude_post_ids = Array.isArray(d.home_exclude_post_ids) ? d.home_exclude_post_ids : []
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 {
loading.value = false
}
}
async function handleSave() {
saving.value = true
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)
if (res?.code === 'success') {
message.success('首页设置已保存')
return
}
throw new Error(res?.message || '保存失败')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadSettings()
})
</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;
border: 1px dashed var(--n-border-color);
border-radius: 4px;
margin-bottom: 12px;
}
.homepage-settings__tab-row {
margin-bottom: 8px;
}
.homepage-settings__tab-fields {
display: flex;
gap: 8px;
align-items: center;
}
.aksrt-form-actions {
margin-top: 16px;
text-align: right;
}
</style>