导入 AKSRT 主题开发源码

This commit is contained in:
Claude Code
2026-07-03 22:50:34 +08:00
parent 9681dd3d48
commit 1268a55dd0
595 changed files with 127705 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* extract-naive-styles.mjs
*
* 在构建时直接调用 NaiveUI 组件的 .cssr.mjs 样式函数的 render() 方法,
* 提取所有组件的运行时 JSS 为静态 CSS 文件。
* 彻底解决 WordPress 后台环境中样式丢失问题。
*
* 原理:
* 每个 NaiveUI 组件都有 src/styles/index.cssr.mjs 文件
* 导出的函数返回 css-render 的 CNode 对象
* 调用 CNode.render({ clsPrefix: 'n' }) 即可获取纯 CSS 字符串
* 无需虚拟 DOM、无需 Vue、无任何浏览器 API 依赖
*/
import { writeFile, mkdir } from 'fs/promises'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const BUILD_DIR = join(__dirname, 'build')
// ============================================================
// 需要提取样式的组件列表(对应各组件的 .cssr.mjs 入口)
// 格式:[导入路径在 naive-ui/es 内部的相对路径]
// ============================================================
const COMPONENT_STYLES = [
// 内部基础组件
['_internal/scrollbar/src/styles/index.cssr.mjs'],
// 布局
['layout/src/styles/index.cssr.mjs'],
['layout-sider/src/styles/index.cssr.mjs'],
// 导航
['menu/src/styles/index.cssr.mjs'],
['breadcrumb/src/styles/index.cssr.mjs'],
['tabs/src/styles/index.cssr.mjs'],
['steps/src/styles/index.cssr.mjs'],
['pagination/src/styles/index.cssr.mjs'],
// 表单
['form/src/styles/index.cssr.mjs'],
['form-item/src/styles/index.cssr.mjs'],
['input/src/styles/index.cssr.mjs'],
['input-number/src/styles/index.cssr.mjs'],
['switch/src/styles/index.cssr.mjs'],
['select/src/styles/index.cssr.mjs'],
['radio/src/styles/index.cssr.mjs'],
['checkbox/src/styles/index.cssr.mjs'],
['slider/src/styles/index.cssr.mjs'],
['rate/src/styles/index.cssr.mjs'],
['date-picker/src/styles/indexcode/index.cssr.mjs'],
['time-picker/src/styles/index.cssr.mjs'],
// 展示
['card/src/styles/index.cssr.mjs'],
['tag/src/styles/index.cssr.mjs'],
['alert/src/styles/index.cssr.mjs'],
['data-table/src/styles/index.cssr.mjs'],
['descriptions/src/styles/index.cssr.mjs'],
['empty/src/styles/index.cssr.mjs'],
['result/src/styles/index.cssr.mjs'],
['statistic/src/styles/index.cssr.mjs'],
['list/src/styles/index.cssr.mjs'],
['collapse/src/styles/index.cssr.mjs'],
['timeline/src/styles/index.cssr.mjs'],
// 反馈
['button/src/styles/index.cssr.mjs'],
['modal/src/styles/index.cssr.mjs'],
['dialog/src/styles/index.cssr.mjs'],
['message/src/styles/index.cssr.mjs'],
['notification/src/styles/index.cssr.mjs'],
['popconfirm/src/styles/index.cssr.mjs'],
['popover/src/styles/index.cssr.mjs'],
['tooltip/src/styles/index.cssr.mjs'],
['dropdown/src/styles/index.cssr.mjs'],
// 其他
['icon/src/styles/index.cssr.mjs'],
['icon-wrapper/src/styles/index.cssr.mjs'],
['space/src/styles/index.cssr.mjs'],
['divider/src/styles/index.cssr.mjs'],
['gradient-text/src/styles/index.cssr.mjs'],
['ellipsis/src/styles/index.cssr.mjs'],
['spin/src/styles/index.cssr.mjs'],
['loading-bar/src/styles/index.cssr.mjs'],
['upload/src/styles/index.cssr.mjs'],
['image/src/styles/index.cssr.mjs'],
['carousel/src/styles/index.cssr.mjs'],
['transfer/src/styles/index.cssr.mjs'],
['tree-select/src/styles/index.cssr.mjs'],
['tree/src/styles/index.cssr.mjs'],
['thing/src/styles/index.cssr.mjs'],
['color-picker/src/styles/indexcode/index.cssr.mjs'],
['typography/src/styles/index.cssr.mjs'],
['auto-complete/src/styles/index.cssr.mjs'],
['cascader/src/styles/index.cssr.mjs'],
['dynamic-input/src/styles/index.cssr.mjs'],
['dynamic-tags/src/styles/index.cssr.mjs'],
['input-otp/src/styles/indexcode/index.cssr.mjs'],
// 过渡动画
['_styles/transitions/fade-in-width-expand.cssr.mjs'],
['_styles/transitions/fade-in-height-expand.cssr.mjs'],
['_styles/transitions/icon-switch.cssr.mjs'],
['_styles/transitions/scale-transition.cssr.mjs'],
// 全局
['global-style/src/styles/index.cssr.mjs'],
]
async function extractStyles() {
console.log('\n[AKSRT] 开始提取 NaiveUI 组件样式...\n')
const allCss = []
let successCount = 0
let failCount = 0
for (const [componentPath] of COMPONENT_STYLES) {
try {
// 动态导入每个组件的 cssr 样式模块
const styleModule = await import(
`naive-ui/es/${componentPath}`
)
const defaultExport = styleModule.default || styleModule
if (typeof defaultExport === 'function') {
// 直接调用 render() 提取 CSS
const css = defaultExport({
clsPrefix: 'n',
// 使用 light 主题变量
})
if (typeof css === 'string' && css.trim()) {
allCss.push(`/* ${componentPath} */\n${css}`)
successCount++
}
} else if (defaultExport && typeof defaultExport.render === 'function') {
// 如果是 CNode 对象,调用 render()
const css = defaultExport.render()
if (typeof css === 'string' && css.trim()) {
allCss.push(`/* ${componentPath} */\n${css}`)
successCount++
}
}
} catch (err) {
// 某些路径可能不存在或结构不同,跳过但不中断
failCount++
}
}
const cssOutput = allCss.join('\n\n')
if (!cssOutput.trim()) {
console.warn('[AKSRT] 警告:未提取到任何 NaiveUI 样式!')
return null
}
// 确保 build 目录存在
await mkdir(BUILD_DIR, { recursive: true })
// 写入 CSS 文件
const cssFile = join(BUILD_DIR, 'naive-ui.css')
const fullContent = `/* Auto-extracted by extract-naive-styles.mjs */
/* Naive UI v2 Component Styles - Build-time extraction */
/* DO NOT EDIT MANUALLY */
${cssOutput}`
await writeFile(cssFile, fullContent, 'utf-8')
console.log(`[AKSRT] NaiveUI 样式提取完成:`)
console.log(` 成功: ${successCount} 个组件`)
console.log(` 跳过: ${failCount} 个组件`)
console.log(` 输出: ${cssFile}`)
console.log(` 大小: ${(Buffer.byteLength(fullContent) / 1024).toFixed(1)} KB\n`)
return cssFile
}
// 执行
extractStyles().catch((err) => {
console.error('[AKSRT] 样式提取失败:', err)
process.exit(1)
})
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AKSRT 主题设置</title>
</head>
<body>
<div id="aksrt-admin-app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1853
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","vite":"^6.0.0"}}
+271
View File
@@ -0,0 +1,271 @@
<template>
<n-config-provider :style-mount-target="styleMountTarget">
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider>
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
show-collapse-trigger
:collapsed="collapsed"
:native-scrollbar="false"
@collapse="collapsed = true"
@expand="collapsed = false"
>
<div class="aksrt-logo">
<span class="logo-icon">AK</span>
<span v-if="!collapsed" class="logo-text">AKSRT</span>
</div>
<n-menu
:value="activeMenuKey"
:collapsed="collapsed"
:collapsed-width="64"
:options="menuOptions"
@update:value="handleMenuClick"
/>
</n-layout-sider>
<n-layout :native-scrollbar="false" class="aksrt-admin-content">
<div class="aksrt-admin-content__inner">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<div class="aksrt-page-wrapper" :key="route.fullPath">
<component :is="Component" />
</div>
</transition>
</router-view>
<div v-if="pageError" class="aksrt-error-fallback">
<p class="aksrt-error-title">页面渲染异常</p>
<p class="aksrt-error-msg">{{ pageError }}</p>
<n-button size="small" @click="pageError = null">重试</n-button>
</div>
</div>
</n-layout>
</n-layout>
<div v-else class="aksrt-admin-mobile">
<MobileTopBar :title="currentPageTitle" @toggle="drawerActive = true" />
<n-drawer v-model:show="drawerActive" placement="left" :width="280">
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
<div class="aksrt-logo aksrt-logo--drawer">
<span class="logo-icon">AK</span>
<span class="logo-text">AKSRT</span>
</div>
<n-menu
:value="activeMenuKey"
:options="menuOptions"
@update:value="handleMenuClick"
/>
</n-drawer-content>
</n-drawer>
<div class="aksrt-admin-mobile__body">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<div class="aksrt-page-wrapper" :key="route.fullPath">
<component :is="Component" />
</div>
</transition>
</router-view>
<div v-if="pageError" class="aksrt-error-fallback">
<p class="aksrt-error-title">页面渲染异常</p>
<p class="aksrt-error-msg">{{ pageError }}</p>
<n-button size="small" @click="pageError = null">重试</n-button>
</div>
</div>
</div>
</n-notification-provider>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup>
import { computed, onBeforeUnmount, onErrorCaptured, onMounted, provide, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NConfigProvider } from 'naive-ui'
import MobileTopBar from './components/MobileTopBar.vue'
import { styleMountTarget } from './style-target'
import { ADMIN_SECTIONS } from './constants/admin-sections'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const pageError = ref(null)
const isMobile = ref(false)
const drawerActive = ref(false)
function checkMobile() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
provide('collapsed', collapsed)
onErrorCaptured((err) => {
console.error('[AKSRT] 组件渲染错误:', err)
pageError.value = err?.message || String(err)
return false
})
/* ------------------------------------------------------------------ */
/* Build key → path lookup from ADMIN_SECTIONS */
/* ------------------------------------------------------------------ */
const keyToPath = {}
for (const section of ADMIN_SECTIONS) {
if (section.children) {
for (const child of section.children) {
keyToPath[child.key] = child.route
}
}
// For placeholder sections (no children), the section key maps to its route
if (section.placeholder) {
keyToPath[section.key] = section.route
}
}
/* ------------------------------------------------------------------ */
/* Build NaiveUI menu options from ADMIN_SECTIONS */
/* ------------------------------------------------------------------ */
const menuOptions = ADMIN_SECTIONS.filter((section) => !section.hidden).map((section) => {
if (section.placeholder) {
// Placeholder sections have no children — render as a leaf menu item
return {
label: section.label,
key: section.key,
icon: section.icon,
}
}
return {
label: section.label,
key: section.key,
icon: section.icon,
children: section.children.map((child) => ({
label: child.label,
key: child.key,
})),
}
})
/* ------------------------------------------------------------------ */
/* Active menu key resolution */
/* ------------------------------------------------------------------ */
const activeMenuKey = computed(() => {
const path = route.path
// Find the child whose route matches the current path
for (const section of ADMIN_SECTIONS) {
if (section.children) {
for (const child of section.children) {
if (path === child.route || path.startsWith(child.route + '/')) {
return child.key
}
}
}
// Placeholder section (no children)
if (section.placeholder && (path === section.route || path.startsWith(section.route + '/'))) {
return section.key
}
}
// Default: first section's first child
return ADMIN_SECTIONS[0]?.children?.[0]?.key || 'global-common'
})
const currentPageTitle = computed(() => route.meta?.title || 'AKSRT')
function handleMenuClick(key) {
if (keyToPath[key]) {
router.push(keyToPath[key])
if (isMobile.value) {
drawerActive.value = false
}
}
}
</script>
<style scoped>
.aksrt-logo {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
gap: 10px;
border-bottom: 1px solid var(--n-border-color, #efeff5);
}
.aksrt-logo--drawer {
justify-content: flex-start;
padding: 0 18px;
}
.logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #18a058;
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: 1px;
}
.aksrt-admin-content__inner {
padding: 24px 28px;
}
.aksrt-admin-mobile {
background: #f5f7fb;
min-height: 100vh;
}
.aksrt-admin-mobile__body {
padding: 14px 12px 32px;
}
.aksrt-page-wrapper {
min-height: calc(100vh - 120px);
}
.aksrt-error-fallback {
padding: 48px 20px;
text-align: center;
}
.aksrt-error-title {
margin: 0 0 10px;
font-size: 16px;
color: #d03050;
}
.aksrt-error-msg {
margin: 0 0 16px;
font-size: 13px;
color: #777;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
/**
* AKSRT Admin API Client
*
* Communicates with the WordPress REST API at /wp-json/aksrt/v1.
* Methods are organized into domain modules while the client keeps the shared
* request pipeline and schema-domain normalization contract.
*/
import { apiMethods } from './modules/index.js'
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
const NONCE = window.aksrtAdminData?.nonce || ''
function normalizeDomainKey(domain) {
return String(domain || 'global').trim().replace(/-/g, '_')
}
class ApiClient {
constructor() {
this.baseUrl = API_ROOT
}
normalizeDomainKey(domain) {
return normalizeDomainKey(domain)
}
async request(endpointOrConfig, options = {}) {
// Dual-signature support:
// request('/forum/overview')
// request({ url: '/aksrt/v1/forum/topics', method: 'GET', params: { limit: 20 } })
// request({ url: '/aksrt/v1/verify/review', method: 'POST', data: { user_id: 1 } })
let endpoint, method, headers, params, body, rest
if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) {
const cfg = endpointOrConfig
endpoint = cfg.url || cfg.endpoint || ''
method = cfg.method || 'GET'
params = cfg.params
body = cfg.data
headers = cfg.headers
rest = { ...cfg }
delete rest.url; delete rest.endpoint; delete rest.method
delete rest.params; delete rest.data; delete rest.headers
} else {
endpoint = endpointOrConfig
method = options.method || 'GET'
params = options.params
body = options.body !== undefined ? options.body : options.data
headers = options.headers
rest = { ...options }
delete rest.method; delete rest.params; delete rest.body
delete rest.data; delete rest.headers
}
if (endpoint.startsWith('/wp-json/aksrt/v1')) {
endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/'
} else if (endpoint.startsWith('/aksrt/v1')) {
endpoint = endpoint.replace('/aksrt/v1', '') || '/'
}
let url = `${this.baseUrl}${endpoint}`
if (params && typeof params === 'object') {
const qs = new URLSearchParams(params).toString()
if (qs) url += (url.includes('?') ? '&' : '?') + qs
}
let finalBody = body
if (body !== undefined && typeof body === 'object' && !(body instanceof FormData)) {
finalBody = JSON.stringify(body)
}
const config = {
method,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': NONCE,
...headers,
},
...rest,
}
if (finalBody !== undefined && method !== 'GET') {
config.body = finalBody
}
try {
const response = await fetch(url, config)
const text = await response.text()
let data = {}
try {
data = text ? JSON.parse(text) : {}
} catch {
throw new Error(
`REST 返回非 JSON,endpoint=${endpoint},status=${response.status},body=${text.slice(0, 160)}`
)
}
if (!response.ok || data.success === false) {
const msg = data.message || data.data?.message || `HTTP ${response.status}`
if (response.status === 403 || response.status === 401) {
console.warn(`[API Auth] ${endpoint}: ${msg} — 可能需要刷新页面重新登录`)
}
throw new Error(msg)
}
return data
} catch (error) {
console.error(`[API Error] ${endpoint}:`, error)
throw error
}
}
}
Object.assign(ApiClient.prototype, apiMethods)
export { normalizeDomainKey }
export default new ApiClient()
@@ -0,0 +1,8 @@
export const extensionsApiMethods = {
/* ================================================================
* 扩展&增强 (Extensions)
* ================================================================ */
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', body: JSON.stringify({ ip }) }) },
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', body: JSON.stringify({ text }) }) },
}
+28
View File
@@ -0,0 +1,28 @@
export const globalApiMethods = {
/* ================================================================
* 全局&功能 (Global & Functions)
* ================================================================ */
getMailSettings() { return this.request('/settings/mail') },
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
getFooterSettings() { return this.request('/settings/footer') },
saveFooterSettings(data) { return this.request('/settings/footer', { method: 'POST', body: JSON.stringify(data) }) },
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) },
reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getFloatingButtons() { return this.request('/settings/floating-buttons') },
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getFriendLinks() { return this.request('/friend-links') },
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) },
reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getCmsFeatures() { return this.request('/settings/cms-features') },
saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) },
}
+19
View File
@@ -0,0 +1,19 @@
import { globalApiMethods } from './global.js'
import { pageDisplayApiMethods } from './page-display.js'
import { permissionsApiMethods } from './permissions.js'
import { usersApiMethods } from './users.js'
import { paymentApiMethods } from './payment.js'
import { extensionsApiMethods } from './extensions.js'
import { toolsApiMethods } from './tools.js'
import { schemaApiMethods } from './schema.js'
export const apiMethods = {
...globalApiMethods,
...pageDisplayApiMethods,
...permissionsApiMethods,
...usersApiMethods,
...paymentApiMethods,
...extensionsApiMethods,
...toolsApiMethods,
...schemaApiMethods,
}
@@ -0,0 +1,30 @@
export const pageDisplayApiMethods = {
/* ================================================================
* 页面&显示 (Page Display)
* ================================================================ */
getHomepageSettings() { return this.request('/settings/homepage') },
saveHomepageSettings(data) { return this.request('/settings/homepage', { method: 'POST', body: JSON.stringify(data) }) },
getHomepageCategories() { return this.request('/homepage/categories') },
getCategoryRecommendSettings() { return this.request('/settings/category-recommend') },
saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) },
getCategories() { return this.request('/categories') },
updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) },
searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) },
getBanners(kind = 'hero') { return this.request(`/banners?kind=${encodeURIComponent(kind)}`) },
createBanner(data) { return this.request('/banners', { method: 'POST', body: JSON.stringify(data) }) },
updateBanner(id, data) { return this.request(`/banners/${id}`, { method: 'PUT', body: JSON.stringify(data) }) },
deleteBanner(id) { return this.request(`/banners/${id}`, { method: 'DELETE' }) },
getHeroes() { return this.getBanners('hero') },
createHero(data) { return this.createBanner({ ...data, kind: 'hero' }) },
updateHero(id, data) { return this.updateBanner(id, { ...data, kind: 'hero' }) },
deleteHero(id) { return this.deleteBanner(id) },
getHomepageBanners() { return this.getBanners('banner') },
createHomepageBanner(data) { return this.createBanner({ ...data, kind: 'banner' }) },
updateHomepageBanner(id, data) { return this.updateBanner(id, { ...data, kind: 'banner' }) },
deleteHomepageBanner(id) { return this.deleteBanner(id) },
}
+7
View File
@@ -0,0 +1,7 @@
export const paymentApiMethods = {
/* ================================================================
* 支付&付费 (Payment)
* ================================================================ */
getPaymentOverview() { return this.request('/payment/overview') },
}
@@ -0,0 +1,17 @@
export const permissionsApiMethods = {
/* ================================================================
* 功能&权限 (Permissions)
* ================================================================ */
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
savePermissionsModerationSettings(data) { return this.saveSchemaPage('permissions', 'moderation', data) },
getCapabilities() { return this.request('/permissions/capabilities') },
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
}
+17
View File
@@ -0,0 +1,17 @@
export const schemaApiMethods = {
getSchema(domain) { return this.request(`/settings/schema/${domain}`) },
getSchemaPage(domain, section) {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
},
saveSchemaPage(domain, section, data, version = '1.0.0') {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
method: 'POST',
body: JSON.stringify({
domain: this.normalizeDomainKey(domain),
section,
data,
version,
}),
})
},
}
+28
View File
@@ -0,0 +1,28 @@
export const toolsApiMethods = {
/* ================================================================
* 扩展&增强 / 备份&导入 (Extensions & Tools)
* ================================================================ */
getHealthEmailVerification() { return this.request('/health/email-verification') },
getUpdateInfo() { return this.request('/tools/update-info') },
detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) },
dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) },
getEmailRollbackUsers() { return this.request('/email-rollback/users') },
executeEmailRollback(id) { return this.request(`/email-rollback/users/${id}/rollback`, { method: 'POST' }) },
/* ================================================================
* Settings Management (Import / Export / Reset / Health / Schema)
* ================================================================ */
exportSettings() { return this.request('/settings/export') },
importSettings(data) { return this.request('/settings/import', { method: 'POST', body: JSON.stringify(data) }) },
listSettingBackups() { return this.request('/settings/backups') },
createSettingBackup(data = {}) { return this.request('/settings/backups', { method: 'POST', body: JSON.stringify(data) }) },
restoreSettingBackup(id) { return this.request(`/settings/backups/${id}/restore`, { method: 'POST' }) },
deleteSettingBackup(id) { return this.request(`/settings/backups/${id}`, { method: 'DELETE' }) },
resetDomain(domain) { return this.request(`/settings/reset/${domain}`, { method: 'POST' }) },
resetAllSettings() { return this.request('/settings/reset-all', { method: 'POST' }) },
getHealthCheck() { return this.request('/settings/health') },
getMigrationStatus() { return this.request('/settings/migration-status') },
}
+36
View File
@@ -0,0 +1,36 @@
export const usersApiMethods = {
/* ================================================================
* 用户&互动 (User & Interaction)
* ================================================================ */
getAuthSettings() { return this.request('/settings/auth') },
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
getOAuthSettings() { return this.request('/settings/oauth') },
saveOAuthSettings(data) { return this.request('/settings/oauth', { method: 'POST', body: JSON.stringify(data) }) },
getNotificationSettings() { return this.request('/settings/notifications') },
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
getNotificationOverview(params = {}) {
const qs = new URLSearchParams(params).toString()
return this.request('/notifications/overview' + (qs ? `?${qs}` : ''))
},
markNotificationRead(id) { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }) },
markAllNotificationsRead() { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ mark_all: true }) }) },
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
getCreditSettings() { return this.request('/credits/settings') },
saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) },
listCreditUsers(params = {}) {
const qs = new URLSearchParams(params).toString()
return this.request('/credits/users' + (qs ? `?${qs}` : ''))
},
adjustUserCredits(id, delta, reason) {
return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) })
},
getCreditLog(params = {}) {
const qs = new URLSearchParams(params).toString()
return this.request('/credits/log' + (qs ? `?${qs}` : ''))
},
}
@@ -0,0 +1,162 @@
<template>
<div class="aksrt-deferred-page">
<div class="aksrt-deferred-page__icon">
<FaIcon :icon="resolvedIcon" :size="48" />
</div>
<h2 class="aksrt-deferred-page__title">{{ resolvedTitle }}</h2>
<p class="aksrt-deferred-page__desc">{{ resolvedDesc }}</p>
<div class="aksrt-deferred-page__meta">
<n-tag size="small" type="info">{{ resolvedStatus }}</n-tag>
<n-tag v-if="resolvedPhase" size="small" type="warning">Phase {{ resolvedPhase }}</n-tag>
</div>
<div v-if="resolvedDependsOn.length" class="aksrt-deferred-page__depends">
<span class="aksrt-deferred-page__label">依赖</span>
<div class="aksrt-deferred-page__depends-list">
<n-tag v-for="item in resolvedDependsOn" :key="item" size="small" type="default">{{ item }}</n-tag>
</div>
</div>
<div v-if="resolvedReason" class="aksrt-deferred-page__reason">
<span class="aksrt-deferred-page__label">原因</span>
<p>{{ resolvedReason }}</p>
</div>
<div v-if="resolvedFeatures && resolvedFeatures.length" class="aksrt-deferred-page__features">
<div
v-for="(feat, i) in resolvedFeatures"
:key="i"
class="aksrt-deferred-page__feature"
>
<FaIcon icon="fas fa-check-circle" :size="14" />
<span>{{ feat }}</span>
</div>
</div>
<div class="aksrt-deferred-page__badge">
<n-tag type="info" size="small">即将推出</n-tag>
</div>
</div>
</template>
<script setup>
/**
* DeferredPage — "Coming Soon" page component for deferred sections.
* Shows a friendly message instead of a blank page.
*
* When used as a route component, reads props from route.meta.deferredProps.
* When used directly, accepts props normally.
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import FaIcon from './form/FaIcon.vue'
const props = defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
icon: { type: String, default: '' },
features: { type: Array, default: () => [] },
status: { type: String, default: '' },
phase: { type: String, default: '' },
dependsOn: { type: Array, default: () => [] },
reason: { type: String, default: '' },
})
const route = useRoute()
/** Merge direct props with route meta deferredProps */
const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中')
const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。')
const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fas fa-rocket')
const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred')
const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '')
const resolvedDependsOn = computed(() => {
if (props.dependsOn && props.dependsOn.length) return props.dependsOn
return route.meta?.deferredProps?.dependsOn || []
})
const resolvedReason = computed(() => props.reason || route.meta?.deferredProps?.reason || '')
const resolvedFeatures = computed(() => {
if (props.features && props.features.length) return props.features
return route.meta?.deferredProps?.features || []
})
</script>
<style scoped>
.aksrt-deferred-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
padding: 40px 20px;
}
.aksrt-deferred-page__icon {
color: #c0c4cc;
margin-bottom: 20px;
}
.aksrt-deferred-page__title {
margin: 0 0 10px;
font-size: 20px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-deferred-page__desc {
margin: 0 0 20px;
font-size: 14px;
color: #8a909c;
max-width: 400px;
line-height: 1.6;
}
.aksrt-deferred-page__meta {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.aksrt-deferred-page__depends,
.aksrt-deferred-page__reason {
width: min(520px, 100%);
margin-bottom: 18px;
text-align: left;
}
.aksrt-deferred-page__label {
display: inline-block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.aksrt-deferred-page__depends-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aksrt-deferred-page__reason p {
margin: 0;
color: #666;
line-height: 1.7;
}
.aksrt-deferred-page__features {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.aksrt-deferred-page__feature {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #666;
}
.aksrt-deferred-page__feature :deep(i) {
color: #52c41a;
}
</style>
@@ -0,0 +1,48 @@
<template>
<div class="aksrt-empty-state">
<div class="aksrt-empty-state__icon">
<i :class="icon" style="font-size: 48px;"></i>
</div>
<h3 class="aksrt-empty-state__title">{{ title }}</h3>
<p class="aksrt-empty-state__desc">{{ desc }}</p>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '功能开发中' },
desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' },
icon: { type: String, default: 'fas fa-hourglass-half' },
})
</script>
<style scoped>
.aksrt-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.aksrt-empty-state__icon {
margin-bottom: 20px;
color: #c0c4cc;
}
.aksrt-empty-state__title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-empty-state__desc {
margin: 0;
font-size: 13px;
color: #7a7f8a;
max-width: 360px;
line-height: 1.65;
}
</style>
@@ -0,0 +1,86 @@
<template>
<header class="aksrt-mobile-topbar">
<button
class="aksrt-mobile-topbar__menu"
type="button"
aria-label="打开菜单"
@click="$emit('toggle')"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="4" y1="6" x2="20" y2="6"/>
<line x1="4" y1="12" x2="20" y2="12"/>
<line x1="4" y1="18" x2="20" y2="18"/>
</svg>
</button>
<div class="aksrt-mobile-topbar__title">{{ title }}</div>
<div class="aksrt-mobile-topbar__brand">AK</div>
</header>
</template>
<script setup>
defineProps({
title: { type: String, default: 'AKSRT' },
})
defineEmits(['toggle'])
</script>
<style scoped>
.aksrt-mobile-topbar {
display: flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 14px;
background: #fff;
border-bottom: 1px solid #eceff5;
position: sticky;
top: 0;
z-index: 50;
}
.aksrt-mobile-topbar__menu {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: #1a1a2e;
cursor: pointer;
margin-left: -6px;
}
.aksrt-mobile-topbar__menu:hover {
background: #f3f5f9;
}
.aksrt-mobile-topbar__title {
flex: 1 1 auto;
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-mobile-topbar__brand {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #18a058;
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
</style>
@@ -0,0 +1,164 @@
<template>
<n-card class="oauth-provider-card" title="">
<!-- 平台信息头部 -->
<div class="oauth-provider-header">
<div class="oauth-header-left">
<span class="oauth-platform-icon">
<img v-if="icon" :src="icon" :alt="name" class="oauth-platform-img" />
<span v-else class="oauth-platform-text">{{ name.charAt(0) }}</span>
</span>
<div class="oauth-platform-info">
<h4>{{ name }}</h4>
<a :href="docUrl" target="_blank" class="oauth-doc-link">
前往 {{ name }} 创建应用 &#8599;
</a>
</div>
</div>
<n-switch v-model:value="modelValue.enabled" class="oauth-enable-switch">
<template #checked>启用</template>
<template #unchecked>停用</template>
</n-switch>
</div>
<!-- 表单区域(启用后才展开) -->
<div v-if="modelValue.enabled" class="oauth-form-body">
<n-form label-placement="top" class="aksrt-form oauth-form-grid">
<n-form-item
v-for="field in fields"
:key="field.key"
:label="field.label"
>
<n-input
v-if="!field.readonly"
v-model:value="modelValue[field.key]"
:type="field.secret ? 'password' : 'text'"
:show-password-on-click="field.secret"
:placeholder="'输入 ' + field.label"
size="large"
/>
<n-input
v-else
:value="callbackUrl"
readonly
size="large"
style='font-family: "SF Mono", Consolas, monospace; font-size:13px;'
/>
<template v-if="field.hint" #feedback>
<span class="aksrt-hint-text">{{ field.hint }}</span>
</template>
<template v-if="field.readonly && !field.hint" #feedback>
<span class="aksrt-hint-text">将此地址填写到对应平台的「授权回调域 / 回调 URL」中</span>
</template>
</n-form-item>
</n-form>
</div>
<!-- 未启用时的提示 -->
<div v-else class="oauth-disabled-hint">
<p>开启开关以配置此平台的 OAuth 凭证</p>
</div>
</n-card>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true },
name: { type: String, required: true },
docUrl: { type: String, required: true },
fields: { type: Array, required: true },
icon: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
const callbackUrl = computed(() => {
const siteUrl = window.aksrtAdminData?.siteUrl || window.location.origin
return `${siteUrl}/oauth/callback/`
})
</script>
<style scoped>
.oauth-provider-card {
border-radius: 12px !important;
}
/* 平台头部 */
.oauth-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
margin-bottom: 8px;
}
.oauth-header-left {
display: flex;
align-items: center;
gap: 14px;
}
.oauth-platform-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #f7f8fa;
border-radius: 12px;
}
.oauth-platform-img {
width: 26px;
height: 26px;
object-fit: contain;
}
.oauth-platform-text {
font-size: 20px;
font-weight: 700;
color: #666;
}
.oauth-platform-info h4 {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 2px;
}
.oauth-doc-link {
font-size: 12px;
color: #1565c0;
text-decoration: none;
}
.oauth-doc-link:hover {
text-decoration: underline;
}
/* 启用开关 */
.oauth-enable-switch {
flex-shrink: 0;
}
/* 表单体 */
.oauth-form-body {
padding-top: 8px;
border-top: 1px solid #f0f0f5;
}
.oauth-form-grid :deep(.n-form-item) {
max-width: 520px;
}
/* 禁用提示 */
.oauth-disabled-hint {
text-align: center;
padding: 28px 0 10px;
}
.oauth-disabled-hint p {
font-size: 13px;
color: #bbb;
}
</style>
@@ -0,0 +1,81 @@
<template>
<header class="aksrt-page-header">
<div class="aksrt-page-header__main">
<h2 class="aksrt-page-header__title">{{ title }}</h2>
<p v-if="desc" class="aksrt-page-header__desc">
<slot name="desc">{{ desc }}</slot>
</p>
<div v-else-if="$slots.desc" class="aksrt-page-header__desc">
<slot name="desc" />
</div>
</div>
<div v-if="$slots.actions" class="aksrt-page-header__actions">
<slot name="actions" />
</div>
</header>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
desc: { type: String, default: '' },
})
</script>
<style scoped>
.aksrt-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.aksrt-page-header__main {
flex: 1 1 auto;
min-width: 0;
}
.aksrt-page-header__title {
margin: 0 0 6px;
color: #1a1a2e;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.3;
}
.aksrt-page-header__desc {
margin: 0;
color: #7a7f8a;
font-size: 13px;
line-height: 1.65;
}
.aksrt-page-header__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {
.aksrt-page-header {
margin-bottom: 14px;
gap: 10px;
}
.aksrt-page-header__title {
font-size: 18px;
}
.aksrt-page-header__desc {
font-size: 12px;
}
.aksrt-page-header__actions {
width: 100%;
}
}
</style>
@@ -0,0 +1,66 @@
<template>
<n-form label-placement="left" label-width="130">
<n-space vertical style="width:100%">
<n-divider title-placement="left">前端配置</n-divider>
<n-form-item label="所属地区">
<n-select
v-model:value="model.region"
:options="[
{ label: '中国内地 (cn)', value: 'cn' },
{ label: '新加坡 (sgp)', value: 'sgp' },
]"
placeholder="选择地区"
/>
</n-form-item>
<n-form-item label="身份标 (prefix)">
<n-input
v-model:value="model.prefix"
placeholder="从概览页获取身份标"
autocomplete="off"
/>
</n-form-item>
<n-form-item label="场景ID">
<n-input
v-model:value="model.scene_id"
placeholder="从验证码场景列表获取 SceneId"
autocomplete="off"
/>
</n-form-item>
<n-divider title-placement="left">后端 API 密钥(用于服务端验签)</n-divider>
<n-form-item label="AccessKey ID">
<n-input
v-model:value="model.access_key_id"
placeholder="阿里云 RAM 用户 AccessKey ID"
autocomplete="off"
/>
</n-form-item>
<n-form-item label="AccessKey Secret">
<n-input
v-model:value="model.access_key_secret"
type="password"
show-password-on="click"
autocomplete="current-password"
placeholder="阿里云 RAM 用户 AccessKey Secret"
/>
</n-form-item>
<n-alert type="warning" style="margin-bottom: 8px;">
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
</n-alert>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
</n-space>
</n-form>
</template>
<script setup>
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const isConfigured = computed(() => {
const c = model.value
return !!(c.region && c.prefix && c.scene_id && c.access_key_id && c.access_key_secret)
})
</script>
@@ -0,0 +1,44 @@
<template>
<n-form label-placement="left" label-width="130">
<n-space vertical style="width:100%">
<n-form-item label="Site Key">
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
</n-form-item>
<n-form-item label="Secret Key">
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="current-password" />
</n-form-item>
<n-form-item label="自定义域名">
<n-input v-model:value="model.domain" placeholder="留空使用默认域名" autocomplete="url" />
</n-form-item>
<n-form-item label="主题">
<n-select v-model:value="model.theme" :options="themeOptions" clearable />
</n-form-item>
<n-form-item label="尺寸">
<n-select v-model:value="model.size" :options="sizeOptions" clearable />
</n-form-item>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
</n-space>
</n-form>
</template>
<script setup>
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const isConfigured = computed(() => {
const c = model.value
return !!(c.site_key && c.secret_key)
})
const themeOptions = [
{ label: '自动', value: 'auto' },
{ label: '浅色', value: 'light' },
{ label: '深色', value: 'dark' },
]
const sizeOptions = [
{ label: '正常', value: 'normal' },
{ label: '紧凑', value: 'compact' },
]
</script>
@@ -0,0 +1,25 @@
<template>
<n-form label-placement="left" label-width="130">
<n-space vertical style="width:100%">
<n-form-item label="Captcha ID">
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
</n-form-item>
<n-form-item label="Captcha Key">
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="current-password" />
</n-form-item>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
</n-space>
</n-form>
</template>
<script setup>
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
const isConfigured = computed(() => {
const c = model.value
return !!(c.captcha_id && c.captcha_key)
})
</script>
@@ -0,0 +1,42 @@
<template>
<n-form label-placement="left" label-width="130">
<n-space vertical style="width:100%">
<n-divider title-placement="left">云 API 密钥(用于 TC3 签名)</n-divider>
<n-alert type="warning" style="margin-bottom: 12px;">
请从 <n-a href="https://console.cloud.tencent.com/cam/capi" target="_blank">访问管理控制台 → API 密钥管理</n-a> 获取。建议使用子账号密钥并仅授予 <code>QcloudCaptchaFullAccess</code> 权限。
</n-alert>
<n-form-item label="SecretId">
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
</n-form-item>
<n-form-item label="SecretKey">
<n-input v-model:value="model.secret_key" type="text" placeholder="腾讯云 API SecretKey" autocomplete="off" show-password-on="click" />
</n-form-item>
<n-divider title-placement="left">验证码密钥</n-divider>
<n-form-item label="CaptchaAppId">
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
</n-form-item>
<n-form-item label="AppSecretKey">
<n-input v-model:value="model.app_secret_key" type="text" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" show-password-on="click" />
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
</n-form-item>
<n-alert type="info" style="margin-bottom: 12px;">
腾讯云验证码 2.0 — 验证类型(滑动拼图、文字点选、空间语义等)请在<a href="https://console.cloud.tencent.com/captcha/graphical" target="_blank">验证码控制台</a>配置。
</n-alert>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
</n-space>
</n-form>
</template>
<script setup>
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const isConfigured = computed(() => {
const c = model.value
return !!(c.secret_id && c.secret_key && c.app_id && c.app_secret_key)
})
</script>
@@ -0,0 +1,128 @@
<template>
<div class="aksrt-array-table">
<n-data-table
:columns="tableColumns"
:data="modelValue || []"
:bordered="false"
size="small"
max-height="400"
/>
<n-button
size="small"
dashed
block
class="aksrt-array-table__add"
@click="addItem"
>
<FaIcon icon="fas fa-plus" :size="12" />
{{ addText || '添加行' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksArrayTable — table-style editor for array data.
* Renders rows in a data table with inline editing.
*/
import { computed, h } from 'vue'
import { NInput, NSwitch, NButton, NSelect, NInputNumber } from 'naive-ui'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Column definitions: [{ key, label, type?, options?, width? }] */
columns: { type: Array, required: true },
defaults: { type: Object, default: () => ({}) },
addText: { type: String, default: '' },
maxRows: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function updateCell(rowIndex, key, value) {
const list = [...(props.modelValue || [])]
list[rowIndex] = { ...list[rowIndex], [key]: value }
emit('update:modelValue', list)
}
function removeItem(index) {
const list = (props.modelValue || []).filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function addItem() {
if ((props.modelValue || []).length >= props.maxRows) return
const item = {}
for (const col of props.columns) {
item[col.key] = col.default ?? (col.type === 'switch' ? false : '')
}
emit('update:modelValue', [...(props.modelValue || []), { ...item, ...props.defaults }])
}
function moveUp(index) {
if (index <= 0) return
const list = [...(props.modelValue || [])]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
const list = [...(props.modelValue || [])]
if (index >= list.length - 1) return
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
const tableColumns = computed(() => {
const cols = props.columns.map(col => ({
title: col.label,
key: col.key,
width: col.width || 150,
render(row, rowIndex) {
const val = row[col.key]
const update = (v) => updateCell(rowIndex, col.key, v)
if (col.type === 'switch') {
return h(NSwitch, { value: val, size: 'small', 'onUpdate:value': update })
}
if (col.type === 'select') {
return h(NSelect, { value: val, options: col.options || [], size: 'small', 'onUpdate:value': update })
}
if (col.type === 'number') {
return h(NInputNumber, { value: val, size: 'small', style: 'width:100%', 'onUpdate:value': update })
}
return h(NInput, { value: val, size: 'small', 'onUpdate:value': update })
},
}))
// Add actions column
cols.push({
title: '操作',
key: '_actions',
width: 100,
render(row, rowIndex) {
return h('div', { style: 'display:flex;gap:2px' }, [
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-up', size: 12 }),
}) : null,
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-down', size: 12 }),
}) : null,
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-trash', size: 12 }),
}),
])
},
})
return cols
})
</script>
<style scoped>
.aksrt-array-table__add {
margin-top: 10px;
}
</style>
@@ -0,0 +1,105 @@
<template>
<div ref="editorRef" class="aks-code-editor" :class="{ 'is-disabled': disabled }" />
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: { type: Boolean, default: false },
rows: { type: Number, default: 8 },
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref(null)
const view = shallowRef(null)
let updating = false
function resolveLang(lang) {
switch (lang) {
case 'css': return css()
case 'js':
case 'javascript': return javascript()
case 'html':
default: return html()
}
}
onMounted(() => {
if (!editorRef.value) return
const state = EditorState.create({
doc: props.modelValue || '',
extensions: [
basicSetup,
resolveLang(props.language),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged && !updating) {
const val = update.state.doc.toString()
emit('update:modelValue', val)
}
}),
EditorView.editable.of(!props.disabled),
EditorState.readOnly.of(props.disabled),
],
})
view.value = new EditorView({ state, parent: editorRef.value })
})
watch(() => props.modelValue, (newVal) => {
if (!view.value) return
const current = view.value.state.doc.toString()
if (newVal !== current) {
updating = true
view.value.dispatch({
changes: { from: 0, to: current.length, insert: newVal || '' },
})
updating = false
}
})
watch(() => props.disabled, (val) => {
if (!view.value) return
view.value.dispatch({
effects: [
EditorView.editable.reconfigure(EditorView.editable.of(!val)),
EditorState.readOnly.reconfigure(EditorState.readOnly.of(val)),
],
})
})
onBeforeUnmount(() => {
if (view.value) {
view.value.destroy()
view.value = null
}
})
</script>
<style scoped>
.aks-code-editor {
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 4px;
overflow: hidden;
min-height: calc(var(--rows, 8) * 22px);
}
.aks-code-editor :deep(.cm-editor) {
font-size: 13px;
}
.aks-code-editor :deep(.cm-editor.cm-focused) {
outline: none;
}
.aks-code-editor.is-disabled {
opacity: 0.6;
pointer-events: none;
}
</style>
@@ -0,0 +1,824 @@
<template>
<div class="aksrt-field" :class="fieldClasses">
<div class="aksrt-field__label-row">
<label class="aksrt-field__label">
{{ schema.label }}
<span v-if="schema.required" class="aksrt-field__required">*</span>
</label>
<n-tooltip v-if="schema.help" trigger="hover">
<template #trigger>
<FaIcon icon="fas fa-question-circle" :size="13" class="aksrt-field__help-icon" />
</template>
{{ schema.help }}
</n-tooltip>
</div>
<p v-if="schema.description && !schema.help" class="aksrt-field__desc">
{{ schema.description }}
</p>
<!-- Switch -->
<n-switch
v-if="schema.type === 'switch'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Input (text, email, url, password) -->
<n-input
v-else-if="schema.type === 'input' || schema.type === 'text'"
:value="modelValue"
:placeholder="schema.placeholder || ''"
:type="schema.inputType || 'text'"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
>
<template v-if="schema.prefix" #prefix>{{ schema.prefix }}</template>
<template v-if="schema.suffix" #suffix>{{ schema.suffix }}</template>
</n-input>
<!-- Textarea -->
<n-input
v-else-if="schema.type === 'textarea'"
:value="modelValue"
type="textarea"
:placeholder="schema.placeholder || ''"
:rows="schema.rows || 4"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Number -->
<n-input-number
v-else-if="schema.type === 'number'"
:value="modelValue"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.placeholder || ''"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Select -->
<n-select
v-else-if="schema.type === 'select'"
:value="modelValue"
:options="normalizedOptions"
:placeholder="schema.placeholder || '请选择'"
:multiple="schema.multiple"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Taxonomy Select (async-loaded category/tag terms) -->
<n-select
v-else-if="schema.type === 'taxonomy_select'"
:value="modelValue"
:options="taxonomyOptions"
:loading="taxonomyLoading"
:placeholder="schema.placeholder || '请选择分类'"
:multiple="schema.multiple !== false"
:filterable="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- User Select (async search) -->
<n-select
v-else-if="schema.type === 'user_select'"
:value="modelValue"
:options="userOptions"
:loading="userLoading"
:placeholder="schema.placeholder || '搜索用户'"
:multiple="schema.multiple === true"
:filterable="true"
:remote="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@search="handleUserSearch"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Radio group -->
<n-radio-group
v-else-if="schema.type === 'radio'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
>
<n-space v-if="schema.buttonStyle === false">
<n-radio
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio>
</n-space>
<n-radio-button
v-else
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio-button>
</n-radio-group>
<!-- Checkbox group -->
<n-checkbox-group
v-else-if="schema.type === 'checkbox'"
:value="modelValue || []"
@update:value="emit('update:modelValue', $event)"
>
<n-space>
<n-checkbox
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:disabled="schema.disabled"
/>
</n-space>
</n-checkbox-group>
<!-- Color picker -->
<n-color-picker
v-else-if="schema.type === 'color'"
:value="modelValue"
:modes="['hex']"
:show-alpha="schema.showAlpha || false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Icon picker -->
<FaIconPicker
v-else-if="schema.type === 'icon'"
:model-value="modelValue"
:placeholder="schema.placeholder || '选择图标'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Media upload -->
<AksMediaField
v-else-if="schema.type === 'media'"
:model-value="modelValue"
:accept="schema.accept"
:placeholder="schema.placeholder || '选择图片'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Slider -->
<n-slider
v-else-if="schema.type === 'slider'"
:value="modelValue"
:min="schema.min || 0"
:max="schema.max || 100"
:step="schema.step || 1"
:disabled="schema.disabled"
:marks="schema.marks"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Code editor with syntax highlighting (CodeMirror 6) -->
<AksCodeEditor
v-else-if="schema.type === 'code'"
:model-value="modelValue"
:language="schema.language || 'html'"
:disabled="schema.disabled"
:rows="schema.rows || 12"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Alert/info content -->
<n-alert
v-else-if="schema.type === 'alert' || schema.type === 'notice'"
:type="schema.alertType || 'info'"
:title="schema.alertTitle"
:closable="false"
>
{{ schema.alertContent || modelValue }}
</n-alert>
<!-- Pure HTML content (no form field, just display) -->
<div
v-else-if="schema.type === 'content'"
class="aksrt-field__content"
v-html="schema.content || modelValue || ''"
/>
<!-- Date picker -->
<n-date-picker
v-else-if="schema.type === 'date'"
:value="modelValue"
:type="schema.dateType || 'date'"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Time picker -->
<n-time-picker
v-else-if="schema.type === 'time'"
:value="modelValue"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Dimensions (width x height) -->
<div v-else-if="schema.type === 'dimensions'" class="aksrt-field__dimensions">
<n-input-number
:value="modelValue?.width"
:min="0"
:placeholder="schema.widthPlaceholder || '宽度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('width', $event)"
/>
<span class="aksrt-field__dimensions-sep">×</span>
<n-input-number
:value="modelValue?.height"
:min="0"
:placeholder="schema.heightPlaceholder || '高度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('height', $event)"
/>
<n-select
v-if="schema.unit"
:value="modelValue?.unit"
:options="dimensionUnitOptions"
:disabled="schema.disabled"
style="width: 80px"
@update:value="updateDimension('unit', $event)"
/>
</div>
<!-- Spacing (top right bottom left) -->
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
<n-input-number
:value="modelValue?.top"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('top', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[1] || '右' }}</div>
<n-input-number
:value="modelValue?.right"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('right', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[2] || '下' }}</div>
<n-input-number
:value="modelValue?.bottom"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('bottom', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[3] || '左' }}</div>
<n-input-number
:value="modelValue?.left"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('left', $event)"
/>
</div>
<!-- Background (color + image + position + repeat) -->
<div v-else-if="schema.type === 'background'" class="aksrt-field__background">
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景颜色</label>
<n-color-picker
:value="modelValue?.color"
:show-alpha="true"
:disabled="schema.disabled"
@update:value="updateBackground('color', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景图片</label>
<AksMediaField
:model-value="modelValue?.image"
:accept="'image/*'"
:disabled="schema.disabled"
@update:model-value="updateBackground('image', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片尺寸</label>
<n-select
:value="modelValue?.size"
:options="bgSizeOptions"
:disabled="schema.disabled"
@update:value="updateBackground('size', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片位置</label>
<n-select
:value="modelValue?.position"
:options="bgPositionOptions"
:disabled="schema.disabled"
@update:value="updateBackground('position', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片重复</label>
<n-select
:value="modelValue?.repeat"
:options="bgRepeatOptions"
:disabled="schema.disabled"
@update:value="updateBackground('repeat', $event)"
/>
</div>
</div>
<!-- Image select (radio with image preview) -->
<div v-else-if="schema.type === 'image_select'" class="aksrt-field__image-select">
<div
v-for="opt in normalizedOptions"
:key="opt.value"
class="aksrt-field__image-select-item"
:class="{ 'aksrt-field__image-select-item--active': modelValue === opt.value }"
@click="!schema.disabled && emit('update:modelValue', opt.value)"
>
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="aksrt-field__image-select-img" />
<div v-else class="aksrt-field__image-select-placeholder">
<FaIcon :icon="opt.icon || 'fas fa-image'" :size="20" />
</div>
<span class="aksrt-field__image-select-label">{{ opt.label }}</span>
</div>
</div>
<!-- Repeater (array of objects) -->
<div v-else-if="schema.type === 'repeater'" class="aksrt-field__repeater">
<div
v-for="(item, index) in (modelValue || [])"
:key="item._id || index"
class="aksrt-field__repeater-item"
>
<div class="aksrt-field__repeater-header">
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fas fa-trash" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
:schema="field"
:model-value="item[field.key]"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
<n-button
size="small"
dashed
:disabled="schema.disabled"
@click="addRepeaterItem"
>
<FaIcon icon="fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.addText || '添加一项' }}
</n-button>
</div>
<!-- Custom slot -->
<slot v-else-if="schema.type === 'custom'" />
<!-- Fallback: text input -->
<n-input
v-else
:value="modelValue"
:placeholder="schema.placeholder || ''"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Validation message -->
<p v-if="errorMessage" class="aksrt-field__error">{{ errorMessage }}</p>
</div>
</template>
<script setup>
/**
* AksField — renders a single form field based on its schema definition.
*
* Supported types: switch, input/text, textarea, number, select, radio,
* checkbox, color, icon, media, slider, code, alert, custom (slot).
*/
import { computed, ref, onMounted, watch } from 'vue'
import FaIcon from './FaIcon.vue'
import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import api from '@/api'
const props = defineProps({
/** Field schema definition */
schema: {
type: Object,
required: true,
// Expected shape: { key, label, type, default, options?, help?, ... }
},
/** Current field value */
modelValue: { default: null },
/** Error message to display */
errorMessage: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
/** Normalize options: support both string[] and {label, value}[] */
const normalizedOptions = computed(() => {
const opts = props.schema.options || []
return opts.map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) {
return { label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
}
return { label: String(opt), value: opt }
})
})
// ============ Taxonomy Select (async) ============
const taxonomyOptions = ref([])
const taxonomyLoading = ref(false)
async function loadTaxonomyTerms() {
if (!props.schema.taxonomy) return
taxonomyLoading.value = true
try {
const res = await api.getCategories()
const items = res?.data || res || []
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
value: t.term_id,
}))
} catch (e) {
console.warn('[AksField] taxonomy load failed:', e)
taxonomyOptions.value = []
} finally {
taxonomyLoading.value = false
}
}
onMounted(() => {
if (props.schema.type === 'taxonomy_select') {
loadTaxonomyTerms()
}
if (props.schema.type === 'user_select') {
loadInitialUsers()
}
})
// ============ User Select (async search) ============
const userOptions = ref([])
const userLoading = ref(false)
let userSearchTimer = null
async function loadInitialUsers() {
userLoading.value = true
try {
const res = await api.searchUsers('', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
console.warn('[AksField] user load failed:', e)
userOptions.value = []
} finally {
userLoading.value = false
}
}
function handleUserSearch(query) {
clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(async () => {
userLoading.value = true
try {
const res = await api.searchUsers(query || '', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
userOptions.value = []
} finally {
userLoading.value = false
}
}, 300)
}
const fieldClasses = computed(() => ({
'aksrt-field--disabled': props.schema.disabled,
'aksrt-field--inline': props.schema.inline,
[`aksrt-field--${props.schema.type}`]: !!props.schema.type,
}))
// ============ Dimensions ============
const dimensionUnitOptions = [
{ label: 'px', value: 'px' },
{ label: '%', value: '%' },
{ label: 'em', value: 'em' },
{ label: 'rem', value: 'rem' },
{ label: 'vw', value: 'vw' },
]
function updateDimension(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Spacing ============
function updateSpacing(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Background ============
const bgSizeOptions = [
{ label: '覆盖', value: 'cover' },
{ label: '包含', value: 'contain' },
{ label: '原始大小', value: 'auto' },
]
const bgPositionOptions = [
{ label: '居中', value: 'center center' },
{ label: '顶部居中', value: 'top center' },
{ label: '底部居中', value: 'bottom center' },
{ label: '左侧居中', value: 'center left' },
{ label: '右侧居中', value: 'center right' },
]
const bgRepeatOptions = [
{ label: '不重复', value: 'no-repeat' },
{ label: '平铺', value: 'repeat' },
{ label: '水平平铺', value: 'repeat-x' },
{ label: '垂直平铺', value: 'repeat-y' },
]
function updateBackground(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Repeater ============
let repeaterIdCounter = 0
function addRepeaterItem() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
// Initialize with defaults from schema.fields
for (const field of (props.schema.fields || [])) {
if (field.default !== undefined) {
newItem[field.key] = field.default
}
}
current.push(newItem)
emit('update:modelValue', current)
}
function removeRepeaterItem(index) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current[index] = { ...current[index], [fieldKey]: value }
emit('update:modelValue', current)
}
</script>
<style scoped>
.aksrt-field {
margin-bottom: 18px;
}
.aksrt-field:last-child {
margin-bottom: 0;
}
.aksrt-field__label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aksrt-field__label {
font-size: 13px;
font-weight: 500;
color: #555;
}
.aksrt-field__required {
color: #d03050;
margin-left: 2px;
}
.aksrt-field__help-icon {
color: #c0c4cc;
cursor: help;
transition: color 0.15s;
}
.aksrt-field__help-icon:hover {
color: #1890ff;
}
.aksrt-field__desc {
margin: -2px 0 8px;
font-size: 12px;
color: #8a909c;
line-height: 1.5;
}
.aksrt-field__error {
margin: 4px 0 0;
font-size: 12px;
color: #d03050;
}
.aksrt-field--disabled {
opacity: 0.6;
pointer-events: none;
}
/* Dimensions field */
.aksrt-field__dimensions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field__dimensions-sep {
color: #c0c4cc;
font-size: 14px;
}
/* Spacing field */
.aksrt-field__spacing {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.aksrt-field__spacing-label {
font-size: 12px;
color: #8a909c;
text-align: center;
}
/* Background field */
.aksrt-field__background {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__background-row {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-field__sublabel {
font-size: 12px;
color: #8a909c;
min-width: 70px;
}
/* Image select */
.aksrt-field__image-select {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.aksrt-field__image-select-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border: 2px solid #eef1f6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.aksrt-field__image-select-item:hover {
border-color: #1890ff;
}
.aksrt-field__image-select-item--active {
border-color: #1890ff;
background: #e6f7ff;
}
.aksrt-field__image-select-img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.aksrt-field__image-select-placeholder {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fb;
border-radius: 4px;
color: #c0c4cc;
}
.aksrt-field__image-select-label {
font-size: 11px;
color: #555;
text-align: center;
}
/* Repeater */
.aksrt-field__repeater {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__repeater-item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 12px;
background: #fafbfc;
}
.aksrt-field__repeater-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.aksrt-field__repeater-index {
font-size: 12px;
font-weight: 600;
color: #1890ff;
}
.aksrt-field__repeater-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Content field */
.aksrt-field__content {
font-size: 13px;
color: #555;
line-height: 1.6;
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-field__dimensions {
flex-wrap: wrap;
}
.aksrt-field__spacing {
grid-template-columns: auto 1fr;
}
.aksrt-field__background-row {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<div class="aksrt-field-group">
<div v-if="title" class="aksrt-field-group__header">
<div class="aksrt-field-group__header-left">
<FaIcon v-if="icon" :icon="icon" :size="16" class="aksrt-field-group__icon" />
<div>
<h3 class="aksrt-field-group__title">{{ title }}</h3>
<p v-if="description" class="aksrt-field-group__desc">{{ description }}</p>
</div>
</div>
<div v-if="$slots.actions" class="aksrt-field-group__actions">
<slot name="actions" />
</div>
</div>
<div v-if="collapsible" class="aksrt-field-group__toggle" @click="toggleCollapse">
<FaIcon
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
:size="12"
class="aksrt-field-group__chevron"
/>
<span class="aksrt-field-group__toggle-text">
{{ collapsed ? '展开' : '收起' }}{{ title ? ` ${title}` : '' }}
</span>
</div>
<transition name="aksrt-collapse">
<div v-show="!collapsed" class="aksrt-field-group__body">
<slot />
</div>
</transition>
</div>
</template>
<script setup>
/**
* AksFieldGroup — card-style container for grouping related form fields.
* Supports collapsible sections, icon headers, and description text.
*/
import { ref } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
icon: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
defaultCollapsed: { type: Boolean, default: false },
})
const collapsed = ref(props.defaultCollapsed)
function toggleCollapse() {
collapsed.value = !collapsed.value
}
</script>
<style scoped>
.aksrt-field-group {
margin-bottom: 20px;
border: 1px solid #eef1f6;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.aksrt-field-group__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 18px 20px 0;
}
.aksrt-field-group__header-left {
display: flex;
align-items: flex-start;
gap: 10px;
}
.aksrt-field-group__icon {
color: #1890ff;
margin-top: 2px;
}
.aksrt-field-group__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-field-group__desc {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
line-height: 1.5;
}
.aksrt-field-group__actions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field-group__toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
cursor: pointer;
color: #8a909c;
font-size: 12px;
user-select: none;
transition: color 0.15s;
}
.aksrt-field-group__toggle:hover {
color: #1890ff;
}
.aksrt-field-group__chevron {
transition: transform 0.2s;
}
.aksrt-field-group__body {
padding: 16px 20px 20px;
}
/* Collapse transition */
.aksrt-collapse-enter-active,
.aksrt-collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.aksrt-collapse-enter-from,
.aksrt-collapse-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -0,0 +1,365 @@
<template>
<div class="aksrt-form-renderer">
<!-- Page header with title and description -->
<div v-if="title" class="aksrt-form-renderer__header">
<div class="aksrt-form-renderer__header-left">
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
<div>
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
</div>
</div>
<div v-if="showSearch" class="aksrt-form-renderer__search">
<n-input
v-model:value="searchQuery"
placeholder="搜索设置项..."
size="small"
clearable
>
<template #prefix>
<FaIcon icon="fas fa-search" :size="12" />
</template>
</n-input>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="aksrt-form-renderer__loading">
<n-spin size="medium" />
<span>加载中...</span>
</div>
<!-- Error state -->
<n-alert
v-else-if="loadError"
type="error"
:title="'加载失败'"
:style="{ marginBottom: '16px' }"
>
{{ loadError }}
<template #footer>
<n-button size="small" @click="$emit('reload')">重试</n-button>
</template>
</n-alert>
<!-- Form content -->
<template v-else-if="schema && schema.length">
<template v-for="group in filteredSchema" :key="group.key || group.title">
<AksFieldGroup
:title="group.title"
:description="group.description"
:icon="group.icon"
:collapsible="group.collapsible"
:default-collapsed="group.defaultCollapsed"
>
<template v-if="group.fields">
<template v-for="field in group.fields" :key="field.key">
<template v-if="isFieldVisible(field)">
<slot
v-if="field.type === 'custom'"
:name="`field-${field.key}`"
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
>
<AksField
:schema="field"
:model-value="getFieldValue(field.key)"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
/>
</slot>
<AksField
v-else
:schema="field"
:model-value="getFieldValue(field.key)"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
/>
</template>
</template>
</template>
<!-- Custom group content slot -->
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
</AksFieldGroup>
</template>
</template>
<!-- Empty state -->
<div v-else class="aksrt-form-renderer__empty">
<FaIcon icon="fas fa-cog" :size="32" />
<p>暂无配置项</p>
</div>
<!-- Sticky save bar -->
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
<div class="aksrt-form-renderer__save-bar__inner">
<div class="aksrt-form-renderer__save-bar__info">
<FaIcon icon="fas fa-exclamation-circle" :size="14" />
<span>您有未保存的更改</span>
</div>
<div class="aksrt-form-renderer__save-bar__actions">
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
<n-button
size="small"
type="primary"
:loading="saving"
@click="$emit('save')"
>
<FaIcon v-if="!saving" icon="fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* AksFormRenderer — the core schema-driven form engine.
*
* Takes a schema definition (array of field groups) and a reactive form data object,
* and renders a complete settings page with:
* - Grouped fields in card-style containers
* - Inline search/filter
* - Dirty state tracking with sticky save bar
* - Dependency-based field visibility
* - Loading and error states
*/
import { ref, computed } from 'vue'
import FaIcon from './FaIcon.vue'
import AksFieldGroup from './AksFieldGroup.vue'
import AksField from './AksField.vue'
const props = defineProps({
/** Page title */
title: { type: String, default: '' },
/** Page description */
description: { type: String, default: '' },
/** Title icon (FA5 class) */
icon: { type: String, default: '' },
/**
* Schema definition: array of field groups.
* Each group: { key, title, description?, icon?, collapsible?, fields: FieldDef[] }
* Each field: { key, label, type, default, options?, dependsOn?, help?, ... }
*/
schema: { type: Array, default: () => [] },
/** Reactive form data object */
formData: { type: Object, default: () => ({}) },
/** Whether the form has unsaved changes */
dirty: { type: Boolean, default: false },
/** Whether data is currently loading */
loading: { type: Boolean, default: false },
/** Whether a save is in progress */
saving: { type: Boolean, default: false },
/** Load error message */
loadError: { type: String, default: '' },
/** Whether to show the search bar */
showSearch: { type: Boolean, default: true },
/** Validation errors keyed by field key */
errors: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:formData', 'save', 'reload'])
const searchQuery = ref('')
/** Check if a field should be visible based on its dependsOn rule */
function isFieldVisible(field) {
if (!field.dependsOn) return true
// dependsOn can be a string (truthy check on that key) or an object { key, value }
if (typeof field.dependsOn === 'string') {
return !!props.formData[field.dependsOn]
}
if (typeof field.dependsOn === 'object') {
const { key, value, not } = field.dependsOn
const currentVal = props.formData[key]
if (not) return currentVal !== value
return currentVal === value
}
return true
}
/** Get field value from formData */
function getFieldValue(key) {
return props.formData?.[key]
}
/** Set field value, emitting update for parent */
function setFieldValue(key, value) {
const newData = { ...props.formData, [key]: value }
emit('update:formData', newData)
}
/** Filter schema groups/fields by search query */
const filteredSchema = computed(() => {
const q = searchQuery.value?.toLowerCase().trim()
if (!q) return props.schema
return props.schema
.map(group => {
if (!group.fields) return null
// Check if group title matches
const groupMatch = (group.title || '').toLowerCase().includes(q)
|| (group.description || '').toLowerCase().includes(q)
// Filter fields by label, key, or help text matching query
const matchingFields = group.fields.filter(field => {
if (groupMatch) return true
return (field.label || '').toLowerCase().includes(q)
|| (field.key || '').toLowerCase().includes(q)
|| (field.help || '').toLowerCase().includes(q)
|| (field.description || '').toLowerCase().includes(q)
})
if (matchingFields.length === 0) return null
return { ...group, fields: groupMatch ? group.fields : matchingFields }
})
.filter(Boolean)
})
</script>
<style scoped>
.aksrt-form-renderer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.aksrt-form-renderer__header-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.aksrt-form-renderer__header-icon {
color: #1890ff;
margin-top: 4px;
}
.aksrt-form-renderer__title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: -0.02em;
}
.aksrt-form-renderer__desc {
margin: 4px 0 0;
font-size: 13px;
color: #7a7f8a;
line-height: 1.6;
}
.aksrt-form-renderer__search {
min-width: 200px;
max-width: 280px;
}
.aksrt-form-renderer__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: #8a909c;
font-size: 14px;
}
.aksrt-form-renderer__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: #c0c4cc;
}
.aksrt-form-renderer__empty p {
margin: 0;
font-size: 14px;
}
/* Sticky save bar */
.aksrt-form-renderer__save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #eef1f6;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
animation: slideUp 0.2s ease;
}
.aksrt-form-renderer__save-bar__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 12px 28px;
gap: 16px;
}
.aksrt-form-renderer__save-bar__info {
display: flex;
align-items: center;
gap: 8px;
color: #d48806;
font-size: 13px;
}
.aksrt-form-renderer__save-bar__actions {
display: flex;
gap: 8px;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-form-renderer__header {
flex-direction: column;
gap: 10px;
}
.aksrt-form-renderer__search {
max-width: 100%;
width: 100%;
}
.aksrt-form-renderer__save-bar__inner {
flex-direction: column;
padding: 10px 14px;
gap: 8px;
}
.aksrt-form-renderer__save-bar__actions {
width: 100%;
}
.aksrt-form-renderer__save-bar__actions .n-button {
flex: 1;
}
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="aksrt-media-field">
<div v-if="modelValue" class="aksrt-media-field__preview">
<img
v-if="isImage"
:src="modelValue"
:alt="placeholder"
class="aksrt-media-field__image"
/>
<div v-else class="aksrt-media-field__file">
<FaIcon icon="fas fa-file" :size="24" />
<span class="aksrt-media-field__filename">{{ fileName }}</span>
</div>
<div class="aksrt-media-field__overlay">
<n-button size="tiny" quaternary @click="openMediaLibrary">
<FaIcon icon="fas fa-exchange-alt" /> 更换
</n-button>
<n-button size="tiny" quaternary type="error" @click="clearMedia">
<FaIcon icon="fas fa-trash" /> 移除
</n-button>
</div>
</div>
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
<FaIcon icon="fas fa-cloud-upload-alt" :size="28" />
<span>{{ placeholder }}</span>
</div>
<n-input
:value="modelValue"
:placeholder="'或直接输入 URL'"
size="small"
class="aksrt-media-field__url"
@update:value="emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup>
/**
* AksMediaField — image/file selector with preview.
*
* In the WordPress admin context, this can open the WP Media Library.
* For now, it supports direct URL input with preview.
*/
import { computed } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
accept: { type: String, default: 'image/*' },
placeholder: { type: String, default: '选择图片' },
})
const emit = defineEmits(['update:modelValue'])
const isImage = computed(() => {
if (!props.modelValue) return false
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i.test(props.modelValue)
|| props.modelValue.startsWith('data:image/')
})
const fileName = computed(() => {
if (!props.modelValue) return ''
try {
const url = new URL(props.modelValue, window.location.origin)
const parts = url.pathname.split('/')
return parts[parts.length - 1] || props.modelValue
} catch {
const parts = props.modelValue.split('/')
return parts[parts.length - 1] || props.modelValue
}
})
function openMediaLibrary() {
// In WordPress admin, use wp.media to open the media library
if (typeof wp !== 'undefined' && wp.media) {
const frame = wp.media({
title: props.placeholder,
multiple: false,
library: { type: props.accept === 'image/*' ? 'image' : undefined },
})
frame.on('select', () => {
const attachment = frame.state().get('selection').first().toJSON()
emit('update:modelValue', attachment.url)
})
frame.open()
}
// Outside WP, the URL input serves as the entry point
}
function clearMedia() {
emit('update:modelValue', '')
}
</script>
<style scoped>
.aksrt-media-field__preview {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
margin-bottom: 8px;
}
.aksrt-media-field__image {
display: block;
width: 100%;
max-height: 200px;
object-fit: cover;
}
.aksrt-media-field__file {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
background: #f8fafc;
}
.aksrt-media-field__filename {
font-size: 13px;
color: #555;
word-break: break-all;
}
.aksrt-media-field__overlay {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 4px;
padding: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.aksrt-media-field__preview:hover .aksrt-media-field__overlay {
opacity: 1;
}
.aksrt-media-field__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
border: 2px dashed #d9dce3;
border-radius: 8px;
cursor: pointer;
color: #8a909c;
font-size: 13px;
transition: all 0.2s;
margin-bottom: 8px;
}
.aksrt-media-field__empty:hover {
border-color: #1890ff;
color: #1890ff;
background: #f0f7ff;
}
.aksrt-media-field__url {
margin-top: 4px;
}
</style>
@@ -0,0 +1,185 @@
<template>
<div class="aksrt-repeater">
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
<div
v-for="(item, index) in modelValue"
:key="index"
class="aksrt-repeater__item"
>
<div class="aksrt-repeater__item-header">
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
<div class="aksrt-repeater__item-actions">
<n-button
v-if="index > 0"
size="tiny"
quaternary
@click="moveUp(index)"
>
<FaIcon icon="fas fa-arrow-up" :size="12" />
</n-button>
<n-button
v-if="index < modelValue.length - 1"
size="tiny"
quaternary
@click="moveDown(index)"
>
<FaIcon icon="fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
@click="removeItem(index)"
>
<FaIcon icon="fas fa-trash" :size="12" />
</n-button>
</div>
</div>
<div class="aksrt-repeater__item-fields">
<AksField
v-for="field in fields"
:key="field.key"
:schema="field"
:model-value="item[field.key]"
@update:model-value="updateField(index, field.key, $event)"
/>
</div>
</div>
</div>
<div v-else class="aksrt-repeater__empty">
<FaIcon icon="fas fa-inbox" :size="20" />
<span>{{ emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
class="aksrt-repeater__add"
@click="addItem"
>
<FaIcon icon="fas fa-plus" :size="12" />
{{ addText || '添加项目' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksRepeaterField — dynamic add/remove rows of structured data.
* Each row renders fields based on the provided field schemas.
*/
import AksField from './AksField.vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Array of field schemas for each row */
fields: { type: Array, required: true },
/** Default value for a new item */
defaults: { type: Object, default: () => ({}) },
emptyText: { type: String, default: '' },
addText: { type: String, default: '' },
maxItems: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function createItem() {
const item = {}
for (const field of props.fields) {
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
}
return { ...item, ...props.defaults }
}
function addItem() {
if (props.modelValue.length >= props.maxItems) return
const list = [...props.modelValue, createItem()]
emit('update:modelValue', list)
}
function removeItem(index) {
const list = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function moveUp(index) {
if (index <= 0) return
const list = [...props.modelValue]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
if (index >= props.modelValue.length - 1) return
const list = [...props.modelValue]
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
function updateField(index, key, value) {
const list = [...props.modelValue]
list[index] = { ...list[index], [key]: value }
emit('update:modelValue', list)
}
</script>
<style scoped>
.aksrt-repeater__list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.aksrt-repeater__item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 12px;
background: #fafbfc;
}
.aksrt-repeater__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.aksrt-repeater__item-index {
font-size: 12px;
font-weight: 600;
color: #1890ff;
background: #e6f4ff;
padding: 2px 8px;
border-radius: 4px;
}
.aksrt-repeater__item-actions {
display: flex;
gap: 2px;
}
.aksrt-repeater__item-fields {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-repeater__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: #c0c4cc;
font-size: 13px;
}
.aksrt-repeater__add {
margin-top: 4px;
}
</style>
@@ -0,0 +1,67 @@
<template>
<i
:class="iconClass"
:style="iconStyle"
aria-hidden="true"
></i>
</template>
<script setup>
/**
* FaIcon — Font Awesome 5 icon wrapper component.
*
* Usage:
* <FaIcon icon="fas fa-cog" />
* <FaIcon icon="fa-cog" /> → defaults to "fas" prefix
* <FaIcon icon="fab fa-github" />
* <FaIcon :size="18" icon="far fa-heart" />
* <FaIcon spin icon="fas fa-spinner" />
*/
import { computed } from 'vue'
const props = defineProps({
/** FA5 class string, e.g. "fas fa-cog", "fab fa-github", or just "fa-cog" */
icon: { type: String, required: true },
/** Pixel size — maps to font-size */
size: { type: [Number, String], default: null },
/** Fixed width icon */
fw: { type: Boolean, default: false },
/** Spin animation */
spin: { type: Boolean, default: false },
/** Pulse animation */
pulse: { type: Boolean, default: false },
/** Rotation (90, 180, 270) */
rotate: { type: [Number, String], default: null },
/** Flip direction ('horizontal', 'vertical', 'both') */
flip: { type: String, default: null },
/** Additional CSS classes */
extraClass: { type: String, default: '' },
})
const iconClass = computed(() => {
const parts = []
// Auto-prefix: if only "fa-xxx" is given, default to "fas"
const raw = props.icon.trim()
if (raw.startsWith('fa-')) {
parts.push('fas', raw)
} else {
parts.push(...raw.split(/\s+/))
}
if (props.fw) parts.push('fa-fw')
if (props.spin) parts.push('fa-spin')
if (props.pulse) parts.push('fa-pulse')
if (props.rotate) parts.push(`fa-rotate-${props.rotate}`)
if (props.flip) parts.push(`fa-flip-${props.flip}`)
if (props.extraClass) parts.push(props.extraClass)
return parts.join(' ')
})
const iconStyle = computed(() => {
if (!props.size) return {}
const s = typeof props.size === 'number' ? `${props.size}px` : props.size
return { fontSize: s }
})
</script>
@@ -0,0 +1,221 @@
<template>
<div class="aksrt-fa-icon-picker">
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
<template #trigger>
<n-input
:value="modelValue"
:placeholder="placeholder"
readonly
clearable
@clear="$emit('update:modelValue', '')"
>
<template #prefix>
<FaIcon v-if="modelValue" :icon="modelValue" />
</template>
<template #suffix>
<FaIcon icon="fas fa-chevron-down" :size="12" />
</template>
</n-input>
</template>
<div class="aksrt-icon-picker__panel">
<div class="aksrt-icon-picker__search">
<n-input
v-model:value="search"
placeholder="搜索图标..."
size="small"
clearable
>
<template #prefix>
<FaIcon icon="fas fa-search" :size="12" />
</template>
</n-input>
</div>
<div class="aksrt-icon-picker__tabs">
<n-radio-group v-model:value="activePrefix" size="small">
<n-radio-button value="fas">Solid</n-radio-button>
<n-radio-button value="far">Regular</n-radio-button>
<n-radio-button value="fab">Brands</n-radio-button>
</n-radio-group>
</div>
<div class="aksrt-icon-picker__grid" ref="gridRef">
<div
v-for="icon in filteredIcons"
:key="icon"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `${activePrefix} ${icon}` }"
:title="icon"
@click="selectIcon(icon)"
>
<i :class="[activePrefix, icon]"></i>
</div>
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
未找到匹配图标
</div>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
/**
* FaIconPicker — searchable FA5 icon picker.
* Renders an input that opens a popover grid of FA5 icons.
*/
import { ref, computed } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '选择图标' },
})
const emit = defineEmits(['update:modelValue'])
const search = ref('')
const activePrefix = ref('fas')
const gridRef = ref(null)
// Common FA5 icons subset — a curated list covering most admin UI needs.
// Full FA5 has 1600+ icons; this subset has ~200 of the most useful ones.
const SOLID_ICONS = [
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-tools',
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync', 'fa-redo', 'fa-undo',
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user-shield', 'fa-user-circle',
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-fingerprint',
'fa-globe', 'fa-globe-americas', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
'fa-image', 'fa-images', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
'fa-folder', 'fa-folder-open', 'fa-download', 'upload', 'fa-cloud',
'fa-database', 'fa-server', 'fa-hdd', 'fa-memory', 'fa-microchip',
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard-list',
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard-check',
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-palette',
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
'fa-calendar', 'fa-calendar-alt', 'fa-history', 'fa-hourglass',
'fa-map-marker-alt', 'fa-map', 'fa-directions', 'fa-compass',
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
'fa-coins', 'fa-dollar-sign', 'fa-wallet', 'fa-receipt',
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-megaphone',
'fa-flag', 'fa-trophy', 'fa-award', 'fa-medal', 'fa-gift',
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud-sun',
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-door-open',
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
]
const REGULAR_ICONS = [
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
'fa-address-book', 'fa-address-card', 'fa-id-badge',
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
]
const BRAND_ICONS = [
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-x-twitter',
'fa-instagram', 'fa-linkedin', 'fa-linkedin-in', 'fa-youtube',
'fa-weixin', 'fa-qq', 'fa-weibo', 'fa-telegram', 'fa-telegram-plane',
'fa-discord', 'fa-slack', 'fa-whatsapp', 'fa-skype',
'fa-apple', 'fa-windows', 'fa-linux', 'fa-android', 'fa-chrome',
'fa-firefox', 'fa-edge', 'fa-safari', 'fa-opera',
'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-js-square', 'fa-node',
'fa-react', 'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python',
'fa-java', 'fa-rust', 'fa-golang', 'fa-swift',
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare',
'fa-wordpress', 'fa-joomla', 'fa-drupal', 'fa-shopify',
'fa-paypal', 'fa-stripe', 'fa-alipay', 'fa-weixin',
'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn',
'fa-twitch', 'fa-spotify', 'fa-soundcloud', 'fa-music',
'fa-bilibili',
]
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
const filteredIcons = computed(() => {
const set = iconSets[activePrefix.value] || SOLID_ICONS
const q = search.value.toLowerCase().replace(/\s+/g, '')
if (!q) return set
return set.filter(i => i.includes(q))
})
function selectIcon(icon) {
emit('update:modelValue', `${activePrefix.value} ${icon}`)
}
</script>
<style scoped>
.aksrt-icon-picker__panel {
padding: 12px;
}
.aksrt-icon-picker__search {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs {
margin-bottom: 10px;
}
.aksrt-icon-picker__grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
max-height: 260px;
overflow-y: auto;
}
.aksrt-icon-picker__item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
color: #555;
transition: all 0.15s;
}
.aksrt-icon-picker__item:hover {
background: #f0f5ff;
color: #1890ff;
}
.aksrt-icon-picker__item.is-active {
background: #1890ff;
color: #fff;
}
.aksrt-icon-picker__empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: #999;
font-size: 13px;
}
</style>
@@ -0,0 +1,313 @@
<template>
<div class="aksrt-module-canvas">
<div class="aksrt-module-canvas__toolbar">
<n-space>
<n-dropdown :options="addModuleOptions" trigger="click" @select="handleAddModule">
<n-button type="primary" size="small">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
添加模块
</n-button>
</n-dropdown>
<n-button size="small" @click="$emit('pick-preset')">
<template #icon><FaIcon icon="fas fa-magic" :size="12" /></template>
应用预设
</n-button>
<n-button size="small" @click="$emit('export')">
<template #icon><FaIcon icon="fas fa-download" :size="12" /></template>
导出
</n-button>
<n-button size="small" @click="$emit('import')">
<template #icon><FaIcon icon="fas fa-upload" :size="12" /></template>
导入
</n-button>
</n-space>
</div>
<div v-if="modules.length === 0" class="aksrt-module-canvas__empty">
<FaIcon icon="fas fa-th-large" :size="32" color="#c0c4cc" />
<p>暂无首页模块,点击"添加模块"或"应用预设"开始配置。</p>
</div>
<div v-else class="aksrt-module-canvas__list">
<div
v-for="(mod, index) in modules"
:key="mod.id || index"
class="aksrt-module-canvas__item"
:class="{
'aksrt-module-canvas__item--selected': selectedIndex === index,
'aksrt-module-canvas__item--disabled': !mod.enabled,
'aksrt-module-canvas__item--dragging': dragIndex === index,
'aksrt-module-canvas__item--drag-over': dragOverIndex === index && dragOverIndex !== dragIndex,
}"
:draggable="true"
@click="$emit('select', index)"
@dragstart="handleDragStart(index, $event)"
@dragover.prevent="handleDragOver(index)"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop(index)"
@dragend="handleDragEnd"
>
<div class="aksrt-module-canvas__item-handle" title="拖拽排序">
<FaIcon icon="fas fa-grip-vertical" :size="12" color="#c0c4cc" />
</div>
<div class="aksrt-module-canvas__item-icon">
<FaIcon :icon="getModuleIcon(mod.type)" :size="14" />
</div>
<div class="aksrt-module-canvas__item-info">
<span class="aksrt-module-canvas__item-label">{{ getModuleLabel(mod.type) }}</span>
<span v-if="mod.title" class="aksrt-module-canvas__item-title">{{ mod.title }}</span>
</div>
<div class="aksrt-module-canvas__item-actions">
<n-switch
:value="mod.enabled !== false"
size="small"
@update:value="(val) => $emit('toggle', index, val)"
@click.stop
/>
<n-button
size="tiny"
quaternary
title="复制"
@click.stop="$emit('copy', index)"
>
<FaIcon icon="fas fa-copy" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
title="删除"
@click.stop="$emit('remove', index)"
>
<FaIcon icon="fas fa-trash" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
:disabled="index === 0"
title="上移"
@click.stop="$emit('move', index, -1)"
>
<FaIcon icon="fas fa-chevron-up" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
:disabled="index === modules.length - 1"
title="下移"
@click.stop="$emit('move', index, 1)"
>
<FaIcon icon="fas fa-chevron-down" :size="11" />
</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
import FaIcon from '@/components/form/FaIcon.vue'
const props = defineProps({
modules: { type: Array, default: () => [] },
selectedIndex: { type: Number, default: -1 },
})
const emit = defineEmits(['select', 'add', 'remove', 'copy', 'toggle', 'move', 'pick-preset', 'export', 'import'])
// Drag-and-drop state
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
function handleDragStart(index, event) {
dragIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
}
function handleDragOver(index) {
if (dragIndex.value !== index) {
dragOverIndex.value = index
}
}
function handleDragLeave() {
dragOverIndex.value = -1
}
function handleDrop(targetIndex) {
if (dragIndex.value >= 0 && dragIndex.value !== targetIndex) {
const offset = targetIndex - dragIndex.value
emit('move', dragIndex.value, offset)
}
dragIndex.value = -1
dragOverIndex.value = -1
}
function handleDragEnd() {
dragIndex.value = -1
dragOverIndex.value = -1
}
const addModuleOptions = computed(() =>
MODULE_LIBRARY.map(m => ({
label: m.label,
key: m.type,
icon: () => {
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return h('i', { class: iconMap[m.icon] || 'fas fa-cube', style: 'font-size:12px;margin-right:6px;' })
},
}))
)
function getModuleIcon(type) {
const meta = MODULE_LIBRARY.find(m => m.type === type)
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return iconMap[meta?.icon] || 'fas fa-cube'
}
function getModuleLabel(type) {
const meta = MODULE_LIBRARY.find(m => m.type === type)
return meta?.label || type
}
function handleAddModule(type) {
emit('add', type)
}
</script>
<script>
import { h } from 'vue'
export default {
// h is used in addModuleOptions computed icon renderer
}
</script>
<style scoped>
.aksrt-module-canvas__toolbar {
margin-bottom: 12px;
}
.aksrt-module-canvas__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
border: 2px dashed #e0e0e0;
border-radius: 8px;
color: #999;
gap: 12px;
}
.aksrt-module-canvas__empty p {
margin: 0;
font-size: 13px;
}
.aksrt-module-canvas__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-module-canvas__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
background: #fff;
}
.aksrt-module-canvas__item:hover {
border-color: #d0d0d0;
background: #fafafa;
}
.aksrt-module-canvas__item--selected {
border-color: #1890ff;
background: #e6f4ff;
}
.aksrt-module-canvas__item--dragging {
opacity: 0.5;
border-style: dashed;
}
.aksrt-module-canvas__item--drag-over {
border-color: #1890ff;
border-width: 2px;
background: #f0f7ff;
}
.aksrt-module-canvas__item--disabled {
opacity: 0.5;
}
.aksrt-module-canvas__item-handle {
cursor: grab;
padding: 2px;
}
.aksrt-module-canvas__item-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: #f5f7fa;
color: #666;
flex-shrink: 0;
}
.aksrt-module-canvas__item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.aksrt-module-canvas__item-label {
font-size: 13px;
font-weight: 500;
color: #333;
}
.aksrt-module-canvas__item-title {
font-size: 11px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-module-canvas__item-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,399 @@
<template>
<div class="aksrt-module-inspector">
<div v-if="!module" class="aksrt-module-inspector__empty">
<FaIcon icon="fas fa-mouse-pointer" :size="24" color="#c0c4cc" />
<p>选择左侧模块查看和编辑配置</p>
</div>
<template v-else>
<div class="aksrt-module-inspector__header">
<FaIcon :icon="getModuleIcon(module.type)" :size="16" />
<span class="aksrt-module-inspector__type">{{ getModuleLabel(module.type) }}</span>
</div>
<n-scrollbar style="max-height: calc(100vh - 320px);">
<n-form
:model="localConfig"
label-placement="top"
size="small"
class="aksrt-module-inspector__form"
>
<!-- Common fields -->
<n-form-item label="模块标题">
<n-input
:value="module.title || ''"
placeholder="留空使用默认标题"
@update:value="(val) => updateField('title', val)"
/>
</n-form-item>
<n-form-item label="启用模块">
<n-switch
:value="module.enabled !== false"
@update:value="(val) => updateField('enabled', val)"
/>
</n-form-item>
<n-divider />
<!-- Type-specific fields from schema -->
<template v-if="metaFields.length > 0">
<n-form-item
v-for="field in metaFields"
:key="field.key"
:label="field.label"
>
<!-- Switch -->
<n-switch
v-if="field.type === 'switch'"
:value="localConfig[field.key] ?? field.default ?? false"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Number -->
<n-input-number
v-else-if="field.type === 'number'"
:value="localConfig[field.key] ?? field.default ?? 0"
:min="field.min"
:max="field.max"
style="width: 100%;"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Text -->
<n-input
v-else-if="field.type === 'text'"
:value="localConfig[field.key] ?? field.default ?? ''"
:placeholder="field.placeholder || ''"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Textarea -->
<n-input
v-else-if="field.type === 'textarea'"
type="textarea"
:value="localConfig[field.key] ?? field.default ?? ''"
:rows="field.rows || 3"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Select -->
<n-select
v-else-if="field.type === 'select'"
:value="localConfig[field.key] ?? field.default"
:options="field.options || []"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Radio -->
<n-radio-group
v-else-if="field.type === 'radio'"
:value="localConfig[field.key] ?? field.default"
@update:value="(val) => updateConfig(field.key, val)"
>
<n-space>
<n-radio
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</n-radio>
</n-space>
</n-radio-group>
<!-- Category (single) -->
<n-select
v-else-if="field.type === 'cat'"
:value="localConfig[field.key] ?? field.default ?? 0"
:options="categoryOptions"
filterable
placeholder="选择分类"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Categories (multiple) -->
<n-select
v-else-if="field.type === 'cats'"
:value="localConfig[field.key] ?? field.default ?? []"
:options="categoryOptions"
multiple
filterable
placeholder="选择分类"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Media Picker -->
<div v-else-if="field.type === 'media'" class="aksrt-module-inspector__media">
<div v-if="localConfig[field.key]" class="aksrt-module-inspector__media-preview">
<img :src="localConfig[field.key]" alt="" />
<n-button size="tiny" quaternary type="error" @click="updateConfig(field.key, '')">
移除
</n-button>
</div>
<n-input
v-else
:value="localConfig[field.key] ?? ''"
placeholder="输入图片 URL 或点击下方选择"
@update:value="(val) => updateConfig(field.key, val)"
/>
<n-button size="tiny" quaternary @click="openMediaPicker(field.key)">
<template #icon><FaIcon icon="fas fa-image" :size="12" /></template>
从媒体库选择
</n-button>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="aksrt-module-inspector__repeater">
<div
v-for="(item, idx) in (localConfig[field.key] ?? [])"
:key="idx"
class="aksrt-module-inspector__repeater-item"
>
<div class="aksrt-module-inspector__repeater-fields">
<n-input
v-for="sub in (field.fields || [])"
:key="sub.key"
:value="item[sub.key] ?? ''"
:placeholder="sub.placeholder || sub.label || ''"
size="small"
@update:value="(val) => updateRepeaterItem(field.key, idx, sub.key, val)"
/>
</div>
<n-button size="tiny" quaternary type="error" @click="removeRepeaterItem(field.key, idx)">
<FaIcon icon="fas fa-times" :size="10" />
</n-button>
</div>
<n-button size="tiny" quaternary @click="addRepeaterItem(field.key, field.fields)">
<template #icon><FaIcon icon="fas fa-plus" :size="10" /></template>
添加一项
</n-button>
</div>
</n-form-item>
</template>
<n-text v-else depth="3" style="font-size: 12px;">
此模块类型暂无可配置字段。
</n-text>
</n-form>
</n-scrollbar>
</template>
<!-- Image URL input modal (replaces native prompt) -->
<n-modal v-model:show="showImageUrlInput" preset="card" title="输入图片 URL" style="max-width: 400px;" :bordered="false">
<n-input v-model:value="imageUrlValue" placeholder="https://example.com/image.jpg" @keyup.enter="confirmImageUrl" />
<template #footer>
<n-space justify="end">
<n-button @click="showImageUrlInput = false">取消</n-button>
<n-button type="primary" @click="confirmImageUrl">确定</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const props = defineProps({
module: { type: Object, default: null },
moduleIndex: { type: Number, default: -1 },
})
const emit = defineEmits(['update'])
const categoryOptions = ref([])
const meta = computed(() => {
if (!props.module) return null
return MODULE_LIBRARY.find(m => m.type === props.module.type) || null
})
const metaFields = computed(() => {
return meta.value?.fields || []
})
const localConfig = computed(() => {
return props.module?.config || {}
})
function getModuleIcon(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return iconMap[m?.icon] || 'fas fa-cube'
}
function getModuleLabel(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
return m?.label || type
}
function updateField(key, value) {
emit('update', props.moduleIndex, key, value)
}
function updateConfig(key, value) {
const newConfig = { ...(props.module?.config || {}), [key]: value }
emit('update', props.moduleIndex, 'config', newConfig)
}
// Image URL input state (replaces native prompt)
const showImageUrlInput = ref(false)
const imageUrlFieldKey = ref('')
const imageUrlValue = ref('')
function openMediaPicker(fieldKey) {
if (!window.wp || !window.wp.media) {
// Fallback: show inline input instead of native prompt
imageUrlFieldKey.value = fieldKey
imageUrlValue.value = localConfig.value[fieldKey] || ''
showImageUrlInput.value = true
return
}
const frame = wp.media({
title: '选择图片',
button: { text: '使用此图片' },
multiple: false,
library: { type: 'image' },
})
frame.on('select', function () {
const attachment = frame.state().get('selection').first().toJSON()
updateConfig(fieldKey, attachment.url)
})
frame.open()
}
function confirmImageUrl() {
if (imageUrlValue.value.trim()) {
updateConfig(imageUrlFieldKey.value, imageUrlValue.value.trim())
}
showImageUrlInput.value = false
}
function addRepeaterItem(fieldKey, subFields) {
const current = [...(props.module?.config?.[fieldKey] || [])]
const newItem = {}
;(subFields || []).forEach(f => { newItem[f.key] = f.default ?? '' })
current.push(newItem)
updateConfig(fieldKey, current)
}
function removeRepeaterItem(fieldKey, index) {
const current = [...(props.module?.config?.[fieldKey] || [])]
current.splice(index, 1)
updateConfig(fieldKey, current)
}
function updateRepeaterItem(fieldKey, index, subKey, value) {
const current = [...(props.module?.config?.[fieldKey] || [])]
if (current[index]) {
current[index] = { ...current[index], [subKey]: value }
updateConfig(fieldKey, current)
}
}
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 */ }
}
onMounted(loadCategories)
</script>
<style scoped>
.aksrt-module-inspector__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: #999;
gap: 12px;
}
.aksrt-module-inspector__empty p {
margin: 0;
font-size: 13px;
}
.aksrt-module-inspector__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.aksrt-module-inspector__type {
font-size: 15px;
font-weight: 600;
color: #333;
}
.aksrt-module-inspector__form {
padding: 0 4px;
}
.aksrt-module-inspector__media {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.aksrt-module-inspector__media-preview {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-module-inspector__media-preview img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.aksrt-module-inspector__repeater {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.aksrt-module-inspector__repeater-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px;
border-radius: 6px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-module-inspector__repeater-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
@@ -0,0 +1,143 @@
<template>
<n-modal
:show="show"
title="应用预设模板"
preset="dialog"
style="width: 560px;"
:show-icon="false"
@close="$emit('close')"
>
<div class="aksrt-preset-picker">
<p class="aksrt-preset-picker__hint">
选择一个预设模板快速配置首页模块。应用预设将替换当前所有模块。
</p>
<div class="aksrt-preset-picker__list">
<div
v-for="preset in presets"
:key="preset.key"
class="aksrt-preset-picker__item"
:class="{ 'aksrt-preset-picker__item--selected': selectedKey === preset.key }"
@click="selectedKey = preset.key"
>
<div class="aksrt-preset-picker__item-header">
<strong>{{ preset.label }}</strong>
<n-tag size="small" type="info">{{ preset.modules.length }} 个模块</n-tag>
</div>
<p class="aksrt-preset-picker__item-desc">{{ preset.desc }}</p>
<div class="aksrt-preset-picker__item-modules">
<n-tag
v-for="(mod, i) in preset.modules"
:key="i"
size="small"
:bordered="false"
class="aksrt-preset-picker__module-tag"
>
{{ getModuleLabel(mod.type) }}
</n-tag>
</div>
</div>
</div>
</div>
<template #action>
<n-space>
<n-button @click="$emit('close')">取消</n-button>
<n-button
type="primary"
:disabled="!selectedKey"
@click="handleApply"
>
应用预设
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { ref } from 'vue'
import { PRESET_TEMPLATES, MODULE_LIBRARY } from '@/utils/home-module-schema'
defineProps({
show: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'apply'])
const selectedKey = ref('')
const presets = PRESET_TEMPLATES
function getModuleLabel(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
return m?.label || type
}
function handleApply() {
const preset = presets.find(p => p.key === selectedKey.value)
if (preset) {
emit('apply', preset)
selectedKey.value = ''
}
}
</script>
<style scoped>
.aksrt-preset-picker__hint {
margin: 0 0 16px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.aksrt-preset-picker__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.aksrt-preset-picker__item {
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.aksrt-preset-picker__item:hover {
border-color: #d0d0d0;
background: #fafafa;
}
.aksrt-preset-picker__item--selected {
border-color: #1890ff;
background: #e6f4ff;
}
.aksrt-preset-picker__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.aksrt-preset-picker__item-header strong {
font-size: 14px;
color: #333;
}
.aksrt-preset-picker__item-desc {
margin: 0 0 8px;
font-size: 12px;
color: #888;
}
.aksrt-preset-picker__item-modules {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.aksrt-preset-picker__module-tag {
font-size: 11px;
}
</style>
@@ -0,0 +1,50 @@
import { ref, watch, computed } from 'vue'
/**
* useDirtyState — tracks whether a form has unsaved changes.
*
* Usage:
* const { isDirty, markDirty, markClean, confirmLeave } = useDirtyState()
*
* Call markDirty() whenever form data changes from its initial state.
* Call markClean() after a successful save.
* confirmLeave() returns a boolean — false means the user cancelled navigation.
*/
export function useDirtyState() {
const dirty = ref(false)
const saveCount = ref(0)
function markDirty() {
dirty.value = true
}
function markClean() {
dirty.value = false
saveCount.value++
}
/** Reset dirty without incrementing save count (e.g. on initial load) */
function reset() {
dirty.value = false
}
/**
* Confirm navigation away from dirty form.
* Returns true if safe to leave, false if user cancelled.
*/
function confirmLeave() {
if (!dirty.value) return true
return window.confirm('您有未保存的更改,确定要离开此页面吗?')
}
const isDirty = computed(() => dirty.value)
return {
isDirty,
markDirty,
markClean,
reset,
confirmLeave,
saveCount,
}
}
@@ -0,0 +1,140 @@
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
import { useDirtyState } from './useDirtyState'
/**
* useSectionForm — standard composable for schema-driven admin settings pages.
*
* Encapsulates: data loading, dirty tracking, save/reload logic, and loading states.
*
* Usage:
* const {
* formData, loading, saving, loadError,
* dirty, load, save, reset,
* } = useSectionForm({
* fetch: () => api.getBasicSettings(),
* submit: (data) => api.saveBasicSettings(data),
* defaults: { comment_enabled: true, lazy_load: true },
* })
*
* @param {Object} options
* @param {Function} options.fetch — async function returning current settings
* @param {Function} options.submit — async function accepting form data to save
* @param {Object} options.defaults — default values merged under fetched data
* @param {Function} [options.onLoaded] — callback after data is loaded
* @param {Function} [options.onSaved] — callback after successful save
* @param {Function} [options.transform] — transform fetched data before applying
*/
export function useSectionForm(options) {
const {
fetch: fetchData,
submit: submitData,
defaults = {},
onLoaded,
onSaved,
transform,
} = options
const formData = ref({ ...defaults })
const initialSnapshot = ref('')
const loading = ref(false)
const saving = ref(false)
const loadError = ref(null)
const { isDirty, markDirty, markClean, reset: resetDirty, confirmLeave } = useDirtyState()
/** Snapshot the current form state for dirty comparison */
function snapshot() {
initialSnapshot.value = JSON.stringify(formData.value)
}
/** Check if form data differs from snapshot */
function checkDirty() {
const current = JSON.stringify(formData.value)
if (current !== initialSnapshot.value) {
markDirty()
} else {
resetDirty()
}
}
/** Deep watch form data for dirty tracking */
let watcherHandle = null
function startWatching() {
if (watcherHandle) watcherHandle()
watcherHandle = watch(formData, checkDirty, { deep: true })
}
/** Load settings from API */
async function load() {
loading.value = true
loadError.value = null
try {
const data = await fetchData()
const raw = data?.data ?? data ?? {}
const merged = { ...defaults, ...(transform ? transform(raw) : raw) }
formData.value = merged
snapshot()
startWatching()
onLoaded?.(merged)
} catch (err) {
loadError.value = err?.message || '加载失败'
console.error('[useSectionForm] load error:', err)
} finally {
loading.value = false
}
}
/** Save current form data */
async function save() {
saving.value = true
try {
await submitData(formData.value)
snapshot()
markClean()
onSaved?.(formData.value)
} catch (err) {
console.error('[useSectionForm] save error:', err)
throw err // let caller handle the error display
} finally {
saving.value = false
}
}
/** Reload form data from API (discards changes) */
async function reload() {
if (watcherHandle) watcherHandle()
resetDirty()
await load()
}
/** Reset form to defaults */
function resetToDefaults() {
if (watcherHandle) watcherHandle()
formData.value = { ...defaults }
snapshot()
startWatching()
resetDirty()
}
// Auto-load on mount
onMounted(load)
// Cleanup watcher
onBeforeUnmount(() => {
if (watcherHandle) watcherHandle()
})
return {
formData,
loading,
saving,
loadError,
dirty: isDirty,
load,
save,
reload,
resetToDefaults,
confirmLeave,
}
}
@@ -0,0 +1,10 @@
/**
* Admin sections — central menu and route registry.
*
* Sidebar menu, route metadata, schema domain mapping, and deferred-page
* planning details are all registered in domain modules to avoid drift between
* the navigation tree and router definitions while keeping each file focused.
*/
export { ADMIN_SECTIONS, getAllAdminPages } from './sections/index.js'
export { renderFaIcon, customPage, schemaPage, deferredPage } from './sections/helpers.js'
@@ -0,0 +1,20 @@
import { renderFaIcon, customPage } from './helpers'
export const docsSection = {
key: 'tools-compat',
label: '兼容&工具',
icon: renderFaIcon('fas fa-toolbox'),
route: '/tools',
hidden: true,
children: [
customPage({
key: 'docs-dashboard',
name: 'DocsDashboard',
label: '文档中心',
route: '/docs',
component: 'DocsPage',
title: '文档中心',
desc: '更新日志、文档链接和发布说明。',
}),
],
}
@@ -0,0 +1,80 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const extensionsSection = {
key: 'extensions',
label: '扩展&增强',
icon: renderFaIcon('fas fa-puzzle-piece'),
route: '/extensions',
children: [
schemaPage({
key: 'extensions-ip',
name: 'ExtensionsIp',
label: 'IP归属地',
route: '/extensions/ip',
title: 'IP归属地',
desc: '配置 IP 数据源、缓存策略和前台归属地展示。',
canonicalDomain: 'extensions',
section: 'ip',
}),
schemaPage({
key: 'extensions-audit',
name: 'ExtensionsAudit',
label: 'API内容审核',
route: '/extensions/audit',
title: 'API内容审核',
desc: '配置第三方内容审核 API 接入、审核范围和失败回退策略。',
canonicalDomain: 'extensions',
section: 'audit',
}),
schemaPage({
key: 'extensions-push',
name: 'ExtensionsPush',
label: '百度推送',
route: '/extensions/push',
title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
canonicalDomain: 'extensions',
section: 'push',
}),
schemaPage({
key: 'extensions-docs-mode',
name: 'ExtensionsDocsMode',
label: '文档模式',
route: '/extensions/docs-mode',
title: '文档模式',
desc: '文档专题内容模式与页面增强。',
canonicalDomain: 'extensions',
section: 'docs-mode',
}),
customPage({
key: 'extensions-security',
name: 'ExtensionsSecurity',
label: '网站安全',
route: '/extensions/security',
component: 'ExtensionSecurity',
title: '网站安全',
desc: '扩展安全策略和第三方安全集成。',
}),
customPage({
key: 'extensions-tools',
name: 'ExtensionsTools',
label: '系统工具',
route: '/extensions/tools',
component: 'ExtensionTools',
title: '系统工具',
desc: '额外运维工具与扩展能力。',
}),
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 子系统',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 子系统',
desc: '配置 AI 内容辅助能力。',
canonicalDomain: 'extensions',
section: 'ai',
}),
],
}
@@ -0,0 +1,165 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const forumSection = {
key: 'forum',
label: '社区&论坛',
icon: renderFaIcon('fas fa-comments'),
route: '/forum',
children: [
schemaPage({
key: 'forum-settings-basic',
name: 'ForumSettingsBasic',
label: '基础设置',
route: '/forum/settings',
title: '论坛基础设置',
desc: '论坛总开关、管理员和名称定义。',
canonicalDomain: 'forum',
section: 'basic',
}),
schemaPage({
key: 'forum-settings-names',
name: 'ForumSettingsNames',
label: '名称定义',
route: '/forum/settings/names',
title: '名称定义',
desc: '自定义论坛各实体的显示名称。',
canonicalDomain: 'forum',
section: 'names',
}),
schemaPage({
key: 'forum-settings-homepage',
name: 'ForumSettingsHomepage',
label: '首页设置',
route: '/forum/settings/homepage',
title: '论坛首页设置',
desc: '首页 Tab 引擎、快速发帖入口和移动端交互配置。',
canonicalDomain: 'forum',
section: 'homepage',
}),
schemaPage({
key: 'forum-settings-board-page',
name: 'ForumSettingsBoardPage',
label: '版块页面',
route: '/forum/settings/board-page',
title: '版块页面设置',
desc: '版块页布局、Tab 栏目和移动端交互配置。',
canonicalDomain: 'forum',
section: 'board_page',
}),
schemaPage({
key: 'forum-settings-post-page',
name: 'ForumSettingsPostPage',
label: '帖子页面',
route: '/forum/settings/post-page',
title: '帖子页面设置',
desc: '面包屑、评分、高度限制和封面配置。',
canonicalDomain: 'forum',
section: 'post_page',
}),
schemaPage({
key: 'forum-settings-reply',
name: 'ForumSettingsReply',
label: '回复评论',
route: '/forum/settings/reply',
title: '回复评论设置',
desc: '回复翻页方式、工具栏功能和快捷回复配置。',
canonicalDomain: 'forum',
section: 'reply',
}),
schemaPage({
key: 'forum-settings-permissions',
name: 'ForumSettingsPermissions',
label: '权限配置',
route: '/forum/settings/permissions',
title: '论坛权限配置',
desc: '发帖限制、帖子付费和版块付费关注。',
canonicalDomain: 'forum',
section: 'permissions',
}),
schemaPage({
key: 'forum-settings-other',
name: 'ForumSettingsOther',
label: '其它设置',
route: '/forum/settings/other',
title: '论坛其它设置',
desc: '投票、版主申请说明、标题字数和 URL 别名。',
canonicalDomain: 'forum',
section: 'other',
}),
schemaPage({
key: 'forum-settings-upload',
name: 'ForumSettingsUpload',
label: '上传权限',
route: '/forum/settings/upload',
title: '上传权限设置',
desc: '图片、视频和文件上传大小与格式限制。',
canonicalDomain: 'forum',
section: 'upload',
}),
schemaPage({
key: 'forum-settings-credits',
name: 'ForumSettingsCredits',
label: '积分设置',
route: '/forum/settings/credits',
title: '论坛积分设置',
desc: '论坛行为积分奖励矩阵和每日上限。',
canonicalDomain: 'forum',
section: 'credits',
}),
customPage({
key: 'forum-boards',
name: 'ForumBoards',
label: '板块管理',
route: '/forum/boards',
component: 'ForumBoards',
title: '板块管理',
desc: '创建、编辑和排序论坛板块。',
canonicalDomain: 'forum',
section: 'boards',
}),
customPage({
key: 'forum-topics',
name: 'ForumTopics',
label: '主题治理',
route: '/forum/topics',
component: 'ForumTopics',
title: '主题治理',
desc: '集中处理主题审核、可见性、版块移动和治理状态。',
canonicalDomain: 'forum',
section: 'topics',
}),
customPage({
key: 'forum-moderator-applications',
name: 'ForumModeratorApplications',
label: '版主申请',
route: '/forum/moderator-applications',
component: 'ForumModeratorApplications',
title: '版主申请',
desc: '处理前台版主申请和审核结果反馈。',
canonicalDomain: 'forum',
section: 'moderator-applications',
}),
customPage({
key: 'forum-moderators',
name: 'ForumModerators',
label: '版主管理',
route: '/forum/moderators',
component: 'ForumModerators',
title: '版主管理',
desc: '为每个论坛版块分配和管理版主。',
canonicalDomain: 'forum',
section: 'moderators',
}),
customPage({
key: 'forum-dashboard',
name: 'ForumDashboard',
label: '论坛概览',
route: '/forum',
component: 'ForumDashboard',
title: '论坛概览',
desc: '社区讨论、帖子管理、版块配置等论坛功能概览。',
canonicalDomain: 'forum',
section: 'dashboard',
}),
],
}
@@ -0,0 +1,108 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const globalSection = {
key: 'global',
label: '全局&功能',
icon: renderFaIcon('fas fa-globe'),
route: '/global',
children: [
schemaPage({
key: 'global-common',
name: 'GlobalCommon',
label: '常用功能',
route: '/global/common',
title: '常用功能',
desc: '评论、性能、头像源与备案等全局基础配置。',
canonicalDomain: 'global',
section: 'common',
}),
schemaPage({
key: 'global-logo',
name: 'GlobalLogo',
label: 'LOGO图像',
route: '/global/logo',
title: 'LOGO图像',
desc: '管理站点 Logo、站点图标和品牌标识。',
canonicalDomain: 'global',
section: 'logo',
}),
schemaPage({
key: 'global-seo',
name: 'GlobalSeo',
label: 'SEO优化',
route: '/global/seo',
title: 'SEO优化',
desc: '站点标题、描述、关键词、Sitemap 等搜索引擎优化设置。',
canonicalDomain: 'global',
section: 'seo',
}),
schemaPage({
key: 'global-layout',
name: 'GlobalLayout',
label: '显示&布局',
route: '/global/layout',
title: '显示&布局',
desc: '全局布局风格、颜色方案、字体设置。',
canonicalDomain: 'global',
section: 'layout',
}),
schemaPage({
key: 'global-search',
name: 'GlobalSearch',
label: '搜索功能',
route: '/global/search',
title: '搜索功能',
desc: '搜索页面样式、搜索建议、热门搜索配置。',
canonicalDomain: 'global',
section: 'search',
}),
customPage({
key: 'global-tabbar',
name: 'GlobalTabbar',
label: '手机底部Tab',
route: '/global/tabbar',
title: '手机底部Tab',
desc: '移动端底部导航栏配置。',
component: 'FooterTabbarSettings',
}),
customPage({
key: 'global-floating',
name: 'GlobalFloating',
label: '右侧悬浮按钮',
route: '/global/floating',
title: '右侧悬浮按钮',
desc: '页面右侧浮动按钮的显示与配置。',
component: 'FloatingButtonsSettings',
}),
schemaPage({
key: 'global-i18n',
name: 'GlobalI18n',
label: '多语言翻译',
route: '/global/i18n',
title: '多语言翻译',
desc: '配置站点语言、语言切换器和翻译管理。',
canonicalDomain: 'global',
section: 'i18n',
}),
schemaPage({
key: 'global-email',
name: 'GlobalEmail',
label: 'Email邮件',
route: '/global/email',
title: 'Email邮件',
desc: 'SMTP 发信配置、邮件模板与测试。',
canonicalDomain: 'global',
section: 'email',
}),
schemaPage({
key: 'global-custom-code',
name: 'GlobalCustomCode',
label: '自定义代码',
route: '/global/custom-code',
title: '自定义代码',
desc: '自定义 CSS、JS 代码注入。',
canonicalDomain: 'global',
section: 'custom-code',
}),
],
}
@@ -0,0 +1,39 @@
import { h } from 'vue'
export function renderFaIcon(faClass) {
return () => h('i', { class: faClass, style: 'font-size:18px;width:18px;text-align:center' })
}
function sectionPage(config) {
return {
status: 'available',
component: 'SchemaPage',
canonicalDomain: '',
section: '',
phase: '',
dependsOn: [],
reason: '',
features: [],
iconClass: '',
...config,
}
}
export function customPage(config) {
return sectionPage(config)
}
export function schemaPage(config) {
return sectionPage({
component: 'SchemaPage',
...config,
})
}
export function deferredPage(config) {
return sectionPage({
component: 'DeferredPage',
status: 'deferred',
...config,
})
}
@@ -0,0 +1,66 @@
import { globalSection } from './global'
import { pageDisplaySection } from './page-display'
import { postListSection } from './post-list'
import { permissionsSection } from './permissions'
import { usersSection } from './users'
import { paymentSection } from './payment'
import { shopSection } from './shop'
import { forumSection } from './forum'
import { extensionsSection } from './extensions'
import { themeInfoSection } from './theme-info'
import { docsSection } from './docs'
import { toolsSection } from './tools'
const primarySections = [
globalSection,
pageDisplaySection,
postListSection,
permissionsSection,
usersSection,
paymentSection,
shopSection,
forumSection,
extensionsSection,
]
const compatChildren = [
...(toolsSection.children || []),
...(themeInfoSection.children || []),
...(docsSection.children || []),
]
export const compatToolsSection = {
key: 'tools-compat',
label: '兼容&工具',
icon: toolsSection.icon,
route: '/tools',
children: compatChildren,
}
export const ADMIN_SECTIONS = [
...primarySections,
compatToolsSection,
]
export function getAllAdminPages() {
const pages = []
for (const section of ADMIN_SECTIONS) {
if (section.placeholder) {
pages.push({
sectionKey: section.key,
...section,
})
continue
}
for (const child of section.children || []) {
pages.push({
sectionKey: section.key,
...child,
})
}
}
return pages
}
@@ -0,0 +1,118 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const pageDisplaySection = {
key: 'page-display',
label: '页面&显示',
icon: renderFaIcon('fas fa-desktop'),
route: '/page-display',
children: [
schemaPage({
key: 'page-display-header-nav',
name: 'PageDisplayHeaderNav',
label: '顶部导航栏',
route: '/page-display/header-nav',
title: '顶部导航栏',
desc: '导航栏布局、菜单位置、LOGO 与搜索入口配置。',
canonicalDomain: 'page_display',
section: 'header-nav',
}),
schemaPage({
key: 'page-display-background',
name: 'PageDisplayBackground',
label: '全局背景',
route: '/page-display/background',
title: '全局背景',
desc: '配置站点全局背景图、背景色、暗色模式背景和遮罩。',
canonicalDomain: 'page_display',
section: 'background',
}),
schemaPage({
key: 'page-display-menu-decoration',
name: 'PageDisplayMenuDecoration',
label: '菜单装饰',
route: '/page-display/menu-decoration',
title: '菜单装饰',
desc: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。',
canonicalDomain: 'page_display',
section: 'menu-decoration',
}),
schemaPage({
key: 'page-display-header-components',
name: 'PageDisplayHeaderComponents',
label: '顶部多功能组件',
route: '/page-display/header-components',
title: '顶部多功能组件',
desc: '顶部轮播、图标卡片、搜索条、公告条等组件配置。',
canonicalDomain: 'page_display',
section: 'header-components',
}),
customPage({
key: 'page-display-footer',
name: 'PageDisplayFooter',
label: '底部页脚',
route: '/page-display/footer',
title: '底部页脚',
desc: '页脚布局、友链、版权信息配置。',
component: 'FooterSettings',
}),
customPage({
key: 'page-display-homepage',
name: 'PageDisplayHomepage',
label: '首页配置',
route: '/page-display/homepage',
title: '首页配置',
desc: '首页模块、布局、推荐位配置。',
component: 'HomepageSettings',
}),
schemaPage({
key: 'page-display-category',
name: 'PageDisplayCategory',
label: '分类页面',
route: '/page-display/category',
title: '分类页面',
desc: '分类归档页的头图、简介、列表模式、侧栏配置。',
canonicalDomain: 'page_display',
section: 'category',
}),
schemaPage({
key: 'page-display-tag',
name: 'PageDisplayTag',
label: '标签页面',
route: '/page-display/tag',
title: '标签页面',
desc: '标签归档页的展示样式与配置。',
canonicalDomain: 'page_display',
section: 'tag',
}),
schemaPage({
key: 'page-display-special',
name: 'PageDisplaySpecial',
label: '专题页面',
route: '/page-display/special',
title: '专题页面',
desc: '配置专题聚合页的封面、摘要、布局、聚合规则和子页面展示策略。',
canonicalDomain: 'page_display',
section: 'special',
}),
schemaPage({
key: 'page-display-user-home',
name: 'PageDisplayUserHome',
label: '用户主页',
route: '/page-display/user-home',
title: '用户主页',
desc: '用户公开主页的布局与展示配置。',
canonicalDomain: 'page_display',
section: 'user-home',
}),
schemaPage({
key: 'page-display-other',
name: 'PageDisplayOther',
label: '其它页面',
route: '/page-display/other',
title: '其它页面',
desc: '404 页面、搜索结果页等其它页面配置。',
canonicalDomain: 'page_display',
section: 'other',
}),
],
}
@@ -0,0 +1,100 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const paymentSection = {
key: 'payment',
label: '支付&付费',
icon: renderFaIcon('fas fa-wallet'),
route: '/payment',
children: [
customPage({
key: 'payment-gateway',
name: 'PaymentGateway',
label: '收款接口',
route: '/payment/gateway',
component: 'PaymentGateway',
status: 'available',
title: '收款接口',
desc: '支付网关适配器、测试网关和手动确认配置。',
canonicalDomain: 'payment',
section: 'gateway',
}),
customPage({
key: 'payment-paid',
name: 'PaymentPaid',
label: '下载/付费内容中心',
route: '/payment/paid',
title: '下载/付费内容中心',
desc: '资源下载门槛、付费内容策略和人工交付说明。',
component: 'ResourceDownload',
}),
schemaPage({
key: 'payment-recharge',
name: 'PaymentRecharge',
label: '余额充值',
route: '/payment/recharge',
status: 'available',
title: '余额充值',
desc: '余额充值策略、记录和手动确认配置。',
canonicalDomain: 'payment',
section: 'recharge',
}),
schemaPage({
key: 'payment-vip',
name: 'PaymentVip',
label: 'VIP会员',
route: '/payment/vip',
status: 'available',
title: 'VIP会员',
desc: '会员套餐、会员特权和免扣策略。',
canonicalDomain: 'payment',
section: 'vip',
}),
customPage({
key: 'payment-revenue',
name: 'PaymentRevenue',
label: '创作分成',
route: '/payment/revenue',
component: 'PaymentRevenue',
status: 'available',
title: '创作分成',
desc: '创作者收益分配和结算记录。',
canonicalDomain: 'payment',
section: 'revenue',
}),
schemaPage({
key: 'payment-affiliate',
name: 'PaymentAffiliate',
label: '推广返佣',
route: '/payment/affiliate',
status: 'available',
title: '推广返佣',
desc: '返佣规则、邀请链路和结算记录。',
canonicalDomain: 'payment',
section: 'affiliate',
}),
customPage({
key: 'payment-charge-card',
name: 'PaymentChargeCard',
label: '充值卡',
route: '/payment/charge-card',
component: 'PaymentChargeCard',
status: 'available',
title: '充值卡',
desc: '创建和管理充值卡。',
canonicalDomain: 'payment',
section: 'charge-card',
}),
customPage({
key: 'payment-rebate',
name: 'PaymentRebate',
label: '返佣管理',
route: '/payment/rebate',
component: 'PaymentRebate',
status: 'available',
title: '返佣管理',
desc: '管理订单返佣和推广返佣记录。',
canonicalDomain: 'payment',
section: 'rebate',
}),
],
}
@@ -0,0 +1,74 @@
import { renderFaIcon, customPage, schemaPage } from './helpers'
export const permissionsSection = {
key: 'permissions',
label: '功能&权限',
icon: renderFaIcon('fas fa-shield-alt'),
route: '/permissions',
children: [
customPage({
key: 'permissions-comment',
name: 'PermissionsComment',
label: '评论权限',
route: '/permissions/comment',
component: 'CommentPermissions',
title: '评论权限',
desc: '游客评论、频率限制、敏感词和审核策略。',
canonicalDomain: 'permissions',
section: 'comment',
}),
customPage({
key: 'permissions-attachment',
name: 'PermissionsAttachment',
label: '附件权限',
route: '/permissions/attachment',
component: 'AttachmentPermissions',
title: '附件权限',
desc: '上传角色、文件类型、大小限制和审核状态。',
canonicalDomain: 'permissions',
section: 'attachment',
}),
customPage({
key: 'permissions-download',
name: 'PermissionsDownload',
label: '下载权限',
route: '/permissions/download',
component: 'DownloadPermissions',
title: '下载权限',
desc: '登录要求、积分要求、角色要求和 VIP 特权。',
canonicalDomain: 'permissions',
section: 'download',
}),
schemaPage({
key: 'permissions-login',
name: 'PermissionsLogin',
label: '登录权限',
route: '/permissions/login',
title: '登录权限',
desc: '登录验证、人机验证、注册限制配置。',
canonicalDomain: 'permissions',
section: 'login',
}),
schemaPage({
key: 'permissions-moderation',
name: 'PermissionsModeration',
label: '审核和风控',
route: '/permissions/moderation',
title: '审核和风控',
desc: '评论审核、反垃圾、限流与风控配置。',
canonicalDomain: 'permissions',
section: 'moderation',
}),
customPage({
key: 'permissions-capabilities',
name: 'PermissionsCapabilities',
label: '基本权限',
route: '/permissions/capabilities',
component: 'PermissionsCapabilities',
title: '基本权限 / 能力矩阵',
desc: '统一管理用户治理、投稿和评论的角色能力矩阵。',
canonicalDomain: 'permissions',
section: 'capabilities',
}),
],
}
@@ -0,0 +1,92 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const postListSection = {
key: 'post-list',
label: '文章&列表',
icon: renderFaIcon('fas fa-file-alt'),
route: '/post-list',
children: [
schemaPage({
key: 'post-list-list',
name: 'PostListList',
label: '文章列表',
route: '/post-list/list',
title: '文章列表',
desc: '列表样式、元信息开关、分页方式配置。',
canonicalDomain: 'post_list',
section: 'list',
}),
schemaPage({
key: 'post-list-thumb',
name: 'PostListThumb',
label: '列表缩略图',
route: '/post-list/thumb',
title: '列表缩略图',
desc: '缩略图策略、封面回退、多图模式配置。',
canonicalDomain: 'post_list',
section: 'thumb',
}),
schemaPage({
key: 'post-list-page',
name: 'PostListPage',
label: '文章页面',
route: '/post-list/page',
component: 'PostPageSettings',
title: '文章页面',
desc: '文章页布局、目录树、灯箱、版权区配置。',
canonicalDomain: 'post_list',
section: 'page',
}),
schemaPage({
key: 'post-list-filter',
name: 'PostListFilter',
label: '高级筛选',
route: '/post-list/filter',
title: '高级筛选',
desc: '筛选栏、排序方式、分类筛选配置。',
canonicalDomain: 'post_list',
section: 'filter',
}),
schemaPage({
key: 'post-list-feature',
name: 'PostListFeature',
label: '文章功能',
route: '/post-list/feature',
title: '文章功能',
desc: '收藏、分享、阅读进度、相关推荐等功能开关。',
canonicalDomain: 'post_list',
section: 'feature',
}),
schemaPage({
key: 'post-list-share',
name: 'PostListShare',
label: '分享设置',
route: '/post-list/share',
title: '分享设置',
desc: '配置微信分享凭据、海报分享和社交分享选项。',
canonicalDomain: 'post_list',
section: 'share',
}),
schemaPage({
key: 'post-list-comment',
name: 'PostListComment',
label: '评论设置',
route: '/post-list/comment',
component: 'CommentSettings',
title: '评论设置',
desc: '评论排序、楼层展示、登录限制、回复限制等配置。',
canonicalDomain: 'post_list',
section: 'comment',
}),
schemaPage({
key: 'post-list-submit',
name: 'PostListSubmit',
label: '前台投稿',
route: '/post-list/submit',
title: '前台投稿',
desc: '配置前台投稿表单字段、审核流程、附件权限和封面策略。',
canonicalDomain: 'post_list',
section: 'submit',
}),
],
}
+157
View File
@@ -0,0 +1,157 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const shopSection = {
key: 'shop',
label: '商城&商品',
icon: renderFaIcon('fas fa-shopping-cart'),
route: '/shop',
children: [
customPage({
key: 'shop-products',
name: 'ShopProducts',
label: '商品管理',
route: '/shop/products',
component: 'ShopProducts',
title: '商品管理',
desc: '管理所有商品的上架、编辑和库存。',
canonicalDomain: 'shop',
section: 'products',
}),
customPage({
key: 'shop-orders',
name: 'ShopOrders',
label: '订单管理',
route: '/shop/orders',
component: 'ShopOrders',
title: '订单管理',
desc: '查看和管理所有交易订单。',
canonicalDomain: 'shop',
section: 'orders',
}),
customPage({
key: 'shop-withdrawals',
name: 'ShopWithdrawals',
label: '提现管理',
route: '/shop/withdrawals',
component: 'ShopWithdrawals',
title: '提现管理',
desc: '审核和管理创作者提现申请。',
canonicalDomain: 'shop',
section: 'withdrawals',
}),
customPage({
key: 'shop-coupons',
name: 'ShopCoupons',
label: '优惠券',
route: '/shop/coupons',
component: 'ShopCoupons',
title: '优惠券',
desc: '创建和管理优惠券。',
canonicalDomain: 'shop',
section: 'coupons',
}),
customPage({
key: 'shop-after-sale',
name: 'ShopAfterSale',
label: '售后管理',
route: '/shop/after-sale',
component: 'ShopAfterSale',
title: '售后管理',
desc: '管理退款、换货等售后工单。',
canonicalDomain: 'shop',
section: 'after-sale',
}),
customPage({
key: 'shop-shipping',
name: 'ShopShipping',
label: '物流管理',
route: '/shop/shipping',
component: 'ShopShipping',
title: '物流管理',
desc: '管理订单发货、物流单号和发货状态。',
canonicalDomain: 'shop',
section: 'shipping',
}),
schemaPage({
key: 'shop-basic',
name: 'ShopBasic',
label: '商城基础',
route: '/shop/basic',
title: '商城基础',
desc: '商城总开关、首页链接、面包屑和 URL 别名设置。',
canonicalDomain: 'shop',
section: 'basic',
}),
schemaPage({
key: 'shop-list',
name: 'ShopList',
label: '商品列表',
route: '/shop/list',
title: '商品列表',
desc: '商品列表排序、分页、卡片样式和销量显示配置。',
canonicalDomain: 'shop',
section: 'list',
}),
schemaPage({
key: 'shop-contact',
name: 'ShopContact',
label: '客服配置',
route: '/shop/contact',
title: '客服配置',
desc: '商品详情页客服按钮和联系方式配置。',
canonicalDomain: 'shop',
section: 'contact',
}),
schemaPage({
key: 'shop-single',
name: 'ShopSingle',
label: '商品详情',
route: '/shop/single',
title: '商品详情',
desc: '商品详情页布局、相关推荐和底部内容设置。',
canonicalDomain: 'shop',
section: 'single',
}),
schemaPage({
key: 'shop-product-defaults',
name: 'ShopProductDefaults',
label: '商品默认参数',
route: '/shop/product-defaults',
title: '商品默认参数',
desc: '主题级商品参数默认值,作为参数继承链的根。',
canonicalDomain: 'shop',
section: 'product-defaults',
}),
schemaPage({
key: 'shop-shipping-config',
name: 'ShopShippingConfig',
label: '物流配置',
route: '/shop/shipping-config',
title: '物流配置',
desc: '快递查询接口、物流公司列表和收货地址设置。',
canonicalDomain: 'shop',
section: 'shipping-config',
}),
schemaPage({
key: 'shop-timing',
name: 'ShopTiming',
label: '时效与售后原因',
route: '/shop/timing',
title: '时效与售后原因',
desc: '确认收货、评价、退货时效和售后原因配置。',
canonicalDomain: 'shop',
section: 'timing',
}),
customPage({
key: 'shop-dashboard',
name: 'ShopDashboard',
label: '商城概览',
route: '/shop',
component: 'ShopDashboard',
title: '商城概览',
desc: '商品管理、订单系统、库存管理等电商功能概览。',
canonicalDomain: 'shop',
section: 'dashboard',
}),
],
}
@@ -0,0 +1,29 @@
import { renderFaIcon, customPage } from './helpers'
export const themeInfoSection = {
key: 'tools-compat',
label: '兼容&工具',
icon: renderFaIcon('fas fa-toolbox'),
route: '/tools',
hidden: true,
children: [
customPage({
key: 'theme-info-dashboard',
name: 'ThemeInfoDashboard',
label: '主题信息',
route: '/theme-info',
component: 'ThemeInfoPage',
title: '主题信息',
desc: '主题版本、授权信息和帮助入口。',
}),
customPage({
key: 'theme-authorization',
name: 'ThemeAuthorization',
label: '主题授权',
route: '/theme-info/authorization',
component: 'ThemeAuthorizationPage',
title: '主题授权',
desc: '当前版本的授权策略与产品决策说明。',
}),
],
}
@@ -0,0 +1,67 @@
import { renderFaIcon, customPage, schemaPage } from './helpers'
export const toolsSection = {
key: 'tools-compat',
label: '兼容&工具',
icon: renderFaIcon('fas fa-toolbox'),
route: '/tools',
children: [
customPage({
key: 'tools-import-export',
name: 'ToolsImportExport',
label: '导入导出',
route: '/tools/import-export',
title: '导入导出',
desc: '导出当前配置、执行 dry-run 导入并按域重置。',
component: 'ToolsImportExport',
}),
schemaPage({
key: 'tools-backup',
name: 'ToolsBackup',
label: '数据备份',
route: '/tools/backup',
title: '数据备份',
desc: '管理设置快照、创建备份并执行恢复或删除。',
canonicalDomain: 'tools',
section: 'backup',
}),
customPage({
key: 'tools-health',
name: 'ToolsHealth',
label: '健康检查',
route: '/tools/health',
title: '健康检查',
desc: '查看主题环境、数据表、邮箱验证迁移和系统诊断状态。',
component: 'ToolsHealth',
}),
customPage({
key: 'tools-migration',
name: 'ToolsMigration',
label: '迁移状态',
route: '/tools/migration',
title: '迁移状态',
desc: '查看数据库 schema 版本、设置迁移版本和迁移日志。',
component: 'ToolsMigration',
}),
customPage({
key: 'tools-email-rollback',
name: 'ToolsEmailRollback',
label: '邮件回滚',
route: '/tools/email-rollback',
title: '邮件回滚',
desc: '查看 14 天邮箱变更窗口内的账号并执行回滚。',
component: 'ToolsEmailRollback',
}),
customPage({
key: 'tools-update',
name: 'ToolsUpdate',
label: '更新中心',
route: '/tools/update',
title: '更新中心',
desc: '查看版本状态、检测更新并浏览更新日志与教程入口。',
component: 'ToolsUpdate',
canonicalDomain: 'tools',
section: 'update',
}),
],
}
@@ -0,0 +1,188 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
export const usersSection = {
key: 'users',
label: '用户&互动',
icon: renderFaIcon('fas fa-users'),
route: '/users',
children: [
schemaPage({
key: 'users-auth',
name: 'UsersAuth',
label: '注册登录',
route: '/users/auth',
title: '注册登录',
desc: '注册方式、登录限制和密码策略配置。',
canonicalDomain: 'users',
section: 'auth',
}),
schemaPage({
key: 'users-oauth',
name: 'UsersOauth',
label: '第三方登录',
route: '/users/oauth',
title: '第三方登录',
desc: 'GitHub、Gitee、QQ、Google 等第三方登录配置。',
canonicalDomain: 'users',
section: 'oauth',
}),
customPage({
key: 'users-features',
name: 'UsersFeatures',
label: '用户功能',
route: '/users/features',
component: 'UsersFeatures',
title: '用户功能',
desc: '用户中心与个人主页模块配置。',
canonicalDomain: 'users',
section: 'features',
}),
customPage({
key: 'users-signin',
name: 'UsersSignin',
label: '签到奖励',
route: '/users/signin',
component: 'UsersSignin',
title: '签到奖励',
desc: '签到规则、连续签到奖励和签到页面配置。',
canonicalDomain: 'users',
section: 'signin',
}),
customPage({
key: 'users-credits',
name: 'UsersCredits',
label: '用户积分',
route: '/users/credits',
title: '用户积分',
desc: '积分规则、积分日志和用户积分管理。',
component: 'CmsCredits',
canonicalDomain: 'users',
section: 'credits',
}),
customPage({
key: 'users-verify',
name: 'UsersVerify',
label: '身份认证',
route: '/users/verify',
component: 'UsersVerify',
status: 'available',
title: '身份认证',
desc: '身份认证字段、审核流和展示标签。',
canonicalDomain: 'users',
section: 'verify',
}),
customPage({
key: 'users-level',
name: 'UsersLevel',
label: '用户等级',
route: '/users/level',
component: 'UsersLevel',
status: 'available',
title: '用户等级',
desc: '等级规则、升级条件和权益映射。',
canonicalDomain: 'users',
section: 'level',
}),
customPage({
key: 'users-badge',
name: 'UsersBadge',
label: '用户徽章',
route: '/users/badge',
component: 'UsersBadge',
status: 'available',
title: '用户徽章',
desc: '徽章规则、授予条件和前台展示。',
canonicalDomain: 'users',
section: 'badge',
}),
schemaPage({
key: 'users-notifications',
name: 'UsersNotifications',
label: '消息通知',
route: '/users/notifications',
title: '消息通知',
desc: '站内通知、邮件通知和通知策略配置。',
canonicalDomain: 'users',
section: 'notifications',
}),
customPage({
key: 'users-invite-codes',
name: 'UsersInviteCodes',
label: '邀请码运营',
route: '/users/invite-codes',
component: 'UsersInviteCodes',
title: '邀请码运营',
desc: '管理邀请码列表、生成新邀请码和查看邀请统计。',
canonicalDomain: 'users',
section: 'invite-codes',
}),
schemaPage({
key: 'users-sms',
name: 'UsersSms',
label: '短信接口',
route: '/users/sms',
title: '短信接口',
desc: '配置短信验证码和短信通知服务的供应商、模板和限流策略。',
canonicalDomain: 'users',
section: 'sms',
}),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({
key: 'users-wechat-template',
name: 'UsersWechatTemplate',
label: '微信模板消息',
route: '/users/wechat-template',
component: 'UsersWechatTemplate',
status: 'available',
title: '微信模板消息',
desc: '配置微信公众号模板消息推送。',
canonicalDomain: 'users',
section: 'wechat-template',
}),
customPage({
key: 'users-messages',
name: 'UsersMessages',
label: '消息中心',
route: '/users/messages',
component: 'UsersMessages',
status: 'available',
title: '消息中心',
desc: '管理站内通知、系统消息和用户私信记录。',
canonicalDomain: 'users',
section: 'messages',
}),
customPage({
key: 'users-ban',
name: 'UsersBan',
label: '禁封管理',
route: '/users/ban',
component: 'UsersBan',
status: 'available',
title: '禁封管理',
desc: '查看和管理用户封禁、限制状态。',
canonicalDomain: 'users',
section: 'ban',
}),
customPage({
key: 'users-report',
name: 'UsersReport',
label: '禁封/举报',
route: '/users/report',
component: 'UsersReport',
status: 'available',
title: '禁封/举报',
desc: '举报受理、用户封禁和治理台账。',
canonicalDomain: 'users',
section: 'report',
}),
],
}
@@ -0,0 +1,28 @@
import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js'
function cloneDefaults(defaults = {}) {
return JSON.parse(JSON.stringify(defaults))
}
export function getSectionDefaults(domain, section) {
const domainKey = normalizeSchemaDomain(domain)
const sectionSchema = ALL_SCHEMAS[domainKey]?.[section]
return cloneDefaults(sectionSchema?.defaults || {})
}
export function getDomainDefaults(domain) {
const domainKey = normalizeSchemaDomain(domain)
const sections = Object.values(ALL_SCHEMAS[domainKey] || {})
return sections.reduce((defaults, schema) => {
return {
...defaults,
...cloneDefaults(schema?.defaults || {}),
}
}, {})
}
export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => {
defaults[domain] = getDomainDefaults(domain)
return defaults
}, {})
+101
View File
@@ -0,0 +1,101 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import NaiveUI from 'naive-ui'
import App from './App.vue'
import router from './router'
import './style.css'
import { styleMountTarget } from './style-target'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(NaiveUI)
// 全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('[AKSRT] 全局错误:', err, info)
}
// 兜底:立即显示加载状态(防止白屏)
function showFallback(el) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:#666;font-family:sans-serif;">'
+ '<p style="font-size:18px;margin-bottom:8px;">AKSRT 正在加载...</p>'
+ '<p style="font-size:13px;color:#999;">如果长时间停留在此页面,请按 F12 查看控制台错误</p>'
+ '</div>'
}
showFallback(document.getElementById('aksrt-admin-app') || document.body.appendChild(
Object.assign(document.createElement('div'), { id: 'aksrt-admin-app' })
))
/**
* 获取或创建一个干净的挂载容器
* 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM,
* 导致 PHP 输出的 <div id="aksrt-admin-app"> 可能被外部修改或其兄弟节点被移除,
* Vue patch 时遍历到 null 节点 → nextSibling 报错。
*
* 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的,
* 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。
*/
function getCleanContainer() {
const existing = document.getElementById('aksrt-admin-app')
if (!existing) {
// 极端情况:PHP 没有输出容器,手动创建
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
document.body.appendChild(fresh)
return fresh
}
// 创建全新容器替换旧容器
// 这确保新容器没有 WP 脚本留下的任何副作用(事件监听、属性等)
const parent = existing.parentNode
if (!parent) {
// 旧容器已脱离文档树,重新创建
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
document.body.appendChild(fresh)
return fresh
}
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
// 将旧容器的位置信息转移给新容器(保留 fallback 内容)
fresh.innerHTML = existing.innerHTML || ''
parent.replaceChild(fresh, existing)
return fresh
}
/**
* 显示错误信息
*/
function showError(container, err) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#e74c3c;font-family:sans-serif;">'
+ '<p style="font-size:16px;margin-bottom:12px;">页面加载失败</p>'
+ '<p style="font-size:13px;color:#999;word-break:break-all;">' + (err.message || String(err)) + '</p>'
+ '<button onclick="location.reload()" style="margin-top:16px;padding:8px 24px;cursor:pointer;border:1px solid #e74c3c;background:none;color:#e74c3c;border-radius:4px;font-size:14px;">重新加载</button>'
+ '</div>'
}
// 等待 router 就绪后延迟挂载
router.isReady().then(() => {
// 三重 rAF:确保 load-scripts.php 加载的所有 jQuery 插件完成初始化和 DOM 操作
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
const container = getCleanContainer()
// 关键:在挂载前设置样式注入目标,确保 NaiveUI 组件渲染时目标已就绪
styleMountTarget.value = container
app.mount(container)
} catch (err) {
console.error('[AKSRT] Vue 挂载失败:', err)
showError(getCleanContainer(), err)
}
})
})
})
}).catch((err) => {
console.error('[AKSRT] Router 初始化失败:', err)
})
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="aksrt-page-container">
<h2 class=”aksrt-page-title”>积分配置</h2>
<p class=”aksrt-page-desc”>
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。
</p>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>主开关 + 基础设置</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用积分系统">
<n-switch v-model:value="cfg.enabled" />
</n-form-item>
<n-form-item label="自定义货币名称">
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
<template #feedback>
<span class="aksrt-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
</template>
</n-form-item>
<n-form-item label="注册赠送积分">
<n-input-number v-model:value="cfg.register_bonus" :min="0" :max="10000" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>每日签到</h3></div>
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.daily_signin.enabled" />
</n-form-item>
<n-form-item label="基础奖励">
<n-input-number v-model:value="cfg.daily_signin.base" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="连续奖励(JSON)">
<n-input
type="textarea"
v-model:value="streakJson"
placeholder='{"3":2,"7":5,"30":20}'
:autosize="{ minRows: 2 }"
/>
<template #feedback>
<span class="aksrt-hint-text">连续 N 天时额外发放的积分。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>评论奖励</h3></div>
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.comment_reward.enabled" />
</n-form-item>
<n-form-item label="单次奖励">
<n-input-number v-model:value="cfg.comment_reward.amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="每日上限">
<n-input-number v-model:value="cfg.comment_reward.daily_cap" :min="0" :max="100" />
</n-form-item>
<n-form-item label="最少字符数">
<n-input-number v-model:value="cfg.comment_reward.min_length" :min="0" :max="500" />
</n-form-item>
<n-form-item label="审核通过后发放">
<n-switch v-model:value="cfg.comment_reward.on_approved" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>邀请奖励</h3></div>
<n-form :model="cfg.invite_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.invite_reward.enabled" />
</n-form-item>
<n-form-item label="邀请人奖励">
<n-input-number v-model:value="cfg.invite_reward.inviter_amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="被邀请人奖励">
<n-input-number v-model:value="cfg.invite_reward.invitee_amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="需邮箱验证后发放">
<n-switch v-model:value="cfg.invite_reward.require_verify" />
</n-form-item>
<n-form-item label="同 IP 当日上限">
<n-input-number v-model:value="cfg.invite_reward.ip_daily_cap" :min="0" :max="100" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>下载消费</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item :label="`默认资源价格(${cfg.currency_name || '积分'})`">
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
<template #feedback>
<span class="aksrt-hint-text">资源未单独设价时回退使用。设为 0 表示免费</span>
</template>
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-footer">
<n-button type="primary" :loading="saving" @click="saveCfg">保存配置</n-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const message = useMessage()
// ---------- 配置 ----------
const cfg = reactive({
enabled: true,
currency_name: '',
register_bonus: 10,
daily_signin: { enabled: true, base: 1, streak: { 3: 2, 7: 5, 30: 20 } },
comment_reward: { enabled: true, amount: 1, daily_cap: 5, min_length: 10, on_approved: true },
invite_reward: {
enabled: true,
inviter_amount: 5,
invitee_amount: 2,
require_verify: true,
ip_daily_cap: 3,
register_field: 'optional',
reject_invalid: true,
},
download_default_cost: 0,
})
const streakJson = ref('{}')
const saving = ref(false)
async function loadCfg() {
try {
const data = await api.getCreditSettings()
Object.assign(cfg, data)
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
} catch (e) {
message.error(e.message || '加载失败')
}
}
async function saveCfg() {
saving.value = true
try {
let streak = {}
try { streak = JSON.parse(streakJson.value || '{}') } catch (_) { /* ignore */ }
cfg.daily_signin.streak = streak
await api.saveCreditSettings(cfg)
message.success('已保存')
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadCfg()
})
</script>
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="下载/付费内容中心"
desc="统一管理资源下载门槛、默认扣费、角色免扣和人工交付说明。运行中的下载 token 流程保持不变。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
<n-button quaternary @click="go('/payment/gateway')">收款接口</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动摘要</h3>
</div>
<n-space>
<n-tag round :type="form.download_login ? 'warning' : 'default'">
登录门槛:{{ form.download_login ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.download_credit ? 'warning' : 'default'">
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.download_captcha ? 'warning' : 'default'">
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round type="info">
默认价格:{{ form.download_default_cost }} {{ currencyName }}
</n-tag>
<n-tag round :type="form.payment_vip_free_download ? 'success' : 'default'">
角色免扣:{{ form.payment_vip_free_download ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="info">
Token TTL:10 分钟
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>下载门槛</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="下载需要登录">
<n-switch v-model:value="form.download_login" />
</n-form-item>
<n-form-item label="下载启用积分扣费">
<n-switch v-model:value="form.download_credit" />
</n-form-item>
<n-form-item label="下载启用验证码">
<n-switch v-model:value="form.download_captcha" />
</n-form-item>
<n-form-item :label="`默认资源价格(${currencyName})`">
<n-input-number v-model:value="form.download_default_cost" :min="0" :max="100000" style="width: 180px;" />
<template #feedback>
<span class="aksrt-hint-text">资源未单独设价时回退到这里。角色免扣命中时会直接跳过积分扣费。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>付费内容策略</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="受管内容类型">
<n-input v-model:value="form.payment_paid_post_types" placeholder="post,course,doc" />
<template #feedback>
<span class="aksrt-hint-text">逗号分隔,标记纳入统一付费策略的内容类型。</span>
</template>
</n-form-item>
<n-form-item label="角色白名单免扣下载">
<n-switch v-model:value="form.payment_vip_free_download" />
</n-form-item>
<n-form-item label="免扣角色列表" v-if="form.payment_vip_free_download">
<n-input v-model:value="form.payment_vip_exempt_roles" placeholder="administrator,editor,vip" />
<template #feedback>
<span class="aksrt-hint-text">命中这些 WordPress role 的用户下载资源时将直接跳过积分扣费。</span>
</template>
</n-form-item>
<n-form-item label="前台付费提示">
<n-input
v-model:value="form.payment_paid_catalog_note"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="可填写资源购买须知、交付说明或人工确认提示。"
/>
</n-form-item>
<n-form-item label="人工交付备注">
<n-input
v-model:value="form.payment_manual_delivery_note"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
当前收款模式:{{ gatewayModeLabel }}。充值、VIP、返佣和分成页均已提供稳定结构,订单和结算台账直接衔接这些字段。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const featureSnapshot = ref({})
const creditSnapshot = ref({})
const overview = ref({})
const form = reactive({
download_login: true,
download_credit: true,
download_captcha: false,
download_default_cost: 0,
payment_paid_post_types: 'post',
payment_vip_free_download: false,
payment_vip_exempt_roles: '',
payment_paid_catalog_note: '',
payment_manual_delivery_note: '付费或人工确认内容请在订单备注中记录交付方式。',
})
const currencyName = computed(() => overview.value.currency_name || creditSnapshot.value.currency_name || '积分')
const gatewayModeLabel = computed(() => {
const mode = overview.value.gateway?.mode || 'manual_review'
if (mode === 'test_gateway') return '测试网关'
if (mode === 'adapter_ready') return '适配器就绪'
return '人工确认'
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [featureRes, creditRes, schemaRes, overviewRes] = await Promise.all([
api.getCmsFeatures(),
api.getCreditSettings(),
api.getSchemaPage('payment', 'paid'),
api.getPaymentOverview(),
])
const featureData = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
const creditData = extractData(creditRes)
const schemaData = extractData(schemaRes)
featureSnapshot.value = featureData
creditSnapshot.value = creditData
overview.value = extractData(overviewRes)
form.download_login = featureData.download_login !== false
form.download_credit = featureData.download_credit !== false
form.download_captcha = !!featureData.download_captcha
form.download_default_cost = Number(creditData.download_default_cost || 0)
form.payment_paid_post_types = schemaData.payment_paid_post_types || 'post'
form.payment_vip_free_download = !!schemaData.payment_vip_free_download
form.payment_vip_exempt_roles = schemaData.payment_vip_exempt_roles || ''
form.payment_paid_catalog_note = schemaData.payment_paid_catalog_note || ''
form.payment_manual_delivery_note = schemaData.payment_manual_delivery_note || '付费或人工确认内容请在订单备注中记录交付方式。'
} catch (error) {
message.error(error.message || '加载下载/付费内容中心失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const nextFeatures = {
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
}
const nextCredits = {
...creditSnapshot.value,
download_default_cost: Number(form.download_default_cost || 0),
}
await Promise.all([
api.saveCmsFeatures(nextFeatures),
api.saveCreditSettings(nextCredits),
api.saveSchemaPage('payment', 'paid', {
payment_paid_post_types: form.payment_paid_post_types,
payment_vip_free_download: form.payment_vip_free_download,
payment_vip_exempt_roles: form.payment_vip_exempt_roles,
payment_paid_catalog_note: form.payment_paid_catalog_note,
payment_manual_delivery_note: form.payment_manual_delivery_note,
}),
])
featureSnapshot.value = nextFeatures
creditSnapshot.value = nextCredits
message.success('下载/付费内容中心已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
+508
View File
@@ -0,0 +1,508 @@
<template>
<div class="aksrt-schema-page">
<AksFormRenderer
:title="pageMeta.title"
:description="pageMeta.description"
:icon="pageMeta.icon"
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="showSearch"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
>
<template
v-if="isPermissionsLogin"
#field-tencent="{ value, setValue }"
>
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-aliyun="{ value, setValue }"
>
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-cloudflare="{ value, setValue }"
>
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-geetest="{ value, setValue }"
>
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsModeration"
#field-geoip_block_countries="{ value, setValue }"
>
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
</template>
<!-- Slot for custom group content (override per page if needed) -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</AksFormRenderer>
<div
v-if="isPermissionsLogin && !loading"
class="aksrt-schema-page__tool-actions"
>
<n-button
:disabled="!formData?.enabled || !formData?.active_provider"
@click="handleCaptchaTest"
>
测试验证
</n-button>
</div>
<div v-if="showTestModal" class="captcha-test-overlay" @click.self="closeTestModal">
<div class="captcha-test-panel">
<div class="captcha-test-header">
<span>测试验证码</span>
<button class="captcha-test-close" @click="closeTestModal">&times;</button>
</div>
<div class="captcha-test-body">
<div v-if="testLoading" style="text-align:center;padding:40px;">
<n-spin />
<p style="margin-top:10px;color:#999;">加载中...</p>
</div>
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}">
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
</div>
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* SchemaPage — generic Schema-driven settings page.
*
* Reads the schema from the Vue-side schema registry, fetches/saves settings
* via the API client, and renders using AksFormRenderer.
*
* Usage (in router or parent):
* <SchemaPage domain="global" section="logo" />
*
* Or auto-detect from route meta:
* The router's meta.schemaKey provides domain/section lookup.
*/
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
import api from '@/api/index.js'
import TencentConfig from '@/components/captcha/TencentConfig.vue'
import AliyunConfig from '@/components/captcha/AliyunConfig.vue'
import CloudflareConfig from '@/components/captcha/CloudflareConfig.vue'
import GeetestConfig from '@/components/captcha/GeetestConfig.vue'
const props = defineProps({
/** Schema domain key (e.g. 'global') */
domain: { type: String, default: '' },
/** Schema section key (e.g. 'logo') */
section: { type: String, default: '' },
/** Whether to show inline search */
showSearch: { type: Boolean, default: true },
})
const route = useRoute()
const message = useMessage()
const showTestModal = ref(false)
const testLoading = ref(false)
const testResult = ref(null)
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
function resolveDomainFromPath(path) {
const parts = path.split('/').filter(Boolean)
return parts[0] || 'global'
}
/** Extract section from route path: /global/logo → logo */
function resolveSectionFromPath(path) {
const parts = path.split('/').filter(Boolean)
return parts[parts.length - 1] || 'common'
}
/** Load schema from the Vue-side registry */
const pageMeta = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
if (schema && schema.groups && schema.groups.length > 0) {
return {
title: schema.title || '',
description: schema.description || '',
icon: schema.icon || '',
groups: schema.groups,
}
}
// Fallback: read from route meta
return {
title: route.meta?.title || '',
description: route.meta?.desc || '',
icon: '',
groups: [],
}
})
/** Default values from schema */
const defaults = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
if (!schema) return {}
const d = {}
for (const group of schema.groups || []) {
for (const field of group.fields || []) {
if (field.default !== undefined) {
d[field.key] = field.default
}
}
}
return d
})
/** Use the section form composable */
const {
formData, loading, saving, loadError, dirty,
save: doSave, reload,
} = useSectionForm({
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value),
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data),
defaults: defaults.value,
onSaved: () => {
message.success('设置已保存')
},
})
async function handleSave() {
try {
await doSave()
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
}
}
function handleReload() {
closeTestModal()
reload()
message.info('已放弃更改')
}
function getCaptchaProviderConfig(provider) {
if (!formData.value || !provider) return {}
const cfg = formData.value[provider]
return cfg && typeof cfg === 'object' ? cfg : {}
}
function handleCaptchaTest() {
const provider = formData.value?.active_provider
if (!provider) return
const cfg = getCaptchaProviderConfig(provider)
const required = {
tencent: ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
aliyun: ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
cloudflare: ['site_key', 'secret_key'],
geetest: ['captcha_id', 'captcha_key'],
}
const missing = (required[provider] || []).filter((key) => !cfg[key])
if (missing.length) {
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
return
}
if (provider === 'tencent') {
loadTencentTest(cfg)
return
}
showTestModal.value = true
testResult.value = null
testLoading.value = true
nextTick(() => {
createTestIframe(provider, cfg)
})
}
function loadTencentTest(cfg) {
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
if (typeof window.TencentCaptcha !== 'undefined') {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
return
}
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
if (existingScript) {
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
return
}
const script = document.createElement('script')
script.src = sdkSrc
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
}
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
document.head.appendChild(script)
}
function createTestIframe(provider, cfg) {
setTimeout(() => {
testLoading.value = false
loadProviderSDK(provider, cfg)
}, 100)
}
function loadProviderSDK(provider, cfg) {
const oldScript = document.getElementById('captcha-test-sdk')
if (oldScript) oldScript.remove()
if (window.__gtTestCaptcha) {
try { window.__gtTestCaptcha.destroy() } catch (error) {}
window.__gtTestCaptcha = null
}
const script = document.createElement('script')
script.id = 'captcha-test-sdk'
switch (provider) {
case 'tencent':
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'cloudflare':
window.onTurnstileTestLoad = function() {
try {
window.turnstile.render('#captcha-test-box', {
sitekey: cfg.site_key,
theme: 'auto',
size: cfg.size || 'normal',
callback: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('Cloudflare Turnstile 测试成功!')
setTimeout(closeTestModal, 1500)
},
'error-callback': function() {
testResult.value = { success: false, message: '验证失败' }
},
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'geetest':
script.src = 'https://static.geetest.com/v4/gt4.js'
script.onload = function () {
try {
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
window.__gtTestCaptcha = captchaObj
const onSuccess = function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('极验 GeeTest 测试成功!')
setTimeout(closeTestModal, 1500)
}
const onError = function(error) {
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
}
const onClose = function() {
if (!testResult.value) testResult.value = { success: false, message: '已关闭' }
}
if ((cfg.product || 'float') === 'bind') {
captchaObj.onReady(() => captchaObj.showCaptcha()).onSuccess(onSuccess).onError(onError).onClose(onClose)
} else {
captchaObj.appendTo('#captcha-test-box').onReady(() => {}).onSuccess(onSuccess).onError(onError).onClose(onClose)
}
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'aliyun':
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
script.onload = function () {
try {
window.initAliyunCaptcha({
SceneId: cfg.scene_id,
mode: 'embed',
element: '#captcha-test-box',
success: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('阿里云测试成功!')
setTimeout(closeTestModal, 1500)
},
fail: function(result) {
testResult.value = { success: false, message: String(result) }
},
getInstance: function() {},
slideStyle: { width: 360, height: 40 },
language: 'cn',
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
default:
testResult.value = { success: false, message: '未知服务商' }
return
}
document.head.appendChild(script)
}
function closeTestModal() {
showTestModal.value = false
testResult.value = null
testLoading.value = false
if (window.__gtTestCaptcha) {
try { window.__gtTestCaptcha.destroy() } catch (error) {}
window.__gtTestCaptcha = null
}
const script = document.getElementById('captcha-test-sdk')
if (script) script.remove()
if (window.onTurnstileTestLoad) {
try { delete window.onTurnstileTestLoad } catch (error) {}
}
}
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
if (nextDomain !== prevDomain || nextSection !== prevSection) {
closeTestModal()
reload()
}
})
onBeforeUnmount(() => {
closeTestModal()
})
</script>
<style scoped>
.aksrt-schema-page {
max-width: 1100px;
margin: 0 auto;
}
.aksrt-schema-page__tool-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.aksrt-schema-page__field-hint {
margin: 8px 0 0;
font-size: 12px;
color: #8a909c;
}
.captcha-test-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.captcha-test-panel {
background: #fff;
border-radius: 12px;
width: 420px;
max-width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.captcha-test-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid #e8e8ec;
font-size: 16px;
font-weight: 600;
}
.captcha-test-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.captcha-test-close:hover {
color: #333;
}
.captcha-test-body {
padding: 24px;
min-height: 180px;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="aksrt-page-container">
<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;">
<!-- 1. 评论 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<h3>评论设置</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="全局评论开关">
<n-switch v-model:value="basic.comment_enabled">
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
<template #feedback>
<span class="aksrt-hint-text">关闭后全站禁用评论功能。</span>
</template>
</n-form-item>
<n-form-item v-if="basic.comment_enabled" label="必须注册才能评论">
<n-switch v-model:value="basic.comment_requires_registration">
<template #checked>是</template>
<template #unchecked>否</template>
</n-switch>
</n-form-item>
</n-form>
</n-card>
<!-- 2. 分页 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<h3>分页设置</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="文章列表分页">
<n-radio-group v-model:value="basic.post_pagination_mode">
<n-space size="large">
<n-radio-button value="numbered">数字翻页</n-radio-button>
<n-radio-button value="load_more">加载更多</n-radio-button>
<n-radio-button value="infinite">无限滚动</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="评论列表分页">
<n-radio-group v-model:value="basic.comment_pagination_mode">
<n-space size="large">
<n-radio-button value="numbered">数字翻页</n-radio-button>
<n-radio-button value="load_more">加载更多</n-radio-button>
<n-radio-button value="infinite">无限滚动</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</n-card>
<!-- 3. 懒加载 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
<h3>图片懒加载</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="启用懒加载">
<n-switch v-model:value="basic.lazyload_enabled">
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
<template #feedback>
<span class="aksrt-hint-text">开启后图片在进入视口时才加载,可显著提升页面加载速度。</span>
</template>
</n-form-item>
<n-form-item v-if="basic.lazyload_enabled" label="自定义占位图 URL">
<n-input v-model:value="basic.lazyload_placeholder_url" placeholder="留空使用默认占位图" />
</n-form-item>
</n-form>
</n-card>
<!-- 4. 头像 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
<h3>Gravatar 头像源</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="130" class="aksrt-form">
<n-form-item label="头像服务源">
<n-select v-model:value="basic.avatar_source" :options="avatarSourceOptions" placeholder="选择头像镜像源" />
<template #feedback>
<span class="aksrt-hint-text">国内站点建议优先选 Cravatar。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<!-- 5. 备案 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
<h3>备案信息</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="120" class="aksrt-form">
<n-form-item label="ICP 备案号">
<n-input v-model:value="basic.icp_number" placeholder="例如:ICP 备 xxxxxxxx 号" />
</n-form-item>
<n-form-item label="公安备案号">
<n-input v-model:value="basic.police_number" placeholder="例如:公网安备 xxxxxx 号" />
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存全部</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
// 基础设置(aksrt_basic_settings)
// 后端 /settings/basic 已经做了部分合并,本接口只发送基础字段;
// auth 相关字段由 /settings/auth 独立维护,互不影响。
const basic = reactive({
comment_enabled: true,
comment_requires_registration: false,
avatar_source: 'cn.cravatar.com',
icp_number: '',
police_number: '',
lazyload_enabled: true,
lazyload_placeholder_url: '',
post_pagination_mode: 'numbered',
comment_pagination_mode: 'numbered',
})
const avatarSourceOptions = [
{ label: 'Cravatar (国内推荐)', value: 'cn.cravatar.com' },
{ label: 'Gravatar 国内镜像', value: 'cn.gravatar.com' },
{ label: 'Gravatar 官方', value: 'gravatar.com' },
]
async function loadAll() {
loading.value = true
try {
const basicRes = await api.getBasicSettings()
if (basicRes?.code === 'success' && basicRes.data) {
const d = basicRes.data
basic.comment_enabled = d.comment_enabled !== false
basic.comment_requires_registration = !!d.comment_requires_registration
basic.avatar_source = d.avatar_source || 'cn.cravatar.com'
basic.icp_number = d.icp_number || ''
basic.police_number = d.police_number || ''
basic.lazyload_enabled = d.lazyload_enabled !== false
basic.lazyload_placeholder_url = d.lazyload_placeholder_url || ''
basic.post_pagination_mode = d.post_pagination_mode || 'numbered'
basic.comment_pagination_mode = d.comment_pagination_mode || 'numbered'
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const basicPayload = {
comment_enabled: basic.comment_enabled,
comment_requires_registration: basic.comment_requires_registration,
avatar_source: basic.avatar_source,
icp_number: basic.icp_number,
police_number: basic.police_number,
lazyload_enabled: basic.lazyload_enabled,
lazyload_placeholder_url: basic.lazyload_placeholder_url,
post_pagination_mode: basic.post_pagination_mode,
comment_pagination_mode: basic.comment_pagination_mode,
}
const res = await api.saveBasicSettings(basicPayload)
if (res?.code === 'success') {
message.success('已保存')
} else {
throw new Error(res?.message || '保存失败')
}
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(loadAll)
</script>
@@ -0,0 +1,392 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="Banner 管理"
desc="管理首页 Banner 数据,供固定首页和相关展示区域调用;可控制是否显示文字。"
/>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>Banner 显示开关</strong></template>
<n-form label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="显示首页 Banner">
<div class="aksrt-inline-setting">
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
</div>
</n-form-item>
</n-form>
<div class="aksrt-inline-setting__actions">
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
</div>
</n-card>
<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">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="14" x="3" y="5" rx="2"/>
<path d="M3 10h18"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
</div>
</template>
<n-data-table
v-if="banners.length"
:columns="columns"
:data="banners"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
</n-card>
</n-spin>
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="开启文字后显示的主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="开启文字后显示的副标题" />
</n-form-item>
<n-form-item label="Banner 图片">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
</div>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const banners = ref([])
const bannerEnabled = ref(true)
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
image_url: '',
link_url: '',
show_text: true,
enabled: true,
show_meta: false,
sort_order: 0,
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadBanners() {
try {
loading.value = true
const res = await api.getHomepageBanners()
if (res.code === 'success') {
banners.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function loadBannerSettings() {
settingsLoading.value = true
try {
const res = await api.getHomepageSettings()
const data = res?.data || {}
bannerEnabled.value = data.homepage_module_banner_enabled !== false
} catch (e) {
message.error(e.message || '加载 Banner 开关失败')
} finally {
settingsLoading.value = false
}
}
async function saveBannerSettings() {
settingsSaving.value = true
try {
const res = await api.saveHomepageSettings({
homepage_module_banner_enabled: bannerEnabled.value,
})
if (res?.code === 'success') {
message.success('Banner 开关已保存')
return
}
throw new Error(res?.message || '保存 Banner 开关失败')
} catch (e) {
message.error(e.message || '保存 Banner 开关失败')
} finally {
settingsSaving.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
image_url: row.image_url || '',
link_url: row.link_url || '',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Banner 图片',
buttonText: '使用这张 Banner 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Banner 图片')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHomepageBanner(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHomepageBanner(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadBanners()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Banner「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHomepageBanner(row.id)
message.success('已删除')
await loadBanners()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadBanners()
loadBannerSettings()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,988 @@
<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-tabs
v-model:value="activeTab"
type="line"
animated
class="aksrt-tabs"
>
<!-- ============== 品牌与Logo ============== -->
<n-tab-pane name="brand" tab="品牌与Logo">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
<h3>品牌列(第一列)</h3>
</div>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_t1_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="页脚 Logo(日间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
</n-form-item>
<n-form-item label="页脚 Logo(夜间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
</n-form-item>
<n-form-item label="品牌标题">
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
</n-form-item>
<n-form-item label="品牌列自定义代码">
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 联系方式 ============== -->
<n-tab-pane name="contacts" tab="联系方式">
<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"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
</div>
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
</div>
</template>
<p class="section-desc">页脚中的社交平台或联系入口。</p>
<n-data-table
v-if="form.contacts.length"
:columns="contactColumns"
:data="form.contacts"
:row-key="(row) => row._uid"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无联系方式图标" />
<n-divider>标准联系字段</n-divider>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_contact_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="微信二维码">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
</div>
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
</n-form-item>
<n-form-item label="QQ">
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
</n-form-item>
<n-form-item label="微博">
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
</n-form-item>
<n-form-item label="联系区上方自定义代码">
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="2" placeholder="可选 HTML" />
</n-form-item>
<n-form-item label="联系区下方自定义代码">
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
</n-form-item>
</n-form>
<n-divider>更多联系项</n-divider>
<n-card class="aksrt-section-card" embedded>
<template #header>
<div class="aksrt-card-header-row">
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
</div>
</template>
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
<n-data-table
v-if="form.footer_contact_more.length"
:columns="contactMoreColumns"
:data="form.footer_contact_more"
:row-key="(_, i) => i"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无扩展联系项" />
</n-card>
</n-card>
</n-tab-pane>
<!-- ============== 快速链接 ============== -->
<n-tab-pane name="quick" tab="快速链接">
<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"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">快速链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚第二列的常用导航链接。</p>
<n-data-table
v-if="form.quick_links.length"
:columns="createLinkColumns('quick_links')"
:data="form.quick_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无快速链接" />
</n-card>
</n-tab-pane>
<!-- ============== 更多链接 ============== -->
<n-tab-pane name="more" tab="更多链接">
<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"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">更多链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚补充链接区域。</p>
<n-data-table
v-if="form.more_links.length"
:columns="createLinkColumns('more_links')"
:data="form.more_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无更多链接" />
</n-card>
</n-tab-pane>
<!-- ============== 友情链接 ============== -->
<n-tab-pane name="friends" tab="友情链接">
<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"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
</div>
</template>
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
<n-data-table
v-if="friendLinks.length"
:columns="friendColumns"
:data="friendLinks"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无友情链接" />
</n-card>
</n-tab-pane>
<!-- ============== 二维码图片组 ============== -->
<n-tab-pane name="mini_qr" tab="二维码图片组">
<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"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
</div>
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
</div>
</template>
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
</n-form>
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
<n-space align="center" :size="12" style="width: 100%;">
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
<div class="aksrt-media-row" style="flex: 1;">
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
</div>
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
</n-space>
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
</div>
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
</n-card>
</n-tab-pane>
<!-- ============== 自定义代码 ============== -->
<n-tab-pane name="custom_code" tab="自定义代码">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
<h3>底部自定义代码</h3>
</div>
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
<n-form :model="form" label-placement="top" class="aksrt-form">
<n-form-item label="fcode_customize_code">
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 版权与杂项 ============== -->
<n-tab-pane name="misc" tab="版权与杂项">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<h3>其他设置</h3>
</div>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="页脚布局模板">
<n-radio-group v-model:value="form.layout_template">
<n-radio-button value="default">默认</n-radio-button>
<n-radio-button value="compact">紧凑</n-radio-button>
<n-radio-button value="centered">居中</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="热门分类数量">
<n-space align="center" :size="10">
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
<span class="aksrt-hint-text">范围 1 - 20</span>
</n-space>
</n-form-item>
<n-form-item label="自定义版权文案">
<n-input
v-model:value="form.copyright_text"
type="textarea"
:rows="3"
placeholder="留空则使用默认版权信息"
/>
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
</div>
</n-spin>
<!-- ===== 联系方式 Modal ===== -->
<n-modal
v-model:show="showContactModal"
:mask-closable="false"
preset="card"
title="编辑联系方式图标"
class="aksrt-modal-card"
>
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
<n-form-item label="图标类型">
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContact.url" placeholder="https://..." />
</n-form-item>
<n-form-item label="标题提示">
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingContact.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactModal = false">取消</n-button>
<n-button type="primary" @click="saveContact">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 链接 Modal ===== -->
<n-modal
v-model:show="showLinkModal"
:mask-closable="false"
preset="card"
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
class="aksrt-modal-card"
>
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
<n-form-item label="链接名称">
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingLink.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showLinkModal = false">取消</n-button>
<n-button type="primary" @click="saveLink">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 友情链接 Modal ===== -->
<n-modal
v-model:show="showFriendModal"
:mask-closable="false"
preset="card"
title="添加友情链接"
class="aksrt-modal-card"
>
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
<n-form-item label="站点名称">
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
</n-form-item>
<n-form-item label="站点 URL">
<n-input v-model:value="editingFriend.url" placeholder="https://" />
</n-form-item>
<n-form-item label="Logo 图片 URL">
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
</n-form-item>
<n-form-item label="描述">
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showFriendModal = false">取消</n-button>
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 扩展联系项 Modal ===== -->
<n-modal
v-model:show="showContactMoreModal"
:mask-closable="false"
preset="card"
title="编辑扩展联系项"
class="aksrt-modal-card"
>
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
<n-form-item label="描述">
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
</n-form-item>
<n-form-item label="图标类名">
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
</n-form-item>
<n-form-item label="Hover 图片">
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactMoreModal = false">取消</n-button>
<n-button type="primary" @click="saveContactMore">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import { openMediaPicker } from '@/utils/media'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('contacts')
const form = reactive({
contacts: [],
quick_links: [],
more_links: [],
hot_categories: 5,
copyright_text: '',
layout_template: 'default',
// §2.3 三列结构化字段
footer_t1_m_s: ['pc', 'mobile'],
footer_t1_img: '',
footer_t1_img_dark: '',
footer_t1_t: '',
fcode_t1_code: '',
footer_contact_wechat_img: '',
footer_contact_qq: '',
footer_contact_weibo: '',
footer_contact_email: '',
footer_contact_m_s: ['pc', 'mobile'],
footer_contact_more: [],
fcode_t2_code_1: '',
fcode_t2_code_2: '',
footer_mini_img_m_s: ['pc', 'mobile'],
footer_mini_img: [],
fcode_customize_code: '',
})
// ============== 联系方式 ==============
const showContactModal = ref(false)
const editingContactIndex = ref(-1)
const editingContact = reactive({
icon: '',
url: '',
title: '',
enabled: true,
})
let contactUid = 0
const iconOptions = [
{ label: 'GitHub', value: 'github' },
{ label: 'X (Twitter)', value: 'x' },
{ label: 'Email', value: 'email' },
{ label: '微信', value: 'wechat' },
{ label: 'QQ', value: 'qq' },
{ label: '微博', value: 'weibo' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Bilibili', value: 'bilibili' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'Instagram', value: 'instagram' },
{ label: 'RSS / 网站', value: 'web' },
]
function addContact() {
Object.assign(editingContact, {
icon: '',
url: '',
title: '',
enabled: true,
})
editingContactIndex.value = -1
showContactModal.value = true
}
function editContact(row, index) {
Object.assign(editingContact, {
icon: row.icon,
url: row.url,
title: row.title,
enabled: row.enabled,
})
editingContactIndex.value = index
showContactModal.value = true
}
function saveContact() {
if (!editingContact.icon || !editingContact.url) {
message.warning('请填写图标类型和链接地址')
return
}
const item = {
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
icon: editingContact.icon,
url: editingContact.url,
title: editingContact.title,
enabled: editingContact.enabled,
}
if (editingContactIndex.value >= 0) {
form.contacts[editingContactIndex.value] = item
} else {
form.contacts.push(item)
}
showContactModal.value = false
}
function deleteContact(index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个联系方式图标吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.contacts.splice(index, 1)
},
})
}
const contactColumns = [
{
title: '平台',
key: 'icon',
width: 120,
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
},
{
title: '链接',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '提示',
key: 'title',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
]),
},
]
// ============== 快速 / 更多链接 ==============
const showLinkModal = ref(false)
const editingLinkType = ref('quick_links')
const editingLinkIndex = ref(-1)
const editingLink = reactive({
id: '',
name: '',
url: '',
enabled: true,
sort_order: 0,
})
function openLinkEditor(type, row = null, index = -1) {
editingLinkType.value = type
editingLinkIndex.value = index
Object.assign(editingLink, row || {
id: '',
name: '',
url: '',
enabled: true,
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
})
showLinkModal.value = true
}
function saveLink() {
if (!editingLink.name || !editingLink.url) {
message.warning('请填写链接名称和链接地址')
return
}
const target = form[editingLinkType.value]
const item = {
id: editingLink.id || `link_${Date.now()}`,
name: editingLink.name,
url: editingLink.url,
enabled: editingLink.enabled,
sort_order: editingLink.sort_order ?? target.length,
}
if (editingLinkIndex.value >= 0) {
target[editingLinkIndex.value] = item
} else {
target.push(item)
}
showLinkModal.value = false
}
function deleteLink(type, index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个链接吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form[type].splice(index, 1)
},
})
}
function createLinkColumns(type) {
return [
{
title: '名称',
key: 'name',
width: 160,
},
{
title: 'URL',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
]),
},
]
}
// ============== 友情链接 ==============
const friendLinks = ref([])
const showFriendModal = ref(false)
const addingFriend = ref(false)
const editingFriend = reactive({
name: '',
url: '',
logo: '',
description: '',
})
const friendColumns = [
{
title: 'Logo',
key: 'logo',
width: 60,
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
},
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
{
title: '操作',
key: 'actions',
width: 90,
render: (row) => h(NButton, {
quaternary: true,
type: 'error',
size: 'tiny',
onClick: () => deleteFriend(row),
}, { default: () => '删除' }),
},
]
function openFriendEditor() {
Object.assign(editingFriend, {
name: '',
url: '',
logo: '',
description: '',
})
showFriendModal.value = true
}
async function loadFriendLinks() {
try {
const res = await api.getFriendLinks()
if (res.code === 'success') friendLinks.value = res.data || []
} catch (e) {
console.error('[FriendLinks] load failed:', e)
}
}
async function saveFriend() {
if (!editingFriend.name || !editingFriend.url) {
message.warning('请填写站点名称和 URL')
return
}
addingFriend.value = true
try {
const res = await api.addFriendLink({
name: editingFriend.name,
url: editingFriend.url,
logo: editingFriend.logo,
description: editingFriend.description,
sort_order: friendLinks.value.length,
})
if (res.code === 'success') {
message.success(res.message || '已添加')
showFriendModal.value = false
await loadFriendLinks()
}
} catch (e) {
message.error(e.message || '添加失败')
} finally {
addingFriend.value = false
}
}
function deleteFriend(row) {
dialog.warning({
title: '确认删除',
content: `确定删除友链「${row.name}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteFriendLink(row.id)
message.success('已删除')
await loadFriendLinks()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
// ============== 加载 / 保存 ==============
async function loadSettings() {
loading.value = true
try {
const [footerRes] = await Promise.all([
api.getFooterSettings(),
loadFriendLinks(),
])
if (footerRes.code === 'success' && footerRes.data) {
const data = footerRes.data
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
form.quick_links = data.quick_links || []
form.more_links = data.more_links || []
form.hot_categories = data.hot_categories ?? 5
form.copyright_text = data.copyright_text || ''
form.layout_template = data.layout_template || 'default'
// §2.3 三列结构化字段
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
form.footer_t1_img = data.footer_t1_img || ''
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
form.footer_t1_t = data.footer_t1_t || ''
form.fcode_t1_code = data.fcode_t1_code || ''
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
form.footer_contact_qq = data.footer_contact_qq || ''
form.footer_contact_weibo = data.footer_contact_weibo || ''
form.footer_contact_email = data.footer_contact_email || ''
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
form.fcode_customize_code = data.fcode_customize_code || ''
}
} catch (error) {
console.error('[Footer] load failed:', error)
message.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
quick_links: form.quick_links,
more_links: form.more_links,
hot_categories: form.hot_categories,
copyright_text: form.copyright_text,
layout_template: form.layout_template,
// §2.3 三列结构化字段
footer_t1_m_s: form.footer_t1_m_s,
footer_t1_img: form.footer_t1_img,
footer_t1_img_dark: form.footer_t1_img_dark,
footer_t1_t: form.footer_t1_t,
fcode_t1_code: form.fcode_t1_code,
footer_contact_wechat_img: form.footer_contact_wechat_img,
footer_contact_qq: form.footer_contact_qq,
footer_contact_weibo: form.footer_contact_weibo,
footer_contact_email: form.footer_contact_email,
footer_contact_m_s: form.footer_contact_m_s,
footer_contact_more: form.footer_contact_more,
fcode_t2_code_1: form.fcode_t2_code_1,
fcode_t2_code_2: form.fcode_t2_code_2,
footer_mini_img_m_s: form.footer_mini_img_m_s,
footer_mini_img: form.footer_mini_img,
fcode_customize_code: form.fcode_customize_code,
}
const res = await api.saveFooterSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (error) {
console.error('[Footer] save failed:', error)
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
// ============== 媒体选择 ==============
async function pickMedia(field) {
try {
const media = await openMediaPicker({ title: '选择图片' })
if (media?.url) {
form[field] = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
// ============== 扩展联系项 ==============
function addContactMore() {
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
}
function removeContactMore(index) {
form.footer_contact_more.splice(index, 1)
}
const showContactMoreModal = ref(false)
const editingContactMoreIndex = ref(-1)
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
function editContactMore(row, index) {
Object.assign(editingContactMore, row)
editingContactMoreIndex.value = index
showContactMoreModal.value = true
}
function saveContactMore() {
const item = { ...editingContactMore }
if (editingContactMoreIndex.value >= 0) {
form.footer_contact_more[editingContactMoreIndex.value] = item
}
showContactMoreModal.value = false
}
const contactMoreColumns = [
{ title: '描述', key: 'desc', width: 120 },
{ title: '图标', key: 'icon', width: 100 },
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
]),
},
]
// ============== 二维码图片组 ==============
function addMiniImg() {
if (form.footer_mini_img.length < 4) {
form.footer_mini_img.push({ text: '', img: '' })
}
}
function removeMiniImg(idx) {
form.footer_mini_img.splice(idx, 1)
}
async function pickMiniImg(idx) {
try {
const media = await openMediaPicker({ title: '选择二维码图片' })
if (media?.url) {
form.footer_mini_img[idx].img = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.aksrt-media-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.aksrt-media-preview {
display: block;
max-width: 200px;
max-height: 80px;
margin-top: 8px;
border-radius: 6px;
border: 1px solid var(--n-border-color, #e0e0e6);
object-fit: contain;
}
.aksrt-media-preview--sm {
max-width: 64px;
max-height: 64px;
}
.aksrt-mini-img-row {
padding: 10px 0;
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
}
.aksrt-mini-img-row:last-child {
border-bottom: none;
}
</style>
@@ -0,0 +1,790 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="底部Tab配置" 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="启用底部Tab">
<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-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="文章页专属Tab">
<n-select
v-model:value="form.footer_tabbar_single"
:options="[
{ label: '默认按钮', value: 'default' },
{ label: '文章扩展按钮(评论/收藏/分享)', value: 'extend' },
]"
style="width: 240px;"
/>
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
</n-form-item>
</n-form>
</n-card>
<!-- Tab按钮配置 -->
<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-list-ul"></i></span>
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ 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">配置移动端底部导航栏显示的按钮,支持拖拽排序。建议按钮数量不超过5个。</p>
<div class="section-desc section-desc--tight">当前已启用 {{ enabledPreviewItems.length }} 个按钮,最多允许 5 个。</div>
<draggable
v-if="form.items.length"
v-model="form.items"
item-key="id"
handle=".tabbar-sort__handle"
ghost-class="tabbar-sort__ghost"
animation="180"
class="tabbar-sort"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="tabbar-sort__item">
<div class="tabbar-sort__main">
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<span class="tabbar-sort__icon">
<i v-if="element.icon" :class="element.icon"></i>
</span>
<div class="tabbar-sort__meta">
<div class="tabbar-sort__title-row">
<strong>{{ element.text || '按钮' }}</strong>
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
</div>
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
</div>
</div>
<div class="tabbar-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="暂无Tab按钮,请点击上方按钮添加" />
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
</div>
</n-spin>
<!-- 编辑弹窗 -->
<n-modal
v-model:show="showModal"
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
class="aksrt-modal-card"
style="max-width: 520px;"
>
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
<n-form-item label="按钮类型">
<n-select
v-model:value="editingItem.type"
:options="typeOptions"
placeholder="选择按钮类型"
@update:value="handleTypeChange"
/>
</n-form-item>
<n-form-item label="图标 (Font Awesome)">
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
<template #feedback>
<div class="icon-preview-row">
<span>预览:</span>
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
<span v-else class="muted-text">无图标</span>
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
</a>
</div>
</template>
</n-form-item>
<n-form-item label="显示文字">
<n-input v-model:value="editingItem.text" placeholder="例如: 首页" />
</n-form-item>
<n-form-item label="徽标文本">
<n-input v-model:value="editingItem.badge" placeholder="例如: NEW, 9+,留空不显示" maxlength="4" />
</n-form-item>
<n-form-item label="徽标模式">
<n-radio-group v-model:value="editingItem.badge_mode">
<n-space>
<n-radio value="static">静态文本</n-radio>
<n-radio value="dynamic">动态徽标</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="editingItem.badge_mode === 'dynamic'" type="info" :show-icon="false" style="margin-bottom: 16px;">
<template v-if="editingItem.type === 'auth'">未登录时显示提醒徽标,登录后自动隐藏。</template>
<template v-else-if="editingItem.type === 'user'">登录用户显示评论数徽标,游客不显示。</template>
<template v-else-if="editingItem.type === 'search'">搜索结果页显示 ON 徽标,其它页面隐藏。</template>
<template v-else>当前类型不提供动态徽标,前端将回退为静态徽标。</template>
</n-alert>
<n-form-item v-if="editingItem.type === 'category'" label="指定分类">
<n-select
v-model:value="editingItem.category_id"
:options="categoryOptions"
placeholder="留空则跳转分类聚合页"
clearable
filterable
/>
</n-form-item>
<n-form-item v-if="editingItem.type === 'search'" label="默认搜索类型">
<n-radio-group v-model:value="editingItem.search_default_type">
<n-space>
<n-radio value="post">文章</n-radio>
<n-radio value="category">分类</n-radio>
<n-radio value="tag">标签</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type)" label="链接地址">
<n-input v-model:value="editingItem.link" :placeholder="editingItem.type === 'msg' ? '留空使用默认消息中心地址' : editingItem.type === 'pay_vip' ? '留空使用默认会员页地址' : editingItem.type === 'shop_cart' ? '留空使用默认购物车地址' : 'https:// 或 /path'" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'link'" label="新窗口打开">
<n-switch v-model:value="editingItem.new_window" />
</n-form-item>
<div v-if="editingItem.type === 'add'" class="add-items-editor">
<div class="add-items-editor__head">
<strong>发布菜单项</strong>
<n-button size="small" type="primary" @click="appendAddItem">
<template #icon><i class="fas fa-plus" /></template>
添加子项
</n-button>
</div>
<div class="add-items-editor__presets">
<span class="add-items-editor__presets-label">快速添加:</span>
<n-button
v-for="preset in addPresets"
:key="preset.url"
size="tiny"
secondary
:type="editingItem.add_items.some((item) => item.url === preset.url) ? 'success' : 'default'"
:disabled="editingItem.add_items.some((item) => item.url === preset.url)"
@click="appendAddPreset(preset)"
>
{{ preset.label }}
</n-button>
</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"
>
<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="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fas fa-trash-alt" /></template>
</n-button>
</div>
</template>
</draggable>
</div>
<div v-else class="muted-text">当前没有子项,前端将回退为默认发布入口。</div>
</div>
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
<n-switch v-model:value="editingItem.ontop" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
</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, 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 categoryOptions = ref([])
const form = reactive({
enabled: true,
scroll_hide: false,
scroll_down_hide: true,
footer_tabbar_single: 'default',
items: [],
})
const editingItem = reactive({
id: '',
type: 'link',
icon: '',
text: '',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: 0,
ontop: false,
})
const typeOptions = [
{ label: '首页', value: 'home' },
{ label: '分类页', value: 'category' },
{ label: '链接', value: 'link' },
{ label: '中央发布按钮', value: 'add' },
{ label: '用户中心', value: 'user' },
{ label: '登录/我的', value: 'auth' },
{ label: '搜索', value: 'search' },
{ label: '消息中心', value: 'msg' },
{ label: '开通会员', value: 'pay_vip' },
{ label: '购物车', value: 'shop_cart' },
]
const typeLabelMap = {
home: '首页',
category: '分类页',
link: '链接',
add: '中央发布按钮',
user: '用户中心',
auth: '登录/我的',
search: '搜索',
msg: '消息中心',
pay_vip: '开通会员',
shop_cart: '购物车',
}
const typeDefaults = {
home: { icon: 'fas fa-home', text: '首页' },
category: { icon: 'fas fa-th-large', text: '分类' },
link: { icon: 'fas fa-link', text: '链接' },
add: { icon: 'fas fa-plus', text: '发布' },
user: { icon: 'fas fa-user', text: '用户中心' },
auth: { icon: 'fas fa-user', text: '我的' },
search: { icon: 'fas fa-search', text: '搜索' },
msg: { icon: 'fas fa-bell', text: '消息' },
pay_vip: { icon: 'fas fa-crown', text: '会员' },
shop_cart: { icon: 'fas fa-shopping-cart', text: '购物车' },
}
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
function getItemSummary(item) {
if (item.type === 'home') return item.ontop ? '首页按钮,滚动后切换返回顶部' : '首页按钮'
if (item.type === 'category') {
if (item.category_id) {
const target = categoryOptions.value.find((option) => option.value === item.category_id)
return target ? `跳转到分类: ${target.label}` : `跳转到分类 ID ${item.category_id}`
}
return '跳转到分类聚合页'
}
if (item.type === 'add') {
return item.add_items?.length ? `展开 ${item.add_items.length} 个发布入口` : '点击后打开默认发布入口'
}
if (item.type === 'search') return `打开顶部搜索面板,默认搜索${item.search_default_type === 'category' ? '分类' : item.search_default_type === 'tag' ? '标签' : '文章'}`
if (item.type === 'user') return '自动跳转到用户中心或登录页'
if (item.type === 'auth') return '登录用户显示我的,游客显示登录'
if (item.type === 'msg') return '跳转到消息中心页面'
if (item.type === 'pay_vip') return item.link || '跳转到会员开通页'
if (item.type === 'shop_cart') return item.link || '跳转到购物车页面'
return item.link || '自定义链接按钮'
}
const addPresets = [
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/post-new' },
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' },
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' },
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' },
]
function appendAddPreset(preset) {
if (editingItem.add_items.some((item) => item.url === preset.url)) return
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: preset.label,
icon: preset.icon,
url: preset.url,
})
}
function appendAddItem() {
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: '',
icon: 'fas fa-pen',
url: '',
})
}
function removeAddItem(index) {
editingItem.add_items.splice(index, 1)
}
function resetSortOrder() {
form.items = form.items.map((item, index) => ({
...item,
sort_order: index,
}))
}
function applyTypeDefaults(type, force = false) {
const preset = typeDefaults[type] || typeDefaults.link
if (force || !editingItem.icon) editingItem.icon = preset.icon
if (force || !editingItem.text) editingItem.text = preset.text
if (!['link', 'msg', 'pay_vip', 'shop_cart'].includes(type)) editingItem.link = ''
if (type !== 'category') editingItem.category_id = null
if (type !== 'search') editingItem.search_default_type = 'post'
if (type !== 'add') editingItem.add_items = []
if (type !== 'home') editingItem.ontop = false
}
function handleTypeChange(value) {
applyTypeDefaults(value, false)
}
function addItem() {
if (form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
Object.assign(editingItem, {
id: `tab_${Date.now()}`,
type: 'link',
icon: 'fas fa-link',
text: '链接',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: form.items.length,
ontop: false,
})
editingIndex.value = -1
showModal.value = true
}
function editItem(index) {
const item = form.items[index]
Object.assign(editingItem, {
id: item.id,
type: item.type || 'link',
icon: item.icon || '',
text: item.text || '',
link: item.link || '',
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || null,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items.map((sub) => ({ ...sub })) : [],
enabled: item.enabled !== false,
sort_order: item.sort_order ?? index,
ontop: item.ontop || false,
})
editingIndex.value = index
showModal.value = true
}
function saveItem() {
if (editingIndex.value < 0 && form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
if (!editingItem.icon) {
message.warning('请填写图标')
return
}
if (!editingItem.text) {
message.warning('请填写显示文字')
return
}
const item = {
id: editingItem.id || `tab_${Date.now()}`,
type: editingItem.type,
icon: editingItem.icon,
text: editingItem.text,
link: ['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type) ? editingItem.link : '',
badge: (editingItem.badge || '').trim(),
badge_mode: editingItem.badge_mode,
category_id: editingItem.type === 'category' ? (editingItem.category_id || 0) : 0,
search_default_type: editingItem.type === 'search' ? editingItem.search_default_type : 'post',
new_window: editingItem.type === 'link' ? !!editingItem.new_window : false,
add_items: editingItem.type === 'add'
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
id: sub.id || `add_${Date.now()}`,
label: sub.label,
icon: sub.icon || 'fas fa-pen',
url: sub.url,
}))
: [],
enabled: editingItem.enabled,
sort_order: editingItem.sort_order,
ontop: editingItem.type === 'home' ? editingItem.ontop : false,
}
if (editingIndex.value >= 0) {
form.items[editingIndex.value] = item
} else {
form.items.push(item)
}
showModal.value = false
message.success(editingIndex.value >= 0 ? '已更新' : '已添加')
}
async function loadCategories() {
try {
const res = await api.getCategories()
if (res.code === 'success' && Array.isArray(res.data)) {
categoryOptions.value = res.data.map((item) => ({
label: item.name,
value: Number(item.id),
}))
}
} catch (e) {
console.error('[FooterTabbar] load categories failed:', e)
}
}
function deleteItem(index) {
dialog.warning({
title: '确认删除',
content: `确定删除「${form.items[index].text}」按钮吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.items.splice(index, 1)
resetSortOrder()
message.success('已删除')
},
})
}
async function loadSettings() {
loading.value = true
try {
const [res] = await Promise.all([api.getFooterTabbarSettings(), loadCategories()])
if (res.code === 'success' && res.data) {
const data = res.data
form.enabled = data.enabled !== false
form.scroll_hide = data.scroll_hide || false
form.scroll_down_hide = data.scroll_down_hide !== false
form.footer_tabbar_single = data.footer_tabbar_single || 'default'
form.items = (data.items || []).map((item, idx) => ({
...item,
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || 0,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items : [],
sort_order: item.sort_order ?? idx,
}))
}
} catch (e) {
console.error('[FooterTabbar] load failed:', e)
message.error('加载配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
if (form.items.length > 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
saving.value = true
try {
const payload = {
enabled: form.enabled,
scroll_hide: form.scroll_hide,
scroll_down_hide: form.scroll_down_hide,
footer_tabbar_single: form.footer_tabbar_single,
items: form.items.map((item, idx) => ({
...item,
sort_order: idx,
})),
}
const res = await api.saveFooterTabbarSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (e) {
console.error('[FooterTabbar] save failed:', e)
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(loadSettings)
</script>
<style scoped>
.icon-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
}
.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;
}
.section-desc--tight {
margin-top: -6px;
}
.add-items-editor {
margin-bottom: 16px;
}
.add-items-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.add-items-editor__presets {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.add-items-editor__presets-label {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
.add-items-editor__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-items-editor__row {
display: grid;
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
gap: 8px;
align-items: center;
}
.add-items-editor__drag {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.add-items-editor__ghost {
opacity: 0.5;
}
.tabbar-sort {
display: flex;
flex-direction: column;
gap: 12px;
}
.tabbar-sort__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--n-border-color);
border-radius: 14px;
background: #fff;
}
.tabbar-sort__main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.tabbar-sort__handle {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.tabbar-sort__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-size: 18px;
flex: 0 0 auto;
}
.tabbar-sort__meta {
min-width: 0;
flex: 1;
}
.tabbar-sort__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.tabbar-sort__desc {
color: var(--n-text-color-3);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbar-sort__actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
.tabbar-sort__ghost {
opacity: 0.5;
}
@media (max-width: 1023px) {
.tabbar-sort__item {
flex-direction: column;
align-items: stretch;
}
.tabbar-sort__actions {
justify-content: flex-end;
}
.add-items-editor__row {
grid-template-columns: 32px 1fr;
}
}
</style>
@@ -0,0 +1,548 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 轮播数据,供固定首页和相关展示区域调用;标题、副标题可留空。" />
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>Hero 显示开关</strong></template>
<n-form label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示首页 Hero">
<div class="aksrt-inline-setting">
<n-switch v-model:value="heroEnabled" :disabled="settingsLoading || settingsSaving" />
<span class="aksrt-inline-setting__hint">关闭后,首页 Hero 区块将不再渲染。</span>
</div>
</n-form-item>
</n-form>
<div class="aksrt-inline-setting__actions">
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveHeroSettings">保存 Hero 开关</n-button>
</div>
</n-card>
<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">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
</div>
</template>
<n-data-table
v-if="slides.length"
:columns="columns"
:data="slides"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
</n-card>
</n-spin>
<!-- 编辑/新增 弹窗 -->
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
</n-form-item>
<n-form-item label="背景图 URL">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<n-form-item label="描述文字">
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
</n-form-item>
<n-form-item label="视频背景 URL">
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="链接打开方式">
<n-radio-group v-model:value="editing.link_target">
<n-radio-button value="_self">当前窗口</n-radio-button>
<n-radio-button value="_blank">新窗口</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字对齐">
<n-radio-group v-model:value="editing.text_align">
<n-radio-button value="left">左</n-radio-button>
<n-radio-button value="center">居中</n-radio-button>
<n-radio-button value="right">右</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="设备可见性">
<n-radio-group v-model:value="editing.device_visibility">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="pc">仅电脑</n-radio-button>
<n-radio-button value="mobile">仅移动</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字视差">
<n-switch v-model:value="editing.text_parallax" />
</n-form-item>
<n-form-item label="文字大小(PC)">
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
</n-form-item>
<n-form-item label="文字大小(移动)">
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
</n-form-item>
</div>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
</div>
<!-- 图片图层 -->
<n-divider title-placement="left" style="margin:16px 0 12px;">
图片图层(视差叠加)
</n-divider>
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
<div class="aksrt-layer-item__header">
<strong>图层 {{ idx + 1 }}</strong>
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
</div>
<n-form-item label="图片">
<n-input-group>
<n-input v-model:value="layer.image" placeholder="图片 URL" />
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
</n-input-group>
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="原始尺寸">
<n-switch v-model:value="layer.free_size" />
</n-form-item>
<n-form-item label="对齐方式">
<n-select v-model:value="layer.align" :options="[
{ label: '居中', value: 'center' },
{ label: '左上', value: 'left top' },
{ label: '右上', value: 'right top' },
{ label: '左下', value: 'left bottom' },
{ label: '右下', value: 'right bottom' },
]" />
</n-form-item>
<n-form-item label="视差">
<n-switch v-model:value="layer.parallax" />
</n-form-item>
<n-form-item label="视差强度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
</n-form-item>
<n-form-item label="视差透明度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
</n-form-item>
</div>
</div>
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const slides = ref([])
const heroEnabled = ref(true)
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
description: '',
image_url: '',
video_url: '',
link_url: '',
link_target: '_self',
text_align: 'left',
device_visibility: 'all',
enabled: true,
show_text: true,
show_meta: false,
sort_order: 0,
text_size_pc: '',
text_size_m: '',
text_parallax: false,
layers: [],
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '跳转',
key: 'link_url',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
render(row) {
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadSlides() {
try {
loading.value = true
const res = await api.getHeroes()
if (res.code === 'success') {
slides.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function loadHeroSettings() {
settingsLoading.value = true
try {
const res = await api.getHomepageSettings()
const data = res?.data || {}
heroEnabled.value = data.homepage_module_hero_enabled !== false
} catch (e) {
message.error(e.message || '加载 Hero 开关失败')
} finally {
settingsLoading.value = false
}
}
async function saveHeroSettings() {
settingsSaving.value = true
try {
const res = await api.saveHomepageSettings({
homepage_module_hero_enabled: heroEnabled.value,
})
if (res?.code === 'success') {
message.success('Hero 开关已保存')
return
}
throw new Error(res?.message || '保存 Hero 开关失败')
} catch (e) {
message.error(e.message || '保存 Hero 开关失败')
} finally {
settingsSaving.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
description: row.description || '',
image_url: row.image_url || '',
video_url: row.video_url || '',
link_url: row.link_url || '',
link_target: row.link_target || '_self',
text_align: row.text_align || 'left',
device_visibility: row.device_visibility || 'all',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
text_size_pc: row.text_size_pc || '',
text_size_m: row.text_size_m || '',
text_parallax: !!row.text_parallax,
layers: Array.isArray(row.layers) ? row.layers : [],
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Hero 背景图',
buttonText: '使用这张 Hero 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
function addLayer() {
editing.value.layers.push({
image: '',
free_size: false,
align: 'center',
parallax: false,
parallax_scale: 0.3,
parallax_opacity: 0.6,
})
}
function removeLayer(idx) {
editing.value.layers.splice(idx, 1)
}
async function pickLayerImage(idx) {
try {
const media = await openMediaPicker({
title: '选择图层图片',
buttonText: '使用这张图',
})
if (media?.url) {
editing.value.layers[idx].image = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Hero 背景图')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHero(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHero(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadSlides()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Hero「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHero(row.id)
message.success('已删除')
await loadSlides()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadSlides()
loadHeroSettings()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.aksrt-layer-item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
background: #fafbfc;
}
.aksrt-layer-item__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -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>
@@ -0,0 +1,155 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
<template #actions>
<n-space>
<n-button :loading="cleaning" @click="runCleanup">立即清理</n-button>
<n-button type="primary" :loading="saving" @click="saveSettings">保存</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card title="通知内容开关" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="评论回复通知">
<n-switch v-model:value="settings.enabled_types.comment_reply" />
</n-form-item>
<n-form-item label="评论审核通过通知">
<n-switch v-model:value="settings.enabled_types.comment_approved" />
</n-form-item>
<n-form-item label="资源下载就绪通知">
<n-switch v-model:value="settings.enabled_types.download_ready" />
</n-form-item>
<n-form-item label="积分增加通知">
<n-switch v-model:value="settings.enabled_types.credits_added" />
</n-form-item>
<n-form-item label="积分扣减通知">
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
</n-form-item>
<n-form-item label="邮箱待验证提醒">
<n-switch v-model:value="settings.enabled_types.verify_email" />
</n-form-item>
<n-form-item label="待审核评论提醒">
<n-switch v-model:value="settings.enabled_types.pending_comments" />
</n-form-item>
<n-form-item label="图片审核通知">
<n-switch v-model:value="settings.enabled_types.upload_moderation" />
</n-form-item>
</n-form>
</n-card>
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
<n-grid-item span="24 l:10">
<n-card title="user 徽标规则" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="启用 user 动态徽标">
<n-switch v-model:value="settings.user_badge_enabled" />
</n-form-item>
<n-form-item label="徽标包含状态提醒">
<n-switch v-model:value="settings.badge_include_stateful" />
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
<n-grid-item span="24 l:14">
<n-card title="通知清理策略" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="已读通知保留天数">
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
</n-form-item>
<n-form-item label="未读通知保留天数">
<n-input-number v-model:value="settings.unread_retention_days" :min="7" :max="3650" style="width: 180px;" />
</n-form-item>
<n-form-item label="每用户最大保留数">
<n-input-number v-model:value="settings.max_per_user" :min="20" :max="5000" style="width: 180px;" />
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
</n-grid>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const cleaning = ref(false)
const settings = reactive({
enabled_types: {
comment_reply: true,
comment_approved: true,
download_ready: true,
credits_added: true,
credits_deducted: true,
verify_email: true,
pending_comments: true,
upload_moderation: true,
},
user_badge_enabled: true,
badge_include_stateful: true,
read_retention_days: 30,
unread_retention_days: 180,
max_per_user: 200,
})
async function loadSettings() {
loading.value = true
try {
const res = await api.getNotificationSettings()
if (res.code === 'success' && res.data) {
Object.assign(settings, res.data)
if (res.data.enabled_types) {
settings.enabled_types = { ...settings.enabled_types, ...res.data.enabled_types }
}
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function saveSettings() {
saving.value = true
try {
const res = await api.saveNotificationSettings({
enabled_types: settings.enabled_types,
user_badge_enabled: settings.user_badge_enabled,
badge_include_stateful: settings.badge_include_stateful,
read_retention_days: settings.read_retention_days,
unread_retention_days: settings.unread_retention_days,
max_per_user: settings.max_per_user,
})
if (res.code === 'success') message.success(res.message || '保存成功')
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function runCleanup() {
cleaning.value = true
try {
const res = await api.runNotificationCleanup()
if (res.code === 'success') message.success(res.message || '清理完成')
} catch (e) {
message.error(e.message || '清理失败')
} finally {
cleaning.value = false
}
}
onMounted(loadSettings)
</script>
+354
View File
@@ -0,0 +1,354 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
<!-- Docs Mode Status -->
<n-card class="aksrt-docs__section" size="small">
<div class="aksrt-docs__mode-status">
<div class="aksrt-docs__mode-info">
<FaIcon :icon="docsModeEnabled ? 'fas fa-book-open' : 'fas fa-book'" :size="18" :color="docsModeEnabled ? '#52c41a' : '#8a909c'" />
<div>
<strong>{{ docsModeEnabled ? '文档模式已启用' : '文档模式未启用' }}</strong>
<p>{{ docsModeEnabled ? '文档导航页面和文档站式侧边栏已激活' : '启用后将激活文档导航页面模板和文档站式侧边栏' }}</p>
</div>
</div>
<n-space>
<router-link to="/extensions/docs-mode" class="aksrt-docs__mode-link">
<FaIcon icon="fas fa-cog" :size="12" />
{{ docsModeEnabled ? '管理文档模式' : '启用文档模式' }}
</router-link>
</n-space>
</div>
</n-card>
<!-- Doc Pages Overview -->
<n-card title="文档页面" size="small" class="aksrt-docs__section">
<n-alert v-if="docPageCount === 0" type="info" size="small">
尚未创建文档页面。在 WordPress 后台新建页面时,选择"文档导航页"或"专题聚合页"模板。
</n-alert>
<n-grid v-else :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="stat in docPageStats" :key="stat.key">
<div class="aksrt-docs__stat">
<FaIcon :icon="stat.icon" :size="16" :color="stat.color" />
<div>
<strong>{{ stat.value }}</strong>
<p>{{ stat.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Quick Actions -->
<n-card title="常用操作" size="small" class="aksrt-docs__section">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-palette" :size="16" />
<div>
<strong>全局设置</strong>
<p>Logo、背景、菜单装饰、自定义代码</p>
<router-link to="/global/common">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-home" :size="16" />
<div>
<strong>首页配置</strong>
<p>首页模块、布局、排序和预设模板</p>
<router-link to="/page-display/homepage">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-users" :size="16" />
<div>
<strong>用户管理</strong>
<p>登录注册、OAuth、签到、积分</p>
<router-link to="/users/auth">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-tools" :size="16" />
<div>
<strong>系统工具</strong>
<p>导入导出、健康检查、迁移状态</p>
<router-link to="/tools/import-export">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Reference Docs -->
<div class="aksrt-docs__grid">
<n-card
v-for="doc in docs"
:key="doc.key"
class="aksrt-docs__card"
size="small"
hoverable
@click="openDoc(doc)"
>
<div class="aksrt-docs__card-inner">
<div class="aksrt-docs__card-icon" :style="{ background: doc.color }">
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
</div>
<div class="aksrt-docs__card-body">
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
<p class="aksrt-docs__card-desc">{{ doc.description }}</p>
</div>
<FaIcon icon="fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
</div>
</n-card>
</div>
<!-- Version Info -->
<n-card title="当前版本" size="small" class="aksrt-docs__section">
<n-descriptions :column="2" bordered label-placement="left" size="small">
<n-descriptions-item label="主题版本">
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="Schema 版本">
<n-tag type="info" size="small">{{ dbSchemaVersion }}</n-tag>
</n-descriptions-item>
</n-descriptions>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
const dbSchemaVersion = ref('—')
const docsModeEnabled = ref(false)
const docPageCount = ref(0)
const docPageStats = ref([
{ key: 'total', icon: 'fas fa-file-alt', color: '#1890ff', value: 0, label: '文档页面' },
{ key: 'nav', icon: 'fas fa-sitemap', color: '#722ed1', value: 0, label: '导航页' },
{ key: 'special', icon: 'fas fa-layer-group', color: '#fa8c16', value: 0, label: '专题页' },
])
const docs = ref([
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fas fa-sitemap', color: '#1890ff', file: 'admin-ia-map.md' },
{ key: 'zibll-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → Zibll 参考 → AKSRT 目标三层对照表。', icon: 'fas fa-columns', color: '#722ed1', file: 'zibll-gap-matrix.md' },
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fas fa-clipboard-check', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
])
onMounted(async () => {
try {
const res = await api.getSchemaPage('extensions', 'docs-mode')
if (res?.data) {
docsModeEnabled.value = !!res.data.docs_mode_enabled
}
} catch {
// Schema endpoint may not exist yet
}
// Fetch doc page stats via REST if available
try {
const statsRes = await api.request('/docs/stats')
if (statsRes?.data) {
docPageCount.value = statsRes.data.total || 0
docPageStats.value[0].value = statsRes.data.total || 0
docPageStats.value[1].value = statsRes.data.nav_count || 0
docPageStats.value[2].value = statsRes.data.special_count || 0
}
} catch {
// Endpoint may not exist yet
}
// Fetch schema version from health
try {
const healthRes = await api.getHealthCheck()
if (healthRes?.data) {
dbSchemaVersion.value = healthRes.data.db_schema_version || '—'
}
} catch {
// Endpoint may not exist yet
}
})
function openDoc(doc) {
// Link to local docs directory (admin can open in new tab)
const base = window.aksrtAdminData?.siteUrl || window.location.origin
window.open(`${base}/wp-content/themes/AKSRT-theme/docs/${doc.file}`, '_blank')
}
</script>
<style scoped>
.aksrt-docs__mode-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.aksrt-docs__mode-info {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-docs__mode-info strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-docs__mode-info p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__mode-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
background: #f5f7fa;
color: #1890ff;
text-decoration: none;
font-size: 13px;
transition: background 0.2s;
}
.aksrt-docs__mode-link:hover {
background: #e6f4ff;
}
.aksrt-docs__stat {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs__stat strong {
display: block;
font-size: 20px;
color: #1a1a2e;
}
.aksrt-docs__stat p {
margin: 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-docs__card {
cursor: pointer;
transition: box-shadow 0.2s;
}
.aksrt-docs__card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.aksrt-docs__card-inner {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-docs__card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.aksrt-docs__card-body {
flex: 1;
min-width: 0;
}
.aksrt-docs__card-title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-docs__card-desc {
margin: 0;
font-size: 12px;
color: #8a909c;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-docs__card-arrow {
flex-shrink: 0;
color: #c0c4cc;
}
.aksrt-docs__section {
margin-bottom: 16px;
}
.aksrt-docs__action {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs__action i {
color: #1890ff;
margin-top: 2px;
}
.aksrt-docs__action strong {
display: block;
font-size: 14px;
color: #1a1a2e;
margin-bottom: 2px;
}
.aksrt-docs__action p {
margin: 0 0 4px;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__action a {
font-size: 12px;
color: #1890ff;
text-decoration: none;
}
.aksrt-docs__action a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,314 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="AI 子系统" desc="配置 AI 内容辅助能力,包括 SEO 建议、摘要生成、标签建议等。" />
<!-- 状态卡片 -->
<div class="aksrt-ai__status">
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.enabled ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
<i :class="status.enabled ? 'fas fa-robot' : 'fas fa-robot'"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">AI 状态</div>
<div class="aksrt-ai__status-value">{{ status.enabled ? '已启用' : '已关闭' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.configured ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-key"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">API Key</div>
<div class="aksrt-ai__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
<i class="fas fa-brain"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">模型</div>
<div class="aksrt-ai__status-value">{{ status.model || '未设置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-check-circle"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">可用状态</div>
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
</div>
</div>
</div>
<!-- 配置表单 -->
<div class="aksrt-ai__form-card">
<h3>基本配置</h3>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用 AI 内容辅助
</label>
<p class="aksrt-form-hint">开启后可在投稿、编辑等场景使用 AI 辅助功能</p>
</div>
<div class="aksrt-form-group">
<label>AI Provider</label>
<select v-model="form.provider" class="aksrt-select">
<option value="none">不使用</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>API Key</label>
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
<label>API Base URL</label>
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://api.openai.com/v1">
<p class="aksrt-form-hint">可替换为兼容 OpenAI 接口的第三方服务</p>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>模型</label>
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>最大 Token 数</label>
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>每分钟请求限制</label>
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<!-- 功能说明 -->
<div class="aksrt-ai__form-card">
<h3>支持的 AI 功能</h3>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>功能</th>
<th>说明</th>
<th>使用场景</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="feat in features" :key="feat.key">
<td>{{ feat.name }}</td>
<td>{{ feat.desc }}</td>
<td>{{ feat.scene }}</td>
<td>
<span class="aksrt-badge" :class="status.available ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ status.available ? '可用' : '未启用' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 重要提示 -->
<div class="aksrt-ai__form-card aksrt-ai__notice">
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
<ul>
<li>AI 生成的内容仅作为<strong>建议参考</strong>,不会自动应用到线上内容</li>
<li>所有 AI 输出需要<strong>人工审核确认</strong>后才能发布</li>
<li>AI 默认关闭,需管理员显式启用并配置 API Key</li>
<li>API Key 不会回显到前端,不会通过 <code>wp_localize_script</code> 透出</li>
<li>内置速率限制,防止滥用</li>
</ul>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
const API = '/wp-json/aksrt/v1/ai';
export default {
name: 'ExtensionAI',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
provider: 'none',
configured: false,
model: '',
api_key: '',
rate_limit: 10,
available: false,
});
const form = reactive({
enabled: false,
provider: 'none',
api_key: '',
api_base: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo',
max_tokens: 1000,
rate_limit: 10,
});
const saving = ref(false);
const features = [
{ key: 'seo', name: 'SEO 建议', desc: '生成标题、描述、关键词建议', scene: '文章编辑' },
{ key: 'summary', name: '摘要生成', desc: '自动生成文章摘要', scene: '文章编辑、投稿' },
{ key: 'tags', name: '标签建议', desc: '根据内容推荐标签', scene: '文章编辑、投稿' },
{ key: 'excerpt', name: '摘要建议', desc: '生成吸引人的列表摘要', scene: '文章编辑' },
];
async function loadStatus() {
try {
const res = await fetch(`${API}/status`, {
headers: { 'X-WP-Nonce': window.aksrtAdmin?.nonce || '' },
});
const json = await res.json();
if (json.success) {
status.value = json.data;
form.enabled = json.data.enabled || false;
form.provider = json.data.provider || 'none';
form.model = json.data.model || 'gpt-3.5-turbo';
form.rate_limit = json.data.rate_limit || 10;
// 不回显 api_key
}
} catch (e) {
console.error('Load AI status failed:', e);
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = { ...form };
// 如果密钥为空且已配置,发送掩码
if (!payload.api_key && status.value.configured) {
payload.api_key = '***';
}
const res = await fetch(`${API}/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.aksrtAdmin?.nonce || '',
},
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.success) {
alert('设置已保存');
loadStatus();
} else {
alert(json.message || '保存失败');
}
} catch (e) {
console.error('Save failed:', e);
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, features, saveSettings };
},
};
</script>
<style scoped>
.aksrt-ai__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-ai__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-ai__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-ai__status-icon--ok {
background: #d4edda;
color: #155724;
}
.aksrt-ai__status-icon--warn {
background: #fff3cd;
color: #856404;
}
.aksrt-ai__status-icon--off {
background: #f8d7da;
color: #721c24;
}
.aksrt-ai__status-icon--info {
background: #d1ecf1;
color: #0c5460;
}
.aksrt-ai__status-value {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-ai__status-label {
font-size: 12px;
color: #888;
}
.aksrt-ai__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-ai__form-card h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
}
.aksrt-form-hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.aksrt-ai__notice {
background: #fff8e1;
border-color: #ffe082;
}
.aksrt-ai__notice h3 {
color: #e65100;
}
.aksrt-ai__notice ul {
margin: 0;
padding-left: 20px;
}
.aksrt-ai__notice li {
margin-bottom: 8px;
font-size: 14px;
}
.aksrt-ai__notice code {
background: rgba(0,0,0,0.06);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
</style>
@@ -0,0 +1,311 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="文档模式" desc="启用文档专题内容模式,配置文档导航页面和文档站式体验。" />
<n-card class="aksrt-docs-mode__card" title="文档模式" size="small">
<n-form label-placement="left" label-width="160">
<n-form-item label="启用文档模式">
<n-switch v-model:value="formData.docs_mode_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
开启后将激活文档导航页面模板和文档站式侧边栏
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="文档首页模板">
<n-select
v-model:value="formData.docs_mode_default_template"
:options="templateOptions"
placeholder="选择文档首页模板"
style="max-width: 360px;"
/>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="文档分类">
<n-select
v-model:value="formData.docs_mode_cats"
:options="categoryOptions"
:loading="categoriesLoading"
multiple
clearable
filterable
placeholder="选择用于文档模式的分类"
style="max-width: 520px;"
/>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="首页排除">
<n-switch v-model:value="formData.docs_mode_exclude" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
开启后,所选文档分类的内容不会出现在首页常规文章流中
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="显示目录树">
<n-switch v-model:value="formData.docs_mode_toc_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
在文档页面侧边栏显示章节目录树
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="显示章节导航">
<n-switch v-model:value="formData.docs_mode_chapter_nav_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
在文档底部显示上一章/下一章导航
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="默认排序">
<n-radio-group v-model:value="formData.docs_mode_default_order">
<n-radio value="menu_order">按菜单顺序</n-radio>
<n-radio value="date">按发布时间</n-radio>
<n-radio value="title">按标题排序</n-radio>
</n-radio-group>
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button @click="handleReset">重置</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</n-space>
</template>
</n-card>
<n-card title="文档页面统计" size="small" class="aksrt-docs-mode__card">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.total || 0 }}</strong>
<p>文档页面总数</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.nav_count || 0 }}</strong>
<p>文档导航页</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.special_count || 0 }}</strong>
<p>专题聚合页</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="可用页面模板" size="small" class="aksrt-docs-mode__card">
<n-alert type="info" size="small" style="margin-bottom: 12px;">
以下页面模板可用于创建文档类页面。在 WordPress 后台新建页面时,从"页面属性 → 模板"中选择对应模板。
</n-alert>
<n-list bordered size="small">
<n-list-item v-for="tpl in availableTemplates" :key="tpl.file">
<n-thing :title="tpl.name" :description="tpl.description">
<template #avatar>
<FaIcon :icon="tpl.icon" :size="18" color="#1890ff" />
</template>
<template #header-extra>
<n-tag size="small" type="info">{{ tpl.file }}</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<n-card title="快速入口" size="small" class="aksrt-docs-mode__card">
<n-space>
<router-link to="/page-display/special" class="aksrt-docs-mode__link">
<FaIcon icon="fas fa-layer-group" :size="14" />
专题页面配置
</router-link>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const saving = ref(false)
const docStats = ref({ total: 0, nav_count: 0, special_count: 0 })
const categoriesLoading = ref(false)
const categoryOptions = ref([])
const defaults = {
docs_mode_enabled: false,
docs_mode_default_template: 'documentnav',
docs_mode_toc_enabled: true,
docs_mode_chapter_nav_enabled: true,
docs_mode_default_order: 'menu_order',
docs_mode_cats: [],
docs_mode_exclude: true,
}
const formData = ref({ ...defaults })
const templateOptions = [
{ label: '文档导航页 (page-documentnav)', value: 'documentnav' },
{ label: '专题聚合页 (page-special)', value: 'special' },
]
const availableTemplates = [
{
file: 'page-documentnav.php',
name: '文档导航页',
description: '站式文档导航模板,支持侧边栏目录树、章节高亮和快速搜索。',
icon: 'fas fa-book',
},
{
file: 'page-special.php',
name: '专题聚合页',
description: '专题内容聚合模板,支持封面、摘要、多列布局和子页面矩阵。',
icon: 'fas fa-layer-group',
},
{
file: 'page-download.php',
name: '下载中心页',
description: '资源下载中心模板,支持资源类型筛选、权限说明和交易承接。',
icon: 'fas fa-download',
},
{
file: 'page-categories.php',
name: '分类导航页',
description: '全站分类导航模板,展示分类树和文章统计。',
icon: 'fas fa-folder-open',
},
{
file: 'page-friends.php',
name: '友情链接页',
description: '友情链接展示模板。',
icon: 'fas fa-link',
},
]
onMounted(async () => {
await Promise.all([loadSettings(), loadDocStats(), loadCategories()])
})
async function loadSettings() {
try {
const res = await api.getSchemaPage('extensions', 'docs-mode')
if (res?.data) {
formData.value = {
...defaults,
...res.data,
docs_mode_cats: normalizeCategoryIds(res.data.docs_mode_cats),
}
}
} catch {
// Schema endpoint may not exist yet; use defaults
}
}
async function loadDocStats() {
try {
const statsRes = await api.request('/docs/stats')
if (statsRes?.data) {
docStats.value = statsRes.data
}
} catch {
// Endpoint may not exist yet
}
}
async function loadCategories() {
categoriesLoading.value = true
try {
const res = await api.getCategories()
const items = res?.data || res || []
categoryOptions.value = (Array.isArray(items) ? items : []).map((item) => ({
label: item.name + (item.count ? ` (${item.count})` : ''),
value: Number(item.term_id),
}))
} catch {
categoryOptions.value = []
} finally {
categoriesLoading.value = false
}
}
function normalizeCategoryIds(value) {
if (Array.isArray(value)) {
return value.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item > 0)
}
if (typeof value === 'string' && value.trim()) {
return value
.split(',')
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
}
return []
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('extensions', 'docs-mode', {
...formData.value,
docs_mode_cats: normalizeCategoryIds(formData.value.docs_mode_cats),
})
message.success('文档模式设置已保存')
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
function handleReset() {
formData.value = { ...defaults }
message.info('已重置为默认值')
}
</script>
<style scoped>
.aksrt-docs-mode__card {
margin-bottom: 16px;
}
.aksrt-docs-mode__stat {
text-align: center;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs-mode__stat strong {
display: block;
font-size: 24px;
color: #1a1a2e;
}
.aksrt-docs-mode__stat p {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs-mode__link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
background: #f5f7fa;
color: #1890ff;
text-decoration: none;
font-size: 13px;
transition: background 0.2s;
}
.aksrt-docs-mode__link:hover {
background: #e6f4ff;
}
</style>
@@ -0,0 +1,293 @@
<template>
<ExtensionsSectionPage
title="网站安全"
desc="扩展安全策略和第三方安全集成。"
>
<n-card title="安全概览" size="small" class="aksrt-security__section">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in securityItems" :key="item.key">
<div class="aksrt-security__overview-card">
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
<div>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="安全能力" size="small" class="aksrt-security__section">
<n-list bordered size="small">
<n-list-item v-for="feat in features" :key="feat.key">
<n-thing :title="feat.name" :description="feat.description">
<template #avatar>
<FaIcon :icon="feat.icon" :size="16" :color="feat.color" />
</template>
<template #header-extra>
<n-space align="center" size="small">
<n-tag :type="feat.enabled ? 'success' : 'default'" size="small">
{{ feat.enabled ? '已启用' : '未启用' }}
</n-tag>
<router-link v-if="feat.settingsRoute" :to="feat.settingsRoute" class="aksrt-security__config-link">
前往配置
</router-link>
</n-space>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<n-card title="在线测试" size="small" class="aksrt-security__section">
<n-grid :cols="2" :x-gap="16" :y-gap="16">
<n-grid-item>
<n-form label-placement="top">
<n-form-item label="IP 归属地测试">
<n-input v-model:value="ipTest.ip" placeholder="输入 IPv4 / IPv6,例如 8.8.8.8" />
</n-form-item>
<n-space>
<n-button :loading="ipTest.loading" @click="handleTestIp">测试 IP 接口</n-button>
</n-space>
<pre v-if="ipTest.result" class="aksrt-security__output">{{ ipTest.result }}</pre>
</n-form>
</n-grid-item>
<n-grid-item>
<n-form label-placement="top">
<n-form-item label="内容审核测试">
<n-input v-model:value="auditTest.text" type="textarea" :autosize="{ minRows: 4, maxRows: 8 }" placeholder="输入一段测试文本,检查当前审核策略是否生效" />
</n-form-item>
<n-space>
<n-button :loading="auditTest.loading" @click="handleTestAudit">测试内容审核</n-button>
</n-space>
<pre v-if="auditTest.result" class="aksrt-security__output">{{ auditTest.result }}</pre>
</n-form>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="风险开关" size="small" class="aksrt-security__section">
<n-alert type="warning" size="small" style="margin-bottom: 12px;">
以下开关会影响站点安全策略,请谨慎操作。
</n-alert>
<n-form label-placement="left" label-width="200" size="small">
<n-form-item label="允许未审核评论显示">
<n-switch v-model:value="riskSwitches.show_unapproved_comments" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后未审核评论也会在前台显示(标记为待审核)
</n-text>
</n-form-item>
<n-form-item label="允许前台投稿无需审核">
<n-switch v-model:value="riskSwitches.skip_submit_moderation" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后用户投稿直接发布,不经过审核流程
</n-text>
</n-form-item>
<n-form-item label="允许游客评论">
<n-switch v-model:value="riskSwitches.guest_comment" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后未登录用户也可以发表评论
</n-text>
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button type="primary" size="small" :loading="saving" @click="handleSaveRisk">保存风险设置</n-button>
</n-space>
</template>
</n-card>
<n-card title="安全审计" size="small" class="aksrt-security__section">
<n-empty v-if="auditLogs.length === 0" description="暂无审计记录" />
<n-list v-else bordered size="small">
<n-list-item v-for="log in auditLogs" :key="log.id">
<n-thing :title="log.action" :description="log.detail">
<template #header-extra>
<n-text depth="3" style="font-size: 11px;">{{ log.time }}</n-text>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
</ExtensionsSectionPage>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import ExtensionsSectionPage from '@/pages/extensions/ExtensionsSectionPage.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const saving = ref(false)
const securityItems = ref([
{ key: 'email_gate', icon: 'fas fa-envelope', color: '#1890ff', value: '—', label: '邮箱验证' },
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#52c41a', value: '—', label: '验证码保护' },
{ key: 'moderation', icon: 'fas fa-gavel', color: '#722ed1', value: '—', label: '内容审核' },
{ key: 'ip_geo', icon: 'fas fa-map-marker-alt', color: '#fa8c16', value: '—', label: 'IP 归属地' },
])
const features = ref([
{ key: 'email_verification', icon: 'fas fa-envelope', color: '#1890ff', name: '邮箱验证', description: '新用户注册需要邮箱验证', enabled: false, settingsRoute: '/users/auth' },
{ key: 'captcha_comment', icon: 'fas fa-shield-alt', color: '#52c41a', name: '评论验证码', description: '发表评论时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
{ key: 'captcha_download', icon: 'fas fa-shield-alt', color: '#52c41a', name: '下载验证码', description: '下载资源时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
{ key: 'content_audit', icon: 'fas fa-shield-virus', color: '#722ed1', name: '内容审核 API', description: '接入第三方内容安全审核', enabled: false, settingsRoute: '/extensions/audit' },
{ key: 'ip_geolocation', icon: 'fas fa-map-marker-alt', color: '#fa8c16', name: 'IP 归属地', description: '在评论和用户主页显示 IP 归属地', enabled: false, settingsRoute: '/extensions/ip' },
])
const riskSwitches = ref({
show_unapproved_comments: false,
skip_submit_moderation: false,
guest_comment: false,
})
const auditLogs = ref([])
const ipTest = ref({ ip: '8.8.8.8', loading: false, result: '' })
const auditTest = ref({ text: '这是一段用于审核测试的示例文本。', loading: false, result: '' })
onMounted(async () => {
try {
const res = await api.request('/settings/security')
if (res?.data) {
const d = res.data
const svc = res.services || {}
securityItems.value[0].value = d.email_gate_enabled ? '已开启' : '未开启'
securityItems.value[0].color = d.email_gate_enabled ? '#52c41a' : '#8a909c'
securityItems.value[1].value = d.captcha_enabled ? '已开启' : '未开启'
securityItems.value[1].color = d.captcha_enabled ? '#52c41a' : '#8a909c'
if (svc.audit?.enabled) {
securityItems.value[2].value = svc.audit.configured ? '已配置' : '待补全'
securityItems.value[2].color = svc.audit.configured ? '#52c41a' : '#faad14'
} else {
securityItems.value[2].value = '未开启'
securityItems.value[2].color = '#8a909c'
}
if (svc.ip_geo?.enabled) {
securityItems.value[3].value = svc.ip_geo.configured ? '已配置' : '待补全'
securityItems.value[3].color = svc.ip_geo.configured ? '#52c41a' : '#faad14'
} else {
securityItems.value[3].value = '未开启'
securityItems.value[3].color = '#8a909c'
}
features.value[0].enabled = !!d.email_gate_enabled
features.value[1].enabled = !!d.captcha_comment_enabled
features.value[2].enabled = !!d.captcha_download_enabled
features.value[3].enabled = !!(svc.audit?.enabled ?? d.content_audit_enabled)
features.value[4].enabled = !!(svc.ip_geo?.enabled ?? d.ip_geo_enabled)
if (d.risk_switches) {
riskSwitches.value = { ...riskSwitches.value, ...d.risk_switches }
}
}
} catch {
// Endpoint may not exist yet
}
try {
const logRes = await api.request('/security/audit-log')
if (logRes?.data?.logs) {
auditLogs.value = logRes.data.logs.slice(0, 10)
}
} catch {
// Endpoint may not exist yet
}
})
async function handleSaveRisk() {
saving.value = true
try {
await api.request('/settings/security', {
method: 'POST',
body: JSON.stringify({ risk_switches: riskSwitches.value }),
})
message.success('风险设置已保存')
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
async function handleTestIp() {
ipTest.value.loading = true
try {
const res = await api.testIpGeo(ipTest.value.ip)
ipTest.value.result = JSON.stringify(res?.data || {}, null, 2)
message.success('IP 接口测试完成')
} catch (err) {
message.error(err?.message || 'IP 测试失败')
} finally {
ipTest.value.loading = false
}
}
async function handleTestAudit() {
auditTest.value.loading = true
try {
const res = await api.testContentAudit(auditTest.value.text)
auditTest.value.result = JSON.stringify(res?.data || {}, null, 2)
message.success('内容审核测试完成')
} catch (err) {
message.error(err?.message || '内容审核测试失败')
} finally {
auditTest.value.loading = false
}
}
</script>
<style scoped>
.aksrt-security__section {
margin-bottom: 16px;
}
.aksrt-security__overview-card {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-security__overview-card strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-security__overview-card p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-security__config-link {
font-size: 12px;
color: #1890ff;
text-decoration: none;
white-space: nowrap;
}
.aksrt-security__config-link:hover {
text-decoration: underline;
}
.aksrt-security__output {
margin: 12px 0 0;
padding: 12px 14px;
border-radius: 12px;
background: #101828;
color: #d0d7e2;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,271 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="系统工具" desc="额外运维工具与扩展能力。" />
<!-- Health Overview -->
<n-card title="系统健康" size="small" class="aksrt-tools__section">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in healthItems" :key="item.key">
<div class="aksrt-tools__health-card" :class="'aksrt-tools__health-card--' + item.status">
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
<div>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Tool Categories -->
<n-card title="工具集合" size="small" class="aksrt-tools__section">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="tool in tools" :key="tool.key">
<div class="aksrt-tools__tool-card" @click="navigateTo(tool.route)">
<div class="aksrt-tools__tool-icon" :style="{ background: tool.bg }">
<FaIcon :icon="tool.icon" :size="18" color="#fff" />
</div>
<div class="aksrt-tools__tool-body">
<strong>{{ tool.title }}</strong>
<p>{{ tool.desc }}</p>
</div>
<FaIcon icon="fas fa-chevron-right" :size="12" color="#c0c4cc" />
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Service Status -->
<n-card title="服务状态" size="small" class="aksrt-tools__section">
<n-list bordered size="small">
<n-list-item v-for="svc in services" :key="svc.key">
<n-thing :title="svc.name" :description="svc.description">
<template #avatar>
<FaIcon :icon="svc.icon" :size="16" :color="svc.color" />
</template>
<template #header-extra>
<n-tag :type="svc.available ? 'success' : 'warning'" size="small">
{{ svc.available ? '可用' : '未配置' }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<!-- Quick Maintenance -->
<n-card title="手动维护" size="small" class="aksrt-tools__section">
<n-space>
<n-button @click="handleFlushCache">
<template #icon><FaIcon icon="fas fa-broom" :size="12" /></template>
清除缓存
</n-button>
<n-button @click="handleOptimizeDb">
<template #icon><FaIcon icon="fas fa-database" :size="12" /></template>
优化数据表
</n-button>
<n-button @click="handleRebuildIndex">
<template #icon><FaIcon icon="fas fa-sync-alt" :size="12" /></template>
重建索引
</n-button>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const router = useRouter()
const message = useMessage()
const healthItems = ref([
{ key: 'php', icon: 'fas fa-code', color: '#1890ff', value: '—', label: 'PHP 版本', status: 'ok' },
{ key: 'wp', icon: 'fab fa-wordpress', color: '#722ed1', value: '—', label: 'WordPress', status: 'ok' },
{ key: 'db', icon: 'fas fa-database', color: '#52c41a', value: '—', label: '数据库', status: 'ok' },
{ key: 'uploads', icon: 'fas fa-folder-open', color: '#fa8c16', value: '—', label: '上传目录', status: 'ok' },
])
const tools = ref([
{ key: 'import-export', icon: 'fas fa-exchange-alt', bg: '#1890ff', title: '导入导出', desc: '设置导入、导出和重置', route: '/tools/import-export' },
{ key: 'health', icon: 'fas fa-heartbeat', bg: '#52c41a', title: '健康检查', desc: '系统状态检测和诊断', route: '/tools/health' },
{ key: 'migration', icon: 'fas fa-exchange-alt', bg: '#722ed1', title: '数据迁移', desc: '设置迁移状态和兼容性', route: '/tools/migration' },
{ key: 'email-rollback', icon: 'fas fa-undo', bg: '#fa8c16', title: '邮箱回滚', desc: '邮箱变更 14 天内回滚', route: '/tools/email-rollback' },
])
const services = ref([
{ key: 'sms', icon: 'fas fa-sms', color: '#1890ff', name: '短信服务', description: '验证码和通知短信', available: false },
{ key: 'wechat', icon: 'fab fa-weixin', color: '#52c41a', name: '微信通知', description: '微信模板消息和订阅通知', available: false },
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#722ed1', name: '验证码服务', description: '人机验证(极验/腾讯/阿里云)', available: false },
{ key: 'push', icon: 'fas fa-paper-plane', color: '#fa8c16', name: '搜索引擎推送', description: '百度/Google 主动推送', available: false },
{ key: 'audit', icon: 'fas fa-shield-virus', color: '#f5222d', name: '内容审核', description: '第三方内容安全审核 API', available: false },
])
onMounted(async () => {
try {
const res = await api.request('/tools/health')
if (res?.data) {
const d = res.data
if (d.php_version) healthItems.value[0].value = d.php_version
if (d.wp_version) healthItems.value[1].value = d.wp_version
if (d.db_status) {
healthItems.value[2].value = d.db_status
healthItems.value[2].status = d.db_ok ? 'ok' : 'warn'
}
if (d.upload_dir) {
healthItems.value[3].value = d.upload_dir
healthItems.value[3].status = d.upload_ok ? 'ok' : 'warn'
}
}
} catch {
// Endpoint may not exist yet
}
// Check service availability from structured status
try {
const settingsRes = await api.request('/settings/extensions')
if (settingsRes?.data) {
const s = settingsRes.data
// Prefer structured services if available
const svc = settingsRes.services || {}
services.value[0].available = !!(svc.sms?.available ?? s.sms_enabled)
services.value[0].status = svc.sms?.provider || (s.sms_enabled ? '已启用' : '未配置')
services.value[1].available = !!(svc.wechat?.available ?? s.wechat_enabled)
services.value[1].status = svc.wechat?.provider || (s.wechat_enabled ? '已启用' : '未配置')
services.value[2].available = !!(svc.captcha?.available ?? s.captcha_enabled)
services.value[2].status = svc.captcha?.provider || (s.captcha_enabled ? '已启用' : '未配置')
services.value[3].available = !!(svc.push?.available ?? s.baidu_push_enabled)
services.value[3].status = svc.push?.provider || (s.baidu_push_enabled ? '已启用' : '未配置')
services.value[4].available = !!(svc.audit?.available ?? s.content_audit_enabled)
services.value[4].status = svc.audit?.provider || (s.content_audit_enabled ? '已启用' : '未配置')
}
} catch {
// Use defaults
}
})
function navigateTo(route) {
router.push(route)
}
async function handleFlushCache() {
message.info('正在清除缓存...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'flush_cache' }),
})
message.success(res?.message || '缓存已清除')
} catch (err) {
message.error(err?.message || '清除缓存失败')
}
}
async function handleOptimizeDb() {
message.info('正在优化数据表...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'optimize_db' }),
})
message.success(res?.message || '数据表已优化')
} catch (err) {
message.error(err?.message || '优化数据表失败')
}
}
async function handleRebuildIndex() {
message.info('正在重建索引...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'rebuild_index' }),
})
message.success(res?.message || '索引重建完成')
} catch (err) {
message.error(err?.message || '重建索引失败')
}
}
</script>
<style scoped>
.aksrt-tools__section {
margin-bottom: 16px;
}
.aksrt-tools__health-card {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-tools__health-card strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__health-card p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-tools__health-card--warn {
border-color: #faad14;
background: #fffbe6;
}
.aksrt-tools__tool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s;
}
.aksrt-tools__tool-card:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
}
.aksrt-tools__tool-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.aksrt-tools__tool-body {
flex: 1;
min-width: 0;
}
.aksrt-tools__tool-body strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__tool-body p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -0,0 +1,39 @@
<template>
<div class="aksrt-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
{{ link.label }}
</n-button>
</n-space>
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
</n-card>
<slot />
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue'
defineProps({
title: { type: String, required: true },
desc: { type: String, default: '' },
note: { type: String, default: '' },
links: { type: Array, default: () => [] },
})
const router = useRouter()
function go(path) {
router.push(path)
}
</script>
@@ -0,0 +1,195 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版块管理" desc="创建、编辑和维护论坛版块信息,并观察版主申请积压情况。" />
<n-card size="small" class="aksrt-forum-boards__section">
<n-space justify="space-between" align="center">
<div class="aksrt-forum-boards__summary">共 {{ boards.length }} 个版块</div>
<n-button type="primary" @click="openCreate">新增版块</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-boards__section">
<n-data-table
:columns="columns"
:data="boards"
:loading="loading"
:bordered="false"
size="small"
:row-key="(row) => row.id"
/>
</n-card>
<n-modal :show="modalVisible" preset="card" style="width: 520px" :title="editingId ? '编辑版块' : '新增版块'" @update:show="modalVisible = $event">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="版块名称" path="name">
<n-input v-model:value="form.name" placeholder="输入版块名称" />
</n-form-item>
<n-form-item label="版块描述" path="description">
<n-input v-model:value="form.description" type="textarea" :rows="3" placeholder="描述主题方向和讨论边界" />
</n-form-item>
<n-form-item label="图标" path="icon">
<n-input v-model:value="form.icon" placeholder="例如:fas fa-comments" />
</n-form-item>
<n-form-item label="排序" path="sort_order">
<n-input-number v-model:value="form.sort_order" :min="0" style="width: 100%" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="modalVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const boards = ref([])
const modalVisible = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
name: '',
description: '',
icon: 'fas fa-comments',
sort_order: 0,
})
const rules = {
name: { required: true, message: '请输入版块名称', trigger: 'blur' },
}
const columns = computed(() => [
{ title: '排序', key: 'sort_order', width: 70 },
{ title: '版块名称', key: 'name', minWidth: 180 },
{ title: '描述', key: 'description', ellipsis: { tooltip: true } },
{
title: '治理状态',
key: 'governance',
width: 180,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NTag, { size: 'small', type: 'info' }, () => `${row.post_count || 0} 主题`),
h(NTag, { size: 'small', type: 'success' }, () => `${row.moderator_count || 0} 版主`),
h(NTag, { size: 'small', type: (row.pending_application_count || 0) > 0 ? 'warning' : 'default' }, () => `${row.pending_application_count || 0} 申请`),
]),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDelete(row) }, () => '删除'),
]),
},
])
function resetForm() {
form.name = ''
form.description = ''
form.icon = 'fas fa-comments'
form.sort_order = boards.value.length
}
async function loadBoards() {
loading.value = true
try {
const res = await api.request('/forum/boards')
boards.value = res?.data?.items || []
} catch (error) {
message.error(error?.message || '加载版块列表失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
resetForm()
modalVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
form.name = row.name || ''
form.description = row.description || ''
form.icon = row.icon || 'fas fa-comments'
form.sort_order = row.sort_order || 0
modalVisible.value = true
}
async function handleSave() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await api.request(`/forum/boards/${editingId.value}`, {
method: 'POST',
data: { ...form },
})
message.success('版块已更新')
} else {
await api.request('/forum/boards', {
method: 'POST',
data: { ...form },
})
message.success('版块已创建')
}
modalVisible.value = false
await loadBoards()
} catch (error) {
message.error(error?.message || '版块保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除版块',
content: `确定删除“${row.name}”吗?如果仍有主题,请先完成迁移。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/forum/boards/${row.id}`, { method: 'DELETE' })
message.success('版块已删除')
await loadBoards()
} catch (error) {
message.error(error?.message || '版块删除失败')
}
},
})
}
onMounted(loadBoards)
</script>
<style scoped>
.aksrt-forum-boards__section {
margin-bottom: 16px;
}
.aksrt-forum-boards__summary {
color: #6b7280;
font-size: 13px;
}
</style>
@@ -0,0 +1,223 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="论坛概览" desc="从待审核主题、版主申请和版块状态三个视角观察论坛治理负载。" />
<div class="aksrt-forum-dashboard__stats">
<div v-for="card in statCards" :key="card.key" class="aksrt-forum-dashboard__stat-card">
<div class="aksrt-forum-dashboard__stat-icon" :style="{ background: card.color }">
<i :class="card.icon"></i>
</div>
<div class="aksrt-forum-dashboard__stat-info">
<div class="aksrt-forum-dashboard__stat-value">{{ stats[card.key] || 0 }}</div>
<div class="aksrt-forum-dashboard__stat-label">{{ card.label }}</div>
</div>
</div>
</div>
<div class="aksrt-forum-dashboard__grid">
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>待审核主题</strong>
<n-button size="small" quaternary type="primary" @click="goTopics">进入主题治理</n-button>
</div>
</template>
<n-data-table
v-if="pendingTopics.length"
:columns="topicColumns"
:data="pendingTopics"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无待审核主题" />
</n-card>
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>待处理版主申请</strong>
<n-button size="small" quaternary type="primary" @click="goApplications">进入申请处理</n-button>
</div>
</template>
<n-data-table
v-if="pendingApplications.length"
:columns="applicationColumns"
:data="pendingApplications"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无待处理申请" />
</n-card>
</div>
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>版块概况</strong>
<n-button size="small" quaternary type="primary" @click="goBoards">进入版块管理</n-button>
</div>
</template>
<n-data-table
v-if="boards.length"
:columns="boardColumns"
:data="boards"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无版块" />
</n-card>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const stats = ref({})
const boards = ref([])
const pendingTopics = ref([])
const pendingApplications = ref([])
const statCards = [
{ key: 'board_count', label: '版块数', icon: 'fas fa-th-large', color: '#dbeafe' },
{ key: 'topic_count', label: '公开主题', icon: 'fas fa-list-alt', color: '#dcfce7' },
{ key: 'pending_topic_count', label: '待审核主题', icon: 'fas fa-hourglass-half', color: '#fef3c7' },
{ key: 'pending_application_count', label: '待处理申请', icon: 'fas fa-user-shield', color: '#fee2e2' },
]
const topicColumns = [
{ title: '标题', key: 'title', minWidth: 260 },
{ title: '作者', key: 'author_name', width: 110, render: (row) => row.author?.display_name || '-' },
{ title: '版块', key: 'board_name', width: 120, render: (row) => row.boards?.[0]?.name || '-' },
{ title: '时间', key: 'date', width: 160 },
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => goTopics() }, () => '治理'),
},
]
const applicationColumns = [
{ title: '版块', key: 'board_name', width: 140 },
{ title: '申请人', key: 'display_name', width: 140 },
{ title: '理由', key: 'reason', ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 160 },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => h(NTag, { size: 'small', type: 'warning' }, () => row.status_label || row.status || '-'),
},
]
const boardColumns = [
{ title: '版块名称', key: 'name', minWidth: 180 },
{ title: '主题数', key: 'post_count', width: 90 },
{ title: '版主数', key: 'moderator_count', width: 90 },
{ title: '待处理申请', key: 'pending_application_count', width: 120 },
]
async function loadOverview() {
try {
const res = await api.request('/forum/overview')
const data = res?.data || {}
stats.value = data.stats || {}
boards.value = data.boards || []
pendingTopics.value = data.pending_topics || []
pendingApplications.value = data.pending_applications || []
} catch (error) {
message.error(error?.message || '加载论坛概览失败')
}
}
function goBoards() {
window.location.hash = '#/forum/boards'
}
function goTopics() {
window.location.hash = '#/forum/topics'
}
function goApplications() {
window.location.hash = '#/forum/moderator-applications'
}
onMounted(loadOverview)
</script>
<style scoped>
.aksrt-forum-dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-forum-dashboard__stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px;
border-radius: 12px;
background: #fff;
border: 1px solid #eef2f7;
}
.aksrt-forum-dashboard__stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: #1d4ed8;
font-size: 18px;
}
.aksrt-forum-dashboard__stat-info {
display: flex;
flex-direction: column;
}
.aksrt-forum-dashboard__stat-value {
color: #111827;
font-size: 24px;
font-weight: 700;
}
.aksrt-forum-dashboard__stat-label {
color: #6b7280;
font-size: 13px;
}
.aksrt-forum-dashboard__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.aksrt-forum-dashboard__section {
margin-bottom: 16px;
}
.aksrt-forum-dashboard__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 1023px) {
.aksrt-forum-dashboard__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,201 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版主申请处理" desc="处理前台版主申请,完成审核、通过和拒绝反馈闭环。" />
<n-card size="small" class="aksrt-forum-apps__section">
<n-space wrap>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索版块 / 用户 / 理由" style="width: 220px" @keyup.enter="loadData" />
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="状态" style="width: 160px" />
<n-button type="primary" @click="loadData">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-apps__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:bordered="false"
size="small"
/>
</n-card>
<n-modal :show="reviewVisible" preset="card" title="处理版主申请" style="width: 520px" @update:show="reviewVisible = $event">
<n-space vertical :size="14">
<n-alert type="info" :show-icon="false">
<div>{{ activeApplication?.display_name || '-' }} / {{ activeApplication?.board_name || '-' }}</div>
<div class="aksrt-forum-apps__reason">{{ activeApplication?.reason || '暂无申请理由' }}</div>
</n-alert>
<n-radio-group v-model:value="reviewForm.status">
<n-radio-button value="approved">通过</n-radio-button>
<n-radio-button value="rejected">拒绝</n-radio-button>
</n-radio-group>
<n-input v-model:value="reviewForm.note" type="textarea" :rows="4" placeholder="记录审核结论和补充说明" />
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="reviewVisible = false">取消</n-button>
<n-button type="primary" :loading="reviewing" @click="submitReview">提交处理</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const loading = ref(false)
const items = ref([])
const boardOptions = ref([{ label: '全部版块', value: null }])
const statusOptions = ref([{ label: '全部状态', value: null }])
const reviewVisible = ref(false)
const reviewing = ref(false)
const activeApplication = ref(null)
const filters = reactive({
keyword: '',
board_id: null,
status: null,
})
const reviewForm = reactive({
status: 'approved',
note: '',
})
const columns = [
{
title: '版块 / 用户',
key: 'board_name',
minWidth: 220,
render: (row) => h('div', { class: 'aksrt-forum-apps__identity' }, [
h('div', { class: 'aksrt-forum-apps__board' }, row.board_name || '-'),
h('div', { class: 'aksrt-forum-apps__user' }, `${row.display_name || '-'} · ${row.user_email || '-'}`),
]),
},
{ title: '申请理由', key: 'reason', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: resolveStatusType(row.status) }, () => row.status_label || row.status || '-'),
},
{ title: '申请时间', key: 'created_at', width: 160 },
{ title: '处理时间', key: 'reviewed_at', width: 160, render: (row) => row.reviewed_at || '-' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => {
if (row.status !== 'pending') {
return '-'
}
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'small', quaternary: true, type: 'success', onClick: () => openReview(row, 'approved') }, () => '通过'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => openReview(row, 'rejected') }, () => '拒绝'),
])
},
},
]
function resolveStatusType(status) {
if (status === 'approved') return 'success'
if (status === 'rejected') return 'error'
return 'warning'
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/forum/moderator/applications', {
params: {
keyword: filters.keyword || '',
board_id: filters.board_id || '',
status: filters.status || '',
},
})
const data = res?.data || {}
items.value = data.items || []
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
statusOptions.value = data.options?.status || [{ label: '全部状态', value: null }]
} catch (error) {
message.error(error?.message || '加载版主申请失败')
} finally {
loading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.board_id = null
filters.status = null
loadData()
}
function openReview(row, status) {
activeApplication.value = row
reviewForm.status = status
reviewForm.note = row.review_note || ''
reviewVisible.value = true
}
async function submitReview() {
if (!activeApplication.value?.id) {
return
}
reviewing.value = true
try {
await api.request(`/forum/moderator/applications/${activeApplication.value.id}/review`, {
method: 'POST',
data: {
status: reviewForm.status,
note: reviewForm.note,
},
})
message.success('申请状态已更新')
reviewVisible.value = false
activeApplication.value = null
await loadData()
} catch (error) {
message.error(error?.message || '申请处理失败')
} finally {
reviewing.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.aksrt-forum-apps__section {
margin-bottom: 16px;
}
.aksrt-forum-apps__identity {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-forum-apps__board {
color: #111827;
font-weight: 600;
}
.aksrt-forum-apps__user,
.aksrt-forum-apps__reason {
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,363 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版主管理" desc="按版块维护版主名单,支持角色分层和权限档位。" />
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen" style="margin-bottom:16px;">
<n-grid-item>
<n-statistic label="总版主数" :value="stats.total_moderators" />
</n-grid-item>
<n-grid-item>
<n-statistic label="总版块数" :value="stats.total_boards" />
</n-grid-item>
<n-grid-item>
<n-statistic label="待处理申请" :value="stats.pending_applications">
<template #suffix>
<n-tag v-if="stats.pending_applications > 0" type="warning" size="small">待处理</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="高级版主" :value="stats.senior_moderators" />
</n-grid-item>
</n-grid>
<n-card size="small" class="aksrt-forum-mod__toolbar">
<n-space justify="space-between" align="center">
<span class="aksrt-forum-mod__summary">已加载 {{ boards.length }} 个版块</span>
<n-space>
<n-button @click="goApplications">查看版主申请</n-button>
<n-button type="primary" @click="showBatchModal = true">批量管理</n-button>
</n-space>
</n-space>
</n-card>
<n-card v-for="board in boards" :key="board.board_id" size="small" class="aksrt-forum-mod__section">
<template #header>
<div class="aksrt-forum-mod__header">
<strong>{{ board.board_name }}</strong>
<n-space size="small">
<n-tag size="small" type="info">{{ board.topic_count }} 主题</n-tag>
<n-tag size="small" :type="board.pending_application_count > 0 ? 'warning' : 'default'">
{{ board.pending_application_count }} 申请待处理
</n-tag>
</n-space>
</div>
</template>
<!-- 版主列表 -->
<div class="aksrt-forum-mod__list">
<div v-for="moderator in board.moderators" :key="moderator.id" class="aksrt-forum-mod__item">
<n-avatar :src="moderator.avatar_url" :size="36" round />
<div class="aksrt-forum-mod__info">
<div class="aksrt-forum-mod__name-row">
<span class="aksrt-forum-mod__name">{{ moderator.display_name }}</span>
<n-tag :type="getRoleTagType(moderator.role)" size="tiny">{{ getRoleLabel(moderator.role) }}</n-tag>
</div>
<span class="aksrt-forum-mod__email">{{ moderator.user_email }}</span>
<div v-if="moderator.permissions?.length" class="aksrt-forum-mod__permissions">
<n-tag v-for="perm in moderator.permissions" :key="perm" size="tiny" type="default">{{ getPermissionLabel(perm) }}</n-tag>
</div>
</div>
<n-space size="small">
<n-select
v-model:value="moderator.role"
:options="roleOptions"
size="tiny"
style="width:100px;"
@update:value="handleRoleChange(board.board_id, moderator.id, $event)"
/>
<n-button size="tiny" quaternary type="error" @click="handleRemove(board.board_id, moderator.id)">移除</n-button>
</n-space>
</div>
<n-empty v-if="board.moderators.length === 0" description="暂无版主" size="small" />
</div>
<!-- 添加版主 -->
<div class="aksrt-forum-mod__add">
<n-input
v-model:value="board._newUser"
size="small"
style="width:140px"
placeholder="用户 ID"
@keyup.enter="handleAdd(board)"
/>
<n-select
v-model:value="board._newRole"
:options="roleOptions"
size="small"
style="width:100px;"
placeholder="角色"
/>
<n-button size="small" type="primary" :disabled="!board._newUser" @click="handleAdd(board)">添加版主</n-button>
</div>
</n-card>
<n-empty v-if="boards.length === 0 && !loading" description="暂无论坛版块" />
<!-- 批量管理弹窗 -->
<n-modal v-model:show="showBatchModal" preset="card" title="批量管理版主" style="max-width:560px;" :bordered="false">
<n-form label-placement="top">
<n-form-item label="选择版块">
<n-select v-model:value="batchForm.board_id" :options="boardOptions" placeholder="选择版块" />
</n-form-item>
<n-form-item label="操作">
<n-radio-group v-model:value="batchForm.action">
<n-space>
<n-radio value="add">批量添加</n-radio>
<n-radio value="remove">批量移除</n-radio>
<n-radio value="update_role">批量修改角色</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="用户 ID 列表">
<n-input v-model:value="batchForm.user_ids" type="textarea" :rows="3" placeholder="每行一个用户 ID" />
</n-form-item>
<n-form-item v-if="batchForm.action === 'update_role'" label="目标角色">
<n-select v-model:value="batchForm.target_role" :options="roleOptions" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showBatchModal = false">取消</n-button>
<n-button type="primary" :loading="batchLoading" @click="handleBatch">执行</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const loading = ref(false)
const boards = ref([])
const stats = reactive({ total_moderators: 0, total_boards: 0, pending_applications: 0, senior_moderators: 0 })
// 批量管理
const showBatchModal = ref(false)
const batchLoading = ref(false)
const batchForm = reactive({ board_id: null, action: 'add', user_ids: '', target_role: 'moderator' })
// 角色选项
const roleOptions = [
{ label: '超级版主', value: 'super_moderator' },
{ label: '高级版主', value: 'senior_moderator' },
{ label: '版主', value: 'moderator' },
{ label: '实习版主', value: 'trainee' },
]
// 权限选项
const permissionLabels = {
manage_topics: '管理主题',
manage_comments: '管理评论',
manage_users: '管理用户',
pin_topics: '置顶主题',
close_topics: '关闭主题',
move_topics: '移动主题',
delete_topics: '删除主题',
ban_users: '封禁用户',
}
const boardOptions = computed(() => boards.value.map(b => ({ label: b.board_name, value: b.board_id })))
function getRoleLabel(role) {
const opt = roleOptions.find(r => r.value === role)
return opt?.label || role || '版主'
}
function getRoleTagType(role) {
const map = { super_moderator: 'error', senior_moderator: 'warning', moderator: 'info', trainee: 'default' }
return map[role] || 'default'
}
function getPermissionLabel(perm) {
return permissionLabels[perm] || perm
}
async function loadModerators() {
loading.value = true
try {
const res = await api.request('/forum/moderators')
boards.value = (res?.data?.boards || []).map(item => ({
...item,
_newUser: '',
_newRole: 'moderator',
}))
// 计算统计
stats.total_boards = boards.value.length
stats.total_moderators = boards.value.reduce((sum, b) => sum + (b.moderators?.length || 0), 0)
stats.pending_applications = boards.value.reduce((sum, b) => sum + (b.pending_application_count || 0), 0)
stats.senior_moderators = boards.value.reduce((sum, b) =>
sum + (b.moderators?.filter(m => ['super_moderator', 'senior_moderator'].includes(m.role))?.length || 0), 0)
} catch (error) {
message.error(error?.message || '加载版主列表失败')
} finally {
loading.value = false
}
}
async function handleAdd(board) {
const userId = parseInt(board._newUser || '', 10)
if (!userId || userId <= 0) {
message.warning('请输入有效的用户 ID')
return
}
try {
await api.request('/forum/moderators', {
method: 'POST',
data: {
board_id: board.board_id,
user_id: userId,
role: board._newRole || 'moderator',
action: 'add',
},
})
message.success('版主已添加')
board._newUser = ''
board._newRole = 'moderator'
await loadModerators()
} catch (error) {
message.error(error?.message || '添加版主失败')
}
}
async function handleRemove(boardId, userId) {
try {
await api.request('/forum/moderators', {
method: 'POST',
data: { board_id: boardId, user_id: userId, action: 'remove' },
})
message.success('版主已移除')
await loadModerators()
} catch (error) {
message.error(error?.message || '移除版主失败')
}
}
async function handleRoleChange(boardId, userId, newRole) {
try {
await api.request('/forum/moderators', {
method: 'POST',
data: { board_id: boardId, user_id: userId, role: newRole, action: 'update_role' },
})
message.success('角色已更新')
await loadModerators()
} catch (error) {
message.error(error?.message || '角色更新失败')
}
}
async function handleBatch() {
const userIds = batchForm.user_ids.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
if (!batchForm.board_id || userIds.length === 0) {
message.warning('请选择版块并输入用户 ID')
return
}
batchLoading.value = true
try {
await api.request('/forum/moderators/batch', {
method: 'POST',
data: {
board_id: batchForm.board_id,
user_ids: userIds,
action: batchForm.action,
target_role: batchForm.target_role,
},
})
message.success('批量操作完成')
showBatchModal.value = false
batchForm.user_ids = ''
await loadModerators()
} catch (error) {
message.error(error?.message || '批量操作失败')
} finally {
batchLoading.value = false
}
}
function goApplications() {
window.location.hash = '#/forum/moderator-applications'
}
onMounted(loadModerators)
</script>
<style scoped>
.aksrt-forum-mod__toolbar,
.aksrt-forum-mod__section {
margin-bottom: 16px;
}
.aksrt-forum-mod__summary {
color: var(--color-ink-3);
font-size: 13px;
}
.aksrt-forum-mod__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.aksrt-forum-mod__list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.aksrt-forum-mod__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: var(--surface-2);
border: 1px solid var(--color-line-soft);
}
.aksrt-forum-mod__info {
flex: 1;
min-width: 0;
}
.aksrt-forum-mod__name-row {
display: flex;
align-items: center;
gap: 6px;
}
.aksrt-forum-mod__name {
display: block;
color: var(--color-ink);
font-weight: 600;
}
.aksrt-forum-mod__email {
display: block;
color: var(--color-ink-3);
font-size: 12px;
}
.aksrt-forum-mod__permissions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.aksrt-forum-mod__add {
display: flex;
gap: 8px;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--color-line-soft);
}
</style>
@@ -0,0 +1,186 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="主题治理" desc="集中处理论坛主题的审核、可见性、版块移动和治理状态。" />
<n-card size="small" class="aksrt-forum-topics__section">
<n-space wrap>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索标题 / 作者 / 版块" style="width: 220px" @keyup.enter="loadData(1)" />
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="帖子状态" style="width: 160px" />
<n-select v-model:value="filters.review_status" :options="reviewOptions" clearable placeholder="审核状态" style="width: 160px" />
<n-select v-model:value="filters.visibility" :options="visibilityOptions" clearable placeholder="可见性" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-topics__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<TopicModerationDrawer
v-model:show="detailVisible"
:topic-id="detailTopicId"
@updated="loadData(page)"
/>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import TopicModerationDrawer from './components/TopicModerationDrawer.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const detailVisible = ref(false)
const detailTopicId = ref(null)
const boardOptions = ref([])
const statusOptions = ref([{ label: '全部', value: 'all' }])
const reviewOptions = ref([])
const visibilityOptions = ref([])
const filters = reactive({
keyword: '',
board_id: null,
status: 'all',
review_status: null,
visibility: null,
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const columns = [
{
title: '主题',
key: 'title',
minWidth: 280,
render: (row) => h('div', { class: 'aksrt-forum-topics__title-cell' }, [
h('div', { class: 'aksrt-forum-topics__title' }, row.title || '-'),
h('div', { class: 'aksrt-forum-topics__subtitle' }, [
row.boards?.[0]?.name || '未分配版块',
' · ',
row.author?.display_name || '未知作者',
]),
]),
},
{
title: '可见性',
key: 'visibility',
width: 110,
render: (row) => h(NTag, { size: 'small', type: row.visibility?.key === 'public' ? 'default' : 'warning' }, () => row.visibility?.label || '-'),
},
{
title: '审核',
key: 'review_status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: resolveReviewType(row.review_status?.key) }, () => row.review_status?.label || '-'),
},
{ title: '回复', key: 'comment_count', width: 80 },
{ title: '浏览', key: 'views', width: 80 },
{ title: '发布时间', key: 'date', width: 160 },
{
title: '操作',
key: 'actions',
width: 110,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '治理'),
},
]
function resolveReviewType(status) {
if (status === 'approved') return 'success'
if (status === 'rejected') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/forum/topics', {
params: {
page: nextPage,
per_page: pagination.pageSize,
keyword: filters.keyword || '',
board_id: filters.board_id || '',
status: filters.status || 'all',
review_status: filters.review_status || '',
visibility: filters.visibility || '',
},
})
const data = res?.data || {}
items.value = data.items || []
pagination.itemCount = data.total || 0
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
statusOptions.value = data.options?.status || [{ label: '全部', value: 'all' }]
reviewOptions.value = [{ label: '全部审核', value: null }, ...(data.options?.review || [])]
visibilityOptions.value = [{ label: '全部可见性', value: null }, ...(data.options?.visibility || [])]
} catch (error) {
message.error(error?.message || '加载主题列表失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function resetFilters() {
filters.keyword = ''
filters.board_id = null
filters.status = 'all'
filters.review_status = null
filters.visibility = null
loadData(1)
}
function openDetail(row) {
detailTopicId.value = row.id
detailVisible.value = true
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-forum-topics__section {
margin-bottom: 16px;
}
.aksrt-forum-topics__title-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-forum-topics__title {
color: #111827;
font-weight: 600;
}
.aksrt-forum-topics__subtitle {
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<n-drawer :show="show" :width="620" @update:show="$emit('update:show', $event)">
<n-drawer-content title="主题治理" closable>
<n-spin :show="loading">
<template v-if="detail.topic">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="主题标题">{{ detail.topic.title || '-' }}</n-descriptions-item>
<n-descriptions-item label="所属版块">{{ primaryBoardName }}</n-descriptions-item>
<n-descriptions-item label="作者">{{ detail.topic.author?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="可见性">{{ detail.topic.visibility?.label || '-' }}</n-descriptions-item>
<n-descriptions-item label="审核状态">{{ detail.topic.review_status?.label || '-' }}</n-descriptions-item>
<n-descriptions-item label="主题状态">{{ detail.topic.status || '-' }}</n-descriptions-item>
<n-descriptions-item label="置顶">{{ stickySummary }}</n-descriptions-item>
<n-descriptions-item label="精华">{{ detail.topic.featured ? '是' : '否' }}</n-descriptions-item>
<n-descriptions-item label="帖子类型">{{ bbsTypeLabel }}</n-descriptions-item>
<n-descriptions-item v-if="detail.topic.bbs_type === 'question'" label="提问状态">{{ questionStatusLabel }}</n-descriptions-item>
<n-descriptions-item label="收藏">{{ detail.topic.favorite_count ?? 0 }}</n-descriptions-item>
<n-descriptions-item label="评分">{{ detail.topic.score ?? 0 }}</n-descriptions-item>
<n-descriptions-item label="专题">{{ detail.topic.special_topic || '-' }}</n-descriptions-item>
<n-descriptions-item label="投票">{{ voteSummary }}</n-descriptions-item>
<n-descriptions-item label="治理备注">{{ detail.topic.moderation_note || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-topic-drawer__excerpt">
<div class="aksrt-topic-drawer__section-title">内容摘要</div>
<p>{{ detail.topic.excerpt || '暂无摘要' }}</p>
</div>
<div class="aksrt-topic-drawer__actions">
<div class="aksrt-topic-drawer__section-title">执行治理动作</div>
<n-space vertical :size="12">
<n-select v-model:value="form.action" :options="actionOptions" />
<n-select
v-if="form.action === 'review'"
v-model:value="form.review_status"
:options="detail.options?.review || []"
placeholder="选择审核状态"
/>
<n-select
v-if="form.action === 'move_board'"
v-model:value="form.target_board_id"
:options="boardOptions"
placeholder="选择目标版块"
/>
<n-select
v-if="form.action === 'visibility'"
v-model:value="form.visibility"
:options="detail.options?.visibility || []"
placeholder="选择可见性"
/>
<n-select
v-if="form.action === 'sticky'"
v-model:value="form.sticky_value"
:options="stickyLevelOptions"
placeholder="选择置顶级别"
/>
<n-select
v-if="form.action === 'question_status'"
v-model:value="form.question_status_value"
:options="questionStatusOptions"
placeholder="选择提问状态"
/>
<n-select
v-if="booleanActions.includes(form.action) && form.action !== 'sticky'"
v-model:value="form.value"
:options="booleanOptions"
placeholder="选择状态"
/>
<n-input
v-model:value="form.note"
type="textarea"
:rows="3"
placeholder="记录本次处理原因,便于后续审计"
/>
<n-space>
<n-button type="primary" :loading="submitting" @click="handleSubmit">执行</n-button>
<n-button @click="loadDetail">刷新详情</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无主题详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
topicId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref(false)
const detail = ref({ topic: null, options: { boards: [], visibility: [], review: [] } })
// sticky 现在是 0~3 分级选择,不再是布尔动作(§7.3 置顶分级)。
const booleanActions = ['featured', 'locked', 'close', 'hide']
const form = reactive({
action: 'review',
review_status: 'pending',
target_board_id: null,
visibility: 'public',
value: '1',
sticky_value: 0,
question_status_value: 0,
note: '',
})
const actionOptions = [
{ label: '审核状态', value: 'review' },
{ label: '移动版块', value: 'move_board' },
{ label: '修改可见性', value: 'visibility' },
{ label: '置顶分级', value: 'sticky' },
{ label: '精华切换', value: 'featured' },
{ label: '锁定切换', value: 'locked' },
{ label: '关闭帖子', value: 'close' },
{ label: '隐藏帖子', value: 'hide' },
{ label: '提问状态', value: 'question_status' },
]
const booleanOptions = [
{ label: '启用', value: '1' },
{ label: '关闭', value: '0' },
]
// 置顶分级选项(对标 zibll topping 0/1/2/3)。
const stickyLevelOptions = [
{ label: '不置顶', value: 0 },
{ label: '版块置顶', value: 1 },
{ label: '超级置顶', value: 2 },
{ label: '全局置顶', value: 3 },
]
const questionStatusOptions = [
{ label: '未解决', value: 0 },
{ label: '已解决', value: 1 },
]
const primaryBoardName = computed(() => detail.value?.topic?.boards?.[0]?.name || '-')
const boardOptions = computed(() => (detail.value?.options?.boards || []).map((item) => ({
label: item.name,
value: item.id,
})))
// 置顶摘要:优先用后端返回的 sticky_label,否则按 sticky_level 映射。
const stickySummary = computed(() => {
const topic = detail.value?.topic
if (!topic) return '-'
const label = topic.sticky_label
if (label) return label
const level = topic.sticky_level ?? 0
const found = stickyLevelOptions.find((opt) => opt.value === level)
return found ? found.label : '不置顶'
})
const voteSummary = computed(() => {
const vote = detail.value?.topic?.vote
if (!vote?.enabled) {
return '未开启'
}
return `${vote.total_votes || 0} 票 / ${vote.multiple ? '多选' : '单选'}`
})
const bbsTypeLabel = computed(() => {
const t = detail.value?.topic?.bbs_type
return t === 'question' ? '提问' : '标准帖子'
})
const questionStatusLabel = computed(() => {
const s = detail.value?.topic?.question_status
return s === 1 ? '已解决' : '未解决'
})
watch(() => props.show, async (visible) => {
if (visible && props.topicId) {
await loadDetail()
} else if (!visible) {
detail.value = { topic: null, options: { boards: [], visibility: [], review: [] } }
resetForm()
}
})
function resetForm() {
form.action = 'review'
form.review_status = detail.value?.topic?.review_status?.key || 'pending'
form.target_board_id = detail.value?.topic?.boards?.[0]?.id || null
form.visibility = detail.value?.topic?.visibility?.key || 'public'
form.value = '1'
form.sticky_value = detail.value?.topic?.sticky_level ?? 0
form.question_status_value = detail.value?.topic?.question_status ?? 0
form.note = ''
}
async function loadDetail() {
if (!props.topicId) {
return
}
loading.value = true
try {
const res = await api.request(`/forum/topics/${props.topicId}`)
detail.value = res?.data || { topic: null, options: { boards: [], visibility: [], review: [] } }
resetForm()
} catch (error) {
message.error(error?.message || '加载主题详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.topicId) {
return
}
const payload = {
action: form.action,
note: form.note,
}
if (form.action === 'review') {
payload.review_status = form.review_status
} else if (form.action === 'move_board') {
payload.target_board_id = form.target_board_id
} else if (form.action === 'visibility') {
payload.visibility = form.visibility
} else if (form.action === 'sticky') {
// 置顶分级:发送 0~3 整数。
payload.value = form.sticky_value
} else if (form.action === 'question_status') {
payload.value = form.question_status_value
} else {
payload.value = form.value === '1'
}
submitting.value = true
try {
await api.request(`/forum/topics/${props.topicId}/moderate`, {
method: 'POST',
data: payload,
})
message.success('治理动作已执行')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '治理动作执行失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-topic-drawer__excerpt,
.aksrt-topic-drawer__actions {
margin-top: 18px;
}
.aksrt-topic-drawer__section-title {
margin-bottom: 8px;
font-weight: 600;
color: #1f2937;
}
.aksrt-topic-drawer__excerpt p {
margin: 0;
color: #4b5563;
line-height: 1.7;
}
</style>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="affiliate"
title="推广返佣"
desc="配置返佣比例、归因窗口和返佣说明。"
note="返佣逻辑复用用户邀请体系作为链路入口,订单归因和结算自动衔接当前策略字段。"
:links="[
{ label: '用户签到/邀请', path: '/users/signin' },
{ label: '创作分成', path: '/payment/revenue' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,453 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
<div class="aksrt-charge-card-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="操作台">
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
<n-button @click="batchVisible = true">批量生成</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
</n-card>
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
<n-form :model="createForm" label-placement="top">
<n-form-item label="卡号">
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡号" />
</n-form-item>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="面额">
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="过期时间">
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="备注">
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="createVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
</n-space>
</n-form>
</n-modal>
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
<n-form :model="batchForm" label-placement="top">
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="数量">
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="面额">
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="前缀">
<n-input v-model:value="batchForm.prefix" placeholder="CARD" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="随机长度">
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="过期时间">
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="batchVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
</n-space>
</n-form>
</n-modal>
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const rows = ref([])
const createVisible = ref(false)
const batchVisible = ref(false)
const drawerVisible = ref(false)
const currentCardId = ref(null)
const submittingCreate = ref(false)
const submittingBatch = ref(false)
const stats = reactive({
total: 0,
active: 0,
used: 0,
total_amount: 0,
used_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const createForm = reactive({
code: '',
amount: 10,
expiresAt: null,
note: '',
})
const batchForm = reactive({
count: 20,
amount: 10,
prefix: 'CARD',
length: 12,
expiresAt: null,
note: '',
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statCards = [
{ key: 'total', label: '总卡数' },
{ key: 'active', label: '未使用' },
{ key: 'used', label: '已使用' },
{ key: 'total_amount', label: '总面额' },
{ key: 'used_amount', label: '已兑换金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '未使用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已过期', value: 'expired' },
{ label: '已禁用', value: 'disabled' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '卡号', key: 'code', minWidth: 190, ellipsis: { tooltip: true } },
{ title: '面额', key: 'amount', width: 90 },
{ title: '余额', key: 'balance', width: 90 },
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '使用人',
key: 'used_by_user',
width: 160,
render: (row) => row.used_by_user?.display_name || '-',
},
{ title: '创建时间', key: 'created_at', width: 170 },
{
title: '过期时间',
key: 'expires_at',
width: 170,
render: (row) => row.expires_at || '-',
},
{
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]
if (row.status === 'active') {
actions.push(h(NButton, {
size: 'tiny',
type: 'warning',
quaternary: true,
onClick: () => handleDisable(row),
}, () => '禁用'))
}
return actions
}),
},
]
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
const seconds = `${date.getSeconds()}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function buildParams(page = pager.page, pageSize = pager.pageSize) {
const params = {
page,
per_page: pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange
params.date_from = formatDateValue(start).slice(0, 10)
params.date_to = formatDateValue(end).slice(0, 10)
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/charge-cards', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载充值卡台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = null
pager.page = 1
loadData()
}
async function handleCreate() {
submittingCreate.value = true
try {
await api.request('/payment/charge-cards', {
method: 'POST',
data: {
code: createForm.code,
amount: createForm.amount,
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
note: createForm.note,
},
})
message.success('充值卡已创建')
createVisible.value = false
createForm.code = ''
createForm.note = ''
await loadData()
} catch (error) {
message.error(error?.message || '创建失败')
} finally {
submittingCreate.value = false
}
}
async function handleBatchCreate() {
submittingBatch.value = true
try {
await api.request('/payment/charge-cards/batch', {
method: 'POST',
data: {
count: batchForm.count,
amount: batchForm.amount,
prefix: batchForm.prefix,
length: batchForm.length,
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
note: batchForm.note,
},
})
message.success('充值卡已批量生成')
batchVisible.value = false
await loadData()
} catch (error) {
message.error(error?.message || '批量生成失败')
} finally {
submittingBatch.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用充值卡 ${row.code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
message.success('充值卡已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '禁用失败')
}
},
})
}
function openDrawer(id) {
currentCardId.value = id
drawerVisible.value = true
}
async function handleExport() {
try {
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
const items = res?.data?.items || []
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
const lines = items.map((row) => [
row.id,
row.code,
row.amount,
row.balance,
row.status_label,
row.used_by_user?.display_name || '',
row.created_at || '',
row.expires_at || '',
row.note || '',
])
downloadCsv('charge-cards.csv', [header, ...lines])
message.success('导出完成')
} catch (error) {
message.error(error?.message || '导出失败')
}
}
function downloadCsv(filename, rows) {
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.aksrt-charge-card-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-charge-card-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,179 @@
<template>
<PaymentSectionPage
section="gateway"
title="收款接口"
desc="管理支付网关、查看连接状态、配置回调口令。"
note="当前支持人工确认与测试联调;回调校验口令在接入真实网关时直接复用。"
:links="[
{ label: '余额充值', path: '/payment/recharge' },
{ label: '付费内容', path: '/payment/paid' },
]"
>
<template #before-form>
<n-spin :show="loading">
<n-card class="aksrt-section-card" title="网关状态">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="gw in gateways" :key="gw.id">
<div class="aksrt-gateway__card">
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
<i :class="gw.id === 'balance' ? 'fas fa-wallet' : 'fas fa-credit-card'"></i>
</div>
<div>
<strong>{{ gw.name }}</strong>
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
</div>
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
{{ gw.configured ? '可用' : '不可用' }}
</n-tag>
</div>
</n-grid-item>
</n-grid>
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
</n-card>
<n-card class="aksrt-section-card" title="运行摘要">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong>{{ modeLabel }}</strong>
<p>当前模式</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong :class="overview.gateway?.test_enabled ? 'aksrt-text-warning' : ''">
{{ overview.gateway?.test_enabled ? '启用' : '关闭' }}
</strong>
<p>测试网关</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong :class="overview.gateway?.manual_review ? 'aksrt-text-info' : ''">
{{ overview.gateway?.manual_review ? '启用' : '关闭' }}
</strong>
<p>人工复核</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<n-tag :type="overview.gateway?.callback_secret ? 'success' : 'warning'" size="small" round>
{{ overview.gateway?.callback_secret ? '已设置' : '未设置' }}
</n-tag>
<p>回调口令</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</n-spin>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const message = useMessage()
const gateways = ref([])
const overview = ref({})
const loading = ref(false)
const modeLabel = computed(() => {
const mode = overview.value.gateway?.mode
if (mode === 'test_gateway') return '测试网关'
if (mode === 'adapter_ready') return '适配器就绪'
return '人工确认'
})
onMounted(async () => {
loading.value = true
try {
const [gwRes, ovRes] = await Promise.all([
api.request('/shop/gateways').catch(() => null),
api.request('/payment/overview').catch(() => null),
])
if (gwRes?.data?.gateways) {
gateways.value = gwRes.data.gateways
}
if (ovRes?.data) {
overview.value = ovRes.data
}
} catch {
message.warning('部分支付接口暂不可用')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.aksrt-gateway__card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
border: 1px solid var(--color-line, #f0f0f0);
}
.aksrt-gateway__icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-3, #f0f0f0);
color: var(--color-ink-4, #8a909c);
font-size: 16px;
}
.aksrt-gateway__icon--active {
background: var(--color-primary-soft, #f0f5ff);
color: var(--color-primary, #1890ff);
}
.aksrt-gateway__card strong {
display: block;
font-size: 14px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-gateway__card p {
margin: 2px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-gateway__stat {
text-align: center;
padding: 16px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
}
.aksrt-gateway__stat strong {
display: block;
font-size: 18px;
font-weight: 600;
color: var(--color-ink, #1a1a2e);
}
.aksrt-gateway__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-text-warning {
color: var(--color-warning, #f59e0b);
}
.aksrt-text-info {
color: var(--color-info, #3b82f6);
}
</style>
@@ -0,0 +1,316 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
<div class="aksrt-rebate-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="筛选与列控制">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-checkbox-group v-model:value="visibleColumnKeys">
<n-space wrap>
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
{{ column.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-space>
</n-card>
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
<n-data-table
:columns="tableColumns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-card>
<RebateDetailDrawer
v-model:show="drawerVisible"
:rebate-id="currentRebateId"
@updated="loadData"
/>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
const message = useMessage()
const loading = ref(false)
const rows = ref([])
const drawerVisible = ref(false)
const currentRebateId = ref(null)
const stats = reactive({
total: 0,
pending: 0,
settled: 0,
total_amount: 0,
settled_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
type: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
types: [],
})
const statCards = [
{ key: 'total', label: '总记录' },
{ key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'total_amount', label: '总返佣' },
{ key: 'settled_amount', label: '已结算金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '待结算', value: 'pending' },
{ label: '已结算', value: 'settled' },
{ label: '已取消', value: 'cancelled' },
])
const typeOptions = computed(() => optionsState.types.length
? optionsState.types
: [
{ label: '全部类型', value: '' },
{ label: '订单返佣', value: 'order' },
{ label: '邀请返佣', value: 'invite' },
])
const columnChoices = [
{ label: '来源用户', value: 'source_user' },
{ label: '创建时间', value: 'created_at' },
{ label: '结算时间', value: 'settled_at' },
{ label: '备注', value: 'note' },
{ label: '处理记录数', value: 'history_count' },
]
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
const baseColumns = computed(() => {
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
{
title: '返佣对象',
key: 'user',
width: 160,
render: (row) => row.user?.display_name || '-',
},
{
title: '返佣金额',
key: 'amount',
width: 100,
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '类型',
key: 'type',
width: 100,
render: (row) => row.type_label || row.type,
},
]
if (visibleColumnKeys.value.includes('source_user')) {
columns.push({
title: '来源用户',
key: 'source_user',
width: 160,
render: (row) => row.source_user?.display_name || '-',
})
}
if (visibleColumnKeys.value.includes('created_at')) {
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
}
if (visibleColumnKeys.value.includes('settled_at')) {
columns.push({
title: '结算时间',
key: 'settled_at',
width: 170,
render: (row) => row.settled_at || '-',
})
}
if (visibleColumnKeys.value.includes('note')) {
columns.push({
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
})
}
if (visibleColumnKeys.value.includes('history_count')) {
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
}
columns.push({
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]),
})
return columns
})
const tableColumns = computed(() => baseColumns.value)
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildParams() {
const params = {
page: pager.page,
per_page: pager.pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (filters.type) params.type = filters.type
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
params.date_from = formatDateValue(filters.dateRange[0])
params.date_to = formatDateValue(filters.dateRange[1])
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/rebate/log', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
optionsState.types = data.options?.types || []
} catch (error) {
message.error(error?.message || '加载返佣台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.type = ''
filters.dateRange = null
pager.page = 1
loadData()
}
function openDrawer(id) {
currentRebateId.value = id
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-rebate-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-rebate-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="recharge"
title="余额充值"
desc="配置充值模式、套餐结构和帮助文案。"
note="当前支持人工确认与测试联调;接入真实网关时复用同一组字段。"
:links="[
{ label: '下载/付费内容中心', path: '/payment/paid' },
{ label: '收款接口', path: '/payment/gateway' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,249 @@
<template>
<PaymentSectionPage
section="revenue"
title="收益中心"
desc="查看收入流水、收益统计和提现记录。"
:links="[
{ label: 'VIP 会员', path: '/payment/vip' },
{ label: '推广返佣', path: '/payment/affiliate' },
]"
>
<template #before-form>
<n-card class="aksrt-section-card" title="收益统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.total.toFixed(2) }}</strong>
<p>总收益 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.pending.toFixed(2) }}</strong>
<p>待结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.settled.toFixed(2) }}</strong>
<p>已结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.count }}</strong>
<p>收益笔数</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card class="aksrt-section-card" title="收益趋势">
<template #header-extra>
<n-space size="small">
<n-button :type="granularity === 'day' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('day')">日</n-button>
<n-button :type="granularity === 'week' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('week')">周</n-button>
<n-button :type="granularity === 'month' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('month')">月</n-button>
</n-space>
</template>
<div v-if="timeSeries.length > 0" class="aksrt-revenue__chart">
<div class="aksrt-revenue__chart-bars">
<div
v-for="(item, index) in timeSeries"
:key="index"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
>
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
</div>
</div>
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>¥{{ periodTotal.toFixed(2) }}</strong></span>
<span>共 {{ periodCount }} 笔</span>
</div>
</div>
<n-empty v-else description="暂无趋势数据" />
</n-card>
<n-card class="aksrt-section-card" title="提现统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total }}</strong>
<p>提现笔数</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.pending }}</strong>
<p>待审核</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.completed }}</strong>
<p>已完成</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total_paid.toFixed(2) }}</strong>
<p>已提现 (元)</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const revenueStats = ref({ total: 0, pending: 0, settled: 0, count: 0 })
const withdrawalStats = ref({ total: 0, pending: 0, completed: 0, total_paid: 0 })
const timeSeries = ref([])
const granularity = ref('day')
const periodTotal = computed(() => timeSeries.value.reduce((s, i) => s + i.amount, 0))
const periodCount = computed(() => timeSeries.value.reduce((s, i) => s + i.count, 0))
const maxAmount = computed(() => Math.max(1, ...timeSeries.value.map(i => i.amount)))
function getBarHeight(amount) {
return Math.max(2, (amount / maxAmount.value) * 100)
}
function formatLabel(date) {
if (granularity.value === 'month') return date.slice(5)
if (granularity.value === 'week') return date.slice(5)
return date.slice(5)
}
function setGranularity(nextGranularity) {
granularity.value = nextGranularity
loadRevenue()
}
async function loadRevenue() {
try {
const params = new URLSearchParams({
time_series: '1',
granularity: granularity.value,
})
const res = await api.request(`/payment/revenue?${params}`)
if (res?.data) {
if (res.data.revenue) {
revenueStats.value = {
total: Number(res.data.revenue.total || 0),
pending: Number(res.data.revenue.pending || 0),
settled: Number(res.data.revenue.settled || 0),
count: Number(res.data.revenue.count || 0),
}
}
if (res.data.withdrawal) {
withdrawalStats.value = {
total: Number(res.data.withdrawal.total || 0),
pending: Number(res.data.withdrawal.pending || 0),
completed: Number(res.data.withdrawal.completed || 0),
total_paid: Number(res.data.withdrawal.total_paid || 0),
}
}
timeSeries.value = res.data.time_series || []
}
} catch {
// silent
}
}
onMounted(loadRevenue)
</script>
<style scoped>
.aksrt-revenue__stat {
text-align: center;
padding: 16px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
}
.aksrt-revenue__stat strong {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 180px;
padding: 0 4px;
border-bottom: 1px solid var(--color-line, #e5e7eb);
}
.aksrt-revenue__chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
min-width: 0;
cursor: default;
}
.aksrt-revenue__chart-bar {
width: 100%;
max-width: 32px;
min-height: 2px;
background: var(--color-primary, #88abda);
border-radius: 3px 3px 0 0;
transition: height 0.3s ease;
}
.aksrt-revenue__chart-bar-wrap:hover .aksrt-revenue__chart-bar {
background: var(--color-primary-hover, #7099cc);
}
.aksrt-revenue__chart-label {
font-size: 10px;
color: var(--color-ink-4, #8a909c);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}
.aksrt-revenue__chart-summary {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: var(--color-ink-3, #525866);
}
.aksrt-revenue__chart-summary strong {
color: var(--color-ink, #1a1a2e);
font-weight: 600;
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="aksrt-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
{{ link.label }}
</n-button>
</n-space>
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
</n-card>
<slot name="before-form" />
<AksFormRenderer
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="false"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema } from '@/schema'
const props = defineProps({
section: { type: String, required: true },
title: { type: String, required: true },
desc: { type: String, default: '' },
note: { type: String, default: '' },
links: { type: Array, default: () => [] },
})
const router = useRouter()
const message = useMessage()
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
const defaults = computed(() => {
const result = {}
;(pageMeta.value.groups || []).forEach((group) => {
;(group.fields || []).forEach((field) => {
if (field.default !== undefined) {
result[field.key] = field.default
}
})
})
return result
})
const {
formData,
loading,
saving,
loadError,
dirty,
save: doSave,
reload,
} = useSectionForm({
fetch: () => api.getSchemaPage('payment', props.section),
submit: (data) => api.saveSchemaPage('payment', props.section, data),
defaults: defaults.value,
onSaved: () => {
message.success('设置已保存')
},
})
function go(path) {
router.push(path)
}
async function handleSave() {
try {
await doSave()
} catch (error) {
message.error(error.message || '保存失败')
}
}
function handleReload() {
reload()
message.info('已放弃更改')
}
</script>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="vip"
title="VIP会员"
desc=”配置会员名称、展示徽标和套餐结构。”
note=”角色免扣下载的运行逻辑在”下载/付费内容中心”中生效;此处管理会员套餐和权益说明。”
:links="[
{ label: '下载/付费内容中心', path: '/payment/paid' },
{ label: '创作分成', path: '/payment/revenue' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,89 @@
<template>
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
<n-drawer-content title="充值卡详情" closable>
<n-spin :show="loading">
<template v-if="detail.card">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用者">
{{ detail.card.used_by_user?.display_name || '-' }}
</n-descriptions-item>
</n-descriptions>
<div class="aksrt-charge-drawer__section">
<div class="aksrt-charge-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.card.note || '暂无备注' }}
</n-alert>
</div>
</template>
<n-empty v-else description="暂无充值卡详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
cardId: { type: [Number, String], default: null },
})
defineEmits(['update:show'])
const message = useMessage()
const loading = ref(false)
const detail = ref({ card: null })
watch(
() => [props.show, props.cardId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { card: null }
}
},
)
async function loadDetail() {
if (!props.cardId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
detail.value = res?.data || { card: null }
} catch (error) {
message.error(error?.message || '加载充值卡详情失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.aksrt-charge-drawer__section {
margin-top: 20px;
}
.aksrt-charge-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
</style>
@@ -0,0 +1,175 @@
<template>
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
<n-drawer-content title="返佣详情" closable>
<n-spin :show="loading">
<template v-if="detail.record">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.record.note || '暂无备注' }}
</n-alert>
</div>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理记录</div>
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
<div class="aksrt-rebate-drawer__timeline-head">
<strong>{{ entry.event || '-' }}</strong>
<span>{{ entry.time || '-' }}</span>
</div>
<p>{{ entry.note || '无备注' }}</p>
<small>{{ entry.operator_name || '系统' }}</small>
</div>
</div>
<n-empty v-else description="暂无处理记录" />
</div>
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理返佣</div>
<n-space vertical :size="12">
<n-input
v-model:value="form.note"
type="textarea"
:rows="3"
placeholder="记录结算说明或取消原因"
/>
<n-space>
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
结算返佣
</n-button>
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
取消返佣
</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无返佣详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
rebateId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref('')
const detail = ref({ record: null, history: [] })
const form = reactive({ note: '' })
watch(
() => [props.show, props.rebateId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { record: null, history: [] }
form.note = ''
}
},
)
async function loadDetail() {
if (!props.rebateId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/rebate/${props.rebateId}`)
detail.value = res?.data || { record: null, history: [] }
form.note = ''
} catch (error) {
message.error(error?.message || '加载返佣详情失败')
} finally {
loading.value = false
}
}
async function handleAction(action) {
if (!props.rebateId) {
return
}
submitting.value = action
try {
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
method: 'POST',
data: { note: form.note },
})
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '处理失败')
} finally {
submitting.value = ''
}
}
</script>
<style scoped>
.aksrt-rebate-drawer__section {
margin-top: 20px;
}
.aksrt-rebate-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-rebate-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-rebate-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-rebate-drawer__timeline-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.aksrt-rebate-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-rebate-drawer__timeline-item small {
color: #6b7280;
}
</style>
@@ -0,0 +1,283 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="附件治理"
desc="统一管理全站上传行为:头像、封面、投稿图片、评论图片、背景图和资源附件。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
<n-button quaternary @click="goModeration">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Summary -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>上传策略总览</h3>
</div>
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in summaryItems" :key="item.key">
<div class="aksrt-attach__summary">
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : '#8a909c'" />
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Avatar & Cover -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>头像与封面</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许用户上传头像">
<n-switch v-model:value="form.allow_avatar_upload" />
<template #feedback>
<span class="aksrt-hint-text">关闭后用户只能使用 Gravatar/Cravatar 自动头像。</span>
</template>
</n-form-item>
<n-form-item label="允许用户上传封面图">
<n-switch v-model:value="form.allow_cover_upload" />
<template #feedback>
<span class="aksrt-hint-text">控制用户中心公开主页封面图上传入口。</span>
</template>
</n-form-item>
<n-form-item label="允许用户上传背景图">
<n-switch v-model:value="form.allow_background_upload" />
<template #feedback>
<span class="aksrt-hint-text">控制用户中心公开可用的背景图上传入口。</span>
</template>
</n-form-item>
<n-form-item label="头像/封面大小上限 (MB)">
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Content uploads -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>内容上传</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许投稿插入图片">
<n-switch v-model:value="form.allow_submit_images" />
<template #feedback>
<span class="aksrt-hint-text">前台投稿编辑器中允许插入图片。</span>
</template>
</n-form-item>
<n-form-item label="投稿图片大小上限 (MB)">
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
</n-form-item>
<n-form-item label="允许评论插入图片">
<n-switch v-model:value="form.allow_comment_images" />
<template #feedback>
<span class="aksrt-hint-text">评论工具栏中允许插入图片链接或上传图片。</span>
</template>
</n-form-item>
<n-form-item label="评论图片大小上限 (MB)">
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Resource attachments -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>资源附件</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许资源附件上传">
<n-switch v-model:value="form.allow_resource_upload" />
<template #feedback>
<span class="aksrt-hint-text">后台资源编辑器中允许上传附件文件。</span>
</template>
</n-form-item>
<n-form-item label="资源附件大小上限 (MB)">
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- AI Moderation -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>审核联动</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="背景图接入 AI 审核">
<n-switch v-model:value="form.ai_moderate_background" />
<template #feedback>
<span class="aksrt-hint-text">命中高风险时会阻断上传,并保留上一次背景图。</span>
</template>
</n-form-item>
<n-form-item label="投稿图片接入 AI 审核">
<n-switch v-model:value="form.ai_moderate_submit" />
<template #feedback>
<span class="aksrt-hint-text">投稿中插入的图片也会经过内容安全审核。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const moderationSnapshot = ref({})
const form = reactive({
allow_avatar_upload: true,
allow_cover_upload: true,
allow_background_upload: false,
avatar_max_size: 2,
allow_submit_images: true,
submit_image_max_size: 5,
allow_comment_images: false,
comment_image_max_size: 2,
allow_resource_upload: true,
resource_max_size: 50,
ai_moderate_background: true,
ai_moderate_submit: false,
})
const summaryItems = computed(() => [
{ key: 'avatar', icon: 'fas fa-user-circle', label: '头像上传', enabled: form.allow_avatar_upload },
{ key: 'cover', icon: 'fas fa-image', label: '封面上传', enabled: form.allow_cover_upload },
{ key: 'background', icon: 'fas fa-photo-video', label: '背景图上传', enabled: form.allow_background_upload },
{ key: 'submit', icon: 'fas fa-edit', label: '投稿图片', enabled: form.allow_submit_images },
{ key: 'comment', icon: 'fas fa-comment-dots', label: '评论图片', enabled: form.allow_comment_images },
{ key: 'resource', icon: 'fas fa-file-archive', label: '资源附件', enabled: form.allow_resource_upload },
])
function go(path) {
router.push(path)
}
function goModeration() { router.push('/permissions/moderation') }
async function load() {
loading.value = true
try {
const [permissionRes, moderationRes] = await Promise.all([
api.getPermissionsAttachmentSettings(),
api.getPermissionsModerationSettings(),
])
const permission = permissionRes?.data ?? permissionRes ?? {}
const moderation = moderationRes?.data ?? moderationRes ?? {}
// Map permission fields
form.allow_avatar_upload = permission.allow_avatar_upload !== false
form.allow_cover_upload = permission.allow_cover_upload !== false
form.allow_background_upload = !!permission.allow_background_upload
form.avatar_max_size = Number(permission.avatar_max_size || 2)
form.allow_submit_images = permission.allow_submit_images !== false
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
form.allow_comment_images = !!permission.allow_comment_images
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
form.allow_resource_upload = permission.allow_resource_upload !== false
form.resource_max_size = Number(permission.resource_max_size || 50)
form.ai_moderate_background = moderation.ai_moderate_background !== false
form.ai_moderate_submit = !!moderation.ai_moderate_submit
moderationSnapshot.value = { ...moderation }
} catch (error) {
message.error(error.message || '加载附件权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await Promise.all([
api.savePermissionsAttachmentSettings({
allow_avatar_upload: form.allow_avatar_upload,
allow_cover_upload: form.allow_cover_upload,
allow_background_upload: form.allow_background_upload,
avatar_max_size: form.avatar_max_size,
allow_submit_images: form.allow_submit_images,
submit_image_max_size: form.submit_image_max_size,
allow_comment_images: form.allow_comment_images,
comment_image_max_size: form.comment_image_max_size,
allow_resource_upload: form.allow_resource_upload,
resource_max_size: form.resource_max_size,
}),
api.savePermissionsModerationSettings({
...moderationSnapshot.value,
ai_moderate_background: form.ai_moderate_background,
ai_moderate_submit: form.ai_moderate_submit,
}),
])
moderationSnapshot.value = {
...moderationSnapshot.value,
ai_moderate_background: form.ai_moderate_background,
ai_moderate_submit: form.ai_moderate_submit,
}
message.success('附件治理配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-attach__summary {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-attach__summary strong {
display: block;
font-size: 13px;
color: #1a1a2e;
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -0,0 +1,150 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="评论权限"
desc="管理评论入口的登录要求与回复权限,并查看当前验证码与审核策略状态。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
<n-button quaternary @click="go('/permissions/moderation')">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动状态</h3>
</div>
<n-space>
<n-tag :type="status.commentCaptchaEnabled ? 'success' : 'default'" round>
评论验证码:{{ status.commentCaptchaEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.moderationEnabled ? 'warning' : 'default'" round>
审核链路:{{ status.moderationEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.rateLimitEnabled ? 'warning' : 'default'" round>
评论限流:{{ status.rateLimitEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.keywordRuleCount > 0 ? 'error' : 'default'" round>
敏感词:{{ status.keywordRuleCount }} 条
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>评论准入</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="评论需要登录">
<n-switch v-model:value="form.comment_require_login" />
<template #feedback>
<span class="aksrt-hint-text">开启后,游客只能浏览评论,不能直接提交评论。</span>
</template>
</n-form-item>
<n-form-item label="允许楼中回复">
<n-switch v-model:value="form.comment_allow_reply" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,前台评论列表将不再展示回复按钮。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
评论排序、分页方式、头像大小和空状态文案继续在“文章&列表 -> 评论设置”中维护;本页只负责准入与治理联动。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
comment_require_login: false,
comment_allow_reply: true,
})
const status = reactive({
commentCaptchaEnabled: false,
moderationEnabled: false,
rateLimitEnabled: false,
keywordRuleCount: 0,
})
function go(path) {
router.push(path)
}
function splitKeywords(value) {
return String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
}
async function load() {
loading.value = true
try {
const [commentData, captchaRes, moderationRes] = await Promise.all([
api.getSchemaPage('post_list', 'comment'),
api.getPermissionsLoginSettings(),
api.getPermissionsModerationSettings(),
])
const comment = commentData?.data ?? commentData ?? {}
const captcha = captchaRes?.data ?? captchaRes ?? {}
const moderation = moderationRes?.data ?? moderationRes ?? {}
const disabledScenarios = Array.isArray(captcha.disabled_scenarios) ? captcha.disabled_scenarios : []
form.comment_require_login = !!comment.comment_require_login
form.comment_allow_reply = comment.comment_allow_reply !== false
status.commentCaptchaEnabled = !!captcha.enabled && !disabledScenarios.includes('comment')
status.moderationEnabled = !!moderation.enabled
status.rateLimitEnabled = !!moderation.rate_limit_enabled
status.keywordRuleCount = splitKeywords(moderation.blocked_keywords).length
} catch (error) {
message.error(error.message || '加载评论权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('post_list', 'comment', {
comment_require_login: form.comment_require_login,
comment_allow_reply: form.comment_allow_reply,
})
message.success('评论权限已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,161 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="下载权限"
desc="管理资源下载所需的登录、积分与验证码门槛,并查看当前默认下载成本。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/payment/paid')">资源下载页</n-button>
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前门槛摘要</h3>
</div>
<n-space>
<n-tag :type="form.download_login ? 'warning' : 'default'" round>
登录要求:{{ form.download_login ? '已启用' : '已关闭' }}
</n-tag>
<n-tag :type="form.download_credit ? 'warning' : 'default'" round>
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
</n-tag>
<n-tag :type="form.download_captcha ? 'warning' : 'default'" round>
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
</n-tag>
<n-tag type="info" round>默认下载积分:{{ defaultCost }}</n-tag>
<n-tag type="info" round>Token TTL:10 分钟</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>下载门槛</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="下载需要登录">
<n-switch v-model:value="form.download_login" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,单个资源的登录要求仍可通过资源元字段单独覆盖。</span>
</template>
</n-form-item>
<n-form-item label="下载启用积分扣费">
<n-switch v-model:value="form.download_credit" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,资源价格将只作为展示信息,不会真正扣除积分。</span>
</template>
</n-form-item>
<n-form-item label="下载启用验证码">
<n-switch v-model:value="form.download_captcha" />
<template #feedback>
<span class="aksrt-hint-text">具体验证码服务商与场景白名单仍在“登录权限”页维护。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
默认下载积分仍由积分配置维护;本页只负责访问门槛。更完整的支付、VIP 与白名单策略将在 Phase 10 统一收口。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const defaultCost = ref(0)
const featureSnapshot = ref({
resource_cpt_enabled: true,
search_include_resource: true,
download_login: true,
download_credit: true,
download_captcha: false,
})
const form = reactive({
download_login: true,
download_credit: true,
download_captcha: false,
})
function go(path) {
router.push(path)
}
async function load() {
loading.value = true
try {
const [featureRes, creditRes] = await Promise.all([
api.getCmsFeatures(),
api.getCreditSettings(),
])
const features = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
const creditSettings = creditRes?.data ?? creditRes ?? {}
featureSnapshot.value = {
resource_cpt_enabled: features.resource_cpt_enabled !== false,
search_include_resource: features.search_include_resource !== false,
download_login: features.download_login !== false,
download_credit: features.download_credit !== false,
download_captcha: !!features.download_captcha,
}
form.download_login = featureSnapshot.value.download_login
form.download_credit = featureSnapshot.value.download_credit
form.download_captcha = featureSnapshot.value.download_captcha
defaultCost.value = Number(creditSettings.download_default_cost || 0)
} catch (error) {
message.error(error.message || '加载下载权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveCmsFeatures({
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
})
featureSnapshot.value = {
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
}
message.success('下载权限已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,245 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="基本权限 / 能力矩阵"
desc="统一管理用户治理、投稿和评论的角色能力矩阵。"
>
<template #actions>
<n-space>
<n-button quaternary @click="resetDefaults">恢复默认</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 240px;">
<n-card class="aksrt-section-card" v-if="summary.cap_count">
<div class="aksrt-section-header">
<h3>矩阵概览</h3>
</div>
<n-space>
<n-tag round type="info">能力项:{{ summary.cap_count }}</n-tag>
<n-tag round type="info">分组:{{ summary.group_count }}</n-tag>
<n-tag round type="info">角色列:{{ summary.column_count }}</n-tag>
</n-space>
</n-card>
<n-card v-for="group in groups" :key="group.key" class="aksrt-section-card">
<template #header>
<div class="aksrt-cap-matrix__header">
<div>
<h3 class="aksrt-cap-matrix__title">{{ group.title }}</h3>
<p class="aksrt-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
</div>
</div>
</template>
<n-data-table
:columns="buildColumns(group)"
:data="group.items"
:bordered="true"
size="small"
:single-line="false"
:scroll-x="1200"
/>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存能力矩阵</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NInputNumber, NSwitch, NTag } from 'naive-ui'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const groups = ref([])
const columnsMeta = ref({})
const summary = reactive({
cap_count: 0,
group_count: 0,
column_count: 0,
})
const userCaps = reactive({})
const defaultsSnapshot = ref({})
onMounted(load)
async function load() {
loading.value = true
try {
const res = await api.getCapabilities()
const data = res?.data || {}
columnsMeta.value = data.columns || {}
groups.value = data.groups || []
Object.assign(summary, data.summary || { cap_count: 0, group_count: 0, column_count: 0 })
const snapshot = {}
for (const group of groups.value) {
for (const item of group.items || []) {
snapshot[item.cap_id] = { ...(item.values || {}) }
}
}
replaceReactive(userCaps, snapshot)
defaultsSnapshot.value = JSON.parse(JSON.stringify(snapshot))
} catch (error) {
message.error(error?.message || '加载能力矩阵失败')
} finally {
loading.value = false
}
}
function replaceReactive(target, source) {
Object.keys(target).forEach((key) => delete target[key])
Object.entries(source).forEach(([key, value]) => {
target[key] = { ...value }
})
}
function buildColumns(group) {
const cols = [
{
title: '能力项',
key: 'label',
fixed: 'left',
width: 280,
render: (row) => h('div', { class: 'aksrt-cap-matrix__cap' }, [
h('strong', row.label),
h('code', row.cap_id),
]),
},
]
const visibleColumns = new Set()
for (const item of group.items || []) {
for (const col of item.visible_columns || []) {
visibleColumns.add(col)
}
}
for (const col of Object.keys(columnsMeta.value || {})) {
if (!visibleColumns.has(col)) continue
cols.push({
title: columnsMeta.value[col],
key: col,
width: col === 'level' || col === 'vip' ? 110 : 100,
render: (row) => renderCapCell(row, col),
})
}
return cols
}
function renderCapCell(row, col) {
const hidden = !(row.visible_columns || []).includes(col)
if (hidden) {
return h('span', { class: 'aksrt-cap-matrix__empty' }, '—')
}
if (!userCaps[row.cap_id]) {
userCaps[row.cap_id] = { ...(row.values || {}) }
}
if (col === 'level' || col === 'vip') {
return h(NInputNumber, {
value: userCaps[row.cap_id]?.[col] ?? -1,
min: -1,
max: col === 'level' ? 10 : 2,
step: 1,
size: 'small',
style: 'width: 90px',
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value ?? -1),
})
}
return h(NSwitch, {
value: !!userCaps[row.cap_id]?.[col],
size: 'small',
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value),
})
}
function updateCapValue(capId, col, value) {
if (!userCaps[capId]) {
userCaps[capId] = {}
}
userCaps[capId][col] = value
}
function resetDefaults() {
replaceReactive(userCaps, JSON.parse(JSON.stringify(defaultsSnapshot.value || {})))
message.info('已恢复默认矩阵')
}
function expandAll() {
message.info('当前矩阵为平铺展示,无需展开')
}
function collapseAll() {
message.info('当前矩阵为平铺展示,无需折叠')
}
async function handleSave() {
saving.value = true
try {
await api.saveCapabilities(JSON.parse(JSON.stringify(userCaps)))
defaultsSnapshot.value = JSON.parse(JSON.stringify(userCaps))
message.success('能力矩阵已保存')
} catch (error) {
message.error(error?.message || '保存能力矩阵失败')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.aksrt-cap-matrix__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.aksrt-cap-matrix__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.aksrt-cap-matrix__desc {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-cap-matrix__cap {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-cap-matrix__cap code {
font-size: 11px;
color: #667085;
}
.aksrt-cap-matrix__empty {
color: #c0c4cc;
}
.aksrt-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,7 @@
<template>
<SchemaPage domain="post_list" section="comment" />
</template>
<script setup>
import SchemaPage from '@/pages/SchemaPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<SchemaPage domain="post_list" section="page" />
</template>
<script setup>
import SchemaPage from '@/pages/SchemaPage.vue'
</script>
@@ -0,0 +1,304 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="售后工作台" desc="把退款、换货、维修放在订单语境里处理,减少运营跳转。" />
<div class="aksrt-after-sale__stats">
<div v-for="item in statCards" :key="item.key" class="aksrt-after-sale__stat-card">
<div class="aksrt-after-sale__stat-icon" :style="{ background: item.color }">
<i :class="item.icon"></i>
</div>
<div class="aksrt-after-sale__stat-info">
<div class="aksrt-after-sale__stat-value">{{ stats[item.key] || 0 }}</div>
<div class="aksrt-after-sale__stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<n-card size="small" class="aksrt-after-sale__section">
<n-space wrap>
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="售后状态" style="width: 160px" />
<n-select v-model:value="filters.stage" :options="stageOptions" clearable placeholder="处理阶段" style="width: 160px" />
<n-select v-model:value="filters.type" :options="typeOptions" clearable placeholder="售后类型" style="width: 160px" />
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
<div class="aksrt-after-sale__toolbar">
<n-space>
<n-button type="primary" @click="showCreateModal = true">新建售后</n-button>
</n-space>
</div>
</n-card>
<n-card size="small" class="aksrt-after-sale__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<AfterSaleDetailDrawer
v-model:show="detailVisible"
:after-sale-id="detailId"
:status-options="detailStatusOptions"
@updated="loadData(page)"
/>
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
<div class="aksrt-modal aksrt-after-sale__modal">
<h3>新建售后工单</h3>
<div class="aksrt-form-group">
<label>订单号</label>
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
</div>
<div class="aksrt-form-group">
<label>售后类型</label>
<select v-model="createForm.type" class="aksrt-select">
<option v-for="item in typeOptions.filter((item) => item.value)" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="aksrt-form-group">
<label>金额</label>
<input v-model.number="createForm.amount" type="number" step="0.01" class="aksrt-input" placeholder="0.00">
</div>
<div class="aksrt-form-group">
<label>原因</label>
<textarea v-model="createForm.reason" class="aksrt-textarea" rows="3" placeholder="售后原因"></textarea>
</div>
<div class="aksrt-form-group">
<label>补充备注</label>
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="补充说明"></textarea>
</div>
<div class="aksrt-modal__actions">
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
{{ creating ? '提交中...' : '提交' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import AfterSaleDetailDrawer from './components/AfterSaleDetailDrawer.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const stats = ref({})
const detailVisible = ref(false)
const detailId = ref(null)
const statusOptions = ref([])
const stageOptions = ref([])
const typeOptions = ref([])
const showCreateModal = ref(false)
const creating = ref(false)
const filters = reactive({
status: null,
stage: null,
type: null,
order_number: '',
date_from: '',
date_to: '',
})
const createForm = reactive({
order_number: '',
type: 'refund',
amount: 0,
reason: '',
note: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const statCards = [
{ key: 'pending', label: '待处理', icon: 'fas fa-clock', color: '#f59e0b' },
{ key: 'processing', label: '处理中', icon: 'fas fa-spinner', color: '#2563eb' },
{ key: 'approved', label: '已通过', icon: 'fas fa-check', color: '#16a34a' },
{ key: 'rejected', label: '已拒绝', icon: 'fas fa-times', color: '#dc2626' },
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
]
const detailStatusOptions = computed(() => statusOptions.value.filter((item) => item.value))
const columns = [
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
{ title: '售后类型', key: 'type_label', width: 110 },
{ title: '阶段', key: 'stage', width: 120, render: (row) => row.stage?.label || '-' },
{ title: '物流联动', key: 'shipping_link', width: 170, render: (row) => row.order_context?.shipping?.latest?.status_label || row.order_context?.shipping?.latest?.status || '-' },
{ title: '申请人', key: 'applicant', width: 120, render: (row) => row.applicant?.display_name || '-' },
{ title: '金额', key: 'amount', width: 110, render: (row) => `¥${Number(row.amount || 0).toFixed(2)}` },
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
},
{ title: '创建时间', key: 'created_at', width: 160 },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
},
]
function resolveTagType(status) {
if (status === 'approved' || status === 'completed') return 'success'
if (status === 'processing') return 'info'
if (status === 'rejected' || status === 'cancelled') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/shop/after-sale', {
params: {
page: nextPage,
per_page: pagination.pageSize,
status: filters.status || '',
stage: filters.stage || '',
type: filters.type || '',
order_number: filters.order_number || '',
date_from: filters.date_from || '',
date_to: filters.date_to || '',
},
})
const data = res?.data || {}
items.value = data.items || []
stats.value = data.stats || {}
pagination.itemCount = data.total || 0
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
stageOptions.value = [{ label: '全部阶段', value: null }, ...(data.options?.stage || [])]
typeOptions.value = [{ label: '全部类型', value: null }, ...(data.options?.type || [])]
} catch (error) {
message.error(error?.message || '加载售后数据失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function openDetail(row) {
detailId.value = row.id
detailVisible.value = true
}
function resetFilters() {
filters.status = null
filters.stage = null
filters.type = null
filters.order_number = ''
filters.date_from = ''
filters.date_to = ''
loadData(1)
}
async function submitCreate() {
if (!createForm.order_number || !createForm.reason) {
message.warning('请填写订单号和售后原因')
return
}
creating.value = true
try {
await api.request('/shop/after-sale', {
method: 'POST',
data: { ...createForm },
})
message.success('售后工单已创建')
showCreateModal.value = false
createForm.order_number = ''
createForm.type = 'refund'
createForm.amount = 0
createForm.reason = ''
createForm.note = ''
loadData(1)
} catch (error) {
message.error(error?.message || '创建售后工单失败')
} finally {
creating.value = false
}
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-after-sale__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-after-sale__stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.aksrt-after-sale__stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.aksrt-after-sale__stat-value {
font-size: 22px;
font-weight: 700;
color: #111827;
}
.aksrt-after-sale__stat-label {
font-size: 12px;
color: #6b7280;
}
.aksrt-after-sale__section {
margin-bottom: 16px;
}
.aksrt-after-sale__toolbar {
margin-top: 14px;
}
.aksrt-after-sale__modal {
width: min(520px, calc(100vw - 32px));
}
</style>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="优惠券管理" desc="创建和管理优惠券,支持百分比和固定金额两种类型。" />
<!-- 操作栏 -->
<n-card size="small" class="aksrt-shop-coupons__section">
<n-space justify="space-between">
<n-space>
<n-input v-model:value="search" placeholder="搜索优惠码" clearable style="width:200px;" @clear="loadCoupons" @keyup.enter="loadCoupons">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态" clearable style="width:120px;" @update:value="loadCoupons" />
</n-space>
<n-button type="primary" @click="showCreate = true">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
新增优惠券
</n-button>
</n-space>
</n-card>
<!-- 优惠券列表 -->
<n-card size="small" class="aksrt-shop-coupons__section">
<n-data-table
:columns="columns"
:data="coupons"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 创建弹窗 -->
<n-modal :show="showCreate" @update:show="showCreate = $event" preset="card" title="新增优惠券" style="width:480px;" closable>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="优惠码" path="code">
<n-input v-model:value="form.code" placeholder="如 SAVE20" />
</n-form-item>
<n-form-item label="类型" path="type">
<n-select v-model:value="form.type" :options="typeOptions" />
</n-form-item>
<n-form-item :label="form.type === 'percent' ? '折扣比例 (%)' : '减免金额 (元)'" path="value">
<n-input-number v-model:value="form.value" :min="0" :max="form.type === 'percent' ? 100 : 99999" style="width:100%;" />
</n-form-item>
<n-form-item label="最低消费 (元)">
<n-input-number v-model:value="form.min_amount" :min="0" style="width:100%;" />
</n-form-item>
<n-form-item label="使用次数上限">
<n-input-number v-model:value="form.usage_limit" :min="0" style="width:100%;" />
<template #feedback>0 表示不限制</template>
</n-form-item>
<n-form-item label="过期时间">
<n-input v-model:value="form.expires_at" type="datetime-local" />
<template #feedback>留空表示永不过期</template>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showCreate = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleCreate">创建</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NButton, useMessage, useDialog } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const coupons = ref([])
const search = ref('')
const statusFilter = ref(null)
const showCreate = ref(false)
const formRef = ref(null)
const form = reactive({
code: '',
type: 'fixed',
value: 10,
min_amount: 0,
usage_limit: 0,
expires_at: '',
})
const rules = {
code: { required: true, message: '请输入优惠码', trigger: 'blur' },
value: { type: 'number', required: true, message: '请输入数值', trigger: 'blur' },
}
const statusOptions = [
{ label: '有效', value: 'active' },
{ label: '已禁用', value: 'disabled' },
]
const typeOptions = [
{ label: '固定金额', value: 'fixed' },
{ label: '百分比折扣', value: 'percent' },
]
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{ title: '优惠码', key: 'code', width: 140 },
{
title: '类型',
key: 'type',
width: 100,
render: (row) => h(NTag, { size: 'small', type: row.type === 'percent' ? 'info' : 'success' }, () => row.type === 'percent' ? `${row.value}%` : `¥${row.value}`),
},
{ title: '最低消费', key: 'min_amount', width: 100, render: (row) => row.min_amount > 0 ? `¥${row.min_amount}` : '—' },
{ title: '已用/上限', key: 'usage', width: 100, render: (row) => `${row.used_count}/${row.usage_limit || '∞'}` },
{
title: '状态',
key: 'status',
width: 80,
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'default' }, () => row.status === 'active' ? '有效' : '已禁用'),
},
{ title: '过期时间', key: 'expires_at', width: 160, render: (row) => row.expires_at || '永不过期' },
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => {
if (row.status !== 'active') return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
return h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDisable(row) }, () => '禁用')
},
},
]
async function loadCoupons() {
loading.value = true
try {
const params = new URLSearchParams({
page: String(pagination.page),
per_page: String(pagination.pageSize),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/coupons?' + params.toString())
if (res?.data) {
coupons.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadCoupons()
}
async function handleCreate() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
await api.request('/shop/coupons', {
method: 'POST',
body: JSON.stringify({ ...form }),
})
message.success('优惠券已创建')
showCreate.value = false
loadCoupons()
} catch {
message.error('创建失败')
} finally {
saving.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用优惠码「${row.code}」吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/shop/coupons/${row.id}/disable`, { method: 'POST' })
message.success('优惠券已禁用')
loadCoupons()
} catch {
message.error('操作失败')
}
},
})
}
onMounted(loadCoupons)
</script>
<style scoped>
.aksrt-shop-coupons__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,180 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="商城&商品" desc="商品管理、订单系统、库存管理等电商功能。" />
<div class="aksrt-shop-dashboard">
<!-- 统计卡片 -->
<div class="aksrt-shop-dashboard__stats">
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-box"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.product_count }}</span>
<span class="aksrt-shop-stat-card__label">商品数量</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-file-invoice"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.order_count }}</span>
<span class="aksrt-shop-stat-card__label">订单数量</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-coins"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ revenueDisplay }}</span>
<span class="aksrt-shop-stat-card__label">累计收入 (元)</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-clock"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.order_pending }}</span>
<span class="aksrt-shop-stat-card__label">待处理订单</span>
</div>
</div>
</div>
<!-- 最近商品 -->
<n-card class="aksrt-section-card" title="最近商品">
<n-data-table
v-if="products.length"
:columns="productColumns"
:data="products"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无商品" />
</n-card>
<!-- 最近订单 -->
<n-card class="aksrt-section-card" title="最近订单" style="margin-top:16px;">
<n-data-table
v-if="orders.length"
:columns="orderColumns"
:data="orders"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无订单" />
</n-card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, h } from 'vue'
import { NTag } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const overview = ref({ product_count: 0, order_count: 0, order_pending: 0, revenue_total: 0 })
const products = ref([])
const orders = ref([])
const revenueDisplay = computed(() => (overview.value.revenue_total || 0).toFixed(2))
const statusMap = {
created: { label: '已创建', type: 'default' },
pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' },
delivered: { label: '已发货', type: 'info' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
expired: { label: '已过期', type: 'default' },
refunding: { label: '退款中', type: 'warning' },
refunded: { label: '已退款', type: 'error' },
}
const productColumns = [
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
{ title: '库存', key: 'stock', width: 80 },
{ title: '销量', key: 'sales', width: 80 },
{ title: '创建时间', key: 'created', width: 160 },
]
const orderColumns = [
{ title: '订单号', key: 'order_number', width: 180, ellipsis: { tooltip: true } },
{ title: '产品类型', key: 'product_type', width: 100 },
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const s = statusMap[row.status] || { label: row.status, type: 'default' }
return h(NTag, { size: 'small', type: s.type }, () => s.label)
},
},
{ title: '创建时间', key: 'created_at', width: 160 },
]
onMounted(async () => {
try {
const [overviewRes, productsRes, ordersRes] = await Promise.all([
api.request('/shop/overview'),
api.request('/shop/products?per_page=5'),
api.request('/shop/orders?per_page=5'),
])
if (overviewRes?.success && overviewRes.data) {
overview.value = { ...overview.value, ...overviewRes.data }
}
if (productsRes?.success && productsRes.data) {
products.value = productsRes.data.items || []
}
if (ordersRes?.success && ordersRes.data) {
orders.value = ordersRes.data.items || []
}
} catch {
// endpoints may not be available yet
}
})
</script>
<style scoped>
.aksrt-shop-dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-shop-stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-shop-stat-card__icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f0f5ff;
color: #1890ff;
font-size: 20px;
}
.aksrt-shop-stat-card__info {
display: flex;
flex-direction: column;
}
.aksrt-shop-stat-card__value {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-shop-stat-card__label {
font-size: 13px;
color: #7a7f8a;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="订单管理" desc="查看和管理所有交易订单。" />
<!-- 筛选栏 -->
<n-card size="small" class="aksrt-shop__section">
<n-space>
<n-input v-model:value="search" placeholder="搜索订单号" clearable style="width: 200px;" @clear="loadOrders" @keyup.enter="loadOrders">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="订单状态" clearable style="width: 140px;" @update:value="loadOrders" />
</n-space>
</n-card>
<!-- 订单列表 -->
<n-card size="small" class="aksrt-shop__section">
<n-data-table
:columns="columns"
:data="orders"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 订单详情抽屉 -->
<OrderDetailDrawer
v-model:show="detailVisible"
:order-number="detailOrderNumber"
@updated="loadOrders"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NButton } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import OrderDetailDrawer from './components/OrderDetailDrawer.vue'
import api from '@/api/index.js'
const loading = ref(false)
const orders = ref([])
const search = ref('')
const statusFilter = ref(null)
const detailVisible = ref(false)
const detailOrderNumber = ref('')
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
{ label: '已退款', value: 'refunded' },
]
const statusMap = {
created: { label: '已创建', type: 'default' },
pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' },
delivered: { label: '已发货', type: 'info' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
expired: { label: '已过期', type: 'default' },
refunding: { label: '退款中', type: 'warning' },
refunded: { label: '已退款', type: 'error' },
}
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{
title: '订单号',
key: 'order_number',
width: 180,
ellipsis: { tooltip: true },
render: (row) => h('a', {
href: 'javascript:;',
style: 'color:#1890ff;cursor:pointer;',
onClick: () => openDetail(row),
}, row.order_number),
},
{ title: '商品', key: 'product_name', ellipsis: { tooltip: true } },
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const s = statusMap[row.status] || { label: row.status, type: 'default' }
return h(NTag, { size: 'small', type: s.type }, () => s.label)
},
},
{ title: '买家', key: 'buyer_name', width: 120 },
{ title: '创建时间', key: 'created_at', width: 160 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => {
const btns = []
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'))
if (row.status === 'pending') {
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleCancel(row) }, () => '取消'))
}
if (row.status === 'paid') {
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => handleDeliver(row) }, () => '发货'))
}
return h('div', { style: 'display:flex;gap:4px;' }, btns)
},
},
]
async function loadOrders() {
loading.value = true
try {
const params = new URLSearchParams({
per_page: String(pagination.pageSize),
page: String(pagination.page),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/orders?' + params.toString())
if (res?.data) {
orders.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// Endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadOrders()
}
function openDetail(row) {
detailOrderNumber.value = row.order_number || ''
detailVisible.value = true
}
// Canonical: use order_number for status updates
async function updateOrderStatus(row, newStatus) {
try {
await api.request('/shop/orders/update-status', {
method: 'POST',
body: JSON.stringify({ order_number: row.order_number, status: newStatus }),
})
loadOrders()
} catch {
// silent
}
}
function handleCancel(row) { updateOrderStatus(row, 'cancelled') }
function handleDeliver(row) { updateOrderStatus(row, 'delivered') }
onMounted(loadOrders)
</script>
<style scoped>
.aksrt-shop__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="商品管理" desc="管理所有商品的上架、编辑和库存。" />
<!-- 操作栏 -->
<n-card size="small" class="aksrt-shop__section">
<n-space justify="space-between">
<n-space>
<n-input v-model:value="search" placeholder="搜索商品名称" clearable style="width: 240px;" @clear="loadProducts" @keyup.enter="loadProducts">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态筛选" clearable style="width: 140px;" @update:value="loadProducts" />
</n-space>
<n-button type="primary" @click="openCreate">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
新增商品
</n-button>
</n-space>
</n-card>
<!-- 商品列表 -->
<n-card size="small" class="aksrt-shop__section">
<n-data-table
:columns="columns"
:data="products"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 商品编辑抽屉 -->
<ProductEditorDrawer
v-model:show="drawerVisible"
:product-id="editingProductId"
@saved="loadProducts"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NSpace, NButton } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import ProductEditorDrawer from './components/ProductEditorDrawer.vue'
import api from '@/api/index.js'
const loading = ref(false)
const products = ref([])
const search = ref('')
const statusFilter = ref(null)
const drawerVisible = ref(false)
const editingProductId = ref(null)
const statusOptions = [
{ label: '已上架', value: 'publish' },
{ label: '已下架', value: 'draft' },
{ label: '全部', value: '' },
]
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
{ title: '库存', key: 'stock', width: 80 },
{ title: '销量', key: 'sales', width: 80 },
{
title: '状态',
key: 'status',
width: 80,
render: (row) => h(NTag, { size: 'small', type: row.status === 'publish' ? 'success' : 'default' }, () => row.status === 'publish' ? '已上架' : '已下架'),
},
{ title: '创建时间', key: 'created', width: 160 },
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
h(NButton, { size: 'tiny', quaternary: true, type: row.status === 'publish' ? 'warning' : 'success', onClick: () => handleToggle(row) }, () => row.status === 'publish' ? '下架' : '上架'),
]),
},
]
async function loadProducts() {
loading.value = true
try {
const params = new URLSearchParams({
per_page: String(pagination.pageSize),
page: String(pagination.page),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/products?' + params.toString())
if (res?.data) {
products.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// Endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadProducts()
}
function openCreate() {
editingProductId.value = null
drawerVisible.value = true
}
function openEdit(row) {
editingProductId.value = row.id
drawerVisible.value = true
}
async function handleToggle(row) {
const newStatus = row.status === 'publish' ? 'draft' : 'publish'
try {
await api.request('/shop/products/' + row.id, {
method: 'POST',
body: JSON.stringify({ status: newStatus }),
})
loadProducts()
} catch {
// silent
}
}
onMounted(loadProducts)
</script>
<style scoped>
.aksrt-shop__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,364 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="物流工作台" desc="围绕订单上下文处理发货、跟踪、签收和异常退回。" />
<div class="aksrt-shipping__stats">
<div v-for="item in statCards" :key="item.key" class="aksrt-shipping__stat-card">
<div class="aksrt-shipping__stat-icon" :style="{ background: item.color }">
<i :class="item.icon"></i>
</div>
<div class="aksrt-shipping__stat-info">
<div class="aksrt-shipping__stat-value">{{ stats[item.key] || 0 }}</div>
<div class="aksrt-shipping__stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<n-card size="small" class="aksrt-shipping__section">
<n-space wrap>
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="物流状态" style="width: 150px" />
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.tracking_no" clearable placeholder="物流单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
<div class="aksrt-shipping__toolbar">
<n-space>
<n-button type="primary" @click="showCreateModal = true">新建发货</n-button>
<n-button :disabled="selectedRowKeys.length === 0" @click="showBatchDialog = true">批量更新状态</n-button>
<n-popover trigger="click" placement="bottom-start">
<template #trigger>
<n-button>列控制</n-button>
</template>
<n-checkbox-group v-model:value="visibleColumnKeys">
<n-space vertical size="small">
<n-checkbox v-for="item in columnOptions" :key="item.key" :value="item.key">
{{ item.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-popover>
</n-space>
</div>
</n-card>
<n-card size="small" class="aksrt-shipping__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:row-key="(row) => row.id"
:pagination="pagination"
@update:page="handlePageChange"
@update:checked-row-keys="selectedRowKeys = $event"
/>
</n-card>
<ShippingDetailDrawer
v-model:show="detailVisible"
:shipping-id="detailId"
@updated="loadData(page)"
/>
<ShippingBatchActionDialog
v-model:show="showBatchDialog"
:ids="selectedRowKeys"
:status-options="batchStatusOptions"
@updated="handleBatchUpdated"
/>
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
<div class="aksrt-modal aksrt-shipping__modal">
<h3>新建发货记录</h3>
<div class="aksrt-form-group">
<label>订单号</label>
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
</div>
<div class="aksrt-form-group">
<label>物流公司</label>
<select v-model="createForm.carrier" class="aksrt-select">
<option value="">请选择</option>
<option v-for="item in carrierOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="aksrt-form-group">
<label>物流单号</label>
<input v-model="createForm.tracking_no" type="text" class="aksrt-input" placeholder="输入物流单号">
</div>
<div class="aksrt-form-group">
<label>备注</label>
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="发货备注"></textarea>
</div>
<div class="aksrt-modal__actions">
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
{{ creating ? '提交中...' : '提交' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import ShippingDetailDrawer from './components/ShippingDetailDrawer.vue'
import ShippingBatchActionDialog from './components/ShippingBatchActionDialog.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const stats = ref({})
const detailVisible = ref(false)
const detailId = ref(null)
const selectedRowKeys = ref([])
const showBatchDialog = ref(false)
const showCreateModal = ref(false)
const creating = ref(false)
const carrierOptions = ref([])
const statusOptions = ref([])
const filters = reactive({
status: null,
order_number: '',
tracking_no: '',
date_from: '',
date_to: '',
})
const createForm = reactive({
order_number: '',
carrier: '',
tracking_no: '',
note: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const statCards = [
{ key: 'shipped', label: '已发货', icon: 'fas fa-box', color: '#0f766e' },
{ key: 'in_transit', label: '运输中', icon: 'fas fa-truck', color: '#2563eb' },
{ key: 'delivered', label: '已签收', icon: 'fas fa-check-circle', color: '#16a34a' },
{ key: 'returned', label: '已退回', icon: 'fas fa-undo', color: '#dc2626' },
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
]
const batchStatusOptions = computed(() => statusOptions.value.filter((item) => item.value !== 'shipped'))
const columnOptions = [
{ key: 'order_number', label: '订单号' },
{ key: 'carrier_label', label: '物流公司' },
{ key: 'tracking_no', label: '物流单号' },
{ key: 'status_label', label: '状态' },
{ key: 'operator', label: '处理人' },
{ key: 'address', label: '收货信息' },
{ key: 'created_at', label: '创建时间' },
]
const visibleColumnKeys = ref(columnOptions.map((item) => item.key))
const baseColumns = [
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
{ title: '物流公司', key: 'carrier_label', width: 120 },
{ title: '物流单号', key: 'tracking_no', width: 180, render: (row) => h('code', row.tracking_no) },
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
},
{ title: '处理人', key: 'operator', width: 120, render: (row) => row.operator?.display_name || '-' },
{ title: '收货信息', key: 'address', ellipsis: { tooltip: true }, render: (row) => row.order_context?.address?.full_text || '-' },
{ title: '创建时间', key: 'created_at', width: 160 },
]
const columns = computed(() => {
const visible = new Set(visibleColumnKeys.value)
const filteredColumns = baseColumns.filter((column) => visible.has(column.key))
return [
{ type: 'selection', width: 40 },
...filteredColumns,
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => h('div', { class: 'aksrt-shipping__action-buttons' }, [
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
h(NButton, { size: 'small', quaternary: true, onClick: () => copyAddress(row) }, () => '复制地址'),
]),
},
]
})
function resolveTagType(status) {
if (status === 'delivered') return 'success'
if (status === 'in_transit') return 'info'
if (status === 'returned') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/shop/shipping', {
params: {
page: nextPage,
per_page: pagination.pageSize,
status: filters.status || '',
order_number: filters.order_number || '',
tracking_no: filters.tracking_no || '',
date_from: filters.date_from || '',
date_to: filters.date_to || '',
},
})
const data = res?.data || {}
items.value = data.items || []
stats.value = data.stats || {}
pagination.itemCount = data.total || 0
carrierOptions.value = data.options?.carrier || []
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
} catch (error) {
message.error(error?.message || '加载物流数据失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function openDetail(row) {
detailId.value = row.id
detailVisible.value = true
}
function handleBatchUpdated() {
selectedRowKeys.value = []
loadData(page.value)
}
function resetFilters() {
filters.status = null
filters.order_number = ''
filters.tracking_no = ''
filters.date_from = ''
filters.date_to = ''
loadData(1)
}
async function copyAddress(row) {
const text = row.order_context?.address?.full_text || ''
if (!text) {
message.warning('当前记录没有地址信息')
return
}
try {
await navigator.clipboard.writeText(text)
message.success('地址已复制')
} catch {
message.warning('复制失败,请手动复制')
}
}
async function submitCreate() {
if (!createForm.order_number || !createForm.carrier || !createForm.tracking_no) {
message.warning('请填写订单号、物流公司和物流单号')
return
}
creating.value = true
try {
await api.request('/shop/shipping', {
method: 'POST',
data: { ...createForm },
})
message.success('发货记录已创建')
showCreateModal.value = false
createForm.order_number = ''
createForm.carrier = ''
createForm.tracking_no = ''
createForm.note = ''
loadData(1)
} catch (error) {
message.error(error?.message || '创建发货记录失败')
} finally {
creating.value = false
}
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-shipping__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-shipping__stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.aksrt-shipping__stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.aksrt-shipping__stat-value {
font-size: 22px;
font-weight: 700;
color: #111827;
}
.aksrt-shipping__stat-label {
font-size: 12px;
color: #6b7280;
}
.aksrt-shipping__section {
margin-bottom: 16px;
}
.aksrt-shipping__toolbar {
margin-top: 14px;
}
.aksrt-shipping__action-buttons {
display: flex;
gap: 4px;
}
.aksrt-shipping__modal {
width: min(520px, calc(100vw - 32px));
}
</style>

Some files were not shown because too many files have changed in this diff Show More