fix(admin): restore Vite admin source files
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="aksrt-schema-page">
|
||||
<AksFormRenderer
|
||||
:title="pageMeta.title"
|
||||
:description="pageMeta.description"
|
||||
:icon="pageMeta.icon"
|
||||
:schema="pageMeta.groups"
|
||||
:form-data="formData"
|
||||
:dirty="dirty"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:load-error="loadError"
|
||||
:show-search="showSearch"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@reload="handleReload"
|
||||
>
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-tencent="{ value, setValue }"
|
||||
>
|
||||
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-aliyun="{ value, setValue }"
|
||||
>
|
||||
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-cloudflare="{ value, setValue }"
|
||||
>
|
||||
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-geetest="{ value, setValue }"
|
||||
>
|
||||
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsModeration"
|
||||
#field-geoip_block_countries="{ value, setValue }"
|
||||
>
|
||||
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
|
||||
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
|
||||
</template>
|
||||
|
||||
<!-- Slot for custom group content (override per page if needed) -->
|
||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData" />
|
||||
</template>
|
||||
</AksFormRenderer>
|
||||
|
||||
<div
|
||||
v-if="isPermissionsLogin && !loading"
|
||||
class="aksrt-schema-page__tool-actions"
|
||||
>
|
||||
<n-button
|
||||
:disabled="!formData?.enabled || !formData?.active_provider"
|
||||
@click="handleCaptchaTest"
|
||||
>
|
||||
测试验证
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="showTestModal" class="captcha-test-overlay" @click.self="closeTestModal">
|
||||
<div class="captcha-test-panel">
|
||||
<div class="captcha-test-header">
|
||||
<span>测试验证码</span>
|
||||
<button class="captcha-test-close" @click="closeTestModal">×</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>
|
||||
@@ -0,0 +1,403 @@
|
||||
<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>
|
||||
@@ -0,0 +1,986 @@
|
||||
<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>
|
||||
@@ -0,0 +1,518 @@
|
||||
<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>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button :loading="cleaning" @click="runCleanup">立即清理</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveSettings">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-card title="通知内容开关" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="评论回复通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_reply" />
|
||||
</n-form-item>
|
||||
<n-form-item label="评论审核通过通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_approved" />
|
||||
</n-form-item>
|
||||
<n-form-item label="资源下载就绪通知">
|
||||
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分增加通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_added" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分扣减通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱待验证提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
||||
</n-form-item>
|
||||
<n-form-item label="待审核评论提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.pending_comments" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图片审核通知">
|
||||
<n-switch v-model:value="settings.enabled_types.upload_moderation" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
|
||||
<n-grid-item span="24 l:10">
|
||||
<n-card title="user 徽标规则" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="启用 user 动态徽标">
|
||||
<n-switch v-model:value="settings.user_badge_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="徽标包含状态提醒">
|
||||
<n-switch v-model:value="settings.badge_include_stateful" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="24 l:14">
|
||||
<n-card title="通知清理策略" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="已读通知保留天数">
|
||||
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="未读通知保留天数">
|
||||
<n-input-number v-model:value="settings.unread_retention_days" :min="7" :max="3650" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="每用户最大保留数">
|
||||
<n-input-number v-model:value="settings.max_per_user" :min="20" :max="5000" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const cleaning = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
enabled_types: {
|
||||
comment_reply: true,
|
||||
comment_approved: true,
|
||||
download_ready: true,
|
||||
credits_added: true,
|
||||
credits_deducted: true,
|
||||
verify_email: true,
|
||||
pending_comments: true,
|
||||
upload_moderation: true,
|
||||
},
|
||||
user_badge_enabled: true,
|
||||
badge_include_stateful: true,
|
||||
read_retention_days: 30,
|
||||
unread_retention_days: 180,
|
||||
max_per_user: 200,
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.getNotificationSettings()
|
||||
if (res.code === 'success' && res.data) {
|
||||
Object.assign(settings, res.data)
|
||||
if (res.data.enabled_types) {
|
||||
settings.enabled_types = { ...settings.enabled_types, ...res.data.enabled_types }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.saveNotificationSettings({
|
||||
enabled_types: settings.enabled_types,
|
||||
user_badge_enabled: settings.user_badge_enabled,
|
||||
badge_include_stateful: settings.badge_include_stateful,
|
||||
read_retention_days: settings.read_retention_days,
|
||||
unread_retention_days: settings.unread_retention_days,
|
||||
max_per_user: settings.max_per_user,
|
||||
})
|
||||
if (res.code === 'success') message.success(res.message || '保存成功')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
cleaning.value = true
|
||||
try {
|
||||
const res = await api.runNotificationCleanup()
|
||||
if (res.code === 'success') message.success(res.message || '清理完成')
|
||||
} catch (e) {
|
||||
message.error(e.message || '清理失败')
|
||||
} finally {
|
||||
cleaning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
@@ -0,0 +1,316 @@
|
||||
<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>
|
||||
@@ -0,0 +1,270 @@
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader :title="title" :desc="desc">
|
||||
<template #actions>
|
||||
<n-space v-if="links.length">
|
||||
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
|
||||
{{ link.label }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-card v-if="note" class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ note }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
links: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,530 @@
|
||||
<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>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader :title="title" :desc="desc">
|
||||
<template #actions>
|
||||
<n-space v-if="links.length">
|
||||
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
|
||||
{{ link.label }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-card v-if="note" class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ note }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<slot name="before-form" />
|
||||
|
||||
<AksFormRenderer
|
||||
:schema="pageMeta.groups"
|
||||
:form-data="formData"
|
||||
:dirty="dirty"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:load-error="loadError"
|
||||
:show-search="false"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@reload="handleReload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
||||
import { useSectionForm } from '@/composables/useSectionForm'
|
||||
import { getSchema } from '@/schema'
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
links: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
|
||||
const defaults = computed(() => {
|
||||
const result = {}
|
||||
;(pageMeta.value.groups || []).forEach((group) => {
|
||||
;(group.fields || []).forEach((field) => {
|
||||
if (field.default !== undefined) {
|
||||
result[field.key] = field.default
|
||||
}
|
||||
})
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const {
|
||||
formData,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
dirty,
|
||||
save: doSave,
|
||||
reload,
|
||||
} = useSectionForm({
|
||||
fetch: () => api.getSchemaPage('payment', props.section),
|
||||
submit: (data) => api.saveSchemaPage('payment', props.section, data),
|
||||
defaults: defaults.value,
|
||||
onSaved: () => {
|
||||
message.success('设置已保存')
|
||||
},
|
||||
})
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await doSave()
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleReload() {
|
||||
reload()
|
||||
message.info('已放弃更改')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="充值卡详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.card">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="使用者">
|
||||
{{ detail.card.used_by_user?.display_name || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-charge-drawer__section">
|
||||
<div class="aksrt-charge-drawer__title">备注</div>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ detail.card.note || '暂无备注' }}
|
||||
</n-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无充值卡详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
cardId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
defineEmits(['update:show'])
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const detail = ref({ card: null })
|
||||
|
||||
watch(
|
||||
() => [props.show, props.cardId],
|
||||
async ([visible, id]) => {
|
||||
if (visible && id) {
|
||||
await loadDetail()
|
||||
}
|
||||
if (!visible) {
|
||||
detail.value = { card: null }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.cardId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
|
||||
detail.value = res?.data || { card: null }
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载充值卡详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-charge-drawer__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.aksrt-charge-drawer__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="返佣详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.record">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">备注</div>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ detail.record.note || '暂无备注' }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理记录</div>
|
||||
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
|
||||
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
|
||||
<div class="aksrt-rebate-drawer__timeline-head">
|
||||
<strong>{{ entry.event || '-' }}</strong>
|
||||
<span>{{ entry.time || '-' }}</span>
|
||||
</div>
|
||||
<p>{{ entry.note || '无备注' }}</p>
|
||||
<small>{{ entry.operator_name || '系统' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无处理记录" />
|
||||
</div>
|
||||
|
||||
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理返佣</div>
|
||||
<n-space vertical :size="12">
|
||||
<n-input
|
||||
v-model:value="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录结算说明或取消原因"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
|
||||
结算返佣
|
||||
</n-button>
|
||||
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
|
||||
取消返佣
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无返佣详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
rebateId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const submitting = ref('')
|
||||
const detail = ref({ record: null, history: [] })
|
||||
const form = reactive({ note: '' })
|
||||
|
||||
watch(
|
||||
() => [props.show, props.rebateId],
|
||||
async ([visible, id]) => {
|
||||
if (visible && id) {
|
||||
await loadDetail()
|
||||
}
|
||||
if (!visible) {
|
||||
detail.value = { record: null, history: [] }
|
||||
form.note = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/payment/rebate/${props.rebateId}`)
|
||||
detail.value = res?.data || { record: null, history: [] }
|
||||
form.note = ''
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载返佣详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = action
|
||||
try {
|
||||
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
|
||||
method: 'POST',
|
||||
data: { note: form.note },
|
||||
})
|
||||
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '处理失败')
|
||||
} finally {
|
||||
submitting.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-rebate-drawer__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item p {
|
||||
margin: 0 0 6px;
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item small {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SchemaPage domain="post_list" section="comment" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SchemaPage domain="post_list" section="page" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,265 @@
|
||||
<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>
|
||||
@@ -0,0 +1,278 @@
|
||||
<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>
|
||||
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
@@ -0,0 +1,111 @@
|
||||
<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>
|
||||
@@ -0,0 +1,190 @@
|
||||
<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>
|
||||
@@ -0,0 +1,265 @@
|
||||
<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>
|
||||
@@ -0,0 +1,210 @@
|
||||
<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>
|
||||
@@ -0,0 +1,290 @@
|
||||
<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>
|
||||
@@ -0,0 +1,308 @@
|
||||
<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>
|
||||
@@ -0,0 +1,357 @@
|
||||
<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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
@@ -0,0 +1,242 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user