feat(widgets): add phase 4 validation and accessibility
This commit is contained in:
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Generated
-1853
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
{"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"}}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,272 +0,0 @@
|
|||||||
<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.secondaryEntry ? `${child.label} · 次级` : child.label,
|
|
||||||
key: child.key,
|
|
||||||
props: child.secondaryEntry ? { class: 'aksrt-menu-item--secondary' } : undefined,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* 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>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export const extensionsApiMethods = {
|
|
||||||
/* ================================================================
|
|
||||||
* 扩展&增强 (Extensions)
|
|
||||||
* ================================================================ */
|
|
||||||
|
|
||||||
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
|
|
||||||
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
|
|
||||||
|
|
||||||
/* AI 辅助 */
|
|
||||||
getAiStatus() { return this.request('/ai/status') },
|
|
||||||
updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) },
|
|
||||||
generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) },
|
|
||||||
suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) },
|
|
||||||
suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) },
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 }) }) },
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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) },
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const paymentApiMethods = {
|
|
||||||
/* ================================================================
|
|
||||||
* 支付&付费 (Payment)
|
|
||||||
* ================================================================ */
|
|
||||||
|
|
||||||
getPaymentOverview() { return this.request('/payment/overview') },
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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') },
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
<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 }} 创建应用 ↗
|
|
||||||
</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>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
|
|
||||||
<div
|
|
||||||
v-if="title"
|
|
||||||
class="aksrt-field-group__header"
|
|
||||||
:class="{ 'is-clickable': collapsible }"
|
|
||||||
@click="collapsible ? toggleCollapse() : undefined"
|
|
||||||
>
|
|
||||||
<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 class="aksrt-field-group__header-right">
|
|
||||||
<div v-if="$slots.actions" class="aksrt-field-group__actions" @click.stop>
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="collapsible"
|
|
||||||
type="button"
|
|
||||||
class="aksrt-field-group__collapse-btn"
|
|
||||||
:aria-expanded="(!collapsed).toString()"
|
|
||||||
:title="collapsed ? '展开配置' : '收起配置'"
|
|
||||||
@click.stop="toggleCollapse"
|
|
||||||
>
|
|
||||||
<FaIcon
|
|
||||||
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
|
|
||||||
:size="12"
|
|
||||||
/>
|
|
||||||
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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.
|
|
||||||
*
|
|
||||||
* defaultCollapsed only seeds initial state. Parent should remount the group
|
|
||||||
* (e.g. via :key when a channel turns on) if it wants to force expand again.
|
|
||||||
*/
|
|
||||||
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() {
|
|
||||||
if (!props.collapsible) return
|
|
||||||
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;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__header.is-clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group.is-collapsed .aksrt-field-group__header {
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__icon {
|
|
||||||
color: #1890ff;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__collapse-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid #e8ecf2;
|
|
||||||
background: #f7f9fc;
|
|
||||||
color: #5c6370;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-field-group__collapse-btn:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
border-color: #91caff;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
<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="groupKey(group)">
|
|
||||||
<AksFieldGroup
|
|
||||||
v-if="isGroupVisible(group)"
|
|
||||||
:title="group.title"
|
|
||||||
:description="group.description"
|
|
||||||
:icon="group.icon"
|
|
||||||
:collapsible="!!group.collapsible"
|
|
||||||
:default-collapsed="resolveGroupCollapsed(group)"
|
|
||||||
>
|
|
||||||
<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('')
|
|
||||||
|
|
||||||
/** Evaluate dependsOn rule against current form data */
|
|
||||||
function matchDependsOn(dependsOn) {
|
|
||||||
if (!dependsOn) return true
|
|
||||||
if (typeof dependsOn === 'string') {
|
|
||||||
return !!props.formData[dependsOn]
|
|
||||||
}
|
|
||||||
if (typeof dependsOn === 'object') {
|
|
||||||
const { key, value, values, not } = dependsOn
|
|
||||||
const currentVal = props.formData[key]
|
|
||||||
if (not) {
|
|
||||||
if (Array.isArray(values)) {
|
|
||||||
// eslint-disable-next-line eqeqeq
|
|
||||||
return !values.some((v) => currentVal == v)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line eqeqeq
|
|
||||||
return currentVal !== value && currentVal != value
|
|
||||||
}
|
|
||||||
// values: match any of a list (e.g. provider ∈ [qq, polling])
|
|
||||||
if (Array.isArray(values)) {
|
|
||||||
// eslint-disable-next-line eqeqeq
|
|
||||||
return values.some((v) => currentVal == v)
|
|
||||||
}
|
|
||||||
// Loose equality so true matches 1 / '1' from some option stores.
|
|
||||||
// eslint-disable-next-line eqeqeq
|
|
||||||
return currentVal == value || (!!value === true && !!currentVal)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a field should be visible based on its dependsOn rule */
|
|
||||||
function isFieldVisible(field) {
|
|
||||||
return matchDependsOn(field.dependsOn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
|
||||||
function isGroupVisible(group) {
|
|
||||||
return matchDependsOn(group.dependsOn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapse preference for a group.
|
|
||||||
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
|
|
||||||
* - defaultCollapsed: static default
|
|
||||||
*/
|
|
||||||
function resolveGroupCollapsed(group) {
|
|
||||||
if (group.expandWhen) {
|
|
||||||
const on = !!props.formData?.[group.expandWhen]
|
|
||||||
return !on
|
|
||||||
}
|
|
||||||
return !!group.defaultCollapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
|
|
||||||
function groupKey(group) {
|
|
||||||
const base = group.key || group.title || 'group'
|
|
||||||
if (group.expandWhen) {
|
|
||||||
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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>
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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, getAdminSections, getAllAdminPages } from './sections/index.js'
|
|
||||||
export { renderFaIcon, customPage, schemaPage, deferredPage } from './sections/helpers.js'
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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: '更新日志、文档链接和发布说明。',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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: '当前版本的授权策略与产品决策说明。',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
}, {})
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
<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">×</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>
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
<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>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<strong>Banner 显示开关</strong>
|
|
||||||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(轮播参数)</n-button>
|
|
||||||
</div>
|
|
||||||
</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 { useRouter } from 'vue-router'
|
|
||||||
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 router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
function goHeaderComponents() {
|
|
||||||
router.push('/page-display/header-components')
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
@@ -1,986 +0,0 @@
|
|||||||
<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="链接 HTML(fcode_t2_code_1)">
|
|
||||||
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="3" placeholder="推荐直接写 <a href="/">首页</a> 等链接 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="template_1">模板一(Zibll)</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: 'template_1',
|
|
||||||
// §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 = 'template_1'
|
|
||||||
// §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>
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
<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>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<strong>显示范围</strong>
|
|
||||||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(页面类型/搜索/卡片)</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="aksrt-inline-setting__hint" style="margin:0;">
|
|
||||||
幻灯片内容在此维护。页面类型启用、设备规则、搜索覆盖层与图标卡片请到「顶部多功能组件」配置;全不选页面类型即为关闭。
|
|
||||||
</p>
|
|
||||||
</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 { useRouter } from 'vue-router'
|
|
||||||
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 router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
function goHeaderComponents() {
|
|
||||||
router.push('/page-display/header-components')
|
|
||||||
}
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const slides = ref([])
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
</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>
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
|
|
||||||
|
|
||||||
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
|
|
||||||
<strong>加载失败:</strong>{{ loadError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__status">
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
|
||||||
<i class="fas fa-robot"></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 class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
|
||||||
<i class="fas fa-plug"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">WP AI Client</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.theme_available ? '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">主题 Provider</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
|
||||||
<i class="fas fa-route"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">当前后端</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card">
|
|
||||||
<h3>主题 Provider 配置(回退链路)</h3>
|
|
||||||
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
|
|
||||||
<div class="aksrt-form-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
|
|
||||||
</label>
|
|
||||||
</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://llm.example.com/v1">
|
|
||||||
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</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>已接通能力</h3>
|
|
||||||
<table class="aksrt-table aksrt-table--sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>能力</th>
|
|
||||||
<th>入口</th>
|
|
||||||
<th>状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="feat in features" :key="feat.key">
|
|
||||||
<td>{{ feat.name }}</td>
|
|
||||||
<td>{{ feat.scene }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
|
||||||
{{ feat.ready ? '可用' : '未就绪' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
|
|
||||||
</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>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
|
|
||||||
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
|
||||||
import PageHeader from '@/components/PageHeader.vue';
|
|
||||||
import api from '@/api';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ExtensionAI',
|
|
||||||
components: { PageHeader },
|
|
||||||
setup() {
|
|
||||||
const status = ref({
|
|
||||||
enabled: false,
|
|
||||||
provider: 'none',
|
|
||||||
configured: false,
|
|
||||||
model: '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: 10,
|
|
||||||
available: false,
|
|
||||||
backend: 'none',
|
|
||||||
wp_ai_available: false,
|
|
||||||
theme_available: false,
|
|
||||||
seo_ai_enabled: false,
|
|
||||||
write_assist: 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 loadError = ref('');
|
|
||||||
|
|
||||||
const backendLabel = computed(() => {
|
|
||||||
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
|
|
||||||
return map[status.value.backend] || status.value.backend || '无';
|
|
||||||
});
|
|
||||||
|
|
||||||
const features = computed(() => {
|
|
||||||
const avail = !!status.value.available;
|
|
||||||
const seoReady = avail && !!status.value.seo_ai_enabled;
|
|
||||||
return [
|
|
||||||
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
|
|
||||||
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
|
|
||||||
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
function hydrateFromStatus(data) {
|
|
||||||
if (!data || typeof data !== 'object') return;
|
|
||||||
status.value = {
|
|
||||||
enabled: !!data.enabled,
|
|
||||||
provider: data.provider || 'none',
|
|
||||||
configured: !!data.configured,
|
|
||||||
model: data.model || '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: data.rate_limit || 10,
|
|
||||||
available: !!data.available,
|
|
||||||
backend: data.backend || 'none',
|
|
||||||
wp_ai_available: !!data.wp_ai_available,
|
|
||||||
theme_available: !!data.theme_available,
|
|
||||||
seo_ai_enabled: !!data.seo_ai_enabled,
|
|
||||||
write_assist: !!data.write_assist,
|
|
||||||
};
|
|
||||||
form.enabled = !!data.enabled;
|
|
||||||
form.provider = data.provider || 'none';
|
|
||||||
form.model = data.model || 'gpt-3.5-turbo';
|
|
||||||
form.rate_limit = data.rate_limit || 10;
|
|
||||||
if (data.api_base) form.api_base = data.api_base;
|
|
||||||
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
|
|
||||||
form.max_tokens = data.max_tokens;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
loadError.value = '';
|
|
||||||
try {
|
|
||||||
const json = await api.getAiStatus();
|
|
||||||
hydrateFromStatus(json.data || {});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Load AI status failed:', e);
|
|
||||||
loadError.value = e?.message || '加载 AI 状态失败';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
enabled: form.enabled,
|
|
||||||
provider: form.provider,
|
|
||||||
api_base: form.api_base,
|
|
||||||
model: form.model,
|
|
||||||
max_tokens: form.max_tokens,
|
|
||||||
rate_limit: form.rate_limit,
|
|
||||||
};
|
|
||||||
// Only send api_key when the user entered a new one.
|
|
||||||
if (form.api_key && form.api_key !== '***') {
|
|
||||||
payload.api_key = form.api_key;
|
|
||||||
}
|
|
||||||
const json = await api.updateAiSettings(payload);
|
|
||||||
alert(json.message || '设置已保存');
|
|
||||||
await loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Save failed:', e);
|
|
||||||
alert(e?.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => loadStatus());
|
|
||||||
|
|
||||||
return { status, form, saving, loadError, features, backendLabel, 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__load-error {
|
|
||||||
background: #fdecea;
|
|
||||||
border-color: #f5c2c0;
|
|
||||||
color: #842029;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
|
||||||
.aksrt-badge--success { background: #d4edda; color: #155724; }
|
|
||||||
.aksrt-badge--warning { background: #fff3cd; color: #856404; }
|
|
||||||
.aksrt-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
|
|
||||||
.aksrt-form-group { margin-bottom: 14px; }
|
|
||||||
.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; }
|
|
||||||
.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; }
|
|
||||||
.aksrt-button--primary { background: #1677ff; color: #fff; }
|
|
||||||
.aksrt-button:disabled { opacity: .6; cursor: not-allowed; }
|
|
||||||
</style>
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
<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: '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 ?? svc.captcha?.configured)
|
|
||||||
services.value[2].status = svc.captcha?.provider || (svc.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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
<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-select v-model:value="createForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="卡密">
|
|
||||||
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡密" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="createForm.type === 'balance'" label="面额">
|
|
||||||
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="createForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-form-item v-if="createForm.type === 'credits'" label="积分">
|
|
||||||
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="过期时间">
|
|
||||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<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-form-item label="卡类型">
|
|
||||||
<n-select v-model:value="batchForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<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 v-if="batchForm.type === 'balance'">
|
|
||||||
<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-item v-if="batchForm.type === 'credits'">
|
|
||||||
<n-form-item label="积分">
|
|
||||||
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="batchForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" 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="AK" />
|
|
||||||
</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({
|
|
||||||
type: 'balance',
|
|
||||||
code: '',
|
|
||||||
amount: 10,
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const batchForm = reactive({
|
|
||||||
type: 'balance',
|
|
||||||
count: 20,
|
|
||||||
amount: 10,
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
prefix: 'AK',
|
|
||||||
length: 12,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeOptions = [
|
|
||||||
{ label: '余额卡', value: 'balance' },
|
|
||||||
{ label: '会员卡', value: 'vip' },
|
|
||||||
{ label: '积分卡', value: 'credits' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const levelOptions = [
|
|
||||||
{ label: 'VIP1', value: 1 },
|
|
||||||
{ label: 'VIP2', value: 2 },
|
|
||||||
]
|
|
||||||
|
|
||||||
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: 170, ellipsis: { tooltip: true } },
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type_label',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => row.type_label || row.type || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '面额/权益',
|
|
||||||
key: 'amount',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => {
|
|
||||||
if (row.type === 'vip') {
|
|
||||||
const days = Number(row.days || 0)
|
|
||||||
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
|
|
||||||
}
|
|
||||||
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
|
|
||||||
return row.amount
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: {
|
|
||||||
type: createForm.type,
|
|
||||||
code: createForm.code,
|
|
||||||
password: createForm.code,
|
|
||||||
amount: createForm.amount,
|
|
||||||
level: createForm.level,
|
|
||||||
days: createForm.days,
|
|
||||||
points: createForm.points,
|
|
||||||
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: {
|
|
||||||
type: batchForm.type,
|
|
||||||
count: batchForm.count,
|
|
||||||
amount: batchForm.amount,
|
|
||||||
level: batchForm.level,
|
|
||||||
days: batchForm.days,
|
|
||||||
points: batchForm.points,
|
|
||||||
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>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aks-native-ops-redirect">
|
|
||||||
<h2>{{ title }}</h2>
|
|
||||||
<p class="desc">
|
|
||||||
此运营功能已迁移到 WordPress 原生后台侧栏(对齐 zibll)。正在跳转…
|
|
||||||
</p>
|
|
||||||
<p v-if="nativeUrl">
|
|
||||||
<a class="button button-primary" :href="nativeUrl">立即打开原生页面</a>
|
|
||||||
</p>
|
|
||||||
<p v-else class="desc">未配置原生跳转地址,请从左侧 WP 菜单进入。</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const title = computed(() => route.meta?.title || '运营功能已迁移')
|
|
||||||
const nativeUrl = computed(() => {
|
|
||||||
const path = route.meta?.nativeAdminPath || ''
|
|
||||||
if (!path) return ''
|
|
||||||
const base = (window.aksrtAdminData?.adminUrl || '/wp-admin/').replace(/\/?$/, '/')
|
|
||||||
return base + path.replace(/^\//, '')
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (nativeUrl.value) {
|
|
||||||
window.location.replace(nativeUrl.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aks-native-ops-redirect {
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 48px auto;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--aks-surface, #fff);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--aks-border, #e5e7eb);
|
|
||||||
}
|
|
||||||
.desc {
|
|
||||||
color: #646970;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #2271b1;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SchemaPage domain="post_list" section="comment" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SchemaPage domain="post_list" section="page" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
|
||||||
</script>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="主题授权"
|
|
||||||
desc="当前版本按产品决策不实现在线授权/撤销授权,仅保留主题信息与更新能力。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/tools/import-export')">兼容&工具</n-button>
|
|
||||||
<n-button quaternary @click="go('/tools/update')">更新中心</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<n-alert type="info" title="按设计省略" :show-icon="true">
|
|
||||||
本主题当前不实现 zibll 风格的授权码校验、域名绑定、撤销授权、本地免授权等机制。
|
|
||||||
`AKSRT_Update_Checker` 仅负责匿名版本检测与更新提示,不与授权系统耦合。
|
|
||||||
</n-alert>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" title="当前策略" size="small">
|
|
||||||
<n-list bordered>
|
|
||||||
<n-list-item>不提供授权码输入框或“立即授权”按钮</n-list-item>
|
|
||||||
<n-list-item>不提供“已授权 / 未授权 / 本地免授权”三态逻辑</n-list-item>
|
|
||||||
<n-list-item>更新能力统一收口到“更新中心”与“主题信息”页</n-list-item>
|
|
||||||
<n-list-item>若未来恢复授权体系,应单独立项,不与现有更新检测器混接</n-list-item>
|
|
||||||
</n-list>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="主题信息" desc="主题版本、运行环境、已启用模块和关键系统信息。" />
|
|
||||||
|
|
||||||
<div v-if="loading" class="aksrt-theme-info__loading">
|
|
||||||
<n-spin size="medium" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- Version Card -->
|
|
||||||
<n-card class="aksrt-theme-info__card" title="版本信息" size="small">
|
|
||||||
<n-descriptions :column="2" bordered label-placement="left" size="small">
|
|
||||||
<n-descriptions-item label="主题版本">
|
|
||||||
<n-tag type="success" size="small">{{ info.themeVersion }}</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="WordPress 版本">
|
|
||||||
{{ info.wpVersion }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="PHP 版本">
|
|
||||||
<n-tag :type="info.phpVersion >= '8.1' ? 'success' : 'warning'" size="small">
|
|
||||||
{{ info.phpVersion }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="数据库 Schema">
|
|
||||||
<n-tag type="info" size="small">{{ info.dbSchemaVersion }}</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="邮箱验证回填">
|
|
||||||
<n-tag :type="info.emailBackfillDone ? 'success' : 'default'" size="small">
|
|
||||||
{{ info.emailBackfillDone ? '已完成' : '未触发' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Environment Card -->
|
|
||||||
<n-card class="aksrt-theme-info__card" title="运行环境" size="small">
|
|
||||||
<n-descriptions :column="2" bordered label-placement="left" size="small">
|
|
||||||
<n-descriptions-item label="站点地址">
|
|
||||||
<n-text code>{{ info.siteUrl }}</n-text>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="REST API">
|
|
||||||
<n-text code>{{ info.apiUrl }}</n-text>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="主题目录">
|
|
||||||
<n-text code>{{ info.themeDir }}</n-text>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="上传目录">
|
|
||||||
<n-text code>{{ info.uploadDir }}</n-text>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="多站点">
|
|
||||||
<n-tag :type="info.isMultisite ? 'warning' : 'default'" size="small">
|
|
||||||
{{ info.isMultisite ? '是' : '否' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="调试模式">
|
|
||||||
<n-tag :type="info.debugMode ? 'warning' : 'default'" size="small">
|
|
||||||
{{ info.debugMode ? '开启' : '关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Modules Card -->
|
|
||||||
<n-card class="aksrt-theme-info__card" title="已启用模块" size="small">
|
|
||||||
<div class="aksrt-theme-info__modules">
|
|
||||||
<n-tag
|
|
||||||
v-for="mod in info.modules"
|
|
||||||
:key="mod.key"
|
|
||||||
:type="mod.enabled ? 'success' : 'default'"
|
|
||||||
size="small"
|
|
||||||
class="aksrt-theme-info__module-tag"
|
|
||||||
>
|
|
||||||
<FaIcon :icon="mod.enabled ? 'fas fa-check-circle' : 'fas fa-circle'" :size="10" />
|
|
||||||
{{ mod.label }}
|
|
||||||
</n-tag>
|
|
||||||
</div>
|
|
||||||
<n-text v-if="!info.modules.length" depth="3" style="font-size: 13px;">
|
|
||||||
暂无模块信息
|
|
||||||
</n-text>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Data Tables Card -->
|
|
||||||
<n-card class="aksrt-theme-info__card" title="数据表状态" size="small">
|
|
||||||
<n-data-table
|
|
||||||
:columns="tableColumns"
|
|
||||||
:data="info.tables"
|
|
||||||
:bordered="true"
|
|
||||||
size="small"
|
|
||||||
:max-height="300"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Docs Links Card -->
|
|
||||||
<n-card class="aksrt-theme-info__card" title="快速入口" size="small">
|
|
||||||
<div class="aksrt-theme-info__links">
|
|
||||||
<router-link to="/docs" class="aksrt-theme-info__link">
|
|
||||||
<FaIcon icon="fas fa-book" :size="16" />
|
|
||||||
<span>文档中心</span>
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/tools/update" class="aksrt-theme-info__link">
|
|
||||||
<FaIcon icon="fas fa-cloud-arrow-down" :size="16" />
|
|
||||||
<span>更新中心</span>
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/tools/backup" class="aksrt-theme-info__link">
|
|
||||||
<FaIcon icon="fas fa-box-archive" :size="16" />
|
|
||||||
<span>数据备份</span>
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/tools/health" class="aksrt-theme-info__link">
|
|
||||||
<FaIcon icon="fas fa-heartbeat" :size="16" />
|
|
||||||
<span>健康检查</span>
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/tools/import-export" class="aksrt-theme-info__link">
|
|
||||||
<FaIcon icon="fas fa-exchange-alt" :size="16" />
|
|
||||||
<span>导入导出</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, h } from 'vue'
|
|
||||||
import { NTag, NText } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
|
||||||
import api from '@/api/index.js'
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const info = ref({
|
|
||||||
themeVersion: '',
|
|
||||||
wpVersion: '',
|
|
||||||
phpVersion: '',
|
|
||||||
dbSchemaVersion: '',
|
|
||||||
emailBackfillDone: false,
|
|
||||||
siteUrl: '',
|
|
||||||
apiUrl: '',
|
|
||||||
themeDir: '',
|
|
||||||
uploadDir: '',
|
|
||||||
isMultisite: false,
|
|
||||||
debugMode: false,
|
|
||||||
modules: [],
|
|
||||||
tables: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableColumns = [
|
|
||||||
{ title: '数据表', key: 'name', width: 220 },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'exists',
|
|
||||||
width: 100,
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, {
|
|
||||||
type: row.exists ? 'success' : 'error',
|
|
||||||
size: 'small',
|
|
||||||
bordered: false,
|
|
||||||
}, { default: () => row.exists ? '正常' : '缺失' })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '行数',
|
|
||||||
key: 'rows',
|
|
||||||
width: 100,
|
|
||||||
render(row) {
|
|
||||||
return row.exists ? row.rows.toLocaleString() : '—'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.getHealthCheck()
|
|
||||||
const data = res?.data || res || {}
|
|
||||||
|
|
||||||
info.value = {
|
|
||||||
themeVersion: window.aksrtAdminData?.themeVersion || data.theme_version || '3.4.0-dev',
|
|
||||||
wpVersion: data.wp_version || '',
|
|
||||||
phpVersion: data.php_version || '',
|
|
||||||
dbSchemaVersion: data.db_schema_version || '',
|
|
||||||
emailBackfillDone: !!data.email_backfill_done,
|
|
||||||
siteUrl: data.site_url || window.aksrtAdminData?.siteUrl || '',
|
|
||||||
apiUrl: data.api_url || window.aksrtAdminData?.apiUrl || '',
|
|
||||||
themeDir: data.theme_dir || '',
|
|
||||||
uploadDir: data.upload_dir || '',
|
|
||||||
isMultisite: !!data.is_multisite,
|
|
||||||
debugMode: !!data.debug_mode,
|
|
||||||
modules: normalizeModules(data.modules || []),
|
|
||||||
tables: normalizeTables(data.tables || []),
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ThemeInfo] Failed to load health data:', err)
|
|
||||||
// Fallback with minimal info
|
|
||||||
info.value.themeVersion = '3.4.0-dev'
|
|
||||||
info.value.siteUrl = window.aksrtAdminData?.siteUrl || ''
|
|
||||||
info.value.apiUrl = window.aksrtAdminData?.apiUrl || ''
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function normalizeModules(raw) {
|
|
||||||
if (!Array.isArray(raw)) return []
|
|
||||||
return raw.map(m => ({
|
|
||||||
key: m.key || m.name || '',
|
|
||||||
label: m.label || m.name || m.key || '',
|
|
||||||
enabled: !!m.enabled,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTables(raw) {
|
|
||||||
if (!Array.isArray(raw)) return []
|
|
||||||
return raw.map(t => ({
|
|
||||||
name: t.name || '',
|
|
||||||
exists: !!t.exists,
|
|
||||||
rows: t.rows || 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-theme-info__loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__modules {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__module-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__links {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #333;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-theme-info__link:hover {
|
|
||||||
background: #e8eaed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="数据备份"
|
|
||||||
desc="管理设置快照、创建手动备份,并执行恢复或删除。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/tools/import-export')">导入导出</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 vertical size="small" style="width: 100%;">
|
|
||||||
<n-space>
|
|
||||||
<n-tag round :type="settings.backup_auto ? 'success' : 'default'">
|
|
||||||
自动备份:{{ settings.backup_auto ? '已开启' : '未开启' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="info">频率:{{ frequencyLabel }}</n-tag>
|
|
||||||
<n-tag round type="info">保留:{{ settings.backup_retain }} 份</n-tag>
|
|
||||||
<n-tag round :type="backups.length ? 'success' : 'default'">现有快照:{{ backups.length }} 份</n-tag>
|
|
||||||
</n-space>
|
|
||||||
<n-space align="center" wrap>
|
|
||||||
<span class="aksrt-backup__label">自动备份</span>
|
|
||||||
<n-switch v-model:value="settings.backup_auto" />
|
|
||||||
<span class="aksrt-backup__label">频率</span>
|
|
||||||
<n-select v-model:value="settings.backup_frequency" :options="frequencyOptions" style="width: 140px;" />
|
|
||||||
<span class="aksrt-backup__label">保留</span>
|
|
||||||
<n-input-number v-model:value="settings.backup_retain" :min="3" :max="20" style="width: 140px;" />
|
|
||||||
<n-button type="primary" :loading="savingPolicy" @click="handleSavePolicy">保存策略</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>手动创建快照</h3>
|
|
||||||
</div>
|
|
||||||
<n-space vertical size="small" style="width: 100%;">
|
|
||||||
<n-input v-model:value="backupLabel" placeholder="可选:输入本次快照备注,如“升级前备份”" />
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" :loading="creating" @click="handleCreateBackup">立即创建快照</n-button>
|
|
||||||
<n-button :loading="loading" @click="load">刷新列表</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>快照列表</h3>
|
|
||||||
</div>
|
|
||||||
<n-empty v-if="!backups.length" description="暂无设置快照" />
|
|
||||||
<n-data-table
|
|
||||||
v-else
|
|
||||||
:columns="columns"
|
|
||||||
:data="backups"
|
|
||||||
:bordered="true"
|
|
||||||
size="small"
|
|
||||||
:single-line="false"
|
|
||||||
:scroll-x="900"
|
|
||||||
/>
|
|
||||||
<p class="aksrt-backup__hint">
|
|
||||||
恢复非当前网站 / 非当前版本的快照可能导致配置异常,恢复前请确认站点环境一致。
|
|
||||||
</p>
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
|
||||||
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 creating = ref(false)
|
|
||||||
const savingPolicy = ref(false)
|
|
||||||
const backupLabel = ref('')
|
|
||||||
const backups = ref([])
|
|
||||||
const settings = ref({
|
|
||||||
backup_auto: false,
|
|
||||||
backup_frequency: 'weekly',
|
|
||||||
backup_retain: 7,
|
|
||||||
})
|
|
||||||
|
|
||||||
const frequencyLabelMap = {
|
|
||||||
daily: '每天',
|
|
||||||
weekly: '每周',
|
|
||||||
monthly: '每月',
|
|
||||||
}
|
|
||||||
|
|
||||||
const frequencyOptions = [
|
|
||||||
{ label: '每天', value: 'daily' },
|
|
||||||
{ label: '每周', value: 'weekly' },
|
|
||||||
{ label: '每月', value: 'monthly' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const frequencyLabel = computed(() => frequencyLabelMap[settings.value.backup_frequency] || '每周')
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: 'ID',
|
|
||||||
key: 'id',
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NTag, { size: 'small', round: true, type: tagType(row.type) }, () => typeLabel(row.type)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '名称',
|
|
||||||
key: 'label',
|
|
||||||
minWidth: 220,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 180,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => handleRestore(row),
|
|
||||||
}, () => '恢复'),
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'error',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => handleDelete(row),
|
|
||||||
}, () => '删除'),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeLabel(type) {
|
|
||||||
return {
|
|
||||||
manual: '手动备份',
|
|
||||||
scheduled: '定期备份',
|
|
||||||
reset_all: '重置全部',
|
|
||||||
reset_domain: '重置分区',
|
|
||||||
update_theme: '主题更新',
|
|
||||||
}[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagType(type) {
|
|
||||||
return {
|
|
||||||
manual: 'info',
|
|
||||||
scheduled: 'success',
|
|
||||||
reset_all: 'warning',
|
|
||||||
reset_domain: 'warning',
|
|
||||||
update_theme: 'error',
|
|
||||||
}[type] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [schemaRes, backupRes] = await Promise.all([
|
|
||||||
api.getSchemaPage('tools', 'backup'),
|
|
||||||
api.listSettingBackups(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const schemaData = schemaRes?.data || {}
|
|
||||||
settings.value = {
|
|
||||||
backup_auto: !!schemaData.backup_auto,
|
|
||||||
backup_frequency: schemaData.backup_frequency || 'weekly',
|
|
||||||
backup_retain: Number(schemaData.backup_retain || 7),
|
|
||||||
}
|
|
||||||
|
|
||||||
backups.value = backupRes?.data?.items || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载备份列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateBackup() {
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
await api.createSettingBackup({
|
|
||||||
type: 'manual',
|
|
||||||
label: backupLabel.value,
|
|
||||||
})
|
|
||||||
backupLabel.value = ''
|
|
||||||
message.success('设置快照已创建')
|
|
||||||
await load()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '创建快照失败')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
|
||||||
savingPolicy.value = true
|
|
||||||
try {
|
|
||||||
await api.saveSchemaPage('tools', 'backup', {
|
|
||||||
backup_auto: !!settings.value.backup_auto,
|
|
||||||
backup_frequency: settings.value.backup_frequency || 'weekly',
|
|
||||||
backup_retain: Math.min(20, Math.max(3, Number(settings.value.backup_retain || 7))),
|
|
||||||
})
|
|
||||||
message.success('备份策略已保存')
|
|
||||||
await load()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '保存备份策略失败')
|
|
||||||
} finally {
|
|
||||||
savingPolicy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRestore(row) {
|
|
||||||
if (!window.confirm(`确认恢复快照「${row.label}」吗?此操作会覆盖当前设置。`)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.restoreSettingBackup(row.id)
|
|
||||||
message.success('设置已恢复')
|
|
||||||
await load()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '恢复快照失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
if (!window.confirm(`确认删除快照「${row.label}」吗?`)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.deleteSettingBackup(row.id)
|
|
||||||
message.success('快照已删除')
|
|
||||||
await load()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '删除快照失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-backup__hint {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-backup__label {
|
|
||||||
color: #344054;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="邮件回滚"
|
|
||||||
desc="查看 14 天邮箱变更窗口内的账号,并在必要时执行回滚。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/tools/health')">健康检查</n-button>
|
|
||||||
<n-button quaternary @click="load">刷新列表</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>
|
|
||||||
<n-tag round type="info">总计 {{ total }}</n-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="items.length" class="aksrt-rollback-list">
|
|
||||||
<div v-for="item in items" :key="item.user_id" class="aksrt-rollback-list__item">
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.display_name || item.login }}</strong>
|
|
||||||
<p style="margin: 6px 0 0; color: #4b5565;">
|
|
||||||
{{ item.current_email }} → {{ item.previous_email }}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 6px 0 0; color: #7a7f8a; font-size: 12px;">
|
|
||||||
剩余窗口:{{ Math.max(0, Math.floor((item.expires_in || 0) / 3600)) }} 小时
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<n-button size="small" :loading="runningId === item.user_id" @click="handleRollback(item.user_id)">
|
|
||||||
执行回滚
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-empty v-else description="当前没有可回滚的邮箱变更记录" />
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, 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 runningId = ref(0)
|
|
||||||
const items = ref([])
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = extractData(await api.getEmailRollbackUsers())
|
|
||||||
items.value = Array.isArray(data.items) ? data.items : []
|
|
||||||
total.value = Number(data.total || 0)
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载邮件回滚列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRollback(userId) {
|
|
||||||
runningId.value = userId
|
|
||||||
try {
|
|
||||||
const result = await api.executeEmailRollback(userId)
|
|
||||||
if (result.code === 'success') {
|
|
||||||
message.success(result.message || '回滚成功')
|
|
||||||
await load()
|
|
||||||
} else {
|
|
||||||
message.error(result.message || '回滚失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '回滚失败')
|
|
||||||
} finally {
|
|
||||||
runningId.value = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-rollback-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-rollback-list__item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #e7ebf3;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.aksrt-rollback-list__item {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="健康检查"
|
|
||||||
desc="集中查看运行环境、数据库表、邮箱验证迁移和系统诊断状态。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/tools/import-export')">导入导出</n-button>
|
|
||||||
<n-button quaternary @click="go('/tools/email-rollback')">邮件回滚</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="emailHealth.feature_enabled ? 'success' : 'default'">
|
|
||||||
功能开关:{{ emailHealth.feature_enabled ? '已启用' : '未启用' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="emailHealth.rest_blocking_active ? 'warning' : 'default'">
|
|
||||||
REST 阻断:{{ emailHealth.rest_blocking_active ? '已开启' : '仅记录/关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="info">
|
|
||||||
待验证用户:{{ emailHealth.pending_users }}
|
|
||||||
</n-tag>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<div class="aksrt-health-grid">
|
|
||||||
<n-card v-for="check in checkItems" :key="check.key" class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>{{ check.label }}</h3>
|
|
||||||
<n-tag round :type="statusToTag(check.status)">{{ check.status }}</n-tag>
|
|
||||||
</div>
|
|
||||||
<p style="margin: 0; color: #4b5565;">{{ check.value }}</p>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, 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 healthData = ref({})
|
|
||||||
const emailHealth = ref({
|
|
||||||
feature_enabled: false,
|
|
||||||
pending_users: 0,
|
|
||||||
rest_blocking_active: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkItems = computed(() => {
|
|
||||||
return Object.entries(healthData.value || {}).map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
...(value || {}),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToTag(status) {
|
|
||||||
if (status === 'ok') return 'success'
|
|
||||||
if (status === 'warning') return 'warning'
|
|
||||||
if (status === 'error') return 'error'
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [healthRes, emailRes] = await Promise.all([
|
|
||||||
api.getHealthCheck(),
|
|
||||||
api.getHealthEmailVerification(),
|
|
||||||
])
|
|
||||||
healthData.value = extractData(healthRes).checks || {}
|
|
||||||
emailHealth.value = extractData(emailRes)
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载健康检查失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-health-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="导入导出"
|
|
||||||
desc="导出当前设置快照、执行 dry-run 导入校验,并支持按域重置到 schema 默认值。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/tools/backup')">数据备份</n-button>
|
|
||||||
<n-button quaternary @click="go('/tools/health')">健康检查</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>导出与导入</h3>
|
|
||||||
</div>
|
|
||||||
<n-space style="margin-bottom: 16px;">
|
|
||||||
<n-button :loading="exporting" @click="handleExport">导出当前配置</n-button>
|
|
||||||
<n-button :loading="importing" @click="handleDryRun">Dry Run 校验</n-button>
|
|
||||||
<n-button type="primary" :loading="importing" @click="handleImport">执行导入</n-button>
|
|
||||||
</n-space>
|
|
||||||
<n-input
|
|
||||||
v-model:value="payloadText"
|
|
||||||
type="textarea"
|
|
||||||
:autosize="{ minRows: 12, maxRows: 20 }"
|
|
||||||
placeholder="导出的 JSON 会出现在这里,也可以直接粘贴导入。"
|
|
||||||
/>
|
|
||||||
<p class="aksrt-tools-hint">
|
|
||||||
正式导入前会自动创建一份“导入前自动快照”,与“数据备份”页的快照体系共用,便于回滚。
|
|
||||||
</p>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>按域重置</h3>
|
|
||||||
</div>
|
|
||||||
<n-space align="center" wrap>
|
|
||||||
<n-select v-model:value="resetDomain" :options="domainOptions" style="width: 220px;" />
|
|
||||||
<n-button :loading="resetting" @click="handleReset">重置所选域</n-button>
|
|
||||||
<n-button type="error" tertiary :loading="resettingAll" @click="handleResetAll">重置全部</n-button>
|
|
||||||
</n-space>
|
|
||||||
<p class="aksrt-tools-hint">仅支持重置 schema 托管的业务域。健康检查、迁移状态和邮件回滚属于运维面板,不参与按域回滚。</p>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>最近结果</h3>
|
|
||||||
</div>
|
|
||||||
<pre class="aksrt-tools-output">{{ resultText || '尚未执行操作。' }}</pre>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { 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 exporting = ref(false)
|
|
||||||
const importing = ref(false)
|
|
||||||
const resetting = ref(false)
|
|
||||||
const resettingAll = ref(false)
|
|
||||||
const payloadText = ref('')
|
|
||||||
const resultText = ref('')
|
|
||||||
const resetDomain = ref('global')
|
|
||||||
|
|
||||||
const domainOptions = [
|
|
||||||
{ label: '全局&功能', value: 'global' },
|
|
||||||
{ label: '页面&显示', value: 'page_display' },
|
|
||||||
{ label: '文章&列表', value: 'post_list' },
|
|
||||||
{ label: '用户&互动', value: 'users' },
|
|
||||||
{ label: '支付&付费', value: 'payment' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayload() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(payloadText.value || '{}')
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('导入 JSON 格式无效')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
exporting.value = true
|
|
||||||
try {
|
|
||||||
const data = extractData(await api.exportSettings())
|
|
||||||
payloadText.value = JSON.stringify(data, null, 2)
|
|
||||||
resultText.value = `导出完成\n版本: ${data.version || 'unknown'}\n时间: ${data.exported || 'unknown'}`
|
|
||||||
message.success('导出成功')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '导出失败')
|
|
||||||
} finally {
|
|
||||||
exporting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDryRun() {
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
const payload = parsePayload()
|
|
||||||
const result = await api.importSettings({ ...payload, dry_run: true })
|
|
||||||
resultText.value = JSON.stringify(result, null, 2)
|
|
||||||
message.success('Dry Run 校验完成')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '校验失败')
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImport() {
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
const payload = parsePayload()
|
|
||||||
const result = await api.importSettings(payload)
|
|
||||||
resultText.value = JSON.stringify(result, null, 2)
|
|
||||||
message.success('导入完成')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '导入失败')
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReset() {
|
|
||||||
resetting.value = true
|
|
||||||
try {
|
|
||||||
const result = await api.resetDomain(resetDomain.value)
|
|
||||||
resultText.value = JSON.stringify(result, null, 2)
|
|
||||||
message.success('域设置已重置')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '重置失败')
|
|
||||||
} finally {
|
|
||||||
resetting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetAll() {
|
|
||||||
if (!window.confirm('确认重置全部 schema 托管设置吗?系统会先自动创建一份“重置全部前快照”。')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resettingAll.value = true
|
|
||||||
try {
|
|
||||||
const result = await api.resetAllSettings()
|
|
||||||
resultText.value = JSON.stringify(result, null, 2)
|
|
||||||
message.success('全部设置已重置')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '重置全部失败')
|
|
||||||
} finally {
|
|
||||||
resettingAll.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-tools-output {
|
|
||||||
margin: 0;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #101828;
|
|
||||||
color: #d0d7e2;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools-hint {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="用户徽章"
|
|
||||||
desc="配置勋章分类、自动授予条件(get_type / get_val)与公开展示。路由 /users/badge,存储键 user_medal_*。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/users/notifications')">消息通知</n-button>
|
|
||||||
<n-button quaternary @click="go('/users/level')">用户等级</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.user_medal_s ? 'success' : 'default'">
|
|
||||||
徽章系统:{{ form.user_medal_s ? '已启用' : '已关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="form.user_medal_public_display ? 'info' : 'default'">
|
|
||||||
公开展示:{{ form.user_medal_public_display ? '开启' : '关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="info">最多显示:{{ form.user_medal_limit }} 枚</n-tag>
|
|
||||||
<n-tag round type="info">分类:{{ form.user_medal_args.length }}</n-tag>
|
|
||||||
<n-tag round type="info">条目:{{ itemCount }}</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.user_medal_s" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.user_medal_s" label="公开页显示徽章">
|
|
||||||
<n-switch v-model:value="form.user_medal_public_display" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.user_medal_s" label="最多显示数量">
|
|
||||||
<n-input-number v-model:value="form.user_medal_limit" :min="1" :max="8" style="width: 180px;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card v-if="form.user_medal_s" class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
||||||
<h3>徽章明细(分类 → 条目)</h3>
|
|
||||||
<n-button size="small" @click="addCategory">添加分类</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-empty v-if="!form.user_medal_args.length" description="暂无分类,请添加" />
|
|
||||||
|
|
||||||
<div v-for="(cat, ci) in form.user_medal_args" :key="'cat-' + ci" class="medal-cat-block">
|
|
||||||
<n-card size="small" embedded>
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-space align="center">
|
|
||||||
<n-input v-model:value="cat.cat_name" placeholder="分类名称" style="width: 220px;" />
|
|
||||||
<n-button size="small" @click="addItem(ci)">添加条目</n-button>
|
|
||||||
<n-button size="small" quaternary type="error" @click="removeCategory(ci)">删除分类</n-button>
|
|
||||||
</n-space>
|
|
||||||
|
|
||||||
<n-empty v-if="!(cat.items && cat.items.length)" description="该分类暂无条目" />
|
|
||||||
|
|
||||||
<div v-for="(item, ii) in cat.items || []" :key="'item-' + ci + '-' + ii" class="medal-item-row">
|
|
||||||
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="item.slug" placeholder="slug" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="item.name" placeholder="名称" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="item.get_type" :options="getTypeOptions" placeholder="获取条件" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input-number
|
|
||||||
v-model:value="item.get_val"
|
|
||||||
:min="0"
|
|
||||||
:disabled="item.get_type === 'manually_add'"
|
|
||||||
placeholder="阈值"
|
|
||||||
style="width: 100%;"
|
|
||||||
/>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="item.icon" placeholder="图标 URL" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="item.desc" placeholder="描述" />
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<div style="margin-top: 8px; text-align: right;">
|
|
||||||
<n-button size="tiny" quaternary type="error" @click="removeItem(ci, ii)">删除条目</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
前台已授予徽章保存在用户 meta aksrt_user_badges;佩戴为 aksrt_wear_badge(slug)。
|
|
||||||
字典来自 user_medal_args;运行时 AKSRT_User_Badges 读取 identity badge.dictionary 派生列表。
|
|
||||||
</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'
|
|
||||||
import { MEDAL_GET_TYPES, defaultMedalArgs } from '@/schema/sections/users/badge.js'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const getTypeOptions = MEDAL_GET_TYPES
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
user_medal_s: false,
|
|
||||||
user_medal_public_display: true,
|
|
||||||
user_medal_limit: 3,
|
|
||||||
user_medal_args: defaultMedalArgs(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const itemCount = computed(() =>
|
|
||||||
form.user_medal_args.reduce((n, c) => n + (Array.isArray(c.items) ? c.items.length : 0), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyItem() {
|
|
||||||
return {
|
|
||||||
slug: '',
|
|
||||||
name: '',
|
|
||||||
desc: '',
|
|
||||||
icon: '',
|
|
||||||
get_type: 'manually_add',
|
|
||||||
get_val: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeArgs(raw) {
|
|
||||||
if (!Array.isArray(raw)) {
|
|
||||||
return defaultMedalArgs()
|
|
||||||
}
|
|
||||||
return raw.map((cat) => ({
|
|
||||||
cat_name: String(cat?.cat_name || cat?.name || '未命名').trim() || '未命名',
|
|
||||||
items: Array.isArray(cat?.items)
|
|
||||||
? cat.items.map((it) => ({
|
|
||||||
slug: String(it?.slug || '').trim(),
|
|
||||||
name: String(it?.name || '').trim(),
|
|
||||||
desc: String(it?.desc || '').trim(),
|
|
||||||
icon: String(it?.icon || it?.icon_url || '').trim(),
|
|
||||||
get_type: String(it?.get_type || 'manually_add').trim() || 'manually_add',
|
|
||||||
get_val: Math.max(0, Number(it?.get_val ?? 0) || 0),
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCategory() {
|
|
||||||
form.user_medal_args.push({ cat_name: '新分类', items: [emptyItem()] })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCategory(ci) {
|
|
||||||
form.user_medal_args.splice(ci, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addItem(ci) {
|
|
||||||
if (!form.user_medal_args[ci].items) {
|
|
||||||
form.user_medal_args[ci].items = []
|
|
||||||
}
|
|
||||||
form.user_medal_args[ci].items.push(emptyItem())
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeItem(ci, ii) {
|
|
||||||
form.user_medal_args[ci].items.splice(ii, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const schemaRes = await api.getSchemaPage('users', 'badge')
|
|
||||||
const schemaData = extractData(schemaRes)
|
|
||||||
|
|
||||||
form.user_medal_s = !!(schemaData.user_medal_s ?? schemaData.user_badge_enabled)
|
|
||||||
form.user_medal_public_display =
|
|
||||||
schemaData.user_medal_public_display !== undefined
|
|
||||||
? schemaData.user_medal_public_display !== false
|
|
||||||
: schemaData.user_badge_public_display !== false
|
|
||||||
form.user_medal_limit = Number(
|
|
||||||
schemaData.user_medal_limit ?? schemaData.user_badge_limit ?? 3,
|
|
||||||
)
|
|
||||||
form.user_medal_args = normalizeArgs(schemaData.user_medal_args ?? null)
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载用户徽章配置失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const args = normalizeArgs(form.user_medal_args)
|
|
||||||
for (const cat of args) {
|
|
||||||
for (const it of cat.items) {
|
|
||||||
if (!it.slug || !it.name || !it.get_type) {
|
|
||||||
message.error('每个条目需要 slug、名称和获取条件')
|
|
||||||
saving.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await api.saveSchemaPage('users', 'badge', {
|
|
||||||
user_medal_s: !!form.user_medal_s,
|
|
||||||
user_medal_public_display: !!form.user_medal_public_display,
|
|
||||||
user_medal_limit: Number(form.user_medal_limit || 3),
|
|
||||||
user_medal_args: args,
|
|
||||||
})
|
|
||||||
form.user_medal_args = args
|
|
||||||
message.success('用户徽章配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.medal-cat-block {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.medal-item-row {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--n-border-color, #efeff5);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="禁封管理"
|
|
||||||
desc="查看和管理用户封禁、限制状态,支持按用户 ID 查询和解除限制。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-grid cols="1 s:2 m:3" :x-gap="16" :y-gap="16" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="总限制记录" :value="stats.total" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="当前生效" :value="stats.active">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.active > 0" type="error" size="small">{{ stats.active }}</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="封禁账号" :value="stats.banned">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.banned > 0" type="error" size="small">严重</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<!-- Search by user ID -->
|
|
||||||
<n-card size="small" title="查询用户限制" style="margin-top:16px;">
|
|
||||||
<n-space>
|
|
||||||
<n-input-number v-model:value="searchUserId" placeholder="用户 ID" :min="1" style="width:160px;" />
|
|
||||||
<n-button type="primary" @click="searchRestrictions" :loading="searching">查询</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- User restrictions list -->
|
|
||||||
<n-card v-if="userRestrictions.length" size="small" :title="`用户 #${searchUserId} 的限制记录`" style="margin-top:16px;">
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="userRestrictions"
|
|
||||||
:loading="searching"
|
|
||||||
:bordered="false"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Add restriction form -->
|
|
||||||
<n-card size="small" title="添加限制" style="margin-top:16px;">
|
|
||||||
<n-form :model="addForm" label-placement="left" label-width="100" class="aksrt-form">
|
|
||||||
<n-form-item label="用户 ID">
|
|
||||||
<n-input-number v-model:value="addForm.user_id" :min="1" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="限制类型">
|
|
||||||
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="原因">
|
|
||||||
<n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="持续时间">
|
|
||||||
<n-select v-model:value="addForm.duration" :options="durationOptions" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-button type="error" @click="addRestriction" :loading="adding" :disabled="!addForm.user_id || !addForm.reason">
|
|
||||||
添加限制
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { h, reactive, ref, onMounted } from 'vue'
|
|
||||||
import { NButton, NTag, NSpace, useMessage } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const searching = ref(false)
|
|
||||||
const adding = ref(false)
|
|
||||||
const searchUserId = ref(null)
|
|
||||||
const userRestrictions = ref([])
|
|
||||||
const stats = reactive({ total: 0, active: 0, banned: 0 })
|
|
||||||
|
|
||||||
const addForm = reactive({
|
|
||||||
user_id: null,
|
|
||||||
type: 'muted',
|
|
||||||
reason: '',
|
|
||||||
duration: '7d',
|
|
||||||
})
|
|
||||||
|
|
||||||
const restrictionTypes = [
|
|
||||||
{ label: '禁言', value: 'muted' },
|
|
||||||
{ label: '限制', value: 'restricted' },
|
|
||||||
{ label: '封禁', value: 'banned' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const durationOptions = [
|
|
||||||
{ label: '1 天', value: '1d' },
|
|
||||||
{ label: '3 天', value: '3d' },
|
|
||||||
{ label: '7 天', value: '7d' },
|
|
||||||
{ label: '30 天', value: '30d' },
|
|
||||||
{ label: '永久', value: 'permanent' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 60 },
|
|
||||||
{
|
|
||||||
title: '类型', key: 'type', width: 80,
|
|
||||||
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
|
|
||||||
},
|
|
||||||
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '生效时间', key: 'created_at', width: 160 },
|
|
||||||
{ title: '到期时间', key: 'expires_at', width: 160 },
|
|
||||||
{
|
|
||||||
title: '状态', key: 'status', width: 80,
|
|
||||||
render: (row) => {
|
|
||||||
const active = !row.expires_at || new Date(row.expires_at) > new Date()
|
|
||||||
return h(NTag, { type: active ? 'error' : 'default', size: 'small' }, () => active ? '生效中' : '已过期')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作', key: 'actions', width: 100,
|
|
||||||
render: (row) => h(NButton, { size: 'tiny', type: 'error', onClick: () => removeRestriction(row) }, () => '解除'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTypeTag(type) {
|
|
||||||
const map = { muted: 'warning', restricted: 'error', banned: 'error' }
|
|
||||||
return map[type] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeLabel(type) {
|
|
||||||
const map = { muted: '禁言', restricted: '限制', banned: '封禁' }
|
|
||||||
return map[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const res = await api.request('/users/restrictions/stats')
|
|
||||||
const data = res.data || res
|
|
||||||
stats.total = data.total || 0
|
|
||||||
stats.active = data.active || 0
|
|
||||||
stats.banned = data.banned || 0
|
|
||||||
} catch (e) {
|
|
||||||
// Stats may not be available yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchRestrictions() {
|
|
||||||
if (!searchUserId.value) {
|
|
||||||
message.warning('请输入用户 ID')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searching.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request(`/users/${searchUserId.value}/restrictions`)
|
|
||||||
const data = res.data || res
|
|
||||||
userRestrictions.value = data.restrictions || []
|
|
||||||
if (userRestrictions.value.length === 0) {
|
|
||||||
message.info('该用户无限制记录')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error('查询失败: ' + (e.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
searching.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRestriction() {
|
|
||||||
if (!addForm.user_id || !addForm.reason) {
|
|
||||||
message.warning('请填写用户 ID 和原因')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
adding.value = true
|
|
||||||
try {
|
|
||||||
await api.request(`/users/${addForm.user_id}/restrictions`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: addForm.type,
|
|
||||||
reason: addForm.reason,
|
|
||||||
duration: addForm.duration,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('限制已添加')
|
|
||||||
addForm.reason = ''
|
|
||||||
if (searchUserId.value === addForm.user_id) {
|
|
||||||
await searchRestrictions()
|
|
||||||
}
|
|
||||||
await loadStats()
|
|
||||||
} catch (e) {
|
|
||||||
message.error('添加失败: ' + (e.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
adding.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRestriction(row) {
|
|
||||||
try {
|
|
||||||
await api.request(`/users/restrictions/${row.id}`, { method: 'DELETE' })
|
|
||||||
message.success('限制已解除')
|
|
||||||
userRestrictions.value = userRestrictions.value.filter((r) => r.id !== row.id)
|
|
||||||
await loadStats()
|
|
||||||
} catch (e) {
|
|
||||||
message.error('解除失败: ' + (e.message || '未知错误'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadStats)
|
|
||||||
</script>
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="用户等级"
|
|
||||||
desc="配置等级开关、最高等级、升级经验阈值与经验获取矩阵(对齐 Zibll 用户等级 / 经验值)。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="go('/users/credits')">用户积分</n-button>
|
|
||||||
<n-button quaternary @click="go('/users/badge')">用户徽章</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.user_level_s ? 'success' : 'default'">
|
|
||||||
等级系统:{{ form.user_level_s ? '已启用' : '已关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="info">最高等级:{{ form.user_level_max }}</n-tag>
|
|
||||||
<n-tag round type="info">日经验上限:{{ form.user_integral_opt.day_max }}</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.user_level_s" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.user_level_s" label="公开页显示等级">
|
|
||||||
<n-switch v-model:value="form.user_level_public_display" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.user_level_s" label="最高等级">
|
|
||||||
<n-input-number v-model:value="form.user_level_max" :min="3" :max="30" style="width: 180px;" @update:value="onMaxChange" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.user_level_s" label="升级通知">
|
|
||||||
<n-switch v-model:value="form.user_level_notify" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card v-if="form.user_level_s" class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header"><h3>升级所需经验(累计)</h3></div>
|
|
||||||
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
|
|
||||||
Lv.1 通常为 0。数值为升到该级所需的累计经验。修改最高等级后会自动补齐/截断行。
|
|
||||||
</n-alert>
|
|
||||||
<n-data-table :columns="levelColumns" :data="levelRows" :bordered="false" size="small" />
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card v-if="form.user_level_s" 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-number v-model:value="form.user_integral_opt.day_max" :min="0" :max="10000" style="width: 180px;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<n-divider />
|
|
||||||
<div v-for="group in integralGroups" :key="group" style="margin-bottom: 16px;">
|
|
||||||
<h4 style="margin: 0 0 8px;">{{ group }}</h4>
|
|
||||||
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
|
|
||||||
<n-grid-item v-for="row in INTEGRAL_CATALOG.filter((r) => r.group === group)" :key="row.key">
|
|
||||||
<div class="integral-field">
|
|
||||||
<div class="integral-field__label">{{ row.label }}</div>
|
|
||||||
<n-input-number v-model:value="form.user_integral_opt[row.key]" :min="0" :max="1000" style="width: 100%;" />
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</div>
|
|
||||||
</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, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NInput, NInputNumber, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import {
|
|
||||||
INTEGRAL_CATALOG,
|
|
||||||
defaultIntegralOpt,
|
|
||||||
defaultLevelOpt,
|
|
||||||
} from '@/schema/sections/users/level.js'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
user_level_s: true,
|
|
||||||
user_level_public_display: true,
|
|
||||||
user_level_max: 10,
|
|
||||||
user_level_opt: defaultLevelOpt(10),
|
|
||||||
user_integral_opt: defaultIntegralOpt(),
|
|
||||||
user_level_notify: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const integralGroups = computed(() => [...new Set(INTEGRAL_CATALOG.map((r) => r.group))])
|
|
||||||
|
|
||||||
const levelRows = computed(() => {
|
|
||||||
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
|
|
||||||
const rows = []
|
|
||||||
for (let i = 1; i <= max; i++) {
|
|
||||||
rows.push({
|
|
||||||
level: i,
|
|
||||||
name: form.user_level_opt['name_' + i] ?? 'LV' + i,
|
|
||||||
upgrade: Number(form.user_level_opt['upgrade_integral_' + i] ?? 0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
})
|
|
||||||
|
|
||||||
const levelColumns = [
|
|
||||||
{ title: '等级', key: 'level', width: 80 },
|
|
||||||
{
|
|
||||||
title: '名称',
|
|
||||||
key: 'name',
|
|
||||||
render: (row) =>
|
|
||||||
h(NInput, {
|
|
||||||
value: form.user_level_opt['name_' + row.level] ?? '',
|
|
||||||
size: 'small',
|
|
||||||
onUpdateValue: (v) => {
|
|
||||||
form.user_level_opt['name_' + row.level] = v
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '等级图标 URL',
|
|
||||||
key: 'icon',
|
|
||||||
render: (row) =>
|
|
||||||
h(NInput, {
|
|
||||||
value: form.user_level_opt['icon_img_' + row.level] ?? '',
|
|
||||||
size: 'small',
|
|
||||||
placeholder: row.level <= 10 ? '留空使用主题内置图标' : '请输入图片 URL',
|
|
||||||
onUpdateValue: (v) => {
|
|
||||||
form.user_level_opt['icon_img_' + row.level] = v
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '升级所需累计经验',
|
|
||||||
key: 'upgrade',
|
|
||||||
width: 200,
|
|
||||||
render: (row) =>
|
|
||||||
h(NInputNumber, {
|
|
||||||
value: Number(form.user_level_opt['upgrade_integral_' + row.level] ?? 0),
|
|
||||||
min: 0,
|
|
||||||
max: 9999999,
|
|
||||||
size: 'small',
|
|
||||||
style: 'width: 160px;',
|
|
||||||
onUpdateValue: (v) => {
|
|
||||||
form.user_level_opt['upgrade_integral_' + row.level] = Number(v || 0)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMaxChange(val) {
|
|
||||||
const max = Math.max(3, Math.min(30, Number(val) || 10))
|
|
||||||
form.user_level_max = max
|
|
||||||
const next = { ...form.user_level_opt }
|
|
||||||
const filled = defaultLevelOpt(max)
|
|
||||||
for (let i = 1; i <= max; i++) {
|
|
||||||
if (next['upgrade_integral_' + i] === undefined) {
|
|
||||||
next['upgrade_integral_' + i] = filled['upgrade_integral_' + i]
|
|
||||||
}
|
|
||||||
if (next['name_' + i] === undefined) {
|
|
||||||
next['name_' + i] = 'LV' + i
|
|
||||||
}
|
|
||||||
if (next['icon_img_' + i] === undefined) {
|
|
||||||
next['icon_img_' + i] = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// drop extras
|
|
||||||
for (const k of Object.keys(next)) {
|
|
||||||
const m = k.match(/_(\d+)$/)
|
|
||||||
if (m && Number(m[1]) > max) {
|
|
||||||
delete next[k]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
form.user_level_opt = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIntegral(raw) {
|
|
||||||
const base = defaultIntegralOpt()
|
|
||||||
if (!raw || typeof raw !== 'object') {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
for (const row of INTEGRAL_CATALOG) {
|
|
||||||
if (raw[row.key] !== undefined) {
|
|
||||||
base[row.key] = Math.max(0, Number(raw[row.key]) || 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (raw.day_max !== undefined) {
|
|
||||||
base.day_max = Math.max(0, Number(raw.day_max) || 0)
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLevelOpt(raw, max) {
|
|
||||||
const base = defaultLevelOpt(max)
|
|
||||||
if (!raw || typeof raw !== 'object') {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
for (let i = 1; i <= max; i++) {
|
|
||||||
if (raw['upgrade_integral_' + i] !== undefined) {
|
|
||||||
base['upgrade_integral_' + i] = Math.max(0, Number(raw['upgrade_integral_' + i]) || 0)
|
|
||||||
}
|
|
||||||
if (raw['name_' + i] !== undefined) {
|
|
||||||
base['name_' + i] = String(raw['name_' + i] || '')
|
|
||||||
}
|
|
||||||
if (raw['icon_img_' + i] !== undefined) {
|
|
||||||
base['icon_img_' + i] = String(raw['icon_img_' + i] || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const schemaRes = await api.getSchemaPage('users', 'level')
|
|
||||||
const d = extractData(schemaRes)
|
|
||||||
const enabled = d.user_level_s !== false
|
|
||||||
form.user_level_s = enabled
|
|
||||||
form.user_level_public_display = d.user_level_public_display !== false
|
|
||||||
form.user_level_max = Math.max(3, Math.min(30, Number(d.user_level_max || 10)))
|
|
||||||
form.user_level_notify = d.user_level_notify !== false
|
|
||||||
form.user_level_opt = normalizeLevelOpt(d.user_level_opt, form.user_level_max)
|
|
||||||
form.user_integral_opt = normalizeIntegral(d.user_integral_opt)
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载用户等级配置失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
|
|
||||||
const payload = {
|
|
||||||
user_level_s: !!form.user_level_s,
|
|
||||||
user_level_public_display: !!form.user_level_public_display,
|
|
||||||
user_level_max: max,
|
|
||||||
user_level_notify: !!form.user_level_notify,
|
|
||||||
user_level_opt: normalizeLevelOpt(form.user_level_opt, max),
|
|
||||||
user_integral_opt: normalizeIntegral(form.user_integral_opt),
|
|
||||||
}
|
|
||||||
await api.saveSchemaPage('users', 'level', payload)
|
|
||||||
Object.assign(form, {
|
|
||||||
user_level_max: max,
|
|
||||||
user_level_opt: payload.user_level_opt,
|
|
||||||
user_integral_opt: payload.user_integral_opt,
|
|
||||||
})
|
|
||||||
message.success('用户等级配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.integral-field__label {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
<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">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="全部消息" :value="stats.total" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="未读消息" :value="stats.unread">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.unread > 0" type="warning" size="small">{{ stats.unread }}</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="今日新增" :value="stats.today" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="系统通知" :value="stats.system" />
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-card size="small" title="消息列表" style="margin-top:16px;">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-space>
|
|
||||||
<n-select
|
|
||||||
v-model:value="filters.type"
|
|
||||||
placeholder="消息类型"
|
|
||||||
:options="typeOptions"
|
|
||||||
clearable
|
|
||||||
style="width:160px;"
|
|
||||||
@update:value="handleFilterChange"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="filters.read"
|
|
||||||
placeholder="已读状态"
|
|
||||||
:options="readOptions"
|
|
||||||
clearable
|
|
||||||
style="width:120px;"
|
|
||||||
@update:value="handleFilterChange"
|
|
||||||
/>
|
|
||||||
<n-button @click="loadMessages" :loading="loading">刷新</n-button>
|
|
||||||
<n-button type="primary" @click="markAllRead" :loading="markingAll">全部已读</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="messages"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-class-name="rowClassName"
|
|
||||||
remote
|
|
||||||
@update:page="handlePageChange"
|
|
||||||
@update:page-size="handlePageSizeChange"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-drawer v-model:show="showDetail" :width="480">
|
|
||||||
<n-drawer-content :title="detailMessage?.title || '消息详情'">
|
|
||||||
<template v-if="detailMessage">
|
|
||||||
<n-descriptions bordered :column="1" label-placement="left">
|
|
||||||
<n-descriptions-item label="类型">
|
|
||||||
<n-tag :type="getTypeTag(detailMessage.type)" size="small">{{ getTypeLabel(detailMessage.type) }}</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="状态">
|
|
||||||
<n-tag :type="detailMessage.is_read ? 'success' : 'warning'" size="small">
|
|
||||||
{{ detailMessage.is_read ? '已读' : '未读' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="用户">{{ detailMessage.user_name || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="时间">{{ detailMessage.created_at || '-' }}</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
|
|
||||||
<div class="aksrt-message-detail">
|
|
||||||
<h4>{{ detailMessage.title }}</h4>
|
|
||||||
<p>{{ detailMessage.description || detailMessage.content || '暂无详细内容' }}</p>
|
|
||||||
<a
|
|
||||||
v-if="detailMessage.target_url"
|
|
||||||
:href="detailMessage.target_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
查看目标
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<n-space>
|
|
||||||
<n-button @click="showDetail = false">关闭</n-button>
|
|
||||||
<n-button v-if="detailMessage && !detailMessage.is_read" type="primary" @click="markCurrentRead">
|
|
||||||
标记已读
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const markingAll = ref(false)
|
|
||||||
const showDetail = ref(false)
|
|
||||||
const detailMessage = ref(null)
|
|
||||||
const messages = ref([])
|
|
||||||
const typeStats = ref([])
|
|
||||||
const stats = reactive({ total: 0, unread: 0, today: 0, system: 0 })
|
|
||||||
const filters = reactive({ type: null, read: null })
|
|
||||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, pageCount: 1, showSizePicker: true, pageSizes: [20, 50, 100] })
|
|
||||||
|
|
||||||
const readOptions = [
|
|
||||||
{ label: '未读', value: '0' },
|
|
||||||
{ label: '已读', value: '1' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const typeOptions = computed(() => typeStats.value.map((item) => ({
|
|
||||||
label: getTypeLabel(item.type),
|
|
||||||
value: item.type,
|
|
||||||
})))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
|
|
||||||
},
|
|
||||||
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '用户', key: 'user_name', width: 140, ellipsis: { tooltip: true } },
|
|
||||||
{ title: '时间', key: 'created_at', width: 180 },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'is_read',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => h(NTag, { type: row.is_read ? 'success' : 'warning', size: 'small' }, () => (row.is_read ? '已读' : '未读')),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 140,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => openDetail(row) }, () => '查看'),
|
|
||||||
!row.is_read ? h(NButton, { size: 'tiny', type: 'primary', onClick: () => markOneRead(row) }, () => '已读') : null,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTypeTag(type) {
|
|
||||||
const map = {
|
|
||||||
notice: 'info',
|
|
||||||
system: 'info',
|
|
||||||
comment_reply: 'success',
|
|
||||||
comment_approved: 'success',
|
|
||||||
credits_added: 'warning',
|
|
||||||
credits_deducted: 'warning',
|
|
||||||
download_ready: 'primary',
|
|
||||||
verify_email: 'info',
|
|
||||||
pending_comments: 'warning',
|
|
||||||
}
|
|
||||||
return map[type] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeLabel(type) {
|
|
||||||
const map = {
|
|
||||||
notice: '系统通知',
|
|
||||||
system: '系统通知',
|
|
||||||
comment_reply: '评论回复',
|
|
||||||
comment_approved: '评论审核',
|
|
||||||
credits_added: '积分增加',
|
|
||||||
credits_deducted: '积分扣减',
|
|
||||||
download_ready: '下载就绪',
|
|
||||||
verify_email: '邮箱验证',
|
|
||||||
pending_comments: '待审评论',
|
|
||||||
}
|
|
||||||
return map[type] || type || '通知'
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowClassName(row) {
|
|
||||||
return row.is_read ? '' : 'aksrt-row--unread'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMessages() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await api.getNotificationOverview({
|
|
||||||
page: pagination.page,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
...(filters.type ? { type: filters.type } : {}),
|
|
||||||
...(filters.read !== null ? { is_read: filters.read } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const payload = response.data || {}
|
|
||||||
const summary = payload.summary || {}
|
|
||||||
const pager = payload.pagination || {}
|
|
||||||
|
|
||||||
messages.value = Array.isArray(payload.recent) ? payload.recent : []
|
|
||||||
typeStats.value = Array.isArray(payload.types) ? payload.types : []
|
|
||||||
|
|
||||||
stats.total = Number(summary.total || payload.total || 0)
|
|
||||||
stats.unread = Number(summary.unread || payload.unread || 0)
|
|
||||||
stats.today = Number(summary.today || payload.today || 0)
|
|
||||||
stats.system = Number(summary.system || payload.system || 0)
|
|
||||||
|
|
||||||
pagination.itemCount = Number(pager.total_items || messages.value.length)
|
|
||||||
pagination.pageCount = Number(pager.total_pages || 1)
|
|
||||||
pagination.page = Number(pager.page || pagination.page)
|
|
||||||
pagination.pageSize = Number(pager.per_page || pagination.pageSize)
|
|
||||||
} catch (error) {
|
|
||||||
message.error('加载消息失败: ' + (error.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(row) {
|
|
||||||
detailMessage.value = row
|
|
||||||
showDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markOneRead(row) {
|
|
||||||
try {
|
|
||||||
await api.markNotificationRead(row.id)
|
|
||||||
row.is_read = true
|
|
||||||
stats.unread = Math.max(0, stats.unread - 1)
|
|
||||||
if (detailMessage.value && detailMessage.value.id === row.id) {
|
|
||||||
detailMessage.value.is_read = true
|
|
||||||
}
|
|
||||||
message.success('已标记为已读')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markCurrentRead() {
|
|
||||||
if (detailMessage.value) {
|
|
||||||
await markOneRead(detailMessage.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAllRead() {
|
|
||||||
markingAll.value = true
|
|
||||||
try {
|
|
||||||
await api.markAllNotificationsRead()
|
|
||||||
messages.value = messages.value.map((item) => ({ ...item, is_read: true }))
|
|
||||||
if (detailMessage.value) {
|
|
||||||
detailMessage.value = { ...detailMessage.value, is_read: true }
|
|
||||||
}
|
|
||||||
stats.unread = 0
|
|
||||||
message.success('全部已读')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '操作失败')
|
|
||||||
} finally {
|
|
||||||
markingAll.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFilterChange() {
|
|
||||||
pagination.page = 1
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageChange(page) {
|
|
||||||
pagination.page = page
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageSizeChange(pageSize) {
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
pagination.page = 1
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadMessages)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-row--unread {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail h4 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail p {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.7;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="举报治理"
|
|
||||||
desc="统一维护公开举报入口与后台工单台,支持证据查看、状态流转和处罚语义联动。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small" title="治理开关">
|
|
||||||
<n-space wrap>
|
|
||||||
<n-tag round :type="form.user_report_enabled ? 'success' : 'default'">
|
|
||||||
举报入口:{{ form.user_report_enabled ? '已启用' : '已关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="form.user_report_public_entry ? 'info' : 'default'">
|
|
||||||
公开入口:{{ form.user_report_public_entry ? '显示' : '隐藏' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="moderationSummary.enabled ? 'success' : 'default'">
|
|
||||||
审核系统:{{ moderationSummary.enabled ? '已启用' : '未启用' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="warning">
|
|
||||||
待处理:{{ counts.pending }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="success">
|
|
||||||
已解决:{{ counts.resolved }}
|
|
||||||
</n-tag>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small" title="前台说明">
|
|
||||||
<n-form :model="form" label-placement="top">
|
|
||||||
<n-form-item label="限制提示文案">
|
|
||||||
<n-input v-model:value="form.user_report_ban_notice_text" maxlength="80" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="举报分类字典">
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.user_report_categories"
|
|
||||||
type="textarea"
|
|
||||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
|
||||||
placeholder="spam|垃圾信息 abuse|人身攻击"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-card size="small" title="入口配置" class="aksrt-report-page__card">
|
|
||||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
|
||||||
<n-form-item label="启用举报/禁封结构">
|
|
||||||
<n-switch v-model:value="form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="公开主页展示举报入口">
|
|
||||||
<n-switch v-model:value="form.user_report_public_entry" :disabled="!form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="公开显示限制提示">
|
|
||||||
<n-switch v-model:value="form.user_report_public_notice" :disabled="!form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card size="small" title="举报工单台" class="aksrt-report-page__card">
|
|
||||||
<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.target_type" :options="targetTypeOptions" 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-button type="primary" block @click="handleSearch">筛选工单</n-button>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="reports"
|
|
||||||
:loading="reportLoading"
|
|
||||||
:bordered="false"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<ReportDetailDrawer
|
|
||||||
v-model:show="drawerVisible"
|
|
||||||
:report-id="currentReportId"
|
|
||||||
@updated="loadReports"
|
|
||||||
/>
|
|
||||||
</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 ReportDetailDrawer from './components/ReportDetailDrawer.vue'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const reportLoading = ref(false)
|
|
||||||
const reports = ref([])
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const currentReportId = ref(null)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
user_report_enabled: false,
|
|
||||||
user_report_public_entry: true,
|
|
||||||
user_report_public_notice: true,
|
|
||||||
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。',
|
|
||||||
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉',
|
|
||||||
})
|
|
||||||
|
|
||||||
const moderationSummary = reactive({
|
|
||||||
enabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
keyword: '',
|
|
||||||
status: '',
|
|
||||||
target_type: '',
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const counts = reactive({
|
|
||||||
pending: 0,
|
|
||||||
reviewing: 0,
|
|
||||||
resolved: 0,
|
|
||||||
dismissed: 0,
|
|
||||||
total: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const pager = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
itemCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optionsState = reactive({
|
|
||||||
statuses: [],
|
|
||||||
targets: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusOptions = computed(() => optionsState.statuses.length
|
|
||||||
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
|
|
||||||
: [
|
|
||||||
{ label: '全部状态', value: '' },
|
|
||||||
{ label: '待处理', value: 'pending' },
|
|
||||||
{ label: '处理中', value: 'reviewing' },
|
|
||||||
{ label: '已解决', value: 'resolved' },
|
|
||||||
{ label: '已驳回', value: 'dismissed' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeOptions = computed(() => optionsState.targets.length
|
|
||||||
? [{ label: '全部类型', value: '' }, ...optionsState.targets]
|
|
||||||
: [
|
|
||||||
{ label: '全部类型', value: '' },
|
|
||||||
{ label: '文章', value: 'post' },
|
|
||||||
{ label: '评论', value: 'comment' },
|
|
||||||
{ label: '用户', value: 'user' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
|
||||||
page: pager.page,
|
|
||||||
pageSize: pager.pageSize,
|
|
||||||
itemCount: pager.itemCount,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 20, 50],
|
|
||||||
onUpdatePage: (page) => {
|
|
||||||
pager.page = page
|
|
||||||
loadReports()
|
|
||||||
},
|
|
||||||
onUpdatePageSize: (pageSize) => {
|
|
||||||
pager.pageSize = pageSize
|
|
||||||
pager.page = 1
|
|
||||||
loadReports()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
|
||||||
{
|
|
||||||
title: '举报人',
|
|
||||||
key: 'reporter',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.reporter?.display_name || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '目标',
|
|
||||||
key: 'target',
|
|
||||||
minWidth: 220,
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render: (row) => row.target?.title || row.target?.display_name || row.target?.excerpt || `目标 #${row.target_id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap = { pending: 'warning', reviewing: 'info', resolved: 'success', dismissed: 'default' }
|
|
||||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
key: 'category',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => row.category || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '处理记录',
|
|
||||||
key: 'note_count',
|
|
||||||
width: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '提交时间',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 170,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => openDrawer(row.id),
|
|
||||||
}, () => '查看'),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await load()
|
|
||||||
})
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 buildReportParams() {
|
|
||||||
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.target_type) params.target_type = filters.target_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 load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [schemaRes, moderationRes] = await Promise.all([
|
|
||||||
api.getSchemaPage('users', 'report'),
|
|
||||||
api.getPermissionsModerationSettings(),
|
|
||||||
])
|
|
||||||
const schemaData = extractData(schemaRes)
|
|
||||||
const moderationData = extractData(moderationRes)
|
|
||||||
|
|
||||||
form.user_report_enabled = !!schemaData.user_report_enabled
|
|
||||||
form.user_report_public_entry = schemaData.user_report_public_entry !== false
|
|
||||||
form.user_report_public_notice = schemaData.user_report_public_notice !== false
|
|
||||||
form.user_report_ban_notice_text = schemaData.user_report_ban_notice_text || '当前账号处于限制状态,部分互动能力已关闭。'
|
|
||||||
form.user_report_categories = schemaData.user_report_categories || 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉'
|
|
||||||
moderationSummary.enabled = !!moderationData.enabled
|
|
||||||
|
|
||||||
await loadReports()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报治理页失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadReports() {
|
|
||||||
reportLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request('/users/reports', { params: buildReportParams() })
|
|
||||||
const data = res?.data || {}
|
|
||||||
reports.value = data.items || []
|
|
||||||
pager.itemCount = data.total || 0
|
|
||||||
Object.assign(counts, data.counts || {})
|
|
||||||
optionsState.statuses = data.options?.statuses || []
|
|
||||||
optionsState.targets = data.options?.targets || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报工单失败')
|
|
||||||
} finally {
|
|
||||||
reportLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await api.saveSchemaPage('users', 'report', {
|
|
||||||
user_report_enabled: form.user_report_enabled,
|
|
||||||
user_report_public_entry: form.user_report_public_entry,
|
|
||||||
user_report_public_notice: form.user_report_public_notice,
|
|
||||||
user_report_ban_notice_text: form.user_report_ban_notice_text,
|
|
||||||
user_report_categories: form.user_report_categories,
|
|
||||||
})
|
|
||||||
message.success('举报治理配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pager.page = 1
|
|
||||||
loadReports()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDrawer(reportId) {
|
|
||||||
currentReportId.value = reportId
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-report-page__card {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<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" />
|
|
||||||
<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>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<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.report">
|
|
||||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
|
||||||
<n-descriptions-item label="举报人">
|
|
||||||
{{ detail.report.reporter?.display_name || `用户 #${detail.report.reporter?.id || 0}` }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="举报目标">
|
|
||||||
{{ targetTitle }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="目标类型">
|
|
||||||
{{ detail.report.target_type }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="分类">
|
|
||||||
{{ detail.report.category || '-' }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="当前状态">
|
|
||||||
{{ detail.report.status }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="提交时间">
|
|
||||||
{{ detail.report.created_at || '-' }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">证据链</div>
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
{{ detail.report.reason || '暂无举报原因' }}
|
|
||||||
</n-alert>
|
|
||||||
<n-space class="aksrt-report-drawer__meta" wrap>
|
|
||||||
<n-tag size="small" round :bordered="false">
|
|
||||||
目标 ID:{{ detail.report.target_id }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag
|
|
||||||
v-if="detail.report.target?.status"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
round
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
目标状态:{{ detail.report.target.status }}
|
|
||||||
</n-tag>
|
|
||||||
<a
|
|
||||||
v-if="detail.report.target?.url"
|
|
||||||
:href="detail.report.target.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="aksrt-report-drawer__link"
|
|
||||||
>
|
|
||||||
打开目标内容
|
|
||||||
</a>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">处理记录</div>
|
|
||||||
<div v-if="detail.report.notes?.length" class="aksrt-report-drawer__timeline">
|
|
||||||
<div v-for="(entry, index) in detail.report.notes" :key="index" class="aksrt-report-drawer__timeline-item">
|
|
||||||
<div class="aksrt-report-drawer__timeline-head">
|
|
||||||
<strong>{{ entry.status || '-' }}</strong>
|
|
||||||
<span>{{ entry.timestamp || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<p>{{ entry.note || '无备注' }}</p>
|
|
||||||
<small>{{ entry.operator_name || '系统' }} {{ entry.effect || '' }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-empty v-else description="暂无处理记录" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">处理工单</div>
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
|
|
||||||
<n-select v-model:value="form.action_taken" :options="detail.options?.actions || []" />
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.note"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
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'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: { type: Boolean, default: false },
|
|
||||||
reportId: { 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({ report: null, options: { statuses: [], actions: [] } })
|
|
||||||
const form = reactive({
|
|
||||||
status: 'reviewing',
|
|
||||||
action_taken: 'none',
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const targetTitle = computed(() => {
|
|
||||||
const target = detail.value?.report?.target
|
|
||||||
if (!target) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
return target.title || target.display_name || target.excerpt || `目标 #${target.id || 0}`
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.show, props.reportId],
|
|
||||||
async ([visible, id]) => {
|
|
||||||
if (visible && id) {
|
|
||||||
await loadDetail()
|
|
||||||
}
|
|
||||||
if (!visible) {
|
|
||||||
detail.value = { report: null, options: { statuses: [], actions: [] } }
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
form.status = 'reviewing'
|
|
||||||
form.action_taken = 'none'
|
|
||||||
form.note = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail() {
|
|
||||||
if (!props.reportId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request(`/users/reports/${props.reportId}`)
|
|
||||||
detail.value = res?.data || { report: null, options: { statuses: [], actions: [] } }
|
|
||||||
form.status = detail.value?.report?.status || 'reviewing'
|
|
||||||
form.action_taken = 'none'
|
|
||||||
form.note = ''
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报详情失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!props.reportId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await api.request(`/users/reports/${props.reportId}/process`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
status: form.status,
|
|
||||||
action_taken: form.action_taken,
|
|
||||||
note: form.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('举报工单已处理')
|
|
||||||
await loadDetail()
|
|
||||||
emit('updated')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '处理失败')
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-report-drawer__section {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__title {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__meta {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__link {
|
|
||||||
color: #2563eb;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item p {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
color: #111827;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item small {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const paymentPageLoaders = {
|
|
||||||
PaymentGateway: () => import('@/pages/payment/PaymentGateway.vue'),
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export const toolsPageLoaders = {
|
|
||||||
ToolsImportExport: () => import('@/pages/tools/ToolsImportExport.vue'),
|
|
||||||
ToolsHealth: () => import('@/pages/tools/ToolsHealth.vue'),
|
|
||||||
ToolsEmailRollback: () => import('@/pages/tools/ToolsEmailRollback.vue'),
|
|
||||||
ToolsUpdate: () => import('@/pages/tools/ToolsUpdate.vue'),
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
export const ipSchema = {
|
|
||||||
key: 'extensions.ip',
|
|
||||||
title: 'IP归属地',
|
|
||||||
icon: 'fas fa-map-marker-alt',
|
|
||||||
description: '配置 IP 数据源、缓存策略和前台归属地展示。',
|
|
||||||
defaults: {
|
|
||||||
ip_geo_enabled: false,
|
|
||||||
ip_addr_sdk: 'null',
|
|
||||||
ip_geo_cache_ttl: 86400,
|
|
||||||
ip_geo_display: ['comment', 'user_home', 'user_center', 'post'],
|
|
||||||
ip_geo_format: 'province_city',
|
|
||||||
ip_geo_fallback_text: '',
|
|
||||||
ip_addr_sdk_qq_appkey: '',
|
|
||||||
ip_addr_sdk_qq_secretkey: '',
|
|
||||||
ip_addr_sdk_amap_appkey: '',
|
|
||||||
ip_addr_sdk_amap_secretkey: '',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '基础配置',
|
|
||||||
icon: 'fas fa-globe',
|
|
||||||
fields: [
|
|
||||||
{ key: 'ip_geo_enabled', label: '启用 IP 归属地', type: 'switch', default: false, help: '开启后将在评论、用户主页等位置显示 IP 归属地信息' },
|
|
||||||
{
|
|
||||||
key: 'ip_addr_sdk',
|
|
||||||
label: 'IP 接口服务商',
|
|
||||||
type: 'radio',
|
|
||||||
default: 'null',
|
|
||||||
options: [
|
|
||||||
{ label: '轮流查询', value: 'polling' },
|
|
||||||
{ label: '腾讯位置服务', value: 'qq' },
|
|
||||||
{ label: '高德位置服务', value: 'amap' },
|
|
||||||
{ label: '太平洋公共接口', value: 'null' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'ip_geo_enabled', value: true },
|
|
||||||
help: '优先使用国内接口,轮流查询会按腾讯→高德→公共接口顺序回退。',
|
|
||||||
},
|
|
||||||
{ key: 'ip_geo_cache_ttl', label: '缓存时间 (秒)', type: 'number', default: 86400, min: 3600, max: 604800, dependsOn: { key: 'ip_geo_enabled', value: true }, help: 'IP 查询结果缓存时长,避免频繁请求外部接口' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'providers',
|
|
||||||
title: '接口凭据',
|
|
||||||
icon: 'fas fa-key',
|
|
||||||
fields: [
|
|
||||||
{ key: 'ip_addr_sdk_qq_appkey', label: '腾讯 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['qq', 'polling'] }, help: '「腾讯位置服务」或「轮流查询」时填写;未填则轮询会跳过腾讯。' },
|
|
||||||
{ key: 'ip_addr_sdk_qq_secretkey', label: '腾讯 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['qq', 'polling'] }, help: '启用 SK 签名时必填;未启用可留空。' },
|
|
||||||
{ key: 'ip_addr_sdk_amap_appkey', label: '高德 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['amap', 'polling'] }, help: '必须在高德开放平台创建「Web服务」类型 Key,并开通 IP 定位。选错平台会报 USERKEY_PLAT_NOMATCH。「轮流查询」时未填则跳过高德。' },
|
|
||||||
{ key: 'ip_addr_sdk_amap_secretkey', label: '高德 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['amap', 'polling'] }, help: '若控制台启用了「数字签名」,必填安全密钥;未启用可留空。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '展示设置',
|
|
||||||
icon: 'fas fa-eye',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'ip_geo_display',
|
|
||||||
label: '展示位置',
|
|
||||||
type: 'checkbox',
|
|
||||||
default: ['comment', 'user_home', 'user_center', 'post'],
|
|
||||||
options: [
|
|
||||||
{ label: '评论区', value: 'comment' },
|
|
||||||
{ label: '用户主页', value: 'user_home' },
|
|
||||||
{ label: '用户中心', value: 'user_center' },
|
|
||||||
{ label: '文章详细页', value: 'post' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'ip_geo_enabled', value: true },
|
|
||||||
help: '用户主页/用户中心还需在「用户 → 用户功能」开启「显示城市」;评论区还需在评论设置开启「评论城市」。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ip_geo_format',
|
|
||||||
label: '显示格式',
|
|
||||||
type: 'radio',
|
|
||||||
default: 'province_city',
|
|
||||||
options: [
|
|
||||||
{ label: '省份+城市', value: 'province_city' },
|
|
||||||
{ label: '仅省份', value: 'province' },
|
|
||||||
{ label: '国家+省份', value: 'country_province' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'ip_geo_enabled', value: true },
|
|
||||||
},
|
|
||||||
{ key: 'ip_geo_fallback_text', label: '查询失败时文案', type: 'input', default: '', placeholder: '留空则不显示', dependsOn: { key: 'ip_geo_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
export const pushSchema = {
|
|
||||||
key: 'extensions.push',
|
|
||||||
title: '百度推送',
|
|
||||||
icon: 'fas fa-paper-plane',
|
|
||||||
description: '配置搜索引擎主动推送、批量提交和状态追踪。',
|
|
||||||
defaults: {
|
|
||||||
baidu_push_enabled: false,
|
|
||||||
baidu_push_token: '',
|
|
||||||
baidu_push_auto: true,
|
|
||||||
baidu_push_on_publish: true,
|
|
||||||
baidu_push_on_update: false,
|
|
||||||
baidu_push_batch_size: 50,
|
|
||||||
baidu_push_log_enabled: true,
|
|
||||||
xzh_on: false,
|
|
||||||
xzh_appid: '',
|
|
||||||
xzh_render_tail: true,
|
|
||||||
xzh_jsonld_single: true,
|
|
||||||
xzh_jsonld_page: false,
|
|
||||||
xzh_jsonld_img: false,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '推送配置',
|
|
||||||
icon: 'fas fa-rocket',
|
|
||||||
fields: [
|
|
||||||
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
|
|
||||||
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'trigger',
|
|
||||||
title: '触发规则',
|
|
||||||
icon: 'fas fa-bolt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
|
|
||||||
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
|
||||||
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'xzh',
|
|
||||||
title: '熊掌号 / JSON-LD',
|
|
||||||
icon: 'fas fa-paw',
|
|
||||||
fields: [
|
|
||||||
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false },
|
|
||||||
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'advanced',
|
|
||||||
title: '高级设置',
|
|
||||||
icon: 'fas fa-cog',
|
|
||||||
fields: [
|
|
||||||
{ key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
|
||||||
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
export const codeHighlightSchema = {
|
|
||||||
key: 'global.code-highlight',
|
|
||||||
title: '代码高亮',
|
|
||||||
icon: 'fas fa-code',
|
|
||||||
description: '代码块语法高亮主题、行号、最大高度等配置。',
|
|
||||||
defaults: {
|
|
||||||
highlight_kg: true,
|
|
||||||
highlight_hh: false,
|
|
||||||
highlight_btn: true,
|
|
||||||
highlight_maxheight: 400,
|
|
||||||
highlight_zt: 'enlighter',
|
|
||||||
highlight_dark_zt: 'monokai',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '代码高亮设置',
|
|
||||||
icon: 'fas fa-highlighter',
|
|
||||||
fields: [
|
|
||||||
{ key: 'highlight_kg', label: '代码高亮开关', type: 'switch', default: true },
|
|
||||||
{ key: 'highlight_hh', label: '显示行号', type: 'switch', default: false },
|
|
||||||
{ key: 'highlight_btn', label: '显示扩展按钮', type: 'switch', default: true, help: '控制原始代码 / 信息 / 新窗口按钮的显示' },
|
|
||||||
{ key: 'highlight_maxheight', label: '最大高度(px,0=不限)', type: 'number', default: 400, min: 0, max: 2000, help: '代码块最大高度,超出滚动显示。0 表示不限制' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'theme',
|
|
||||||
title: '主题设置',
|
|
||||||
icon: 'fas fa-palette',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'highlight_zt',
|
|
||||||
label: '日间默认主题',
|
|
||||||
type: 'select',
|
|
||||||
default: 'enlighter',
|
|
||||||
options: [
|
|
||||||
{ label: 'Enlighter', value: 'enlighter' },
|
|
||||||
{ label: 'Bootstrap 4', value: 'bootstrap4' },
|
|
||||||
{ label: 'Classic', value: 'classic' },
|
|
||||||
{ label: 'Beyond', value: 'beyond' },
|
|
||||||
{ label: 'Mowtwo', value: 'mowtwo' },
|
|
||||||
{ label: 'Eclipse', value: 'eclipse' },
|
|
||||||
{ label: 'Droide', value: 'droide' },
|
|
||||||
{ label: 'Minimal', value: 'minimal' },
|
|
||||||
{ label: 'Rowhammer', value: 'rowhammer' },
|
|
||||||
{ label: 'Godzilla', value: 'godzilla' },
|
|
||||||
{ label: 'Dracula', value: 'dracula' },
|
|
||||||
{ label: 'Atomic', value: 'atomic' },
|
|
||||||
{ label: 'Monokai', value: 'monokai' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'highlight_dark_zt',
|
|
||||||
label: '暗色默认主题',
|
|
||||||
type: 'select',
|
|
||||||
default: 'monokai',
|
|
||||||
options: [
|
|
||||||
{ label: 'Dracula', value: 'dracula' },
|
|
||||||
{ label: 'Atomic', value: 'atomic' },
|
|
||||||
{ label: 'Monokai', value: 'monokai' },
|
|
||||||
{ label: 'Enlighter', value: 'enlighter' },
|
|
||||||
{ label: 'Bootstrap 4', value: 'bootstrap4' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export const customCodeSchema = {
|
|
||||||
key: 'global.custom-code',
|
|
||||||
title: '自定义代码',
|
|
||||||
icon: 'fas fa-code',
|
|
||||||
description: '自定义 CSS、JS 代码注入。',
|
|
||||||
defaults: {
|
|
||||||
custom_css: '',
|
|
||||||
head_code: '',
|
|
||||||
foot_code: '',
|
|
||||||
trackcode: '',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'css',
|
|
||||||
title: '自定义 CSS',
|
|
||||||
icon: 'fab fa-css3-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'custom_css', label: '自定义 CSS', type: 'code', language: 'css', placeholder: '/* 在此输入自定义 CSS */', rows: 10, help: '将在页面 <head> 中加载' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'code',
|
|
||||||
title: '头部 & 底部代码',
|
|
||||||
icon: 'fas fa-code',
|
|
||||||
fields: [
|
|
||||||
{ key: 'head_code', label: '头部 HTML 代码', type: 'code', placeholder: '<!-- 支持 meta/link/style/script 等任意 HTML -->', rows: 6, help: '在 <head> 中输出,支持 meta/link/style/script 等任意 HTML' },
|
|
||||||
{ key: 'foot_code', label: '底部 HTML 代码', type: 'code', placeholder: '<!-- 在 </body> 前输出 -->', rows: 6, help: '在页面底部 </body> 前输出,支持任意 HTML' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stats',
|
|
||||||
title: '统计代码',
|
|
||||||
icon: 'fas fa-chart-bar',
|
|
||||||
fields: [
|
|
||||||
{ key: 'trackcode', label: '统计代码', type: 'code', placeholder: '<!-- 百度统计 / GA / CNZZ / 51la -->', rows: 6, help: '百度统计、Google Analytics、CNZZ、51la 等统计代码,自动在 head 中输出' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
export const emailSchema = {
|
|
||||||
key: 'global.email',
|
|
||||||
title: '邮件配置',
|
|
||||||
icon: 'fas fa-envelope',
|
|
||||||
description: 'SMTP 发信配置、邮件事件开关与模板自定义。',
|
|
||||||
defaults: {
|
|
||||||
email_footer_text: '',
|
|
||||||
email_site_branding: true,
|
|
||||||
email_template_mode: 'plain',
|
|
||||||
email_private_receive_limit: 'all',
|
|
||||||
email_reg_verify_link_enabled: true,
|
|
||||||
email_reg_verify_code_enabled: true,
|
|
||||||
email_reg_success_enabled: true,
|
|
||||||
email_password_reset_enabled: true,
|
|
||||||
email_change_code_enabled: true,
|
|
||||||
email_change_notice_enabled: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'global',
|
|
||||||
title: '全局设置',
|
|
||||||
icon: 'fas fa-cog',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_footer_text', label: '邮件页脚文字', type: 'textarea', rows: 2, placeholder: '留空则使用站点名称', help: '附加在每封邮件底部的文字' },
|
|
||||||
{ key: 'email_site_branding', label: '显示站点品牌', type: 'switch', default: true, help: '在邮件开头显示「[站点名]」前缀' },
|
|
||||||
{ key: 'email_template_mode', label: '邮件格式', type: 'radio', default: 'plain', options: [
|
|
||||||
{ label: '纯文本', value: 'plain' },
|
|
||||||
{ label: 'HTML', value: 'html' },
|
|
||||||
]},
|
|
||||||
{
|
|
||||||
key: 'email_private_receive_limit',
|
|
||||||
label: '私信邮件接收组',
|
|
||||||
type: 'select',
|
|
||||||
default: 'all',
|
|
||||||
options: [
|
|
||||||
{ label: '所有用户', value: 'all' },
|
|
||||||
{ label: 'VIP 及以上', value: 'vip' },
|
|
||||||
{ label: 'VIP2 及以上', value: 'vip2' },
|
|
||||||
{ label: '仅管理员', value: 'admin' },
|
|
||||||
],
|
|
||||||
help: '限制哪些用户组可以收到私信邮件通知',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'branding',
|
|
||||||
title: '邮件品牌',
|
|
||||||
icon: 'fas fa-signature',
|
|
||||||
fields: [
|
|
||||||
{ key: 'mail_showname', label: '发件人名称', type: 'input', default: '', help: '覆盖系统默认发件人名称' },
|
|
||||||
{ key: 'mail_title_prefix', label: '主题前缀', type: 'input', default: '', help: '自动添加在所有邮件主题前面,如【站点名】' },
|
|
||||||
{ key: 'mail_description', label: '邮件底部说明', type: 'textarea', rows: 3, default: '', help: '附加在邮件页脚文字之后,留空不显示' },
|
|
||||||
{ key: 'mail_more_content', label: '邮件附加内容', type: 'textarea', rows: 4, default: '', help: '附加在邮件内容末尾的补充说明' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_verify_link',
|
|
||||||
title: '注册验证链接',
|
|
||||||
icon: 'fas fa-link',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_verify_link_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送验证链接邮件' },
|
|
||||||
{ key: 'email_reg_verify_link_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '验证你的邮箱地址' },
|
|
||||||
{ key: 'email_reg_verify_link_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 验证链接' },
|
|
||||||
{ key: 'email_reg_verify_link_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_verify_code',
|
|
||||||
title: '注册验证码',
|
|
||||||
icon: 'fas fa-keyboard',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_verify_code_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送 6 位验证码邮件' },
|
|
||||||
{ key: 'email_reg_verify_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
|
||||||
{ key: 'email_reg_verify_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
|
||||||
{ key: 'email_reg_verify_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_success',
|
|
||||||
title: '注册成功通知',
|
|
||||||
icon: 'fas fa-check-circle',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_success_enabled', label: '启用', type: 'switch', default: true, help: '邮箱验证成功后发送通知邮件' },
|
|
||||||
{ key: 'email_reg_success_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证成功' },
|
|
||||||
{ key: 'email_reg_success_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name}', help: '可用占位符:{username} 用户名、{site_name} 站点名' },
|
|
||||||
{ key: 'email_reg_success_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password_reset',
|
|
||||||
title: '密码重置',
|
|
||||||
icon: 'fas fa-unlock-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_password_reset_enabled', label: '启用', type: 'switch', default: true, help: '发送密码重置链接邮件' },
|
|
||||||
{ key: 'email_password_reset_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '重置密码' },
|
|
||||||
{ key: 'email_password_reset_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 重置链接' },
|
|
||||||
{ key: 'email_password_reset_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change_code',
|
|
||||||
title: '邮箱变更验证码',
|
|
||||||
icon: 'fas fa-shield-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_change_code_enabled', label: '启用', type: 'switch', default: true, help: '变更邮箱时发送验证码邮件' },
|
|
||||||
{ key: 'email_change_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
|
||||||
{ key: 'email_change_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
|
||||||
{ key: 'email_change_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change_notice',
|
|
||||||
title: '邮箱变更通知',
|
|
||||||
icon: 'fas fa-bell',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_change_notice_enabled', label: '启用', type: 'switch', default: true, help: '邮箱变更成功后向旧邮箱发送通知' },
|
|
||||||
{ key: 'email_change_notice_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '账号邮箱已变更' },
|
|
||||||
{ key: 'email_change_notice_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {new_email}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{new_email} 新邮箱' },
|
|
||||||
{ key: 'email_change_notice_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const floatingSchema = {
|
|
||||||
key: 'global.floating',
|
|
||||||
title: '右侧悬浮按钮',
|
|
||||||
icon: 'fas fa-ellipsis-v',
|
|
||||||
description: '页面右侧浮动按钮的显示与配置。',
|
|
||||||
defaults: {},
|
|
||||||
groups: [],
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
export const i18nSchema = {
|
|
||||||
key: 'global.i18n',
|
|
||||||
title: '多语言翻译',
|
|
||||||
icon: 'fas fa-language',
|
|
||||||
description: '配置站点语言、语言切换器和翻译管理。',
|
|
||||||
defaults: {
|
|
||||||
i18n_enabled: false,
|
|
||||||
i18n_default_lang: 'zh-CN',
|
|
||||||
i18n_switcher_position: 'footer',
|
|
||||||
i18n_auto_detect: true,
|
|
||||||
i18n_available_langs: 'zh-CN|简体中文\nen|English\nzh-TW|繁體中文\nja|日本語\nko|한국어\nfr|Français\nde|Deutsch\nes|Español\nru|Русский',
|
|
||||||
i18n_nomenclature: '',
|
|
||||||
i18n_url_strategy: 'query',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '基础配置',
|
|
||||||
icon: 'fas fa-globe',
|
|
||||||
fields: [
|
|
||||||
{ key: 'i18n_enabled', label: '启用多语言', type: 'switch', default: false, help: '开启后站点将支持多语言切换' },
|
|
||||||
{
|
|
||||||
key: 'i18n_default_lang',
|
|
||||||
label: '默认语言',
|
|
||||||
type: 'select',
|
|
||||||
default: 'zh-CN',
|
|
||||||
options: [
|
|
||||||
{ label: '简体中文', value: 'zh-CN' },
|
|
||||||
{ label: 'English', value: 'en' },
|
|
||||||
{ label: '繁體中文', value: 'zh-TW' },
|
|
||||||
{ label: '日本語', value: 'ja' },
|
|
||||||
{ label: '한국어', value: 'ko' },
|
|
||||||
{ label: 'Français', value: 'fr' },
|
|
||||||
{ label: 'Deutsch', value: 'de' },
|
|
||||||
{ label: 'Español', value: 'es' },
|
|
||||||
{ label: 'Русский', value: 'ru' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'i18n_enabled', value: true },
|
|
||||||
},
|
|
||||||
{ key: 'i18n_auto_detect', label: '自动检测浏览器语言', type: 'switch', default: true, dependsOn: { key: 'i18n_enabled', value: true }, help: '基于浏览器 Accept-Language 自动切换(AKSRT 方案,区别于 zibll IP 定位方案)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'languages',
|
|
||||||
title: '语言管理',
|
|
||||||
icon: 'fas fa-language',
|
|
||||||
fields: [
|
|
||||||
{ key: 'i18n_available_langs', label: '可用语言列表', type: 'textarea', default: 'zh-CN|简体中文\nen|English\nzh-TW|繁體中文\nja|日本語\nko|한국어\nfr|Français\nde|Deutsch\nes|Español\nru|Русский', dependsOn: { key: 'i18n_enabled', value: true }, help: '每行一个语言:语言代码|显示名称(9 种内置语言)' },
|
|
||||||
{ key: 'i18n_nomenclature', label: '自定义术语', type: 'code', default: '', dependsOn: { key: 'i18n_enabled', value: true }, help: 'JSON 格式自定义翻译术语(key => 翻译),对齐 zibll nomenclature' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '展示设置',
|
|
||||||
icon: 'fas fa-eye',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'i18n_switcher_position',
|
|
||||||
label: '语言切换器位置',
|
|
||||||
type: 'radio',
|
|
||||||
default: 'footer',
|
|
||||||
options: [
|
|
||||||
{ label: '导航栏', value: 'header' },
|
|
||||||
{ label: '页脚', value: 'footer' },
|
|
||||||
{ label: '导航栏+页脚', value: 'both' },
|
|
||||||
{ label: '隐藏', value: 'none' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'i18n_enabled', value: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'i18n_url_strategy',
|
|
||||||
label: 'URL 策略',
|
|
||||||
type: 'radio',
|
|
||||||
default: 'query',
|
|
||||||
options: [
|
|
||||||
{ label: '查询参数 (?lang=en)', value: 'query' },
|
|
||||||
{ label: '路径前缀 (/en/page)', value: 'path' },
|
|
||||||
{ label: '子域名 (en.site.com)', value: 'subdomain' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'i18n_enabled', value: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Global & Functions domain schemas.
|
|
||||||
* Each key corresponds to a sub-page under the "全局&功能" section.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { logoSchema } from './logo.js'
|
|
||||||
import { commonSchema } from './common.js'
|
|
||||||
import { layoutSchema } from './layout.js'
|
|
||||||
import { searchSchema } from './search.js'
|
|
||||||
import { emailSchema } from './email.js'
|
|
||||||
import { tabbarSchema } from './tabbar.js'
|
|
||||||
import { floatingSchema } from './floating.js'
|
|
||||||
import { i18nSchema } from './i18n.js'
|
|
||||||
import { customCodeSchema } from './custom-code.js'
|
|
||||||
import { seoSchema } from './seo.js'
|
|
||||||
import { codeHighlightSchema } from './code-highlight.js'
|
|
||||||
import { systemNoticeSchema } from './system-notice.js'
|
|
||||||
|
|
||||||
export const globalSchemas = {
|
|
||||||
logo: logoSchema,
|
|
||||||
common: commonSchema,
|
|
||||||
layout: layoutSchema,
|
|
||||||
search: searchSchema,
|
|
||||||
email: emailSchema,
|
|
||||||
tabbar: tabbarSchema,
|
|
||||||
floating: floatingSchema,
|
|
||||||
i18n: i18nSchema,
|
|
||||||
'custom-code': customCodeSchema,
|
|
||||||
seo: seoSchema,
|
|
||||||
'code-highlight': codeHighlightSchema,
|
|
||||||
'system-notice': systemNoticeSchema,
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
export const layoutSchema = {
|
|
||||||
key: 'global.layout',
|
|
||||||
title: '显示 & 布局',
|
|
||||||
icon: 'fas fa-desktop',
|
|
||||||
description: '全局布局风格、颜色方案、字体设置。',
|
|
||||||
defaults: {
|
|
||||||
theme_mode: 'time-auto',
|
|
||||||
theme_mode_button: ['pc_nav', 'm_menu'],
|
|
||||||
primary_color: '#1890ff',
|
|
||||||
theme_skin: 'f04494',
|
|
||||||
dark_primary_color: '',
|
|
||||||
border_radius: 8,
|
|
||||||
theme_main_radius: 12,
|
|
||||||
container_max_width: 1200,
|
|
||||||
sidebar_enabled: true,
|
|
||||||
sidebar_position: 'right',
|
|
||||||
sidebar_width: 311,
|
|
||||||
sidebar_home_enabled: true,
|
|
||||||
sidebar_category_enabled: true,
|
|
||||||
sidebar_tag_enabled: true,
|
|
||||||
sidebar_single_enabled: true,
|
|
||||||
sidebar_page_enabled: true,
|
|
||||||
sidebar_search_enabled: true,
|
|
||||||
font_family: 'system',
|
|
||||||
qj_loading: false,
|
|
||||||
qj_dh_xs: 'no1',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'loading-animation',
|
|
||||||
title: '加载动画',
|
|
||||||
icon: 'fas fa-spinner',
|
|
||||||
fields: [
|
|
||||||
{ key: 'qj_loading', label: '全站加载动画', type: 'switch', default: false, help: '开启后页面加载时显示全屏加载动画覆盖层' },
|
|
||||||
{
|
|
||||||
key: 'qj_dh_xs',
|
|
||||||
label: '加载动画样式',
|
|
||||||
type: 'select',
|
|
||||||
default: 'no1',
|
|
||||||
dependsOn: { key: 'qj_loading', value: true },
|
|
||||||
options: [
|
|
||||||
{ label: '样式 1', value: 'no1' },
|
|
||||||
{ label: '样式 2', value: 'no2' },
|
|
||||||
{ label: '样式 3', value: 'no3' },
|
|
||||||
{ label: '样式 4', value: 'no4' },
|
|
||||||
{ label: '样式 5', value: 'no5' },
|
|
||||||
{ label: '样式 6', value: 'no6' },
|
|
||||||
{ label: '样式 7', value: 'no7' },
|
|
||||||
{ label: '样式 8', value: 'no8' },
|
|
||||||
{ label: '样式 9', value: 'no9' },
|
|
||||||
{ label: '样式 10', value: 'no10' },
|
|
||||||
],
|
|
||||||
help: '10 种加载动画样式可选',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'color',
|
|
||||||
title: '色彩方案',
|
|
||||||
icon: 'fas fa-palette',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'theme_mode',
|
|
||||||
label: '默认主题模式',
|
|
||||||
type: 'radio',
|
|
||||||
default: 'time-auto',
|
|
||||||
options: [
|
|
||||||
{ label: '浅色', value: 'white-theme' },
|
|
||||||
{ label: '深色', value: 'dark-theme' },
|
|
||||||
{ label: '跟随系统', value: 'time-auto' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'theme_mode_button',
|
|
||||||
label: '主题切换按钮位置',
|
|
||||||
type: 'checkbox',
|
|
||||||
default: ['pc_nav', 'm_menu'],
|
|
||||||
options: [
|
|
||||||
{ label: 'PC 顶部导航', value: 'pc_nav' },
|
|
||||||
{ label: '移动端菜单', value: 'm_menu' },
|
|
||||||
],
|
|
||||||
help: '控制主题切换按钮在哪些位置显示',
|
|
||||||
},
|
|
||||||
{ key: 'primary_color', label: '主题色', type: 'color', default: '#f04494', help: '按钮、链接和强调元素的主色调' },
|
|
||||||
{
|
|
||||||
key: 'theme_skin',
|
|
||||||
label: '预设主题色',
|
|
||||||
type: 'palette',
|
|
||||||
default: 'f04494',
|
|
||||||
options: [
|
|
||||||
{ label: '玫红', value: 'ff1856' },
|
|
||||||
{ label: '粉色', value: 'f04494' },
|
|
||||||
{ label: '紫色', value: 'ae53f3' },
|
|
||||||
{ label: '靛蓝', value: '627bf5' },
|
|
||||||
{ label: '天蓝', value: '00a2e3' },
|
|
||||||
{ label: '青绿', value: '16b597' },
|
|
||||||
{ label: '翠绿', value: '36af18' },
|
|
||||||
{ label: '黄绿', value: '8fb107' },
|
|
||||||
{ label: '棕金', value: 'b18c07' },
|
|
||||||
{ label: '橘橙', value: 'e06711' },
|
|
||||||
{ label: '朱红', value: 'f74735' },
|
|
||||||
],
|
|
||||||
help: '选择预设主题色,将覆盖上方自定义主题色',
|
|
||||||
},
|
|
||||||
{ key: 'dark_primary_color', label: '暗色模式主题色', type: 'color', default: '', help: '暗色模式下独立的主题色,留空则跟随浅色模式' },
|
|
||||||
{ key: 'border_radius', label: '全局圆角 (px)', type: 'slider', min: 0, max: 20, step: 1, default: 8 },
|
|
||||||
{ key: 'theme_main_radius', label: '卡片圆角 (px)', type: 'number', min: 0, max: 30, default: 12, help: '独立卡片圆角,留空则使用全局圆角 +4' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'layout',
|
|
||||||
title: '布局设置',
|
|
||||||
icon: 'fas fa-columns',
|
|
||||||
fields: [
|
|
||||||
{ key: 'container_max_width', label: '内容最大宽度 (px)', type: 'select', default: 1200, options: [
|
|
||||||
{ label: '1200px', value: 1200 },
|
|
||||||
{ label: '1320px', value: 1320 },
|
|
||||||
{ label: '1440px', value: 1440 },
|
|
||||||
{ label: '1600px', value: 1600 },
|
|
||||||
{ label: '1800px', value: 1800 },
|
|
||||||
{ label: '2000px', value: 2000 },
|
|
||||||
{ label: '2200px', value: 2200 },
|
|
||||||
{ label: '全宽', value: 0 },
|
|
||||||
]},
|
|
||||||
{ key: 'sidebar_enabled', label: '开启侧边栏', type: 'switch', default: true },
|
|
||||||
{ key: 'sidebar_position', label: '侧边栏位置', type: 'radio', default: 'right', dependsOn: { key: 'sidebar_enabled', value: true }, options: [
|
|
||||||
{ label: '左侧', value: 'left' },
|
|
||||||
{ label: '右侧', value: 'right' },
|
|
||||||
]},
|
|
||||||
{ key: 'sidebar_width', label: '侧边栏宽度 (px)', type: 'number', default: 300, min: 200, max: 400, step: 10, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sidebar-scope',
|
|
||||||
title: '侧边栏显示范围',
|
|
||||||
icon: 'fas fa-th-list',
|
|
||||||
fields: [
|
|
||||||
{ key: 'sidebar_home_enabled', label: '首页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
{ key: 'sidebar_category_enabled', label: '分类页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
{ key: 'sidebar_tag_enabled', label: '标签页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
{ key: 'sidebar_single_enabled', label: '文章页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
{ key: 'sidebar_page_enabled', label: '页面侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
{ key: 'sidebar_search_enabled', label: '搜索页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'typography',
|
|
||||||
title: '字体设置',
|
|
||||||
icon: 'fas fa-font',
|
|
||||||
fields: [
|
|
||||||
{ key: 'font_family', label: '正文字体', type: 'select', default: 'system', options: [
|
|
||||||
{ label: '系统默认', value: 'system' },
|
|
||||||
{ label: '思源黑体', value: 'noto-sans-sc' },
|
|
||||||
{ label: '思源宋体', value: 'noto-serif-sc' },
|
|
||||||
{ label: '自定义', value: 'custom' },
|
|
||||||
]},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
export const logoSchema = {
|
|
||||||
key: 'global.logo',
|
|
||||||
title: 'LOGO & 品牌',
|
|
||||||
icon: 'fas fa-image',
|
|
||||||
description: '管理站点 Logo、站点图标和品牌标识。',
|
|
||||||
defaults: {
|
|
||||||
site_logo: '',
|
|
||||||
site_logo_dark: '',
|
|
||||||
site_favicon: '',
|
|
||||||
site_icon: '',
|
|
||||||
logo_width: 150,
|
|
||||||
logo_text: '',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'logo',
|
|
||||||
title: 'Logo 设置',
|
|
||||||
icon: 'fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'site_logo', label: '站点 Logo', type: 'media', help: '建议尺寸 300x80,支持 PNG/SVG 透明背景' },
|
|
||||||
{ key: 'site_logo_dark', label: '暗色模式 Logo', type: 'media', help: '暗色模式下显示的 Logo,留空则使用默认 Logo' },
|
|
||||||
{ key: 'logo_width', label: 'Logo 宽度 (px)', type: 'number', min: 50, max: 400, step: 10, help: '导航栏中 Logo 显示宽度' },
|
|
||||||
{ key: 'logo_text', label: 'Logo 文字', type: 'input', placeholder: '当无 Logo 图片时显示的文字', help: '同时作为首页标题和 alt 文本使用' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'favicon',
|
|
||||||
title: '站点图标',
|
|
||||||
icon: 'fas fa-fingerprint',
|
|
||||||
fields: [
|
|
||||||
{ key: 'site_favicon', label: '浏览器图标 (Favicon)', type: 'media', help: '建议 32x32 或 16x16 的 ICO/PNG 文件' },
|
|
||||||
{ key: 'site_icon', label: '移动端图标', type: 'media', help: '建议 192x192 的 PNG,用于移动端添加到桌面' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
export const seoSchema = {
|
|
||||||
key: 'global.seo',
|
|
||||||
title: 'SEO 优化',
|
|
||||||
icon: 'fas fa-search',
|
|
||||||
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。',
|
|
||||||
defaults: {
|
|
||||||
seo_enabled: true,
|
|
||||||
seo_ai_enabled: false,
|
|
||||||
seo_connector: '-',
|
|
||||||
seo_title: '',
|
|
||||||
seo_keywords: '',
|
|
||||||
seo_description: '',
|
|
||||||
ai_seo_title_prompt: '',
|
|
||||||
ai_seo_keywords_prompt: '',
|
|
||||||
ai_seo_description_prompt: '',
|
|
||||||
seo_baidu_push: false,
|
|
||||||
seo_baidu_normal: false,
|
|
||||||
seo_baidu_fast: false,
|
|
||||||
seo_baidu_token: '',
|
|
||||||
seo_image_optimize: true,
|
|
||||||
seo_image_default: '',
|
|
||||||
seo_image_always_default: false,
|
|
||||||
seo_auto_alt: true,
|
|
||||||
seo_go_link: true,
|
|
||||||
seo_go_link_nonce: true,
|
|
||||||
seo_go_link_post: true,
|
|
||||||
seo_go_link_exclude: '',
|
|
||||||
seo_no_category: false,
|
|
||||||
seo_jsonld: true,
|
|
||||||
seo_jsonld_single: true,
|
|
||||||
seo_jsonld_page: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: 'SEO 基础',
|
|
||||||
icon: 'fas fa-globe',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'seo_enabled',
|
|
||||||
label: '启用核心 SEO 优化',
|
|
||||||
type: 'switch',
|
|
||||||
default: true,
|
|
||||||
help: '关闭后主题不再输出 SEO title/description/keywords 等 head 标签。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'seo_ai_enabled',
|
|
||||||
label: '启用 AI SEO 生成',
|
|
||||||
type: 'switch',
|
|
||||||
default: false,
|
|
||||||
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。',
|
|
||||||
dependsOn: { key: 'seo_enabled', value: true },
|
|
||||||
},
|
|
||||||
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
|
|
||||||
{ key: 'seo_title', label: '站点 SEO 标题', type: 'textarea', rows: 2, default: '' },
|
|
||||||
{ key: 'seo_keywords', label: '站点 SEO 关键词', type: 'textarea', rows: 3, default: '' },
|
|
||||||
{ key: 'seo_description', label: '站点 SEO 描述', type: 'textarea', rows: 4, default: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ai-seo',
|
|
||||||
title: 'AI SEO 提示词',
|
|
||||||
icon: 'fas fa-robot',
|
|
||||||
description: '留空则使用内置 Zibll 风格默认提示词。填写后作为 system instruction 覆盖默认。',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'ai_seo_title_prompt',
|
|
||||||
label: '标题提示词',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 5,
|
|
||||||
default: '',
|
|
||||||
dependsOn: { key: 'seo_ai_enabled', value: true },
|
|
||||||
placeholder: '你是一名资深的网站 SEO 编辑……直接输出标题文本本身,不要任何前缀、引号或解释。',
|
|
||||||
help: '控制 AI 如何生成 SEO 标题。留空使用主题内置默认。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ai_seo_keywords_prompt',
|
|
||||||
label: '关键词提示词',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 5,
|
|
||||||
default: '',
|
|
||||||
dependsOn: { key: 'seo_ai_enabled', value: true },
|
|
||||||
placeholder: '你是一名资深 SEO 编辑……使用英文逗号分隔,直接输出关键词列表。',
|
|
||||||
help: '控制 AI 如何生成 meta keywords。留空使用主题内置默认。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ai_seo_description_prompt',
|
|
||||||
label: '描述提示词',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 5,
|
|
||||||
default: '',
|
|
||||||
dependsOn: { key: 'seo_ai_enabled', value: true },
|
|
||||||
placeholder: '你是一名资深的中文 SEO 编辑……字数控制在 80 至 150 个汉字之间。',
|
|
||||||
help: '控制 AI 如何生成 meta description。留空使用主题内置默认。',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'baidu',
|
|
||||||
title: '百度推送',
|
|
||||||
icon: 'fas fa-paper-plane',
|
|
||||||
fields: [
|
|
||||||
{ key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'image',
|
|
||||||
title: '图片 SEO',
|
|
||||||
icon: 'fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'seo_image_optimize', label: '优化搜索引擎图片展示', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_image_default', label: '默认分享图 / SEO 图', type: 'media', default: '' },
|
|
||||||
{ key: 'seo_image_always_default', label: '固定使用默认图', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_auto_alt', label: '自动补全图片 alt', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'redirect',
|
|
||||||
title: '外链重定向',
|
|
||||||
icon: 'fas fa-external-link-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'seo_go_link', label: '启用外链重定向', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_go_link_nonce', label: '启用外链鉴权', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_go_link_post', label: '文章内容链接走重定向', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_go_link_exclude', label: '重定向排除域名', type: 'textarea', rows: 4, default: '', placeholder: '每行一个域名' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'advanced',
|
|
||||||
title: '高级 SEO',
|
|
||||||
icon: 'fas fa-sliders-h',
|
|
||||||
fields: [
|
|
||||||
{ key: 'seo_no_category', label: '分类 URL 去掉 category', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_jsonld', label: '启用 JSON-LD', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_jsonld_single', label: '文章页输出 JSON-LD', type: 'switch', default: true },
|
|
||||||
{ key: 'seo_jsonld_page', label: '页面输出 JSON-LD', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
export const systemNoticeSchema = {
|
|
||||||
key: 'global.system-notice',
|
|
||||||
title: '弹窗通知',
|
|
||||||
icon: 'fas fa-bullhorn',
|
|
||||||
description: '全站弹窗公告配置,支持按用户身份定向、多按钮组、弹窗尺寸与过期周期。',
|
|
||||||
defaults: {
|
|
||||||
system_notice_enabled: true,
|
|
||||||
system_notice_policy: '',
|
|
||||||
system_notice_title: '',
|
|
||||||
system_notice_content: '',
|
|
||||||
system_notice_btn1_text: '',
|
|
||||||
system_notice_btn1_link: '',
|
|
||||||
system_notice_btn1_color: 'primary',
|
|
||||||
system_notice_btn2_text: '',
|
|
||||||
system_notice_btn2_link: '',
|
|
||||||
system_notice_btn2_color: 'primary',
|
|
||||||
system_notice_btn3_text: '',
|
|
||||||
system_notice_btn3_link: '',
|
|
||||||
system_notice_btn3_color: 'primary',
|
|
||||||
system_notice_btn4_text: '',
|
|
||||||
system_notice_btn4_link: '',
|
|
||||||
system_notice_btn4_color: 'primary',
|
|
||||||
system_notice_size: '',
|
|
||||||
system_notice_radius: false,
|
|
||||||
system_notice_class: '',
|
|
||||||
system_notice_title_icon: '',
|
|
||||||
system_notice_title_class: '',
|
|
||||||
system_notice_title_style: '',
|
|
||||||
system_notice_expires: 24,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '公告设置',
|
|
||||||
icon: 'fas fa-bullhorn',
|
|
||||||
fields: [
|
|
||||||
{ key: 'system_notice_enabled', label: '启用系统公告', type: 'switch', default: true },
|
|
||||||
{
|
|
||||||
key: 'system_notice_policy',
|
|
||||||
label: '显示对象',
|
|
||||||
type: 'select',
|
|
||||||
default: '',
|
|
||||||
options: [
|
|
||||||
{ label: '所有访客', value: '' },
|
|
||||||
{ label: '仅登录用户', value: 'signin' },
|
|
||||||
{ label: '仅 VIP 会员', value: 'vip' },
|
|
||||||
{ label: '仅非 VIP', value: 'free' },
|
|
||||||
{ label: '仅认证用户', value: 'auth' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'system_notice_enabled', value: true },
|
|
||||||
},
|
|
||||||
{ key: 'system_notice_title', label: '公告标题', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_content', label: '公告内容', type: 'textarea', default: '', rows: 5, dependsOn: { key: 'system_notice_enabled', value: true }, help: '支持 HTML 标签' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'buttons',
|
|
||||||
title: '按钮组(留空则不显示)',
|
|
||||||
icon: 'fas fa-th-large',
|
|
||||||
fields: [
|
|
||||||
{ key: 'system_notice_btn1_text', label: '按钮 1 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn1_link', label: '按钮 1 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '可选,填写后按钮变为链接' },
|
|
||||||
{ key: 'system_notice_btn1_color', label: '按钮 1 颜色', type: 'select', default: 'primary', options: [
|
|
||||||
{ label: '主题色', value: 'primary' },
|
|
||||||
{ label: '成功绿', value: 'success' },
|
|
||||||
{ label: '警告橙', value: 'warning' },
|
|
||||||
{ label: '危险红', value: 'danger' },
|
|
||||||
{ label: '信息蓝', value: 'info' },
|
|
||||||
{ label: '深紫', value: 'purple' },
|
|
||||||
{ label: '深灰', value: 'dark' },
|
|
||||||
], dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn2_text', label: '按钮 2 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn2_link', label: '按钮 2 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn2_color', label: '按钮 2 颜色', type: 'select', default: 'primary', options: [
|
|
||||||
{ label: '主题色', value: 'primary' },
|
|
||||||
{ label: '成功绿', value: 'success' },
|
|
||||||
{ label: '警告橙', value: 'warning' },
|
|
||||||
{ label: '危险红', value: 'danger' },
|
|
||||||
{ label: '信息蓝', value: 'info' },
|
|
||||||
{ label: '深紫', value: 'purple' },
|
|
||||||
{ label: '深灰', value: 'dark' },
|
|
||||||
], dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn3_text', label: '按钮 3 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn3_link', label: '按钮 3 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn3_color', label: '按钮 3 颜色', type: 'select', default: 'primary', options: [
|
|
||||||
{ label: '主题色', value: 'primary' },
|
|
||||||
{ label: '成功绿', value: 'success' },
|
|
||||||
{ label: '警告橙', value: 'warning' },
|
|
||||||
{ label: '危险红', value: 'danger' },
|
|
||||||
{ label: '信息蓝', value: 'info' },
|
|
||||||
{ label: '深紫', value: 'purple' },
|
|
||||||
{ label: '深灰', value: 'dark' },
|
|
||||||
], dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn4_text', label: '按钮 4 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn4_link', label: '按钮 4 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_btn4_color', label: '按钮 4 颜色', type: 'select', default: 'primary', options: [
|
|
||||||
{ label: '主题色', value: 'primary' },
|
|
||||||
{ label: '成功绿', value: 'success' },
|
|
||||||
{ label: '警告橙', value: 'warning' },
|
|
||||||
{ label: '危险红', value: 'danger' },
|
|
||||||
{ label: '信息蓝', value: 'info' },
|
|
||||||
{ label: '深紫', value: 'purple' },
|
|
||||||
{ label: '深灰', value: 'dark' },
|
|
||||||
], dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'style',
|
|
||||||
title: '样式设置',
|
|
||||||
icon: 'fas fa-paint-brush',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'system_notice_size',
|
|
||||||
label: '弹窗尺寸',
|
|
||||||
type: 'select',
|
|
||||||
default: '',
|
|
||||||
options: [
|
|
||||||
{ label: '默认', value: '' },
|
|
||||||
{ label: '迷你', value: 'modal-mini' },
|
|
||||||
{ label: '小号', value: 'modal-sm' },
|
|
||||||
{ label: '大号', value: 'modal-lg' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'system_notice_enabled', value: true },
|
|
||||||
},
|
|
||||||
{ key: 'system_notice_radius', label: '按钮圆角', type: 'switch', default: false, dependsOn: { key: 'system_notice_enabled', value: true }, help: '开启后弹窗按钮使用圆角样式' },
|
|
||||||
{ key: 'system_notice_class', label: '公告样式类', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
{ key: 'system_notice_title_icon', label: '标题图标', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '如 fas fa-bullhorn' },
|
|
||||||
{ key: 'system_notice_title_class', label: '标题背景配色', type: 'select', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, options: [
|
|
||||||
{ label: '无', value: '' },
|
|
||||||
{ label: '炫彩黄', value: 'jb-yellow' },
|
|
||||||
{ label: '炫彩蓝', value: 'jb-blue' },
|
|
||||||
{ label: '炫彩绿', value: 'jb-green' },
|
|
||||||
{ label: '炫彩紫', value: 'jb-purple' },
|
|
||||||
{ label: '炫彩红', value: 'jb-red' },
|
|
||||||
{ label: '炫彩橙', value: 'jb-orange' },
|
|
||||||
], help: '弹窗标题区域的背景配色方案' },
|
|
||||||
{ key: 'system_notice_title_style', label: '标题样式', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'expire',
|
|
||||||
title: '过期设置',
|
|
||||||
icon: 'fas fa-clock',
|
|
||||||
fields: [
|
|
||||||
{ key: 'system_notice_expires', label: '弹窗周期(小时)', type: 'number', default: 24, min: 0, max: 720, dependsOn: { key: 'system_notice_enabled', value: true }, help: '0 表示永不过期' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const tabbarSchema = {
|
|
||||||
key: 'global.tabbar',
|
|
||||||
title: '手机底部Tab',
|
|
||||||
icon: 'fas fa-mobile-alt',
|
|
||||||
description: '移动端底部导航栏配置。',
|
|
||||||
defaults: {},
|
|
||||||
groups: [],
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
export const cardPassSchema = {
|
|
||||||
key: 'payment.card-pass',
|
|
||||||
title: '充值卡',
|
|
||||||
icon: 'fas fa-ticket-alt',
|
|
||||||
description: '配置充值卡类型、卡号规则、批量生成和兑换逻辑。',
|
|
||||||
defaults: {
|
|
||||||
card_pass_enabled: false,
|
|
||||||
card_pass_desc: '',
|
|
||||||
card_pass_type: 'balance',
|
|
||||||
card_pass_prefix: 'AK',
|
|
||||||
card_pass_length: 16,
|
|
||||||
card_pass_batch_size: 50,
|
|
||||||
card_pass_max_redeem: 1,
|
|
||||||
card_pass_expiry: 0,
|
|
||||||
card_pass_amount: 10,
|
|
||||||
card_pass_vip_days: 30,
|
|
||||||
card_pass_credits: 0,
|
|
||||||
card_pass_redeem_page: '',
|
|
||||||
card_pass_success_msg: '兑换成功!',
|
|
||||||
card_pass_fail_msg: '卡号无效或已过期',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '充值卡设置',
|
|
||||||
icon: 'fas fa-id-card',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_enabled', label: '启用充值卡', type: 'switch', default: false },
|
|
||||||
{ key: 'card_pass_desc', label: '充值卡说明', type: 'textarea', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_type', label: '充值卡类型', type: 'select', default: 'balance', options: [{ label: '余额卡', value: 'balance' }, { label: 'VIP卡', value: 'vip' }, { label: '积分卡', value: 'credits' }], dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_prefix', label: '卡号前缀', type: 'input', default: 'AK', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_length', label: '卡号长度', type: 'number', default: 16, min: 8, max: 32, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_batch_size', label: '批量生成数量', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'redeem',
|
|
||||||
title: '兑换设置',
|
|
||||||
icon: 'fas fa-exchange-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_max_redeem', label: '单卡最大兑换次数', type: 'number', default: 1, min: 1, max: 100, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_amount', label: '面额(元)', type: 'number', default: 10, min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_vip_days', label: 'VIP天数', type: 'number', default: 30, min: 1, max: 3650, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_credits', label: '赠送积分', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '展示设置',
|
|
||||||
icon: 'fas fa-desktop',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_redeem_page', label: '兑换页面', type: 'input', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_success_msg', label: '兑换成功提示', type: 'input', default: '兑换成功!', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_fail_msg', label: '兑换失败提示', type: 'input', default: '卡号无效或已过期', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
export const gatewaySchema = {
|
|
||||||
key: 'payment.gateway',
|
|
||||||
title: '收款接口',
|
|
||||||
icon: 'fas fa-credit-card',
|
|
||||||
description: '管理已支持的收款通道:余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换。开启渠道后展开填写凭据。',
|
|
||||||
defaults: {
|
|
||||||
payment_wechat_enabled: false,
|
|
||||||
payment_alipay_enabled: false,
|
|
||||||
payment_paypal_enabled: false,
|
|
||||||
payment_stripe_enabled: false,
|
|
||||||
payment_epay_enabled: false,
|
|
||||||
payment_balance_enabled: true,
|
|
||||||
payment_card_pass_enabled: false,
|
|
||||||
wechat_merchantid: '',
|
|
||||||
wechat_appid: '',
|
|
||||||
wechat_payment_type: 'native',
|
|
||||||
wechat_api_v3_key: '',
|
|
||||||
wechat_private_key_path: '',
|
|
||||||
wechat_cert_path: '',
|
|
||||||
alipay_appid: '',
|
|
||||||
alipay_privatekey: '',
|
|
||||||
alipay_payment_type: 'page',
|
|
||||||
alipay_app_public_cert_path: '',
|
|
||||||
alipay_public_cert_path: '',
|
|
||||||
alipay_root_cert_path: '',
|
|
||||||
paypal_client_id: '',
|
|
||||||
paypal_client_secret: '',
|
|
||||||
paypal_currency: 'USD',
|
|
||||||
paypal_sandbox: true,
|
|
||||||
stripe_secret_key: '',
|
|
||||||
stripe_publishable_key: '',
|
|
||||||
stripe_webhook_secret: '',
|
|
||||||
stripe_currency: 'usd',
|
|
||||||
stripe_test_mode: true,
|
|
||||||
epay_apiurl: '',
|
|
||||||
epay_pid: '',
|
|
||||||
epay_key: '',
|
|
||||||
epay_channel_alipay: true,
|
|
||||||
epay_channel_wxpay: true,
|
|
||||||
epay_device: 'auto',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'notice',
|
|
||||||
title: '说明',
|
|
||||||
icon: 'fas fa-info-circle',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'payment_gateway_notice',
|
|
||||||
label: '通道说明',
|
|
||||||
type: 'notice',
|
|
||||||
alertType: 'info',
|
|
||||||
alertContent: '以渠道开关 + 凭据是否齐全为准。异步回调由各网关验签(支付宝/微信/Stripe/易支付等),无需额外口令。卡密为兑换入口,不出现在收银台支付方式列表。',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'gateways',
|
|
||||||
title: '支付渠道开关',
|
|
||||||
icon: 'fas fa-toggle-on',
|
|
||||||
description: '先开启渠道,下方对应配置卡片会展开;填写凭据后网关状态变为可用。余额与卡密无第三方凭据,仅开关即可。',
|
|
||||||
fields: [
|
|
||||||
{ key: 'payment_balance_enabled', label: '余额支付', type: 'switch', default: true, help: '需同时在「余额充值」启用余额功能。关闭后用户中心仍可有钱包,但结账不可用余额支付。' },
|
|
||||||
{ key: 'payment_alipay_enabled', label: '支付宝', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_wechat_enabled', label: '微信支付', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_paypal_enabled', label: 'PayPal', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_stripe_enabled', label: 'Stripe', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_epay_enabled', label: '易支付', type: 'switch', default: false, help: '兼容彩虹易支付协议的聚合收银台,可同时开启支付宝/微信子通道。' },
|
|
||||||
{ key: 'payment_card_pass_enabled', label: '卡密充值', type: 'switch', default: false, help: '仅控制卡密兑换入口是否开放;卡密规则与批量发卡在「充值卡」页。不进入结账支付方式列表。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'wechat',
|
|
||||||
title: '微信支付配置',
|
|
||||||
icon: 'fab fa-weixin',
|
|
||||||
description: '开启「微信支付」后填写商户号与密钥。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_wechat_enabled',
|
|
||||||
dependsOn: { key: 'payment_wechat_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'wechat_merchantid', label: '商户号', type: 'input', default: '' },
|
|
||||||
{ key: 'wechat_appid', label: 'AppID', type: 'input', default: '' },
|
|
||||||
{ key: 'wechat_payment_type', label: '支付方式', type: 'select', default: 'native', options: [
|
|
||||||
{ label: '扫码支付(Native)', value: 'native' },
|
|
||||||
{ label: 'H5支付', value: 'h5' },
|
|
||||||
] },
|
|
||||||
{ key: 'wechat_api_v3_key', label: 'APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
|
|
||||||
{ key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' },
|
|
||||||
{ key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'alipay',
|
|
||||||
title: '支付宝配置',
|
|
||||||
icon: 'fab fa-alipay',
|
|
||||||
description: '开启「支付宝」后填写应用与证书。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_alipay_enabled',
|
|
||||||
dependsOn: { key: 'payment_alipay_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
|
|
||||||
{ key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' },
|
|
||||||
{ key: 'alipay_payment_type', label: '支付方式', type: 'select', default: 'page', options: [
|
|
||||||
{ label: '电脑网站支付(web)', value: 'page' },
|
|
||||||
{ label: '手机网站支付(wap)', value: 'wap' },
|
|
||||||
] },
|
|
||||||
{ key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步通知 / 同步返回已固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return},无需在此填写。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'paypal',
|
|
||||||
title: 'PayPal 配置',
|
|
||||||
icon: 'fab fa-paypal',
|
|
||||||
description: '开启「PayPal」后填写 Client 凭据。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_paypal_enabled',
|
|
||||||
dependsOn: { key: 'payment_paypal_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' },
|
|
||||||
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' },
|
|
||||||
{ key: 'paypal_currency', label: '货币', type: 'input', default: 'USD' },
|
|
||||||
{ key: 'paypal_sandbox', label: '沙箱模式', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stripe',
|
|
||||||
title: 'Stripe 配置',
|
|
||||||
icon: 'fab fa-stripe',
|
|
||||||
description: '开启「Stripe」后填写 API Key 与 Webhook。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_stripe_enabled',
|
|
||||||
dependsOn: { key: 'payment_stripe_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'stripe_secret_key', label: 'Secret Key', type: 'input', default: '' },
|
|
||||||
{ key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' },
|
|
||||||
{ key: 'stripe_webhook_secret', label: 'Webhook Secret', type: 'input', default: '', help: 'Stripe Dashboard 配置 Webhook 后获得,用于验签。' },
|
|
||||||
{ key: 'stripe_currency', label: '货币', type: 'input', default: 'usd' },
|
|
||||||
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'epay',
|
|
||||||
title: '易支付配置',
|
|
||||||
icon: 'fas fa-university',
|
|
||||||
description: '开启「易支付」后填写接口地址与商户信息,并选择支付宝/微信子通道。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_epay_enabled',
|
|
||||||
dependsOn: { key: 'payment_epay_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'epay_apiurl', label: '接口地址', type: 'input', default: '', help: '易支付站点根地址,如 https://pay.example.com/(末尾斜杠可选)。' },
|
|
||||||
{ key: 'epay_pid', label: '商户ID', type: 'input', default: '' },
|
|
||||||
{ key: 'epay_key', label: '商户密钥', type: 'input', default: '', help: 'MD5 通信密钥。' },
|
|
||||||
{ key: 'epay_channel_alipay', label: '启用支付宝通道', type: 'switch', default: true },
|
|
||||||
{ key: 'epay_channel_wxpay', label: '启用微信通道', type: 'switch', default: true },
|
|
||||||
{ key: 'epay_device', label: '下单终端', type: 'select', default: 'auto', options: [
|
|
||||||
{ label: '自动(按 UA)', value: 'auto' },
|
|
||||||
{ label: '电脑端 submit', value: 'pc' },
|
|
||||||
{ label: '移动端 mapi', value: 'mobile' },
|
|
||||||
] },
|
|
||||||
{ key: 'epay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步/同步回调固定为 /wp-json/aksrt/v1/payment/epay_alipay/{notify|return} 与 /payment/epay_wxpay/{notify|return},请在易支付后台放行对应 URL。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export const featureSchema = {
|
|
||||||
key: 'post_list.feature',
|
|
||||||
title: '文章功能',
|
|
||||||
icon: 'fas fa-magic',
|
|
||||||
description: '配置文章页的互动功能开关。',
|
|
||||||
defaults: {
|
|
||||||
feature_pdf_export: false,
|
|
||||||
feature_print: true,
|
|
||||||
feature_word_count: true,
|
|
||||||
feature_reading_time: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'tools',
|
|
||||||
title: '工具功能',
|
|
||||||
icon: 'fas fa-tools',
|
|
||||||
fields: [
|
|
||||||
{ key: 'feature_print', label: '打印文章', type: 'switch', default: true },
|
|
||||||
{ key: 'feature_word_count', label: '字数统计', type: 'switch', default: true },
|
|
||||||
{ key: 'feature_reading_time', label: '预计阅读时间', type: 'switch', default: true },
|
|
||||||
{ key: 'feature_pdf_export', label: 'PDF 导出', type: 'switch', default: false, help: '实验性功能' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Post & List domain schemas.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { listSchema } from './list.js'
|
|
||||||
import { thumbSchema } from './thumb.js'
|
|
||||||
import { pageSchema } from './page.js'
|
|
||||||
import { filterSchema } from './filter.js'
|
|
||||||
import { featureSchema } from './feature.js'
|
|
||||||
import { paySchema } from './pay.js'
|
|
||||||
import { shareSchema } from './share.js'
|
|
||||||
import { commentSchema } from './comment.js'
|
|
||||||
import { submitSchema } from './submit.js'
|
|
||||||
|
|
||||||
export const postListSchemas = {
|
|
||||||
list: listSchema,
|
|
||||||
thumb: thumbSchema,
|
|
||||||
page: pageSchema,
|
|
||||||
filter: filterSchema,
|
|
||||||
feature: featureSchema,
|
|
||||||
pay: paySchema,
|
|
||||||
share: shareSchema,
|
|
||||||
comment: commentSchema,
|
|
||||||
submit: submitSchema,
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export const paySchema = {
|
|
||||||
key: 'post_list.pay',
|
|
||||||
title: '付费内容',
|
|
||||||
icon: 'fas fa-yen-sign',
|
|
||||||
description: '配置文章付费阅读、下载功能和购买用户显示。',
|
|
||||||
defaults: {
|
|
||||||
post_pay_enable: false,
|
|
||||||
post_pay_price_default: 0,
|
|
||||||
post_pay_free_logged_show: false,
|
|
||||||
post_pay_box_position: 'content',
|
|
||||||
post_pay_mark: '付费',
|
|
||||||
post_pay_show_user: false,
|
|
||||||
post_download_enable: false,
|
|
||||||
post_download_remote_s: false,
|
|
||||||
post_download_verify_nonce: true,
|
|
||||||
post_download_user_log: true,
|
|
||||||
post_download_post_log: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '付费设置',
|
|
||||||
icon: 'fas fa-coins',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_pay_enable', label: '启用付费内容', type: 'switch', default: false },
|
|
||||||
{ key: 'post_pay_price_default', label: '默认价格', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'post_pay_enable', value: true } },
|
|
||||||
{ key: 'post_pay_free_logged_show', label: '登录用户显示免费', type: 'switch', default: false, dependsOn: { key: 'post_pay_enable', value: true } },
|
|
||||||
{ key: 'post_pay_box_position', label: '付费框位置', type: 'radio', default: 'content', options: [{ label: '内容前', value: 'content' }, { label: '全文遮罩', value: 'mask' }], dependsOn: { key: 'post_pay_enable', value: true } },
|
|
||||||
{ key: 'post_pay_mark', label: '付费标记', type: 'input', default: '付费', dependsOn: { key: 'post_pay_enable', value: true } },
|
|
||||||
{ key: 'post_pay_show_user', label: '显示购买用户', type: 'switch', default: false, dependsOn: { key: 'post_pay_enable', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'download',
|
|
||||||
title: '下载设置',
|
|
||||||
icon: 'fas fa-download',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_download_enable', label: '启用下载功能', type: 'switch', default: false },
|
|
||||||
{ key: 'post_download_remote_s', label: '远程文件下载', type: 'switch', default: false, dependsOn: { key: 'post_download_enable', value: true } },
|
|
||||||
{ key: 'post_download_verify_nonce', label: '下载鉴权', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } },
|
|
||||||
{ key: 'post_download_user_log', label: '用户下载日志', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } },
|
|
||||||
{ key: 'post_download_post_log', label: '文章下载日志', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export const shareSchema = {
|
|
||||||
key: 'post_list.share',
|
|
||||||
title: '分享设置',
|
|
||||||
icon: 'fas fa-share-alt',
|
|
||||||
description: '配置微信分享凭据、海报分享和社交分享选项。',
|
|
||||||
defaults: {
|
|
||||||
share_wechat_s: false,
|
|
||||||
share_wechat_appid: '',
|
|
||||||
share_wechat_app_secret: '',
|
|
||||||
share_only_logo: false,
|
|
||||||
share_img_compatible_s: false,
|
|
||||||
share_img_byimg: '',
|
|
||||||
share_logo: '',
|
|
||||||
share_img: '',
|
|
||||||
share_desc: '扫一扫 立即查看',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'wechat',
|
|
||||||
title: '微信分享',
|
|
||||||
icon: 'fab fa-weixin',
|
|
||||||
fields: [
|
|
||||||
{ key: 'share_wechat_s', label: '启用微信分享', type: 'switch', default: false, help: '启用后文章页将支持微信 JSAPI 分享' },
|
|
||||||
{ key: 'share_wechat_appid', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppID' },
|
|
||||||
{ key: 'share_wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppSecret(请确保 JS 安全域名已配置)' },
|
|
||||||
{ key: 'share_only_logo', label: '仅显示 Logo', type: 'switch', default: false, help: '分享时仅显示站点 Logo' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'poster',
|
|
||||||
title: '海报分享',
|
|
||||||
icon: 'fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'share_img_compatible_s', label: '兼容模式', type: 'switch', default: false, help: '启用后使用兼容模式生成分享海报' },
|
|
||||||
{ key: 'share_img_byimg', label: '海报默认图', type: 'media', default: '', help: '海报分享使用的默认背景图' },
|
|
||||||
{ key: 'share_logo', label: '海报 LOGO', type: 'media', default: '', help: '海报上叠加的站点 LOGO' },
|
|
||||||
{ key: 'share_img', label: '分享图片', type: 'media', default: '', help: '社交平台分享预览图' },
|
|
||||||
{ key: 'share_desc', label: '分享描述', type: 'textarea', default: '扫一扫 立即查看', help: '海报或分享卡片的描述文案' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
export const thumbSchema = {
|
|
||||||
key: 'post_list.thumb',
|
|
||||||
title: '列表缩略图',
|
|
||||||
icon: 'fas fa-images',
|
|
||||||
description: '配置缩略图来源优先级、尺寸、占位图和多图模式。',
|
|
||||||
defaults: {
|
|
||||||
thumb_priority: 'featured,attachment,resource,custom',
|
|
||||||
thumb_fallback: 'placeholder',
|
|
||||||
thumb_spare_images: [],
|
|
||||||
thumb_lazy: true,
|
|
||||||
thumb_aspect_ratio: '16:9',
|
|
||||||
list_thumb_fit: 'cover',
|
|
||||||
thumb_multi_enabled: false,
|
|
||||||
thumb_multi_count: 3,
|
|
||||||
list_thumb_slides_s: true,
|
|
||||||
list_thumb_video_s: true,
|
|
||||||
list_thumb_video_mute_s: true,
|
|
||||||
thumb_postfirstimg_s: true,
|
|
||||||
thumb_catimg_s: true,
|
|
||||||
mult_thumb_cat: [],
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'source',
|
|
||||||
title: '图片来源',
|
|
||||||
icon: 'fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'thumb_priority', label: '图片优先级', type: 'input', default: 'featured,attachment,resource,custom', help: '逗号分隔: featured(特色图), attachment(附件), resource(资源封面), custom(自定义), post_first_img(文章首图), category_cover(分类封面)' },
|
|
||||||
{ key: 'thumb_fallback', label: '无图时处理', type: 'radio', default: 'placeholder', options: [
|
|
||||||
{ label: '显示占位图', value: 'placeholder' },
|
|
||||||
{ label: '隐藏缩略图', value: 'hide' },
|
|
||||||
{ label: '随机封面', value: 'random' },
|
|
||||||
]},
|
|
||||||
{ key: 'thumb_spare_images', label: '备用缩略图', type: 'gallery', default: [], help: '当无图处理选择"随机封面"时从此图库随机选取', dependsOn: { key: 'thumb_fallback', value: 'random' } },
|
|
||||||
{ key: 'thumb_aspect_ratio', label: '图片比例', type: 'select', default: '16:9', options: [
|
|
||||||
{ label: '16:9 宽屏', value: '16:9' },
|
|
||||||
{ label: '4:3 标准', value: '4:3' },
|
|
||||||
{ label: '1:1 正方形', value: '1:1' },
|
|
||||||
{ label: '自适应', value: 'auto' },
|
|
||||||
]},
|
|
||||||
{ key: 'list_thumb_fit', label: '缩略图填充方式', type: 'radio', default: 'cover', options: [
|
|
||||||
{ label: '填充裁剪', value: 'cover' },
|
|
||||||
{ label: '完整显示', value: 'contain' },
|
|
||||||
]},
|
|
||||||
{ key: 'list_thumb_slides_s', label: '幻灯片缩略图', type: 'switch', default: true, help: '允许幻灯片作为列表缩略图' },
|
|
||||||
{ key: 'list_thumb_video_s', label: '视频缩略图', type: 'switch', default: true, help: '鼠标悬停时自动播放视频缩略图' },
|
|
||||||
{ key: 'list_thumb_video_mute_s', label: '视频缩略图静音', type: 'switch', default: true, dependsOn: { key: 'list_thumb_video_s', value: true }, help: '视频缩略图播放时静音' },
|
|
||||||
{ key: 'thumb_postfirstimg_s', label: '自动获取文章首图', type: 'switch', default: true, help: '自动从文章内容中提取第一张图片作为缩略图' },
|
|
||||||
{ key: 'thumb_postfirstimg_size', label: '首图尺寸', type: 'select', default: 'medium', options: [
|
|
||||||
{ label: '缩略图', value: 'thumbnail' },
|
|
||||||
{ label: '中等', value: 'medium' },
|
|
||||||
{ label: '大', value: 'large' },
|
|
||||||
{ label: '原始', value: 'full' },
|
|
||||||
], dependsOn: { key: 'thumb_postfirstimg_s', value: true }, help: '自动获取文章首图时使用的 WP 图片尺寸' },
|
|
||||||
{ key: 'thumb_catimg_s', label: '自动获取分类封面', type: 'switch', default: true, help: '无特色图时使用分类封面图作为缩略图' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'multi',
|
|
||||||
title: '多图模式',
|
|
||||||
icon: 'fas fa-images',
|
|
||||||
fields: [
|
|
||||||
{ key: 'thumb_multi_enabled', label: '多图卡片', type: 'switch', default: false, help: '当文章有多张图片时,在列表中展示多图预览' },
|
|
||||||
{ key: 'thumb_multi_count', label: '最多显示几张', type: 'number', default: 3, min: 2, max: 6, dependsOn: { key: 'thumb_multi_enabled', value: true } },
|
|
||||||
{ key: 'mult_thumb_cat', label: '按分类多图显示', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '选择的分类下所有文章自动显示多张缩略图' },
|
|
||||||
{ key: 'thumb_lazy', label: '懒加载', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export const backupSchema = {
|
|
||||||
key: 'tools.backup',
|
|
||||||
title: '数据备份',
|
|
||||||
icon: 'fas fa-cloud-download-alt',
|
|
||||||
description: '站点数据备份与恢复。',
|
|
||||||
defaults: {},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'backup',
|
|
||||||
title: '备份设置',
|
|
||||||
icon: 'fas fa-save',
|
|
||||||
fields: [
|
|
||||||
{ key: 'backup_auto', label: '自动备份', type: 'switch', help: '启用自动定时备份' },
|
|
||||||
{
|
|
||||||
key: 'backup_frequency',
|
|
||||||
label: '备份频率',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: '每天', value: 'daily' },
|
|
||||||
{ label: '每周', value: 'weekly' },
|
|
||||||
{ label: '每月', value: 'monthly' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: 'backup_retain', label: '保留份数', type: 'number', min: 3, max: 20, default: 7 },
|
|
||||||
{ key: 'backup_now', label: '立即备份', type: 'button', help: '手动触发一次完整备份' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export const emailRollbackSchema = {
|
|
||||||
key: 'tools.email-rollback',
|
|
||||||
title: '邮件回滚',
|
|
||||||
icon: 'fas fa-undo',
|
|
||||||
description: '批量回滚已发送的通知邮件。',
|
|
||||||
defaults: {},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'rollback',
|
|
||||||
title: '邮件回滚',
|
|
||||||
icon: 'fas fa-envelope-open',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'rollback_type',
|
|
||||||
label: '回滚类型',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: '按时间范围', value: 'date_range' },
|
|
||||||
{ label: '按邮件类型', value: 'email_type' },
|
|
||||||
{ label: '全部回滚', value: 'all' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: 'rollback_date_from', label: '开始日期', type: 'date' },
|
|
||||||
{ key: 'rollback_date_to', label: '结束日期', type: 'date' },
|
|
||||||
{ key: 'rollback_execute', label: '执行回滚', type: 'button', danger: true, help: '此操作不可逆' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user