导入 AKSRT 主题开发源码

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

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="aksrt-page-container">
<h2 class=”aksrt-page-title”>积分配置</h2>
<p class=”aksrt-page-desc”>
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。
</p>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>主开关 + 基础设置</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用积分系统">
<n-switch v-model:value="cfg.enabled" />
</n-form-item>
<n-form-item label="自定义货币名称">
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
<template #feedback>
<span class="aksrt-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
</template>
</n-form-item>
<n-form-item label="注册赠送积分">
<n-input-number v-model:value="cfg.register_bonus" :min="0" :max="10000" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>每日签到</h3></div>
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.daily_signin.enabled" />
</n-form-item>
<n-form-item label="基础奖励">
<n-input-number v-model:value="cfg.daily_signin.base" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="连续奖励(JSON)">
<n-input
type="textarea"
v-model:value="streakJson"
placeholder='{"3":2,"7":5,"30":20}'
:autosize="{ minRows: 2 }"
/>
<template #feedback>
<span class="aksrt-hint-text">连续 N 天时额外发放的积分。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>评论奖励</h3></div>
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.comment_reward.enabled" />
</n-form-item>
<n-form-item label="单次奖励">
<n-input-number v-model:value="cfg.comment_reward.amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="每日上限">
<n-input-number v-model:value="cfg.comment_reward.daily_cap" :min="0" :max="100" />
</n-form-item>
<n-form-item label="最少字符数">
<n-input-number v-model:value="cfg.comment_reward.min_length" :min="0" :max="500" />
</n-form-item>
<n-form-item label="审核通过后发放">
<n-switch v-model:value="cfg.comment_reward.on_approved" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>邀请奖励</h3></div>
<n-form :model="cfg.invite_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.invite_reward.enabled" />
</n-form-item>
<n-form-item label="邀请人奖励">
<n-input-number v-model:value="cfg.invite_reward.inviter_amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="被邀请人奖励">
<n-input-number v-model:value="cfg.invite_reward.invitee_amount" :min="0" :max="10000" />
</n-form-item>
<n-form-item label="需邮箱验证后发放">
<n-switch v-model:value="cfg.invite_reward.require_verify" />
</n-form-item>
<n-form-item label="同 IP 当日上限">
<n-input-number v-model:value="cfg.invite_reward.ip_daily_cap" :min="0" :max="100" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>下载消费</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item :label="`默认资源价格(${cfg.currency_name || '积分'})`">
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
<template #feedback>
<span class="aksrt-hint-text">资源未单独设价时回退使用。设为 0 表示免费</span>
</template>
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-footer">
<n-button type="primary" :loading="saving" @click="saveCfg">保存配置</n-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const message = useMessage()
// ---------- 配置 ----------
const cfg = reactive({
enabled: true,
currency_name: '',
register_bonus: 10,
daily_signin: { enabled: true, base: 1, streak: { 3: 2, 7: 5, 30: 20 } },
comment_reward: { enabled: true, amount: 1, daily_cap: 5, min_length: 10, on_approved: true },
invite_reward: {
enabled: true,
inviter_amount: 5,
invitee_amount: 2,
require_verify: true,
ip_daily_cap: 3,
register_field: 'optional',
reject_invalid: true,
},
download_default_cost: 0,
})
const streakJson = ref('{}')
const saving = ref(false)
async function loadCfg() {
try {
const data = await api.getCreditSettings()
Object.assign(cfg, data)
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
} catch (e) {
message.error(e.message || '加载失败')
}
}
async function saveCfg() {
saving.value = true
try {
let streak = {}
try { streak = JSON.parse(streakJson.value || '{}') } catch (_) { /* ignore */ }
cfg.daily_signin.streak = streak
await api.saveCreditSettings(cfg)
message.success('已保存')
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadCfg()
})
</script>
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="下载/付费内容中心"
desc="统一管理资源下载门槛、默认扣费、角色免扣和人工交付说明。运行中的下载 token 流程保持不变。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
<n-button quaternary @click="go('/payment/gateway')">收款接口</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动摘要</h3>
</div>
<n-space>
<n-tag round :type="form.download_login ? 'warning' : 'default'">
登录门槛:{{ form.download_login ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.download_credit ? 'warning' : 'default'">
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.download_captcha ? 'warning' : 'default'">
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round type="info">
默认价格:{{ form.download_default_cost }} {{ currencyName }}
</n-tag>
<n-tag round :type="form.payment_vip_free_download ? 'success' : 'default'">
角色免扣:{{ form.payment_vip_free_download ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="info">
Token TTL:10 分钟
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>下载门槛</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="下载需要登录">
<n-switch v-model:value="form.download_login" />
</n-form-item>
<n-form-item label="下载启用积分扣费">
<n-switch v-model:value="form.download_credit" />
</n-form-item>
<n-form-item label="下载启用验证码">
<n-switch v-model:value="form.download_captcha" />
</n-form-item>
<n-form-item :label="`默认资源价格(${currencyName})`">
<n-input-number v-model:value="form.download_default_cost" :min="0" :max="100000" style="width: 180px;" />
<template #feedback>
<span class="aksrt-hint-text">资源未单独设价时回退到这里。角色免扣命中时会直接跳过积分扣费。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>付费内容策略</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="受管内容类型">
<n-input v-model:value="form.payment_paid_post_types" placeholder="post,course,doc" />
<template #feedback>
<span class="aksrt-hint-text">逗号分隔,标记纳入统一付费策略的内容类型。</span>
</template>
</n-form-item>
<n-form-item label="角色白名单免扣下载">
<n-switch v-model:value="form.payment_vip_free_download" />
</n-form-item>
<n-form-item label="免扣角色列表" v-if="form.payment_vip_free_download">
<n-input v-model:value="form.payment_vip_exempt_roles" placeholder="administrator,editor,vip" />
<template #feedback>
<span class="aksrt-hint-text">命中这些 WordPress role 的用户下载资源时将直接跳过积分扣费。</span>
</template>
</n-form-item>
<n-form-item label="前台付费提示">
<n-input
v-model:value="form.payment_paid_catalog_note"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="可填写资源购买须知、交付说明或人工确认提示。"
/>
</n-form-item>
<n-form-item label="人工交付备注">
<n-input
v-model:value="form.payment_manual_delivery_note"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
当前收款模式:{{ gatewayModeLabel }}。充值、VIP、返佣和分成页均已提供稳定结构,订单和结算台账直接衔接这些字段。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const featureSnapshot = ref({})
const creditSnapshot = ref({})
const overview = ref({})
const form = reactive({
download_login: true,
download_credit: true,
download_captcha: false,
download_default_cost: 0,
payment_paid_post_types: 'post',
payment_vip_free_download: false,
payment_vip_exempt_roles: '',
payment_paid_catalog_note: '',
payment_manual_delivery_note: '付费或人工确认内容请在订单备注中记录交付方式。',
})
const currencyName = computed(() => overview.value.currency_name || creditSnapshot.value.currency_name || '积分')
const gatewayModeLabel = computed(() => {
const mode = overview.value.gateway?.mode || 'manual_review'
if (mode === 'test_gateway') return '测试网关'
if (mode === 'adapter_ready') return '适配器就绪'
return '人工确认'
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [featureRes, creditRes, schemaRes, overviewRes] = await Promise.all([
api.getCmsFeatures(),
api.getCreditSettings(),
api.getSchemaPage('payment', 'paid'),
api.getPaymentOverview(),
])
const featureData = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
const creditData = extractData(creditRes)
const schemaData = extractData(schemaRes)
featureSnapshot.value = featureData
creditSnapshot.value = creditData
overview.value = extractData(overviewRes)
form.download_login = featureData.download_login !== false
form.download_credit = featureData.download_credit !== false
form.download_captcha = !!featureData.download_captcha
form.download_default_cost = Number(creditData.download_default_cost || 0)
form.payment_paid_post_types = schemaData.payment_paid_post_types || 'post'
form.payment_vip_free_download = !!schemaData.payment_vip_free_download
form.payment_vip_exempt_roles = schemaData.payment_vip_exempt_roles || ''
form.payment_paid_catalog_note = schemaData.payment_paid_catalog_note || ''
form.payment_manual_delivery_note = schemaData.payment_manual_delivery_note || '付费或人工确认内容请在订单备注中记录交付方式。'
} catch (error) {
message.error(error.message || '加载下载/付费内容中心失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const nextFeatures = {
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
}
const nextCredits = {
...creditSnapshot.value,
download_default_cost: Number(form.download_default_cost || 0),
}
await Promise.all([
api.saveCmsFeatures(nextFeatures),
api.saveCreditSettings(nextCredits),
api.saveSchemaPage('payment', 'paid', {
payment_paid_post_types: form.payment_paid_post_types,
payment_vip_free_download: form.payment_vip_free_download,
payment_vip_exempt_roles: form.payment_vip_exempt_roles,
payment_paid_catalog_note: form.payment_paid_catalog_note,
payment_manual_delivery_note: form.payment_manual_delivery_note,
}),
])
featureSnapshot.value = nextFeatures
creditSnapshot.value = nextCredits
message.success('下载/付费内容中心已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
+508
View File
@@ -0,0 +1,508 @@
<template>
<div class="aksrt-schema-page">
<AksFormRenderer
:title="pageMeta.title"
:description="pageMeta.description"
:icon="pageMeta.icon"
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="showSearch"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
>
<template
v-if="isPermissionsLogin"
#field-tencent="{ value, setValue }"
>
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-aliyun="{ value, setValue }"
>
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-cloudflare="{ value, setValue }"
>
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsLogin"
#field-geetest="{ value, setValue }"
>
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
</template>
<template
v-if="isPermissionsModeration"
#field-geoip_block_countries="{ value, setValue }"
>
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
</template>
<!-- Slot for custom group content (override per page if needed) -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</AksFormRenderer>
<div
v-if="isPermissionsLogin && !loading"
class="aksrt-schema-page__tool-actions"
>
<n-button
:disabled="!formData?.enabled || !formData?.active_provider"
@click="handleCaptchaTest"
>
测试验证
</n-button>
</div>
<div v-if="showTestModal" class="captcha-test-overlay" @click.self="closeTestModal">
<div class="captcha-test-panel">
<div class="captcha-test-header">
<span>测试验证码</span>
<button class="captcha-test-close" @click="closeTestModal">&times;</button>
</div>
<div class="captcha-test-body">
<div v-if="testLoading" style="text-align:center;padding:40px;">
<n-spin />
<p style="margin-top:10px;color:#999;">加载中...</p>
</div>
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}">
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
</div>
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* SchemaPage — generic Schema-driven settings page.
*
* Reads the schema from the Vue-side schema registry, fetches/saves settings
* via the API client, and renders using AksFormRenderer.
*
* Usage (in router or parent):
* <SchemaPage domain="global" section="logo" />
*
* Or auto-detect from route meta:
* The router's meta.schemaKey provides domain/section lookup.
*/
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
import api from '@/api/index.js'
import TencentConfig from '@/components/captcha/TencentConfig.vue'
import AliyunConfig from '@/components/captcha/AliyunConfig.vue'
import CloudflareConfig from '@/components/captcha/CloudflareConfig.vue'
import GeetestConfig from '@/components/captcha/GeetestConfig.vue'
const props = defineProps({
/** Schema domain key (e.g. 'global') */
domain: { type: String, default: '' },
/** Schema section key (e.g. 'logo') */
section: { type: String, default: '' },
/** Whether to show inline search */
showSearch: { type: Boolean, default: true },
})
const route = useRoute()
const message = useMessage()
const showTestModal = ref(false)
const testLoading = ref(false)
const testResult = ref(null)
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
function resolveDomainFromPath(path) {
const parts = path.split('/').filter(Boolean)
return parts[0] || 'global'
}
/** Extract section from route path: /global/logo → logo */
function resolveSectionFromPath(path) {
const parts = path.split('/').filter(Boolean)
return parts[parts.length - 1] || 'common'
}
/** Load schema from the Vue-side registry */
const pageMeta = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
if (schema && schema.groups && schema.groups.length > 0) {
return {
title: schema.title || '',
description: schema.description || '',
icon: schema.icon || '',
groups: schema.groups,
}
}
// Fallback: read from route meta
return {
title: route.meta?.title || '',
description: route.meta?.desc || '',
icon: '',
groups: [],
}
})
/** Default values from schema */
const defaults = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
if (!schema) return {}
const d = {}
for (const group of schema.groups || []) {
for (const field of group.fields || []) {
if (field.default !== undefined) {
d[field.key] = field.default
}
}
}
return d
})
/** Use the section form composable */
const {
formData, loading, saving, loadError, dirty,
save: doSave, reload,
} = useSectionForm({
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value),
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data),
defaults: defaults.value,
onSaved: () => {
message.success('设置已保存')
},
})
async function handleSave() {
try {
await doSave()
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
}
}
function handleReload() {
closeTestModal()
reload()
message.info('已放弃更改')
}
function getCaptchaProviderConfig(provider) {
if (!formData.value || !provider) return {}
const cfg = formData.value[provider]
return cfg && typeof cfg === 'object' ? cfg : {}
}
function handleCaptchaTest() {
const provider = formData.value?.active_provider
if (!provider) return
const cfg = getCaptchaProviderConfig(provider)
const required = {
tencent: ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
aliyun: ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
cloudflare: ['site_key', 'secret_key'],
geetest: ['captcha_id', 'captcha_key'],
}
const missing = (required[provider] || []).filter((key) => !cfg[key])
if (missing.length) {
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
return
}
if (provider === 'tencent') {
loadTencentTest(cfg)
return
}
showTestModal.value = true
testResult.value = null
testLoading.value = true
nextTick(() => {
createTestIframe(provider, cfg)
})
}
function loadTencentTest(cfg) {
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
if (typeof window.TencentCaptcha !== 'undefined') {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
return
}
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
if (existingScript) {
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
return
}
const script = document.createElement('script')
script.src = sdkSrc
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
}
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
document.head.appendChild(script)
}
function createTestIframe(provider, cfg) {
setTimeout(() => {
testLoading.value = false
loadProviderSDK(provider, cfg)
}, 100)
}
function loadProviderSDK(provider, cfg) {
const oldScript = document.getElementById('captcha-test-sdk')
if (oldScript) oldScript.remove()
if (window.__gtTestCaptcha) {
try { window.__gtTestCaptcha.destroy() } catch (error) {}
window.__gtTestCaptcha = null
}
const script = document.createElement('script')
script.id = 'captcha-test-sdk'
switch (provider) {
case 'tencent':
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'cloudflare':
window.onTurnstileTestLoad = function() {
try {
window.turnstile.render('#captcha-test-box', {
sitekey: cfg.site_key,
theme: 'auto',
size: cfg.size || 'normal',
callback: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('Cloudflare Turnstile 测试成功!')
setTimeout(closeTestModal, 1500)
},
'error-callback': function() {
testResult.value = { success: false, message: '验证失败' }
},
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'geetest':
script.src = 'https://static.geetest.com/v4/gt4.js'
script.onload = function () {
try {
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
window.__gtTestCaptcha = captchaObj
const onSuccess = function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('极验 GeeTest 测试成功!')
setTimeout(closeTestModal, 1500)
}
const onError = function(error) {
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
}
const onClose = function() {
if (!testResult.value) testResult.value = { success: false, message: '已关闭' }
}
if ((cfg.product || 'float') === 'bind') {
captchaObj.onReady(() => captchaObj.showCaptcha()).onSuccess(onSuccess).onError(onError).onClose(onClose)
} else {
captchaObj.appendTo('#captcha-test-box').onReady(() => {}).onSuccess(onSuccess).onError(onError).onClose(onClose)
}
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'aliyun':
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
script.onload = function () {
try {
window.initAliyunCaptcha({
SceneId: cfg.scene_id,
mode: 'embed',
element: '#captcha-test-box',
success: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('阿里云测试成功!')
setTimeout(closeTestModal, 1500)
},
fail: function(result) {
testResult.value = { success: false, message: String(result) }
},
getInstance: function() {},
slideStyle: { width: 360, height: 40 },
language: 'cn',
})
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
break
default:
testResult.value = { success: false, message: '未知服务商' }
return
}
document.head.appendChild(script)
}
function closeTestModal() {
showTestModal.value = false
testResult.value = null
testLoading.value = false
if (window.__gtTestCaptcha) {
try { window.__gtTestCaptcha.destroy() } catch (error) {}
window.__gtTestCaptcha = null
}
const script = document.getElementById('captcha-test-sdk')
if (script) script.remove()
if (window.onTurnstileTestLoad) {
try { delete window.onTurnstileTestLoad } catch (error) {}
}
}
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
if (nextDomain !== prevDomain || nextSection !== prevSection) {
closeTestModal()
reload()
}
})
onBeforeUnmount(() => {
closeTestModal()
})
</script>
<style scoped>
.aksrt-schema-page {
max-width: 1100px;
margin: 0 auto;
}
.aksrt-schema-page__tool-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.aksrt-schema-page__field-hint {
margin: 8px 0 0;
font-size: 12px;
color: #8a909c;
}
.captcha-test-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.captcha-test-panel {
background: #fff;
border-radius: 12px;
width: 420px;
max-width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.captcha-test-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid #e8e8ec;
font-size: 16px;
font-weight: 600;
}
.captcha-test-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.captcha-test-close:hover {
color: #333;
}
.captcha-test-body {
padding: 24px;
min-height: 180px;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="站点设置" desc="集中管理评论、分页、懒加载、头像源与备案信息。">
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存全部</n-button>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- 1. 评论 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<h3>评论设置</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="全局评论开关">
<n-switch v-model:value="basic.comment_enabled">
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
<template #feedback>
<span class="aksrt-hint-text">关闭后全站禁用评论功能。</span>
</template>
</n-form-item>
<n-form-item v-if="basic.comment_enabled" label="必须注册才能评论">
<n-switch v-model:value="basic.comment_requires_registration">
<template #checked>是</template>
<template #unchecked>否</template>
</n-switch>
</n-form-item>
</n-form>
</n-card>
<!-- 2. 分页 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<h3>分页设置</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="文章列表分页">
<n-radio-group v-model:value="basic.post_pagination_mode">
<n-space size="large">
<n-radio-button value="numbered">数字翻页</n-radio-button>
<n-radio-button value="load_more">加载更多</n-radio-button>
<n-radio-button value="infinite">无限滚动</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="评论列表分页">
<n-radio-group v-model:value="basic.comment_pagination_mode">
<n-space size="large">
<n-radio-button value="numbered">数字翻页</n-radio-button>
<n-radio-button value="load_more">加载更多</n-radio-button>
<n-radio-button value="infinite">无限滚动</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</n-card>
<!-- 3. 懒加载 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
<h3>图片懒加载</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="启用懒加载">
<n-switch v-model:value="basic.lazyload_enabled">
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
<template #feedback>
<span class="aksrt-hint-text">开启后图片在进入视口时才加载,可显著提升页面加载速度。</span>
</template>
</n-form-item>
<n-form-item v-if="basic.lazyload_enabled" label="自定义占位图 URL">
<n-input v-model:value="basic.lazyload_placeholder_url" placeholder="留空使用默认占位图" />
</n-form-item>
</n-form>
</n-card>
<!-- 4. 头像 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
<h3>Gravatar 头像源</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="130" class="aksrt-form">
<n-form-item label="头像服务源">
<n-select v-model:value="basic.avatar_source" :options="avatarSourceOptions" placeholder="选择头像镜像源" />
<template #feedback>
<span class="aksrt-hint-text">国内站点建议优先选 Cravatar。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<!-- 5. 备案 -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
<h3>备案信息</h3>
</div>
<n-form :model="basic" label-placement="left" label-width="120" class="aksrt-form">
<n-form-item label="ICP 备案号">
<n-input v-model:value="basic.icp_number" placeholder="例如:ICP 备 xxxxxxxx 号" />
</n-form-item>
<n-form-item label="公安备案号">
<n-input v-model:value="basic.police_number" placeholder="例如:公网安备 xxxxxx 号" />
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存全部</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
// 基础设置(aksrt_basic_settings)
// 后端 /settings/basic 已经做了部分合并,本接口只发送基础字段;
// auth 相关字段由 /settings/auth 独立维护,互不影响。
const basic = reactive({
comment_enabled: true,
comment_requires_registration: false,
avatar_source: 'cn.cravatar.com',
icp_number: '',
police_number: '',
lazyload_enabled: true,
lazyload_placeholder_url: '',
post_pagination_mode: 'numbered',
comment_pagination_mode: 'numbered',
})
const avatarSourceOptions = [
{ label: 'Cravatar (国内推荐)', value: 'cn.cravatar.com' },
{ label: 'Gravatar 国内镜像', value: 'cn.gravatar.com' },
{ label: 'Gravatar 官方', value: 'gravatar.com' },
]
async function loadAll() {
loading.value = true
try {
const basicRes = await api.getBasicSettings()
if (basicRes?.code === 'success' && basicRes.data) {
const d = basicRes.data
basic.comment_enabled = d.comment_enabled !== false
basic.comment_requires_registration = !!d.comment_requires_registration
basic.avatar_source = d.avatar_source || 'cn.cravatar.com'
basic.icp_number = d.icp_number || ''
basic.police_number = d.police_number || ''
basic.lazyload_enabled = d.lazyload_enabled !== false
basic.lazyload_placeholder_url = d.lazyload_placeholder_url || ''
basic.post_pagination_mode = d.post_pagination_mode || 'numbered'
basic.comment_pagination_mode = d.comment_pagination_mode || 'numbered'
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const basicPayload = {
comment_enabled: basic.comment_enabled,
comment_requires_registration: basic.comment_requires_registration,
avatar_source: basic.avatar_source,
icp_number: basic.icp_number,
police_number: basic.police_number,
lazyload_enabled: basic.lazyload_enabled,
lazyload_placeholder_url: basic.lazyload_placeholder_url,
post_pagination_mode: basic.post_pagination_mode,
comment_pagination_mode: basic.comment_pagination_mode,
}
const res = await api.saveBasicSettings(basicPayload)
if (res?.code === 'success') {
message.success('已保存')
} else {
throw new Error(res?.message || '保存失败')
}
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(loadAll)
</script>
@@ -0,0 +1,392 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="Banner 管理"
desc="管理首页 Banner 数据,供固定首页和相关展示区域调用;可控制是否显示文字。"
/>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>Banner 显示开关</strong></template>
<n-form label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="显示首页 Banner">
<div class="aksrt-inline-setting">
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
</div>
</n-form-item>
</n-form>
<div class="aksrt-inline-setting__actions">
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
</div>
</n-card>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="14" x="3" y="5" rx="2"/>
<path d="M3 10h18"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
</div>
</template>
<n-data-table
v-if="banners.length"
:columns="columns"
:data="banners"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
</n-card>
</n-spin>
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="开启文字后显示的主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="开启文字后显示的副标题" />
</n-form-item>
<n-form-item label="Banner 图片">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
</div>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const banners = ref([])
const bannerEnabled = ref(true)
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
image_url: '',
link_url: '',
show_text: true,
enabled: true,
show_meta: false,
sort_order: 0,
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadBanners() {
try {
loading.value = true
const res = await api.getHomepageBanners()
if (res.code === 'success') {
banners.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function loadBannerSettings() {
settingsLoading.value = true
try {
const res = await api.getHomepageSettings()
const data = res?.data || {}
bannerEnabled.value = data.homepage_module_banner_enabled !== false
} catch (e) {
message.error(e.message || '加载 Banner 开关失败')
} finally {
settingsLoading.value = false
}
}
async function saveBannerSettings() {
settingsSaving.value = true
try {
const res = await api.saveHomepageSettings({
homepage_module_banner_enabled: bannerEnabled.value,
})
if (res?.code === 'success') {
message.success('Banner 开关已保存')
return
}
throw new Error(res?.message || '保存 Banner 开关失败')
} catch (e) {
message.error(e.message || '保存 Banner 开关失败')
} finally {
settingsSaving.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
image_url: row.image_url || '',
link_url: row.link_url || '',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Banner 图片',
buttonText: '使用这张 Banner 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Banner 图片')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHomepageBanner(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHomepageBanner(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadBanners()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Banner「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHomepageBanner(row.id)
message.success('已删除')
await loadBanners()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadBanners()
loadBannerSettings()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,988 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="页脚配置" desc="管理页脚区域的联系方式、快速链接、更多链接、友情链接和版权信息。">
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-tabs
v-model:value="activeTab"
type="line"
animated
class="aksrt-tabs"
>
<!-- ============== 品牌与Logo ============== -->
<n-tab-pane name="brand" tab="品牌与Logo">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
<h3>品牌列(第一列)</h3>
</div>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_t1_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="页脚 Logo(日间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
</n-form-item>
<n-form-item label="页脚 Logo(夜间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
</n-form-item>
<n-form-item label="品牌标题">
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
</n-form-item>
<n-form-item label="品牌列自定义代码">
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 联系方式 ============== -->
<n-tab-pane name="contacts" tab="联系方式">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
</div>
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
</div>
</template>
<p class="section-desc">页脚中的社交平台或联系入口。</p>
<n-data-table
v-if="form.contacts.length"
:columns="contactColumns"
:data="form.contacts"
:row-key="(row) => row._uid"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无联系方式图标" />
<n-divider>标准联系字段</n-divider>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_contact_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="微信二维码">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
</div>
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
</n-form-item>
<n-form-item label="QQ">
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
</n-form-item>
<n-form-item label="微博">
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
</n-form-item>
<n-form-item label="联系区上方自定义代码">
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="2" placeholder="可选 HTML" />
</n-form-item>
<n-form-item label="联系区下方自定义代码">
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
</n-form-item>
</n-form>
<n-divider>更多联系项</n-divider>
<n-card class="aksrt-section-card" embedded>
<template #header>
<div class="aksrt-card-header-row">
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
</div>
</template>
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
<n-data-table
v-if="form.footer_contact_more.length"
:columns="contactMoreColumns"
:data="form.footer_contact_more"
:row-key="(_, i) => i"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无扩展联系项" />
</n-card>
</n-card>
</n-tab-pane>
<!-- ============== 快速链接 ============== -->
<n-tab-pane name="quick" tab="快速链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">快速链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚第二列的常用导航链接。</p>
<n-data-table
v-if="form.quick_links.length"
:columns="createLinkColumns('quick_links')"
:data="form.quick_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无快速链接" />
</n-card>
</n-tab-pane>
<!-- ============== 更多链接 ============== -->
<n-tab-pane name="more" tab="更多链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">更多链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚补充链接区域。</p>
<n-data-table
v-if="form.more_links.length"
:columns="createLinkColumns('more_links')"
:data="form.more_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无更多链接" />
</n-card>
</n-tab-pane>
<!-- ============== 友情链接 ============== -->
<n-tab-pane name="friends" tab="友情链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
</div>
</template>
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
<n-data-table
v-if="friendLinks.length"
:columns="friendColumns"
:data="friendLinks"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无友情链接" />
</n-card>
</n-tab-pane>
<!-- ============== 二维码图片组 ============== -->
<n-tab-pane name="mini_qr" tab="二维码图片组">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
</div>
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
</div>
</template>
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
</n-form>
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
<n-space align="center" :size="12" style="width: 100%;">
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
<div class="aksrt-media-row" style="flex: 1;">
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
</div>
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
</n-space>
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
</div>
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
</n-card>
</n-tab-pane>
<!-- ============== 自定义代码 ============== -->
<n-tab-pane name="custom_code" tab="自定义代码">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
<h3>底部自定义代码</h3>
</div>
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
<n-form :model="form" label-placement="top" class="aksrt-form">
<n-form-item label="fcode_customize_code">
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 版权与杂项 ============== -->
<n-tab-pane name="misc" tab="版权与杂项">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<h3>其他设置</h3>
</div>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="页脚布局模板">
<n-radio-group v-model:value="form.layout_template">
<n-radio-button value="default">默认</n-radio-button>
<n-radio-button value="compact">紧凑</n-radio-button>
<n-radio-button value="centered">居中</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="热门分类数量">
<n-space align="center" :size="10">
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
<span class="aksrt-hint-text">范围 1 - 20</span>
</n-space>
</n-form-item>
<n-form-item label="自定义版权文案">
<n-input
v-model:value="form.copyright_text"
type="textarea"
:rows="3"
placeholder="留空则使用默认版权信息"
/>
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
</div>
</n-spin>
<!-- ===== 联系方式 Modal ===== -->
<n-modal
v-model:show="showContactModal"
:mask-closable="false"
preset="card"
title="编辑联系方式图标"
class="aksrt-modal-card"
>
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
<n-form-item label="图标类型">
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContact.url" placeholder="https://..." />
</n-form-item>
<n-form-item label="标题提示">
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingContact.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactModal = false">取消</n-button>
<n-button type="primary" @click="saveContact">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 链接 Modal ===== -->
<n-modal
v-model:show="showLinkModal"
:mask-closable="false"
preset="card"
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
class="aksrt-modal-card"
>
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
<n-form-item label="链接名称">
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingLink.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showLinkModal = false">取消</n-button>
<n-button type="primary" @click="saveLink">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 友情链接 Modal ===== -->
<n-modal
v-model:show="showFriendModal"
:mask-closable="false"
preset="card"
title="添加友情链接"
class="aksrt-modal-card"
>
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
<n-form-item label="站点名称">
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
</n-form-item>
<n-form-item label="站点 URL">
<n-input v-model:value="editingFriend.url" placeholder="https://" />
</n-form-item>
<n-form-item label="Logo 图片 URL">
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
</n-form-item>
<n-form-item label="描述">
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showFriendModal = false">取消</n-button>
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 扩展联系项 Modal ===== -->
<n-modal
v-model:show="showContactMoreModal"
:mask-closable="false"
preset="card"
title="编辑扩展联系项"
class="aksrt-modal-card"
>
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
<n-form-item label="描述">
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
</n-form-item>
<n-form-item label="图标类名">
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
</n-form-item>
<n-form-item label="Hover 图片">
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactMoreModal = false">取消</n-button>
<n-button type="primary" @click="saveContactMore">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import { openMediaPicker } from '@/utils/media'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('contacts')
const form = reactive({
contacts: [],
quick_links: [],
more_links: [],
hot_categories: 5,
copyright_text: '',
layout_template: 'default',
// §2.3 三列结构化字段
footer_t1_m_s: ['pc', 'mobile'],
footer_t1_img: '',
footer_t1_img_dark: '',
footer_t1_t: '',
fcode_t1_code: '',
footer_contact_wechat_img: '',
footer_contact_qq: '',
footer_contact_weibo: '',
footer_contact_email: '',
footer_contact_m_s: ['pc', 'mobile'],
footer_contact_more: [],
fcode_t2_code_1: '',
fcode_t2_code_2: '',
footer_mini_img_m_s: ['pc', 'mobile'],
footer_mini_img: [],
fcode_customize_code: '',
})
// ============== 联系方式 ==============
const showContactModal = ref(false)
const editingContactIndex = ref(-1)
const editingContact = reactive({
icon: '',
url: '',
title: '',
enabled: true,
})
let contactUid = 0
const iconOptions = [
{ label: 'GitHub', value: 'github' },
{ label: 'X (Twitter)', value: 'x' },
{ label: 'Email', value: 'email' },
{ label: '微信', value: 'wechat' },
{ label: 'QQ', value: 'qq' },
{ label: '微博', value: 'weibo' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Bilibili', value: 'bilibili' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'Instagram', value: 'instagram' },
{ label: 'RSS / 网站', value: 'web' },
]
function addContact() {
Object.assign(editingContact, {
icon: '',
url: '',
title: '',
enabled: true,
})
editingContactIndex.value = -1
showContactModal.value = true
}
function editContact(row, index) {
Object.assign(editingContact, {
icon: row.icon,
url: row.url,
title: row.title,
enabled: row.enabled,
})
editingContactIndex.value = index
showContactModal.value = true
}
function saveContact() {
if (!editingContact.icon || !editingContact.url) {
message.warning('请填写图标类型和链接地址')
return
}
const item = {
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
icon: editingContact.icon,
url: editingContact.url,
title: editingContact.title,
enabled: editingContact.enabled,
}
if (editingContactIndex.value >= 0) {
form.contacts[editingContactIndex.value] = item
} else {
form.contacts.push(item)
}
showContactModal.value = false
}
function deleteContact(index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个联系方式图标吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.contacts.splice(index, 1)
},
})
}
const contactColumns = [
{
title: '平台',
key: 'icon',
width: 120,
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
},
{
title: '链接',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '提示',
key: 'title',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
]),
},
]
// ============== 快速 / 更多链接 ==============
const showLinkModal = ref(false)
const editingLinkType = ref('quick_links')
const editingLinkIndex = ref(-1)
const editingLink = reactive({
id: '',
name: '',
url: '',
enabled: true,
sort_order: 0,
})
function openLinkEditor(type, row = null, index = -1) {
editingLinkType.value = type
editingLinkIndex.value = index
Object.assign(editingLink, row || {
id: '',
name: '',
url: '',
enabled: true,
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
})
showLinkModal.value = true
}
function saveLink() {
if (!editingLink.name || !editingLink.url) {
message.warning('请填写链接名称和链接地址')
return
}
const target = form[editingLinkType.value]
const item = {
id: editingLink.id || `link_${Date.now()}`,
name: editingLink.name,
url: editingLink.url,
enabled: editingLink.enabled,
sort_order: editingLink.sort_order ?? target.length,
}
if (editingLinkIndex.value >= 0) {
target[editingLinkIndex.value] = item
} else {
target.push(item)
}
showLinkModal.value = false
}
function deleteLink(type, index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个链接吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form[type].splice(index, 1)
},
})
}
function createLinkColumns(type) {
return [
{
title: '名称',
key: 'name',
width: 160,
},
{
title: 'URL',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
]),
},
]
}
// ============== 友情链接 ==============
const friendLinks = ref([])
const showFriendModal = ref(false)
const addingFriend = ref(false)
const editingFriend = reactive({
name: '',
url: '',
logo: '',
description: '',
})
const friendColumns = [
{
title: 'Logo',
key: 'logo',
width: 60,
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
},
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
{
title: '操作',
key: 'actions',
width: 90,
render: (row) => h(NButton, {
quaternary: true,
type: 'error',
size: 'tiny',
onClick: () => deleteFriend(row),
}, { default: () => '删除' }),
},
]
function openFriendEditor() {
Object.assign(editingFriend, {
name: '',
url: '',
logo: '',
description: '',
})
showFriendModal.value = true
}
async function loadFriendLinks() {
try {
const res = await api.getFriendLinks()
if (res.code === 'success') friendLinks.value = res.data || []
} catch (e) {
console.error('[FriendLinks] load failed:', e)
}
}
async function saveFriend() {
if (!editingFriend.name || !editingFriend.url) {
message.warning('请填写站点名称和 URL')
return
}
addingFriend.value = true
try {
const res = await api.addFriendLink({
name: editingFriend.name,
url: editingFriend.url,
logo: editingFriend.logo,
description: editingFriend.description,
sort_order: friendLinks.value.length,
})
if (res.code === 'success') {
message.success(res.message || '已添加')
showFriendModal.value = false
await loadFriendLinks()
}
} catch (e) {
message.error(e.message || '添加失败')
} finally {
addingFriend.value = false
}
}
function deleteFriend(row) {
dialog.warning({
title: '确认删除',
content: `确定删除友链「${row.name}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteFriendLink(row.id)
message.success('已删除')
await loadFriendLinks()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
// ============== 加载 / 保存 ==============
async function loadSettings() {
loading.value = true
try {
const [footerRes] = await Promise.all([
api.getFooterSettings(),
loadFriendLinks(),
])
if (footerRes.code === 'success' && footerRes.data) {
const data = footerRes.data
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
form.quick_links = data.quick_links || []
form.more_links = data.more_links || []
form.hot_categories = data.hot_categories ?? 5
form.copyright_text = data.copyright_text || ''
form.layout_template = data.layout_template || 'default'
// §2.3 三列结构化字段
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
form.footer_t1_img = data.footer_t1_img || ''
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
form.footer_t1_t = data.footer_t1_t || ''
form.fcode_t1_code = data.fcode_t1_code || ''
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
form.footer_contact_qq = data.footer_contact_qq || ''
form.footer_contact_weibo = data.footer_contact_weibo || ''
form.footer_contact_email = data.footer_contact_email || ''
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
form.fcode_customize_code = data.fcode_customize_code || ''
}
} catch (error) {
console.error('[Footer] load failed:', error)
message.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
quick_links: form.quick_links,
more_links: form.more_links,
hot_categories: form.hot_categories,
copyright_text: form.copyright_text,
layout_template: form.layout_template,
// §2.3 三列结构化字段
footer_t1_m_s: form.footer_t1_m_s,
footer_t1_img: form.footer_t1_img,
footer_t1_img_dark: form.footer_t1_img_dark,
footer_t1_t: form.footer_t1_t,
fcode_t1_code: form.fcode_t1_code,
footer_contact_wechat_img: form.footer_contact_wechat_img,
footer_contact_qq: form.footer_contact_qq,
footer_contact_weibo: form.footer_contact_weibo,
footer_contact_email: form.footer_contact_email,
footer_contact_m_s: form.footer_contact_m_s,
footer_contact_more: form.footer_contact_more,
fcode_t2_code_1: form.fcode_t2_code_1,
fcode_t2_code_2: form.fcode_t2_code_2,
footer_mini_img_m_s: form.footer_mini_img_m_s,
footer_mini_img: form.footer_mini_img,
fcode_customize_code: form.fcode_customize_code,
}
const res = await api.saveFooterSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (error) {
console.error('[Footer] save failed:', error)
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
// ============== 媒体选择 ==============
async function pickMedia(field) {
try {
const media = await openMediaPicker({ title: '选择图片' })
if (media?.url) {
form[field] = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
// ============== 扩展联系项 ==============
function addContactMore() {
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
}
function removeContactMore(index) {
form.footer_contact_more.splice(index, 1)
}
const showContactMoreModal = ref(false)
const editingContactMoreIndex = ref(-1)
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
function editContactMore(row, index) {
Object.assign(editingContactMore, row)
editingContactMoreIndex.value = index
showContactMoreModal.value = true
}
function saveContactMore() {
const item = { ...editingContactMore }
if (editingContactMoreIndex.value >= 0) {
form.footer_contact_more[editingContactMoreIndex.value] = item
}
showContactMoreModal.value = false
}
const contactMoreColumns = [
{ title: '描述', key: 'desc', width: 120 },
{ title: '图标', key: 'icon', width: 100 },
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
]),
},
]
// ============== 二维码图片组 ==============
function addMiniImg() {
if (form.footer_mini_img.length < 4) {
form.footer_mini_img.push({ text: '', img: '' })
}
}
function removeMiniImg(idx) {
form.footer_mini_img.splice(idx, 1)
}
async function pickMiniImg(idx) {
try {
const media = await openMediaPicker({ title: '选择二维码图片' })
if (media?.url) {
form.footer_mini_img[idx].img = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.aksrt-media-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.aksrt-media-preview {
display: block;
max-width: 200px;
max-height: 80px;
margin-top: 8px;
border-radius: 6px;
border: 1px solid var(--n-border-color, #e0e0e6);
object-fit: contain;
}
.aksrt-media-preview--sm {
max-width: 64px;
max-height: 64px;
}
.aksrt-mini-img-row {
padding: 10px 0;
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
}
.aksrt-mini-img-row:last-child {
border-bottom: none;
}
</style>
@@ -0,0 +1,790 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="底部Tab配置" desc="自定义移动端底部导航栏的按钮,支持排序、图标配置和返回顶部功能。">
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<!-- 基础设置 -->
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><i class="fas fa-cog"></i></span>
<strong class="aksrt-card-header-row__title">基础设置</strong>
</div>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="启用底部Tab">
<n-switch v-model:value="form.enabled" />
<span class="aksrt-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
</n-form-item>
<n-form-item label="向下滚动隐藏">
<n-switch v-model:value="form.scroll_down_hide" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
</n-form-item>
<n-form-item label="文章页专属Tab">
<n-select
v-model:value="form.footer_tabbar_single"
:options="[
{ label: '默认按钮', value: 'default' },
{ label: '文章扩展按钮(评论/收藏/分享)', value: 'extend' },
]"
style="width: 240px;"
/>
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
</n-form-item>
</n-form>
</n-card>
<!-- Tab按钮配置 -->
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><i class="fas fa-list-ul"></i></span>
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
</div>
<n-button type="primary" size="small" @click="addItem">
<template #icon><i class="fas fa-plus"></i></template>
添加按钮
</n-button>
</div>
</template>
<p class="section-desc">配置移动端底部导航栏显示的按钮,支持拖拽排序。建议按钮数量不超过5个。</p>
<div class="section-desc section-desc--tight">当前已启用 {{ enabledPreviewItems.length }} 个按钮,最多允许 5 个。</div>
<draggable
v-if="form.items.length"
v-model="form.items"
item-key="id"
handle=".tabbar-sort__handle"
ghost-class="tabbar-sort__ghost"
animation="180"
class="tabbar-sort"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="tabbar-sort__item">
<div class="tabbar-sort__main">
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<span class="tabbar-sort__icon">
<i v-if="element.icon" :class="element.icon"></i>
</span>
<div class="tabbar-sort__meta">
<div class="tabbar-sort__title-row">
<strong>{{ element.text || '按钮' }}</strong>
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
</div>
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
</div>
</div>
<div class="tabbar-sort__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fas fa-edit" style="font-size:12px;" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fas fa-trash-alt" style="font-size:12px;" /></template>
删除
</n-button>
</div>
</div>
</template>
</draggable>
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
</div>
</n-spin>
<!-- 编辑弹窗 -->
<n-modal
v-model:show="showModal"
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
class="aksrt-modal-card"
style="max-width: 520px;"
>
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
<n-form-item label="按钮类型">
<n-select
v-model:value="editingItem.type"
:options="typeOptions"
placeholder="选择按钮类型"
@update:value="handleTypeChange"
/>
</n-form-item>
<n-form-item label="图标 (Font Awesome)">
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
<template #feedback>
<div class="icon-preview-row">
<span>预览:</span>
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
<span v-else class="muted-text">无图标</span>
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
</a>
</div>
</template>
</n-form-item>
<n-form-item label="显示文字">
<n-input v-model:value="editingItem.text" placeholder="例如: 首页" />
</n-form-item>
<n-form-item label="徽标文本">
<n-input v-model:value="editingItem.badge" placeholder="例如: NEW, 9+,留空不显示" maxlength="4" />
</n-form-item>
<n-form-item label="徽标模式">
<n-radio-group v-model:value="editingItem.badge_mode">
<n-space>
<n-radio value="static">静态文本</n-radio>
<n-radio value="dynamic">动态徽标</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="editingItem.badge_mode === 'dynamic'" type="info" :show-icon="false" style="margin-bottom: 16px;">
<template v-if="editingItem.type === 'auth'">未登录时显示提醒徽标,登录后自动隐藏。</template>
<template v-else-if="editingItem.type === 'user'">登录用户显示评论数徽标,游客不显示。</template>
<template v-else-if="editingItem.type === 'search'">搜索结果页显示 ON 徽标,其它页面隐藏。</template>
<template v-else>当前类型不提供动态徽标,前端将回退为静态徽标。</template>
</n-alert>
<n-form-item v-if="editingItem.type === 'category'" label="指定分类">
<n-select
v-model:value="editingItem.category_id"
:options="categoryOptions"
placeholder="留空则跳转分类聚合页"
clearable
filterable
/>
</n-form-item>
<n-form-item v-if="editingItem.type === 'search'" label="默认搜索类型">
<n-radio-group v-model:value="editingItem.search_default_type">
<n-space>
<n-radio value="post">文章</n-radio>
<n-radio value="category">分类</n-radio>
<n-radio value="tag">标签</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type)" label="链接地址">
<n-input v-model:value="editingItem.link" :placeholder="editingItem.type === 'msg' ? '留空使用默认消息中心地址' : editingItem.type === 'pay_vip' ? '留空使用默认会员页地址' : editingItem.type === 'shop_cart' ? '留空使用默认购物车地址' : 'https:// 或 /path'" />
</n-form-item>
<n-form-item v-if="editingItem.type === 'link'" label="新窗口打开">
<n-switch v-model:value="editingItem.new_window" />
</n-form-item>
<div v-if="editingItem.type === 'add'" class="add-items-editor">
<div class="add-items-editor__head">
<strong>发布菜单项</strong>
<n-button size="small" type="primary" @click="appendAddItem">
<template #icon><i class="fas fa-plus" /></template>
添加子项
</n-button>
</div>
<div class="add-items-editor__presets">
<span class="add-items-editor__presets-label">快速添加:</span>
<n-button
v-for="preset in addPresets"
:key="preset.url"
size="tiny"
secondary
:type="editingItem.add_items.some((item) => item.url === preset.url) ? 'success' : 'default'"
:disabled="editingItem.add_items.some((item) => item.url === preset.url)"
@click="appendAddPreset(preset)"
>
{{ preset.label }}
</n-button>
</div>
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
<draggable
v-model="editingItem.add_items"
item-key="id"
handle=".add-items-editor__drag"
ghost-class="add-items-editor__ghost"
animation="180"
>
<template #item="{ element, index: subIndex }">
<div class="add-items-editor__row">
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i>
</button>
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<n-input v-model:value="element.icon" placeholder="图标,例如:fas fa-pen" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fas fa-trash-alt" /></template>
</n-button>
</div>
</template>
</draggable>
</div>
<div v-else class="muted-text">当前没有子项,前端将回退为默认发布入口。</div>
</div>
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
<n-switch v-model:value="editingItem.ontop" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingItem.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="saveItem">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
import draggable from 'vuedraggable'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editingIndex = ref(-1)
const categoryOptions = ref([])
const form = reactive({
enabled: true,
scroll_hide: false,
scroll_down_hide: true,
footer_tabbar_single: 'default',
items: [],
})
const editingItem = reactive({
id: '',
type: 'link',
icon: '',
text: '',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: 0,
ontop: false,
})
const typeOptions = [
{ label: '首页', value: 'home' },
{ label: '分类页', value: 'category' },
{ label: '链接', value: 'link' },
{ label: '中央发布按钮', value: 'add' },
{ label: '用户中心', value: 'user' },
{ label: '登录/我的', value: 'auth' },
{ label: '搜索', value: 'search' },
{ label: '消息中心', value: 'msg' },
{ label: '开通会员', value: 'pay_vip' },
{ label: '购物车', value: 'shop_cart' },
]
const typeLabelMap = {
home: '首页',
category: '分类页',
link: '链接',
add: '中央发布按钮',
user: '用户中心',
auth: '登录/我的',
search: '搜索',
msg: '消息中心',
pay_vip: '开通会员',
shop_cart: '购物车',
}
const typeDefaults = {
home: { icon: 'fas fa-home', text: '首页' },
category: { icon: 'fas fa-th-large', text: '分类' },
link: { icon: 'fas fa-link', text: '链接' },
add: { icon: 'fas fa-plus', text: '发布' },
user: { icon: 'fas fa-user', text: '用户中心' },
auth: { icon: 'fas fa-user', text: '我的' },
search: { icon: 'fas fa-search', text: '搜索' },
msg: { icon: 'fas fa-bell', text: '消息' },
pay_vip: { icon: 'fas fa-crown', text: '会员' },
shop_cart: { icon: 'fas fa-shopping-cart', text: '购物车' },
}
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
function getItemSummary(item) {
if (item.type === 'home') return item.ontop ? '首页按钮,滚动后切换返回顶部' : '首页按钮'
if (item.type === 'category') {
if (item.category_id) {
const target = categoryOptions.value.find((option) => option.value === item.category_id)
return target ? `跳转到分类: ${target.label}` : `跳转到分类 ID ${item.category_id}`
}
return '跳转到分类聚合页'
}
if (item.type === 'add') {
return item.add_items?.length ? `展开 ${item.add_items.length} 个发布入口` : '点击后打开默认发布入口'
}
if (item.type === 'search') return `打开顶部搜索面板,默认搜索${item.search_default_type === 'category' ? '分类' : item.search_default_type === 'tag' ? '标签' : '文章'}`
if (item.type === 'user') return '自动跳转到用户中心或登录页'
if (item.type === 'auth') return '登录用户显示我的,游客显示登录'
if (item.type === 'msg') return '跳转到消息中心页面'
if (item.type === 'pay_vip') return item.link || '跳转到会员开通页'
if (item.type === 'shop_cart') return item.link || '跳转到购物车页面'
return item.link || '自定义链接按钮'
}
const addPresets = [
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/post-new' },
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' },
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' },
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' },
]
function appendAddPreset(preset) {
if (editingItem.add_items.some((item) => item.url === preset.url)) return
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: preset.label,
icon: preset.icon,
url: preset.url,
})
}
function appendAddItem() {
editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: '',
icon: 'fas fa-pen',
url: '',
})
}
function removeAddItem(index) {
editingItem.add_items.splice(index, 1)
}
function resetSortOrder() {
form.items = form.items.map((item, index) => ({
...item,
sort_order: index,
}))
}
function applyTypeDefaults(type, force = false) {
const preset = typeDefaults[type] || typeDefaults.link
if (force || !editingItem.icon) editingItem.icon = preset.icon
if (force || !editingItem.text) editingItem.text = preset.text
if (!['link', 'msg', 'pay_vip', 'shop_cart'].includes(type)) editingItem.link = ''
if (type !== 'category') editingItem.category_id = null
if (type !== 'search') editingItem.search_default_type = 'post'
if (type !== 'add') editingItem.add_items = []
if (type !== 'home') editingItem.ontop = false
}
function handleTypeChange(value) {
applyTypeDefaults(value, false)
}
function addItem() {
if (form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
Object.assign(editingItem, {
id: `tab_${Date.now()}`,
type: 'link',
icon: 'fas fa-link',
text: '链接',
link: '',
badge: '',
badge_mode: 'static',
category_id: null,
search_default_type: 'post',
new_window: false,
add_items: [],
enabled: true,
sort_order: form.items.length,
ontop: false,
})
editingIndex.value = -1
showModal.value = true
}
function editItem(index) {
const item = form.items[index]
Object.assign(editingItem, {
id: item.id,
type: item.type || 'link',
icon: item.icon || '',
text: item.text || '',
link: item.link || '',
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || null,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items.map((sub) => ({ ...sub })) : [],
enabled: item.enabled !== false,
sort_order: item.sort_order ?? index,
ontop: item.ontop || false,
})
editingIndex.value = index
showModal.value = true
}
function saveItem() {
if (editingIndex.value < 0 && form.items.length >= 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
if (!editingItem.icon) {
message.warning('请填写图标')
return
}
if (!editingItem.text) {
message.warning('请填写显示文字')
return
}
const item = {
id: editingItem.id || `tab_${Date.now()}`,
type: editingItem.type,
icon: editingItem.icon,
text: editingItem.text,
link: ['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type) ? editingItem.link : '',
badge: (editingItem.badge || '').trim(),
badge_mode: editingItem.badge_mode,
category_id: editingItem.type === 'category' ? (editingItem.category_id || 0) : 0,
search_default_type: editingItem.type === 'search' ? editingItem.search_default_type : 'post',
new_window: editingItem.type === 'link' ? !!editingItem.new_window : false,
add_items: editingItem.type === 'add'
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
id: sub.id || `add_${Date.now()}`,
label: sub.label,
icon: sub.icon || 'fas fa-pen',
url: sub.url,
}))
: [],
enabled: editingItem.enabled,
sort_order: editingItem.sort_order,
ontop: editingItem.type === 'home' ? editingItem.ontop : false,
}
if (editingIndex.value >= 0) {
form.items[editingIndex.value] = item
} else {
form.items.push(item)
}
showModal.value = false
message.success(editingIndex.value >= 0 ? '已更新' : '已添加')
}
async function loadCategories() {
try {
const res = await api.getCategories()
if (res.code === 'success' && Array.isArray(res.data)) {
categoryOptions.value = res.data.map((item) => ({
label: item.name,
value: Number(item.id),
}))
}
} catch (e) {
console.error('[FooterTabbar] load categories failed:', e)
}
}
function deleteItem(index) {
dialog.warning({
title: '确认删除',
content: `确定删除「${form.items[index].text}」按钮吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.items.splice(index, 1)
resetSortOrder()
message.success('已删除')
},
})
}
async function loadSettings() {
loading.value = true
try {
const [res] = await Promise.all([api.getFooterTabbarSettings(), loadCategories()])
if (res.code === 'success' && res.data) {
const data = res.data
form.enabled = data.enabled !== false
form.scroll_hide = data.scroll_hide || false
form.scroll_down_hide = data.scroll_down_hide !== false
form.footer_tabbar_single = data.footer_tabbar_single || 'default'
form.items = (data.items || []).map((item, idx) => ({
...item,
badge: item.badge || '',
badge_mode: item.badge_mode || 'static',
category_id: item.category_id || 0,
search_default_type: item.search_default_type || 'post',
new_window: !!item.new_window,
add_items: Array.isArray(item.add_items) ? item.add_items : [],
sort_order: item.sort_order ?? idx,
}))
}
} catch (e) {
console.error('[FooterTabbar] load failed:', e)
message.error('加载配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
if (form.items.length > 5) {
message.warning('底部Tab最多只能配置 5 个按钮')
return
}
saving.value = true
try {
const payload = {
enabled: form.enabled,
scroll_hide: form.scroll_hide,
scroll_down_hide: form.scroll_down_hide,
footer_tabbar_single: form.footer_tabbar_single,
items: form.items.map((item, idx) => ({
...item,
sort_order: idx,
})),
}
const res = await api.saveFooterTabbarSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (e) {
console.error('[FooterTabbar] save failed:', e)
message.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(loadSettings)
</script>
<style scoped>
.icon-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
}
.icon-preview-row a {
color: var(--n-primary-color);
text-decoration: none;
}
.icon-preview-row a:hover {
text-decoration: underline;
}
.muted-text {
color: #999;
font-size: 12px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.section-desc {
color: var(--n-text-color-3);
font-size: 13px;
margin: 0 0 16px;
}
.section-desc--tight {
margin-top: -6px;
}
.add-items-editor {
margin-bottom: 16px;
}
.add-items-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.add-items-editor__presets {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.add-items-editor__presets-label {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
.add-items-editor__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-items-editor__row {
display: grid;
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
gap: 8px;
align-items: center;
}
.add-items-editor__drag {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.add-items-editor__ghost {
opacity: 0.5;
}
.tabbar-sort {
display: flex;
flex-direction: column;
gap: 12px;
}
.tabbar-sort__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--n-border-color);
border-radius: 14px;
background: #fff;
}
.tabbar-sort__main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.tabbar-sort__handle {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.tabbar-sort__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-size: 18px;
flex: 0 0 auto;
}
.tabbar-sort__meta {
min-width: 0;
flex: 1;
}
.tabbar-sort__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.tabbar-sort__desc {
color: var(--n-text-color-3);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbar-sort__actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
.tabbar-sort__ghost {
opacity: 0.5;
}
@media (max-width: 1023px) {
.tabbar-sort__item {
flex-direction: column;
align-items: stretch;
}
.tabbar-sort__actions {
justify-content: flex-end;
}
.add-items-editor__row {
grid-template-columns: 32px 1fr;
}
}
</style>
@@ -0,0 +1,548 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 轮播数据,供固定首页和相关展示区域调用;标题、副标题可留空。" />
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>Hero 显示开关</strong></template>
<n-form label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示首页 Hero">
<div class="aksrt-inline-setting">
<n-switch v-model:value="heroEnabled" :disabled="settingsLoading || settingsSaving" />
<span class="aksrt-inline-setting__hint">关闭后,首页 Hero 区块将不再渲染。</span>
</div>
</n-form-item>
</n-form>
<div class="aksrt-inline-setting__actions">
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveHeroSettings">保存 Hero 开关</n-button>
</div>
</n-card>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
</div>
</template>
<n-data-table
v-if="slides.length"
:columns="columns"
:data="slides"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
</n-card>
</n-spin>
<!-- 编辑/新增 弹窗 -->
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
</n-form-item>
<n-form-item label="背景图 URL">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<n-form-item label="描述文字">
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
</n-form-item>
<n-form-item label="视频背景 URL">
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="链接打开方式">
<n-radio-group v-model:value="editing.link_target">
<n-radio-button value="_self">当前窗口</n-radio-button>
<n-radio-button value="_blank">新窗口</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字对齐">
<n-radio-group v-model:value="editing.text_align">
<n-radio-button value="left">左</n-radio-button>
<n-radio-button value="center">居中</n-radio-button>
<n-radio-button value="right">右</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="设备可见性">
<n-radio-group v-model:value="editing.device_visibility">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="pc">仅电脑</n-radio-button>
<n-radio-button value="mobile">仅移动</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字视差">
<n-switch v-model:value="editing.text_parallax" />
</n-form-item>
<n-form-item label="文字大小(PC)">
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
</n-form-item>
<n-form-item label="文字大小(移动)">
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
</n-form-item>
</div>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
</div>
<!-- 图片图层 -->
<n-divider title-placement="left" style="margin:16px 0 12px;">
图片图层(视差叠加)
</n-divider>
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
<div class="aksrt-layer-item__header">
<strong>图层 {{ idx + 1 }}</strong>
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
</div>
<n-form-item label="图片">
<n-input-group>
<n-input v-model:value="layer.image" placeholder="图片 URL" />
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
</n-input-group>
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="原始尺寸">
<n-switch v-model:value="layer.free_size" />
</n-form-item>
<n-form-item label="对齐方式">
<n-select v-model:value="layer.align" :options="[
{ label: '居中', value: 'center' },
{ label: '左上', value: 'left top' },
{ label: '右上', value: 'right top' },
{ label: '左下', value: 'left bottom' },
{ label: '右下', value: 'right bottom' },
]" />
</n-form-item>
<n-form-item label="视差">
<n-switch v-model:value="layer.parallax" />
</n-form-item>
<n-form-item label="视差强度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
</n-form-item>
<n-form-item label="视差透明度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
</n-form-item>
</div>
</div>
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const slides = ref([])
const heroEnabled = ref(true)
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
description: '',
image_url: '',
video_url: '',
link_url: '',
link_target: '_self',
text_align: 'left',
device_visibility: 'all',
enabled: true,
show_text: true,
show_meta: false,
sort_order: 0,
text_size_pc: '',
text_size_m: '',
text_parallax: false,
layers: [],
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '跳转',
key: 'link_url',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
render(row) {
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadSlides() {
try {
loading.value = true
const res = await api.getHeroes()
if (res.code === 'success') {
slides.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function loadHeroSettings() {
settingsLoading.value = true
try {
const res = await api.getHomepageSettings()
const data = res?.data || {}
heroEnabled.value = data.homepage_module_hero_enabled !== false
} catch (e) {
message.error(e.message || '加载 Hero 开关失败')
} finally {
settingsLoading.value = false
}
}
async function saveHeroSettings() {
settingsSaving.value = true
try {
const res = await api.saveHomepageSettings({
homepage_module_hero_enabled: heroEnabled.value,
})
if (res?.code === 'success') {
message.success('Hero 开关已保存')
return
}
throw new Error(res?.message || '保存 Hero 开关失败')
} catch (e) {
message.error(e.message || '保存 Hero 开关失败')
} finally {
settingsSaving.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
description: row.description || '',
image_url: row.image_url || '',
video_url: row.video_url || '',
link_url: row.link_url || '',
link_target: row.link_target || '_self',
text_align: row.text_align || 'left',
device_visibility: row.device_visibility || 'all',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
text_size_pc: row.text_size_pc || '',
text_size_m: row.text_size_m || '',
text_parallax: !!row.text_parallax,
layers: Array.isArray(row.layers) ? row.layers : [],
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Hero 背景图',
buttonText: '使用这张 Hero 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
function addLayer() {
editing.value.layers.push({
image: '',
free_size: false,
align: 'center',
parallax: false,
parallax_scale: 0.3,
parallax_opacity: 0.6,
})
}
function removeLayer(idx) {
editing.value.layers.splice(idx, 1)
}
async function pickLayerImage(idx) {
try {
const media = await openMediaPicker({
title: '选择图层图片',
buttonText: '使用这张图',
})
if (media?.url) {
editing.value.layers[idx].image = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Hero 背景图')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHero(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHero(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadSlides()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Hero「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHero(row.id)
message.success('已删除')
await loadSlides()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadSlides()
loadHeroSettings()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.aksrt-layer-item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
background: #fafbfc;
}
.aksrt-layer-item__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,617 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="首页配置"
desc="配置首页内容模型、模块编排、分类 Tab、卡片样式和交互方式。"
>
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</template>
</PageHeader>
<n-tabs v-model:value="activeTab" type="line" animated>
<!-- Module Workstation Tab -->
<n-tab-pane name="modules" tab="模块编排">
<div class="aksrt-homepage__workstation">
<div class="aksrt-homepage__canvas-panel">
<ModuleCanvas
:modules="homeModules"
:selected-index="selectedModuleIndex"
@select="selectedModuleIndex = $event"
@add="handleAddModule"
@remove="handleRemoveModule"
@copy="handleCopyModule"
@toggle="handleToggleModule"
@move="handleMoveModule"
@pick-preset="showPresetPicker = true"
@export="handleExportModules"
@import="showImportDialog = true"
/>
</div>
<div class="aksrt-homepage__inspector-panel">
<ModuleInspector
:module="selectedModule"
:module-index="selectedModuleIndex"
@update="handleModuleUpdate"
/>
</div>
</div>
</n-tab-pane>
<!-- Main Stream & Tabs Tab -->
<n-tab-pane name="stream" tab="主列表 & Tab">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Content Model -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>内容模型</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="首页内容模型">
<n-radio-group v-model:value="form.homepage_content_model">
<n-space vertical>
<n-radio value="mixed">
<n-text>混合模式</n-text>
<n-text depth="3" style="margin-left:8px;">文章 + 资源(默认)</n-text>
</n-radio>
<n-radio value="post">
<n-text>纯文章</n-text>
<n-text depth="3" style="margin-left:8px;">仅标准文章</n-text>
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</n-card>
<!-- Main Stream -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>主列表(最新发布)</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="启用主列表">
<n-switch v-model:value="form.home_posts_list_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后首页将不显示文章列表(仅显示模块)。</span></template>
</n-form-item>
<n-form-item label="区块标题">
<n-input v-model:value="form.home_list_title" placeholder="最新发布" />
</n-form-item>
<n-form-item label="默认排序">
<n-select v-model:value="form.homepage_default_sort" :options="allSortOptions" />
</n-form-item>
<n-form-item label="启用排序栏">
<n-switch v-model:value="form.home_list_sort_enabled" />
</n-form-item>
<n-form-item v-if="form.home_list_sort_enabled" label="排序栏样式">
<n-radio-group v-model:value="form.home_list_sort_dropdown">
<n-space size="large">
<n-radio-button :value="false">内联链接</n-radio-button>
<n-radio-button :value="true">下拉菜单</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="form.home_list_sort_enabled" label="可用排序选项">
<n-select
v-model:value="form.home_list_sort_options"
:options="allSortOptions"
multiple
placeholder="选择排序选项"
/>
</n-form-item>
</n-form>
</n-card>
<!-- Exclusions -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>排除规则</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排除文章 ID">
<n-input
v-model:value="excludePostIdsText"
type="textarea"
placeholder="逗号分隔的文章 ID,如 12,34,56"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表和 Tab 查询均排除这些文章。</span></template>
</n-form-item>
<n-form-item label="排除分类 ID">
<n-input
v-model:value="excludeCategoryIdsText"
type="textarea"
placeholder="逗号分隔的分类 ID,如 5,8"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类。Tab 不能绑定已排除的分类。</span></template>
</n-form-item>
</n-form>
</n-card>
<!-- Tabs -->
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
<strong>首页 Tab</strong>
<n-button size="small" @click="addTab">+ 添加 Tab</n-button>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form" style="margin-bottom:16px;">
<n-form-item label="Tab 数量上限">
<n-input-number v-model:value="form.homepage_tab_limit" :min="1" :max="12" />
</n-form-item>
<n-form-item label="每 Tab 文章数">
<n-input-number v-model:value="form.homepage_tab_posts_per_page" :min="1" :max="20" />
</n-form-item>
</n-form>
<div v-if="form.homepage_tabs.length === 0" class="homepage-settings__empty-tabs">
<n-text depth="3">暂无 Tab 配置,点击"添加 Tab"添加。</n-text>
</div>
<div v-for="(tab, index) in form.homepage_tabs" :key="index" class="homepage-settings__tab-row">
<div class="homepage-settings__tab-fields">
<n-select
:value="tab.term_id"
:options="categoryOptions"
placeholder="分类"
filterable
style="flex:2;"
@update:value="(val) => updateTab(index, 'term_id', val)"
/>
<n-input
:value="tab.title"
placeholder="自定义标题"
style="flex:1;"
@update:value="(val) => updateTab(index, 'title', val)"
/>
<n-select
:value="tab.orderby"
:options="allSortOptions"
style="flex:1;"
@update:value="(val) => updateTab(index, 'orderby', val)"
/>
<n-switch
:value="tab.enabled"
@update:value="(val) => updateTab(index, 'enabled', val)"
/>
<n-button size="small" quaternary type="error" @click="removeTab(index)">删除</n-button>
</div>
</div>
</n-card>
</n-spin>
</n-tab-pane>
<!-- Cards & Sidebar Tab -->
<n-tab-pane name="cards" tab="卡片 & 侧栏">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>卡片样式</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="卡片风格">
<n-radio-group v-model:value="form.homepage_card_style">
<n-space size="large">
<n-radio-button value="default">默认</n-radio-button>
<n-radio-button value="style2">风格 2</n-radio-button>
<n-radio-button value="style3">风格 3</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="多图卡片">
<n-switch v-model:value="form.homepage_multi_thumb_enabled" />
</n-form-item>
<n-form-item v-if="form.homepage_multi_thumb_enabled" label="最少图片数">
<n-input-number v-model:value="form.homepage_multi_thumb_min_images" :min="2" :max="4" />
</n-form-item>
<n-form-item label="默认视图">
<n-radio-group v-model:value="form.homepage_default_view">
<n-space size="large">
<n-radio-button value="list">列表</n-radio-button>
<n-radio-button value="card">卡片</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>侧边栏</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="迷你侧栏列表">
<n-switch v-model:value="form.homepage_sidebar_mini_enabled" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>交互设置</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排序栏 AJAX">
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后排序链接将使用整页跳转。</span></template>
</n-form-item>
<n-form-item label="Tab 吸顶">
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
</n-form-item>
</n-form>
</n-card>
<!-- Module Status -->
<n-card class="aksrt-section-card">
<template #header><strong>模块状态</strong></template>
<n-form label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="Hero 首屏">
<n-tag type="info" size="small">在 Hero 页面管理</n-tag>
<n-text depth="3" style="margin-left:8px;">首页 Hero 开关已从首页设置中移出。</n-text>
</n-form-item>
<n-form-item label="Banner 轮播">
<n-tag type="info" size="small">在 Banner 页面管理</n-tag>
<n-text depth="3" style="margin-left:8px;">首页 Banner 开关已从首页设置中移出。</n-text>
</n-form-item>
</n-form>
</n-card>
</n-spin>
</n-tab-pane>
</n-tabs>
<!-- Preset Picker Dialog -->
<ModulePresetPicker
:show="showPresetPicker"
@close="showPresetPicker = false"
@apply="handleApplyPreset"
/>
<!-- Import Dialog -->
<n-modal
v-model:show="showImportDialog"
title="导入模块配置"
preset="dialog"
style="width: 480px;"
:show-icon="false"
>
<n-input
v-model:value="importJson"
type="textarea"
:rows="8"
placeholder="粘贴导出的 JSON 配置..."
/>
<template #action>
<n-space>
<n-button @click="showImportDialog = false">取消</n-button>
<n-button type="primary" :disabled="!importJson.trim()" @click="handleImportModules">导入</n-button>
</n-space>
</template>
</n-modal>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ModuleCanvas from '@/components/homepage/ModuleCanvas.vue'
import ModuleInspector from '@/components/homepage/ModuleInspector.vue'
import ModulePresetPicker from '@/components/homepage/ModulePresetPicker.vue'
import { makeDefaultModule } from '@/utils/home-module-schema'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('modules')
const categoryOptions = ref([])
const showPresetPicker = ref(false)
const showImportDialog = ref(false)
const importJson = ref('')
// Module workstation state
const homeModules = ref([])
const selectedModuleIndex = ref(-1)
const selectedModule = computed(() => {
if (selectedModuleIndex.value >= 0 && selectedModuleIndex.value < homeModules.value.length) {
return homeModules.value[selectedModuleIndex.value]
}
return null
})
const allSortOptions = [
{ label: '最近更新', value: 'modified' },
{ label: '发布时间', value: 'date' },
{ label: '浏览量', value: 'views' },
{ label: '点赞数', value: 'likes' },
{ label: '评论数', value: 'comments' },
{ label: '收藏数', value: 'favorites' },
{ label: '下载量', value: 'downloads' },
{ label: '随机', value: 'rand' },
]
const form = reactive({
homepage_content_model: 'mixed',
homepage_tabs: [],
homepage_card_style: 'default',
homepage_multi_thumb_enabled: true,
homepage_multi_thumb_min_images: 3,
homepage_sidebar_mini_enabled: true,
homepage_default_sort: 'modified',
homepage_default_view: 'list',
homepage_sort_ajax_enabled: true,
homepage_sticky_tabs_enabled: true,
home_posts_list_enabled: true,
home_list_title: '最新发布',
home_list_sort_enabled: true,
home_list_sort_options: ['modified', 'date', 'views', 'likes', 'comments'],
home_list_sort_dropdown: false,
home_exclude_post_ids: [],
home_exclude_category_ids: [],
homepage_tab_posts_per_page: 6,
homepage_tab_limit: 4,
})
const excludePostIdsText = computed({
get: () => (form.home_exclude_post_ids || []).join(','),
set: (val) => {
form.home_exclude_post_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
const excludeCategoryIdsText = computed({
get: () => (form.home_exclude_category_ids || []).join(','),
set: (val) => {
form.home_exclude_category_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
// Module operations
function handleAddModule(type) {
const newMod = makeDefaultModule(type)
if (newMod) {
newMod.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
newMod.enabled = true
newMod.title = ''
homeModules.value.push(newMod)
selectedModuleIndex.value = homeModules.value.length - 1
}
}
function handleRemoveModule(index) {
homeModules.value.splice(index, 1)
if (selectedModuleIndex.value >= homeModules.value.length) {
selectedModuleIndex.value = homeModules.value.length - 1
}
}
function handleCopyModule(index) {
const src = homeModules.value[index]
if (!src) return
const copy = JSON.parse(JSON.stringify(src))
copy.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
homeModules.value.splice(index + 1, 0, copy)
selectedModuleIndex.value = index + 1
}
function handleToggleModule(index, val) {
if (homeModules.value[index]) {
homeModules.value[index].enabled = val
}
}
function handleMoveModule(index, direction) {
const newIndex = index + direction
if (newIndex < 0 || newIndex >= homeModules.value.length) return
const temp = homeModules.value[index]
homeModules.value[index] = homeModules.value[newIndex]
homeModules.value[newIndex] = temp
// Trigger reactivity
homeModules.value = [...homeModules.value]
selectedModuleIndex.value = newIndex
}
function handleModuleUpdate(index, key, value) {
if (homeModules.value[index]) {
if (key === 'config') {
homeModules.value[index].config = value
} else {
homeModules.value[index][key] = value
}
// Trigger reactivity
homeModules.value = [...homeModules.value]
}
}
function handleApplyPreset(preset) {
homeModules.value = preset.modules.map((m, i) => ({
id: 'mod_' + Date.now() + '_' + i,
type: m.type,
config: JSON.parse(JSON.stringify(m.config || {})),
enabled: true,
title: '',
}))
selectedModuleIndex.value = -1
showPresetPicker.value = false
message.success(`已应用预设「${preset.label}」,共 ${preset.modules.length} 个模块`)
}
function handleExportModules() {
const json = JSON.stringify(homeModules.value, null, 2)
navigator.clipboard.writeText(json).then(() => {
message.success('模块配置已复制到剪贴板')
}).catch(() => {
// Fallback: show in a dialog
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'homepage-modules.json'
a.click()
URL.revokeObjectURL(url)
message.success('模块配置已导出')
})
}
function handleImportModules() {
try {
const parsed = JSON.parse(importJson.value)
if (!Array.isArray(parsed)) {
throw new Error('JSON 必须是数组格式')
}
homeModules.value = parsed.map((m, i) => ({
id: m.id || 'mod_' + Date.now() + '_' + i,
type: m.type,
config: m.config || {},
enabled: m.enabled !== false,
title: m.title || '',
}))
selectedModuleIndex.value = -1
showImportDialog.value = false
importJson.value = ''
message.success(`已导入 ${homeModules.value.length} 个模块`)
} catch (err) {
message.error('导入失败: ' + (err.message || 'JSON 格式错误'))
}
}
// Tab operations
function addTab() {
form.homepage_tabs.push({ term_id: 0, title: '', orderby: 'date', enabled: true })
}
function removeTab(index) {
form.homepage_tabs.splice(index, 1)
}
function updateTab(index, field, value) {
form.homepage_tabs[index][field] = value
}
// Data loading
async function loadCategories() {
try {
const res = await api.getHomepageCategories()
const cats = res?.data || []
categoryOptions.value = cats.map(c => ({
label: `${c.name} (${c.count})`,
value: c.term_id,
}))
} catch { /* non-critical */ }
}
async function loadSettings() {
loading.value = true
try {
const res = await api.getHomepageSettings()
const d = res?.data || {}
form.homepage_content_model = d.homepage_content_model || 'mixed'
form.homepage_tabs = Array.isArray(d.homepage_tabs) ? d.homepage_tabs : []
form.homepage_card_style = d.homepage_card_style || 'default'
form.homepage_multi_thumb_enabled = d.homepage_multi_thumb_enabled !== false
form.homepage_multi_thumb_min_images = d.homepage_multi_thumb_min_images || 3
form.homepage_sidebar_mini_enabled = d.homepage_sidebar_mini_enabled !== false
form.homepage_default_sort = d.homepage_default_sort || 'modified'
form.homepage_default_view = d.homepage_default_view || 'list'
form.homepage_sort_ajax_enabled = d.homepage_sort_ajax_enabled !== false
form.homepage_sticky_tabs_enabled = d.homepage_sticky_tabs_enabled !== false
form.home_posts_list_enabled = d.home_posts_list_enabled !== false
form.home_list_title = d.home_list_title || '最新发布'
form.home_list_sort_enabled = d.home_list_sort_enabled !== false
form.home_list_sort_options = Array.isArray(d.home_list_sort_options) && d.home_list_sort_options.length
? d.home_list_sort_options
: ['modified', 'date', 'views', 'likes', 'comments']
form.home_list_sort_dropdown = !!d.home_list_sort_dropdown
form.home_exclude_post_ids = Array.isArray(d.home_exclude_post_ids) ? d.home_exclude_post_ids : []
form.home_exclude_category_ids = Array.isArray(d.home_exclude_category_ids) ? d.home_exclude_category_ids : []
form.homepage_tab_posts_per_page = d.homepage_tab_posts_per_page || 6
form.homepage_tab_limit = d.homepage_tab_limit || 4
// Load modules
homeModules.value = Array.isArray(d.homepage_modules) ? d.homepage_modules.map((m, i) => ({
id: m.id || 'mod_' + i,
type: m.type,
config: m.config || {},
enabled: m.enabled !== false,
title: m.title || '',
})) : []
} catch (error) {
message.error(error.message || '加载设置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
...form,
homepage_modules: homeModules.value.map(m => ({
id: m.id,
type: m.type,
config: m.config,
enabled: m.enabled !== false,
title: m.title || '',
})),
}
const res = await api.saveHomepageSettings(payload)
if (res?.code === 'success') {
message.success('首页设置已保存')
return
}
throw new Error(res?.message || '保存失败')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadSettings()
})
</script>
<style scoped>
.aksrt-homepage__workstation {
display: flex;
gap: 16px;
min-height: 500px;
}
.aksrt-homepage__canvas-panel {
flex: 1;
min-width: 0;
}
.aksrt-homepage__inspector-panel {
width: 360px;
flex-shrink: 0;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
background: #fafafa;
}
.homepage-settings__empty-tabs {
padding: 24px;
text-align: center;
border: 1px dashed var(--n-border-color);
border-radius: 4px;
margin-bottom: 12px;
}
.homepage-settings__tab-row {
margin-bottom: 8px;
}
.homepage-settings__tab-fields {
display: flex;
gap: 8px;
align-items: center;
}
.aksrt-form-actions {
margin-top: 16px;
text-align: right;
}
</style>
@@ -0,0 +1,155 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
<template #actions>
<n-space>
<n-button :loading="cleaning" @click="runCleanup">立即清理</n-button>
<n-button type="primary" :loading="saving" @click="saveSettings">保存</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card title="通知内容开关" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="评论回复通知">
<n-switch v-model:value="settings.enabled_types.comment_reply" />
</n-form-item>
<n-form-item label="评论审核通过通知">
<n-switch v-model:value="settings.enabled_types.comment_approved" />
</n-form-item>
<n-form-item label="资源下载就绪通知">
<n-switch v-model:value="settings.enabled_types.download_ready" />
</n-form-item>
<n-form-item label="积分增加通知">
<n-switch v-model:value="settings.enabled_types.credits_added" />
</n-form-item>
<n-form-item label="积分扣减通知">
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
</n-form-item>
<n-form-item label="邮箱待验证提醒">
<n-switch v-model:value="settings.enabled_types.verify_email" />
</n-form-item>
<n-form-item label="待审核评论提醒">
<n-switch v-model:value="settings.enabled_types.pending_comments" />
</n-form-item>
<n-form-item label="图片审核通知">
<n-switch v-model:value="settings.enabled_types.upload_moderation" />
</n-form-item>
</n-form>
</n-card>
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
<n-grid-item span="24 l:10">
<n-card title="user 徽标规则" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="启用 user 动态徽标">
<n-switch v-model:value="settings.user_badge_enabled" />
</n-form-item>
<n-form-item label="徽标包含状态提醒">
<n-switch v-model:value="settings.badge_include_stateful" />
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
<n-grid-item span="24 l:14">
<n-card title="通知清理策略" class="aksrt-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="已读通知保留天数">
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
</n-form-item>
<n-form-item label="未读通知保留天数">
<n-input-number v-model:value="settings.unread_retention_days" :min="7" :max="3650" style="width: 180px;" />
</n-form-item>
<n-form-item label="每用户最大保留数">
<n-input-number v-model:value="settings.max_per_user" :min="20" :max="5000" style="width: 180px;" />
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
</n-grid>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const cleaning = ref(false)
const settings = reactive({
enabled_types: {
comment_reply: true,
comment_approved: true,
download_ready: true,
credits_added: true,
credits_deducted: true,
verify_email: true,
pending_comments: true,
upload_moderation: true,
},
user_badge_enabled: true,
badge_include_stateful: true,
read_retention_days: 30,
unread_retention_days: 180,
max_per_user: 200,
})
async function loadSettings() {
loading.value = true
try {
const res = await api.getNotificationSettings()
if (res.code === 'success' && res.data) {
Object.assign(settings, res.data)
if (res.data.enabled_types) {
settings.enabled_types = { ...settings.enabled_types, ...res.data.enabled_types }
}
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function saveSettings() {
saving.value = true
try {
const res = await api.saveNotificationSettings({
enabled_types: settings.enabled_types,
user_badge_enabled: settings.user_badge_enabled,
badge_include_stateful: settings.badge_include_stateful,
read_retention_days: settings.read_retention_days,
unread_retention_days: settings.unread_retention_days,
max_per_user: settings.max_per_user,
})
if (res.code === 'success') message.success(res.message || '保存成功')
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function runCleanup() {
cleaning.value = true
try {
const res = await api.runNotificationCleanup()
if (res.code === 'success') message.success(res.message || '清理完成')
} catch (e) {
message.error(e.message || '清理失败')
} finally {
cleaning.value = false
}
}
onMounted(loadSettings)
</script>
+354
View File
@@ -0,0 +1,354 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
<!-- Docs Mode Status -->
<n-card class="aksrt-docs__section" size="small">
<div class="aksrt-docs__mode-status">
<div class="aksrt-docs__mode-info">
<FaIcon :icon="docsModeEnabled ? 'fas fa-book-open' : 'fas fa-book'" :size="18" :color="docsModeEnabled ? '#52c41a' : '#8a909c'" />
<div>
<strong>{{ docsModeEnabled ? '文档模式已启用' : '文档模式未启用' }}</strong>
<p>{{ docsModeEnabled ? '文档导航页面和文档站式侧边栏已激活' : '启用后将激活文档导航页面模板和文档站式侧边栏' }}</p>
</div>
</div>
<n-space>
<router-link to="/extensions/docs-mode" class="aksrt-docs__mode-link">
<FaIcon icon="fas fa-cog" :size="12" />
{{ docsModeEnabled ? '管理文档模式' : '启用文档模式' }}
</router-link>
</n-space>
</div>
</n-card>
<!-- Doc Pages Overview -->
<n-card title="文档页面" size="small" class="aksrt-docs__section">
<n-alert v-if="docPageCount === 0" type="info" size="small">
尚未创建文档页面。在 WordPress 后台新建页面时,选择"文档导航页"或"专题聚合页"模板。
</n-alert>
<n-grid v-else :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="stat in docPageStats" :key="stat.key">
<div class="aksrt-docs__stat">
<FaIcon :icon="stat.icon" :size="16" :color="stat.color" />
<div>
<strong>{{ stat.value }}</strong>
<p>{{ stat.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Quick Actions -->
<n-card title="常用操作" size="small" class="aksrt-docs__section">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-palette" :size="16" />
<div>
<strong>全局设置</strong>
<p>Logo、背景、菜单装饰、自定义代码</p>
<router-link to="/global/common">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-home" :size="16" />
<div>
<strong>首页配置</strong>
<p>首页模块、布局、排序和预设模板</p>
<router-link to="/page-display/homepage">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-users" :size="16" />
<div>
<strong>用户管理</strong>
<p>登录注册、OAuth、签到、积分</p>
<router-link to="/users/auth">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<FaIcon icon="fas fa-tools" :size="16" />
<div>
<strong>系统工具</strong>
<p>导入导出、健康检查、迁移状态</p>
<router-link to="/tools/import-export">前往配置 →</router-link>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Reference Docs -->
<div class="aksrt-docs__grid">
<n-card
v-for="doc in docs"
:key="doc.key"
class="aksrt-docs__card"
size="small"
hoverable
@click="openDoc(doc)"
>
<div class="aksrt-docs__card-inner">
<div class="aksrt-docs__card-icon" :style="{ background: doc.color }">
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
</div>
<div class="aksrt-docs__card-body">
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
<p class="aksrt-docs__card-desc">{{ doc.description }}</p>
</div>
<FaIcon icon="fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
</div>
</n-card>
</div>
<!-- Version Info -->
<n-card title="当前版本" size="small" class="aksrt-docs__section">
<n-descriptions :column="2" bordered label-placement="left" size="small">
<n-descriptions-item label="主题版本">
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="Schema 版本">
<n-tag type="info" size="small">{{ dbSchemaVersion }}</n-tag>
</n-descriptions-item>
</n-descriptions>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
const dbSchemaVersion = ref('—')
const docsModeEnabled = ref(false)
const docPageCount = ref(0)
const docPageStats = ref([
{ key: 'total', icon: 'fas fa-file-alt', color: '#1890ff', value: 0, label: '文档页面' },
{ key: 'nav', icon: 'fas fa-sitemap', color: '#722ed1', value: 0, label: '导航页' },
{ key: 'special', icon: 'fas fa-layer-group', color: '#fa8c16', value: 0, label: '专题页' },
])
const docs = ref([
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fas fa-sitemap', color: '#1890ff', file: 'admin-ia-map.md' },
{ key: 'zibll-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → Zibll 参考 → AKSRT 目标三层对照表。', icon: 'fas fa-columns', color: '#722ed1', file: 'zibll-gap-matrix.md' },
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fas fa-clipboard-check', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
])
onMounted(async () => {
try {
const res = await api.getSchemaPage('extensions', 'docs-mode')
if (res?.data) {
docsModeEnabled.value = !!res.data.docs_mode_enabled
}
} catch {
// Schema endpoint may not exist yet
}
// Fetch doc page stats via REST if available
try {
const statsRes = await api.request('/docs/stats')
if (statsRes?.data) {
docPageCount.value = statsRes.data.total || 0
docPageStats.value[0].value = statsRes.data.total || 0
docPageStats.value[1].value = statsRes.data.nav_count || 0
docPageStats.value[2].value = statsRes.data.special_count || 0
}
} catch {
// Endpoint may not exist yet
}
// Fetch schema version from health
try {
const healthRes = await api.getHealthCheck()
if (healthRes?.data) {
dbSchemaVersion.value = healthRes.data.db_schema_version || '—'
}
} catch {
// Endpoint may not exist yet
}
})
function openDoc(doc) {
// Link to local docs directory (admin can open in new tab)
const base = window.aksrtAdminData?.siteUrl || window.location.origin
window.open(`${base}/wp-content/themes/AKSRT-theme/docs/${doc.file}`, '_blank')
}
</script>
<style scoped>
.aksrt-docs__mode-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.aksrt-docs__mode-info {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-docs__mode-info strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-docs__mode-info p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__mode-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
background: #f5f7fa;
color: #1890ff;
text-decoration: none;
font-size: 13px;
transition: background 0.2s;
}
.aksrt-docs__mode-link:hover {
background: #e6f4ff;
}
.aksrt-docs__stat {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs__stat strong {
display: block;
font-size: 20px;
color: #1a1a2e;
}
.aksrt-docs__stat p {
margin: 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-docs__card {
cursor: pointer;
transition: box-shadow 0.2s;
}
.aksrt-docs__card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.aksrt-docs__card-inner {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-docs__card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.aksrt-docs__card-body {
flex: 1;
min-width: 0;
}
.aksrt-docs__card-title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-docs__card-desc {
margin: 0;
font-size: 12px;
color: #8a909c;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-docs__card-arrow {
flex-shrink: 0;
color: #c0c4cc;
}
.aksrt-docs__section {
margin-bottom: 16px;
}
.aksrt-docs__action {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs__action i {
color: #1890ff;
margin-top: 2px;
}
.aksrt-docs__action strong {
display: block;
font-size: 14px;
color: #1a1a2e;
margin-bottom: 2px;
}
.aksrt-docs__action p {
margin: 0 0 4px;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs__action a {
font-size: 12px;
color: #1890ff;
text-decoration: none;
}
.aksrt-docs__action a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,314 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="AI 子系统" desc="配置 AI 内容辅助能力,包括 SEO 建议、摘要生成、标签建议等。" />
<!-- 状态卡片 -->
<div class="aksrt-ai__status">
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.enabled ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
<i :class="status.enabled ? 'fas fa-robot' : 'fas fa-robot'"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">AI 状态</div>
<div class="aksrt-ai__status-value">{{ status.enabled ? '已启用' : '已关闭' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.configured ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-key"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">API Key</div>
<div class="aksrt-ai__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
<i class="fas fa-brain"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">模型</div>
<div class="aksrt-ai__status-value">{{ status.model || '未设置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-check-circle"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">可用状态</div>
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
</div>
</div>
</div>
<!-- 配置表单 -->
<div class="aksrt-ai__form-card">
<h3>基本配置</h3>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用 AI 内容辅助
</label>
<p class="aksrt-form-hint">开启后可在投稿、编辑等场景使用 AI 辅助功能</p>
</div>
<div class="aksrt-form-group">
<label>AI Provider</label>
<select v-model="form.provider" class="aksrt-select">
<option value="none">不使用</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>API Key</label>
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
<label>API Base URL</label>
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://api.openai.com/v1">
<p class="aksrt-form-hint">可替换为兼容 OpenAI 接口的第三方服务</p>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>模型</label>
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>最大 Token 数</label>
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>每分钟请求限制</label>
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<!-- 功能说明 -->
<div class="aksrt-ai__form-card">
<h3>支持的 AI 功能</h3>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>功能</th>
<th>说明</th>
<th>使用场景</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="feat in features" :key="feat.key">
<td>{{ feat.name }}</td>
<td>{{ feat.desc }}</td>
<td>{{ feat.scene }}</td>
<td>
<span class="aksrt-badge" :class="status.available ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ status.available ? '可用' : '未启用' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 重要提示 -->
<div class="aksrt-ai__form-card aksrt-ai__notice">
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
<ul>
<li>AI 生成的内容仅作为<strong>建议参考</strong>,不会自动应用到线上内容</li>
<li>所有 AI 输出需要<strong>人工审核确认</strong>后才能发布</li>
<li>AI 默认关闭,需管理员显式启用并配置 API Key</li>
<li>API Key 不会回显到前端,不会通过 <code>wp_localize_script</code> 透出</li>
<li>内置速率限制,防止滥用</li>
</ul>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
const API = '/wp-json/aksrt/v1/ai';
export default {
name: 'ExtensionAI',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
provider: 'none',
configured: false,
model: '',
api_key: '',
rate_limit: 10,
available: false,
});
const form = reactive({
enabled: false,
provider: 'none',
api_key: '',
api_base: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo',
max_tokens: 1000,
rate_limit: 10,
});
const saving = ref(false);
const features = [
{ key: 'seo', name: 'SEO 建议', desc: '生成标题、描述、关键词建议', scene: '文章编辑' },
{ key: 'summary', name: '摘要生成', desc: '自动生成文章摘要', scene: '文章编辑、投稿' },
{ key: 'tags', name: '标签建议', desc: '根据内容推荐标签', scene: '文章编辑、投稿' },
{ key: 'excerpt', name: '摘要建议', desc: '生成吸引人的列表摘要', scene: '文章编辑' },
];
async function loadStatus() {
try {
const res = await fetch(`${API}/status`, {
headers: { 'X-WP-Nonce': window.aksrtAdmin?.nonce || '' },
});
const json = await res.json();
if (json.success) {
status.value = json.data;
form.enabled = json.data.enabled || false;
form.provider = json.data.provider || 'none';
form.model = json.data.model || 'gpt-3.5-turbo';
form.rate_limit = json.data.rate_limit || 10;
// 不回显 api_key
}
} catch (e) {
console.error('Load AI status failed:', e);
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = { ...form };
// 如果密钥为空且已配置,发送掩码
if (!payload.api_key && status.value.configured) {
payload.api_key = '***';
}
const res = await fetch(`${API}/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.aksrtAdmin?.nonce || '',
},
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.success) {
alert('设置已保存');
loadStatus();
} else {
alert(json.message || '保存失败');
}
} catch (e) {
console.error('Save failed:', e);
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, features, saveSettings };
},
};
</script>
<style scoped>
.aksrt-ai__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-ai__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-ai__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-ai__status-icon--ok {
background: #d4edda;
color: #155724;
}
.aksrt-ai__status-icon--warn {
background: #fff3cd;
color: #856404;
}
.aksrt-ai__status-icon--off {
background: #f8d7da;
color: #721c24;
}
.aksrt-ai__status-icon--info {
background: #d1ecf1;
color: #0c5460;
}
.aksrt-ai__status-value {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-ai__status-label {
font-size: 12px;
color: #888;
}
.aksrt-ai__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-ai__form-card h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
}
.aksrt-form-hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.aksrt-ai__notice {
background: #fff8e1;
border-color: #ffe082;
}
.aksrt-ai__notice h3 {
color: #e65100;
}
.aksrt-ai__notice ul {
margin: 0;
padding-left: 20px;
}
.aksrt-ai__notice li {
margin-bottom: 8px;
font-size: 14px;
}
.aksrt-ai__notice code {
background: rgba(0,0,0,0.06);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
</style>
@@ -0,0 +1,311 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="文档模式" desc="启用文档专题内容模式,配置文档导航页面和文档站式体验。" />
<n-card class="aksrt-docs-mode__card" title="文档模式" size="small">
<n-form label-placement="left" label-width="160">
<n-form-item label="启用文档模式">
<n-switch v-model:value="formData.docs_mode_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
开启后将激活文档导航页面模板和文档站式侧边栏
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="文档首页模板">
<n-select
v-model:value="formData.docs_mode_default_template"
:options="templateOptions"
placeholder="选择文档首页模板"
style="max-width: 360px;"
/>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="文档分类">
<n-select
v-model:value="formData.docs_mode_cats"
:options="categoryOptions"
:loading="categoriesLoading"
multiple
clearable
filterable
placeholder="选择用于文档模式的分类"
style="max-width: 520px;"
/>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="首页排除">
<n-switch v-model:value="formData.docs_mode_exclude" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
开启后,所选文档分类的内容不会出现在首页常规文章流中
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="显示目录树">
<n-switch v-model:value="formData.docs_mode_toc_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
在文档页面侧边栏显示章节目录树
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="显示章节导航">
<n-switch v-model:value="formData.docs_mode_chapter_nav_enabled" />
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
在文档底部显示上一章/下一章导航
</n-text>
</n-form-item>
<n-form-item v-if="formData.docs_mode_enabled" label="默认排序">
<n-radio-group v-model:value="formData.docs_mode_default_order">
<n-radio value="menu_order">按菜单顺序</n-radio>
<n-radio value="date">按发布时间</n-radio>
<n-radio value="title">按标题排序</n-radio>
</n-radio-group>
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button @click="handleReset">重置</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</n-space>
</template>
</n-card>
<n-card title="文档页面统计" size="small" class="aksrt-docs-mode__card">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.total || 0 }}</strong>
<p>文档页面总数</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.nav_count || 0 }}</strong>
<p>文档导航页</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs-mode__stat">
<strong>{{ docStats.special_count || 0 }}</strong>
<p>专题聚合页</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="可用页面模板" size="small" class="aksrt-docs-mode__card">
<n-alert type="info" size="small" style="margin-bottom: 12px;">
以下页面模板可用于创建文档类页面。在 WordPress 后台新建页面时,从"页面属性 → 模板"中选择对应模板。
</n-alert>
<n-list bordered size="small">
<n-list-item v-for="tpl in availableTemplates" :key="tpl.file">
<n-thing :title="tpl.name" :description="tpl.description">
<template #avatar>
<FaIcon :icon="tpl.icon" :size="18" color="#1890ff" />
</template>
<template #header-extra>
<n-tag size="small" type="info">{{ tpl.file }}</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<n-card title="快速入口" size="small" class="aksrt-docs-mode__card">
<n-space>
<router-link to="/page-display/special" class="aksrt-docs-mode__link">
<FaIcon icon="fas fa-layer-group" :size="14" />
专题页面配置
</router-link>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const saving = ref(false)
const docStats = ref({ total: 0, nav_count: 0, special_count: 0 })
const categoriesLoading = ref(false)
const categoryOptions = ref([])
const defaults = {
docs_mode_enabled: false,
docs_mode_default_template: 'documentnav',
docs_mode_toc_enabled: true,
docs_mode_chapter_nav_enabled: true,
docs_mode_default_order: 'menu_order',
docs_mode_cats: [],
docs_mode_exclude: true,
}
const formData = ref({ ...defaults })
const templateOptions = [
{ label: '文档导航页 (page-documentnav)', value: 'documentnav' },
{ label: '专题聚合页 (page-special)', value: 'special' },
]
const availableTemplates = [
{
file: 'page-documentnav.php',
name: '文档导航页',
description: '站式文档导航模板,支持侧边栏目录树、章节高亮和快速搜索。',
icon: 'fas fa-book',
},
{
file: 'page-special.php',
name: '专题聚合页',
description: '专题内容聚合模板,支持封面、摘要、多列布局和子页面矩阵。',
icon: 'fas fa-layer-group',
},
{
file: 'page-download.php',
name: '下载中心页',
description: '资源下载中心模板,支持资源类型筛选、权限说明和交易承接。',
icon: 'fas fa-download',
},
{
file: 'page-categories.php',
name: '分类导航页',
description: '全站分类导航模板,展示分类树和文章统计。',
icon: 'fas fa-folder-open',
},
{
file: 'page-friends.php',
name: '友情链接页',
description: '友情链接展示模板。',
icon: 'fas fa-link',
},
]
onMounted(async () => {
await Promise.all([loadSettings(), loadDocStats(), loadCategories()])
})
async function loadSettings() {
try {
const res = await api.getSchemaPage('extensions', 'docs-mode')
if (res?.data) {
formData.value = {
...defaults,
...res.data,
docs_mode_cats: normalizeCategoryIds(res.data.docs_mode_cats),
}
}
} catch {
// Schema endpoint may not exist yet; use defaults
}
}
async function loadDocStats() {
try {
const statsRes = await api.request('/docs/stats')
if (statsRes?.data) {
docStats.value = statsRes.data
}
} catch {
// Endpoint may not exist yet
}
}
async function loadCategories() {
categoriesLoading.value = true
try {
const res = await api.getCategories()
const items = res?.data || res || []
categoryOptions.value = (Array.isArray(items) ? items : []).map((item) => ({
label: item.name + (item.count ? ` (${item.count})` : ''),
value: Number(item.term_id),
}))
} catch {
categoryOptions.value = []
} finally {
categoriesLoading.value = false
}
}
function normalizeCategoryIds(value) {
if (Array.isArray(value)) {
return value.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item > 0)
}
if (typeof value === 'string' && value.trim()) {
return value
.split(',')
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
}
return []
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('extensions', 'docs-mode', {
...formData.value,
docs_mode_cats: normalizeCategoryIds(formData.value.docs_mode_cats),
})
message.success('文档模式设置已保存')
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
function handleReset() {
formData.value = { ...defaults }
message.info('已重置为默认值')
}
</script>
<style scoped>
.aksrt-docs-mode__card {
margin-bottom: 16px;
}
.aksrt-docs-mode__stat {
text-align: center;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-docs-mode__stat strong {
display: block;
font-size: 24px;
color: #1a1a2e;
}
.aksrt-docs-mode__stat p {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-docs-mode__link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
background: #f5f7fa;
color: #1890ff;
text-decoration: none;
font-size: 13px;
transition: background 0.2s;
}
.aksrt-docs-mode__link:hover {
background: #e6f4ff;
}
</style>
@@ -0,0 +1,293 @@
<template>
<ExtensionsSectionPage
title="网站安全"
desc="扩展安全策略和第三方安全集成。"
>
<n-card title="安全概览" size="small" class="aksrt-security__section">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in securityItems" :key="item.key">
<div class="aksrt-security__overview-card">
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
<div>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="安全能力" size="small" class="aksrt-security__section">
<n-list bordered size="small">
<n-list-item v-for="feat in features" :key="feat.key">
<n-thing :title="feat.name" :description="feat.description">
<template #avatar>
<FaIcon :icon="feat.icon" :size="16" :color="feat.color" />
</template>
<template #header-extra>
<n-space align="center" size="small">
<n-tag :type="feat.enabled ? 'success' : 'default'" size="small">
{{ feat.enabled ? '已启用' : '未启用' }}
</n-tag>
<router-link v-if="feat.settingsRoute" :to="feat.settingsRoute" class="aksrt-security__config-link">
前往配置
</router-link>
</n-space>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<n-card title="在线测试" size="small" class="aksrt-security__section">
<n-grid :cols="2" :x-gap="16" :y-gap="16">
<n-grid-item>
<n-form label-placement="top">
<n-form-item label="IP 归属地测试">
<n-input v-model:value="ipTest.ip" placeholder="输入 IPv4 / IPv6,例如 8.8.8.8" />
</n-form-item>
<n-space>
<n-button :loading="ipTest.loading" @click="handleTestIp">测试 IP 接口</n-button>
</n-space>
<pre v-if="ipTest.result" class="aksrt-security__output">{{ ipTest.result }}</pre>
</n-form>
</n-grid-item>
<n-grid-item>
<n-form label-placement="top">
<n-form-item label="内容审核测试">
<n-input v-model:value="auditTest.text" type="textarea" :autosize="{ minRows: 4, maxRows: 8 }" placeholder="输入一段测试文本,检查当前审核策略是否生效" />
</n-form-item>
<n-space>
<n-button :loading="auditTest.loading" @click="handleTestAudit">测试内容审核</n-button>
</n-space>
<pre v-if="auditTest.result" class="aksrt-security__output">{{ auditTest.result }}</pre>
</n-form>
</n-grid-item>
</n-grid>
</n-card>
<n-card title="风险开关" size="small" class="aksrt-security__section">
<n-alert type="warning" size="small" style="margin-bottom: 12px;">
以下开关会影响站点安全策略,请谨慎操作。
</n-alert>
<n-form label-placement="left" label-width="200" size="small">
<n-form-item label="允许未审核评论显示">
<n-switch v-model:value="riskSwitches.show_unapproved_comments" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后未审核评论也会在前台显示(标记为待审核)
</n-text>
</n-form-item>
<n-form-item label="允许前台投稿无需审核">
<n-switch v-model:value="riskSwitches.skip_submit_moderation" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后用户投稿直接发布,不经过审核流程
</n-text>
</n-form-item>
<n-form-item label="允许游客评论">
<n-switch v-model:value="riskSwitches.guest_comment" />
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
开启后未登录用户也可以发表评论
</n-text>
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button type="primary" size="small" :loading="saving" @click="handleSaveRisk">保存风险设置</n-button>
</n-space>
</template>
</n-card>
<n-card title="安全审计" size="small" class="aksrt-security__section">
<n-empty v-if="auditLogs.length === 0" description="暂无审计记录" />
<n-list v-else bordered size="small">
<n-list-item v-for="log in auditLogs" :key="log.id">
<n-thing :title="log.action" :description="log.detail">
<template #header-extra>
<n-text depth="3" style="font-size: 11px;">{{ log.time }}</n-text>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
</ExtensionsSectionPage>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import ExtensionsSectionPage from '@/pages/extensions/ExtensionsSectionPage.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const saving = ref(false)
const securityItems = ref([
{ key: 'email_gate', icon: 'fas fa-envelope', color: '#1890ff', value: '—', label: '邮箱验证' },
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#52c41a', value: '—', label: '验证码保护' },
{ key: 'moderation', icon: 'fas fa-gavel', color: '#722ed1', value: '—', label: '内容审核' },
{ key: 'ip_geo', icon: 'fas fa-map-marker-alt', color: '#fa8c16', value: '—', label: 'IP 归属地' },
])
const features = ref([
{ key: 'email_verification', icon: 'fas fa-envelope', color: '#1890ff', name: '邮箱验证', description: '新用户注册需要邮箱验证', enabled: false, settingsRoute: '/users/auth' },
{ key: 'captcha_comment', icon: 'fas fa-shield-alt', color: '#52c41a', name: '评论验证码', description: '发表评论时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
{ key: 'captcha_download', icon: 'fas fa-shield-alt', color: '#52c41a', name: '下载验证码', description: '下载资源时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
{ key: 'content_audit', icon: 'fas fa-shield-virus', color: '#722ed1', name: '内容审核 API', description: '接入第三方内容安全审核', enabled: false, settingsRoute: '/extensions/audit' },
{ key: 'ip_geolocation', icon: 'fas fa-map-marker-alt', color: '#fa8c16', name: 'IP 归属地', description: '在评论和用户主页显示 IP 归属地', enabled: false, settingsRoute: '/extensions/ip' },
])
const riskSwitches = ref({
show_unapproved_comments: false,
skip_submit_moderation: false,
guest_comment: false,
})
const auditLogs = ref([])
const ipTest = ref({ ip: '8.8.8.8', loading: false, result: '' })
const auditTest = ref({ text: '这是一段用于审核测试的示例文本。', loading: false, result: '' })
onMounted(async () => {
try {
const res = await api.request('/settings/security')
if (res?.data) {
const d = res.data
const svc = res.services || {}
securityItems.value[0].value = d.email_gate_enabled ? '已开启' : '未开启'
securityItems.value[0].color = d.email_gate_enabled ? '#52c41a' : '#8a909c'
securityItems.value[1].value = d.captcha_enabled ? '已开启' : '未开启'
securityItems.value[1].color = d.captcha_enabled ? '#52c41a' : '#8a909c'
if (svc.audit?.enabled) {
securityItems.value[2].value = svc.audit.configured ? '已配置' : '待补全'
securityItems.value[2].color = svc.audit.configured ? '#52c41a' : '#faad14'
} else {
securityItems.value[2].value = '未开启'
securityItems.value[2].color = '#8a909c'
}
if (svc.ip_geo?.enabled) {
securityItems.value[3].value = svc.ip_geo.configured ? '已配置' : '待补全'
securityItems.value[3].color = svc.ip_geo.configured ? '#52c41a' : '#faad14'
} else {
securityItems.value[3].value = '未开启'
securityItems.value[3].color = '#8a909c'
}
features.value[0].enabled = !!d.email_gate_enabled
features.value[1].enabled = !!d.captcha_comment_enabled
features.value[2].enabled = !!d.captcha_download_enabled
features.value[3].enabled = !!(svc.audit?.enabled ?? d.content_audit_enabled)
features.value[4].enabled = !!(svc.ip_geo?.enabled ?? d.ip_geo_enabled)
if (d.risk_switches) {
riskSwitches.value = { ...riskSwitches.value, ...d.risk_switches }
}
}
} catch {
// Endpoint may not exist yet
}
try {
const logRes = await api.request('/security/audit-log')
if (logRes?.data?.logs) {
auditLogs.value = logRes.data.logs.slice(0, 10)
}
} catch {
// Endpoint may not exist yet
}
})
async function handleSaveRisk() {
saving.value = true
try {
await api.request('/settings/security', {
method: 'POST',
body: JSON.stringify({ risk_switches: riskSwitches.value }),
})
message.success('风险设置已保存')
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
async function handleTestIp() {
ipTest.value.loading = true
try {
const res = await api.testIpGeo(ipTest.value.ip)
ipTest.value.result = JSON.stringify(res?.data || {}, null, 2)
message.success('IP 接口测试完成')
} catch (err) {
message.error(err?.message || 'IP 测试失败')
} finally {
ipTest.value.loading = false
}
}
async function handleTestAudit() {
auditTest.value.loading = true
try {
const res = await api.testContentAudit(auditTest.value.text)
auditTest.value.result = JSON.stringify(res?.data || {}, null, 2)
message.success('内容审核测试完成')
} catch (err) {
message.error(err?.message || '内容审核测试失败')
} finally {
auditTest.value.loading = false
}
}
</script>
<style scoped>
.aksrt-security__section {
margin-bottom: 16px;
}
.aksrt-security__overview-card {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-security__overview-card strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-security__overview-card p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-security__config-link {
font-size: 12px;
color: #1890ff;
text-decoration: none;
white-space: nowrap;
}
.aksrt-security__config-link:hover {
text-decoration: underline;
}
.aksrt-security__output {
margin: 12px 0 0;
padding: 12px 14px;
border-radius: 12px;
background: #101828;
color: #d0d7e2;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,271 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="系统工具" desc="额外运维工具与扩展能力。" />
<!-- Health Overview -->
<n-card title="系统健康" size="small" class="aksrt-tools__section">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in healthItems" :key="item.key">
<div class="aksrt-tools__health-card" :class="'aksrt-tools__health-card--' + item.status">
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
<div>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Tool Categories -->
<n-card title="工具集合" size="small" class="aksrt-tools__section">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="tool in tools" :key="tool.key">
<div class="aksrt-tools__tool-card" @click="navigateTo(tool.route)">
<div class="aksrt-tools__tool-icon" :style="{ background: tool.bg }">
<FaIcon :icon="tool.icon" :size="18" color="#fff" />
</div>
<div class="aksrt-tools__tool-body">
<strong>{{ tool.title }}</strong>
<p>{{ tool.desc }}</p>
</div>
<FaIcon icon="fas fa-chevron-right" :size="12" color="#c0c4cc" />
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Service Status -->
<n-card title="服务状态" size="small" class="aksrt-tools__section">
<n-list bordered size="small">
<n-list-item v-for="svc in services" :key="svc.key">
<n-thing :title="svc.name" :description="svc.description">
<template #avatar>
<FaIcon :icon="svc.icon" :size="16" :color="svc.color" />
</template>
<template #header-extra>
<n-tag :type="svc.available ? 'success' : 'warning'" size="small">
{{ svc.available ? '可用' : '未配置' }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<!-- Quick Maintenance -->
<n-card title="手动维护" size="small" class="aksrt-tools__section">
<n-space>
<n-button @click="handleFlushCache">
<template #icon><FaIcon icon="fas fa-broom" :size="12" /></template>
清除缓存
</n-button>
<n-button @click="handleOptimizeDb">
<template #icon><FaIcon icon="fas fa-database" :size="12" /></template>
优化数据表
</n-button>
<n-button @click="handleRebuildIndex">
<template #icon><FaIcon icon="fas fa-sync-alt" :size="12" /></template>
重建索引
</n-button>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const router = useRouter()
const message = useMessage()
const healthItems = ref([
{ key: 'php', icon: 'fas fa-code', color: '#1890ff', value: '—', label: 'PHP 版本', status: 'ok' },
{ key: 'wp', icon: 'fab fa-wordpress', color: '#722ed1', value: '—', label: 'WordPress', status: 'ok' },
{ key: 'db', icon: 'fas fa-database', color: '#52c41a', value: '—', label: '数据库', status: 'ok' },
{ key: 'uploads', icon: 'fas fa-folder-open', color: '#fa8c16', value: '—', label: '上传目录', status: 'ok' },
])
const tools = ref([
{ key: 'import-export', icon: 'fas fa-exchange-alt', bg: '#1890ff', title: '导入导出', desc: '设置导入、导出和重置', route: '/tools/import-export' },
{ key: 'health', icon: 'fas fa-heartbeat', bg: '#52c41a', title: '健康检查', desc: '系统状态检测和诊断', route: '/tools/health' },
{ key: 'migration', icon: 'fas fa-exchange-alt', bg: '#722ed1', title: '数据迁移', desc: '设置迁移状态和兼容性', route: '/tools/migration' },
{ key: 'email-rollback', icon: 'fas fa-undo', bg: '#fa8c16', title: '邮箱回滚', desc: '邮箱变更 14 天内回滚', route: '/tools/email-rollback' },
])
const services = ref([
{ key: 'sms', icon: 'fas fa-sms', color: '#1890ff', name: '短信服务', description: '验证码和通知短信', available: false },
{ key: 'wechat', icon: 'fab fa-weixin', color: '#52c41a', name: '微信通知', description: '微信模板消息和订阅通知', available: false },
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#722ed1', name: '验证码服务', description: '人机验证(极验/腾讯/阿里云)', available: false },
{ key: 'push', icon: 'fas fa-paper-plane', color: '#fa8c16', name: '搜索引擎推送', description: '百度/Google 主动推送', available: false },
{ key: 'audit', icon: 'fas fa-shield-virus', color: '#f5222d', name: '内容审核', description: '第三方内容安全审核 API', available: false },
])
onMounted(async () => {
try {
const res = await api.request('/tools/health')
if (res?.data) {
const d = res.data
if (d.php_version) healthItems.value[0].value = d.php_version
if (d.wp_version) healthItems.value[1].value = d.wp_version
if (d.db_status) {
healthItems.value[2].value = d.db_status
healthItems.value[2].status = d.db_ok ? 'ok' : 'warn'
}
if (d.upload_dir) {
healthItems.value[3].value = d.upload_dir
healthItems.value[3].status = d.upload_ok ? 'ok' : 'warn'
}
}
} catch {
// Endpoint may not exist yet
}
// Check service availability from structured status
try {
const settingsRes = await api.request('/settings/extensions')
if (settingsRes?.data) {
const s = settingsRes.data
// Prefer structured services if available
const svc = settingsRes.services || {}
services.value[0].available = !!(svc.sms?.available ?? s.sms_enabled)
services.value[0].status = svc.sms?.provider || (s.sms_enabled ? '已启用' : '未配置')
services.value[1].available = !!(svc.wechat?.available ?? s.wechat_enabled)
services.value[1].status = svc.wechat?.provider || (s.wechat_enabled ? '已启用' : '未配置')
services.value[2].available = !!(svc.captcha?.available ?? s.captcha_enabled)
services.value[2].status = svc.captcha?.provider || (s.captcha_enabled ? '已启用' : '未配置')
services.value[3].available = !!(svc.push?.available ?? s.baidu_push_enabled)
services.value[3].status = svc.push?.provider || (s.baidu_push_enabled ? '已启用' : '未配置')
services.value[4].available = !!(svc.audit?.available ?? s.content_audit_enabled)
services.value[4].status = svc.audit?.provider || (s.content_audit_enabled ? '已启用' : '未配置')
}
} catch {
// Use defaults
}
})
function navigateTo(route) {
router.push(route)
}
async function handleFlushCache() {
message.info('正在清除缓存...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'flush_cache' }),
})
message.success(res?.message || '缓存已清除')
} catch (err) {
message.error(err?.message || '清除缓存失败')
}
}
async function handleOptimizeDb() {
message.info('正在优化数据表...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'optimize_db' }),
})
message.success(res?.message || '数据表已优化')
} catch (err) {
message.error(err?.message || '优化数据表失败')
}
}
async function handleRebuildIndex() {
message.info('正在重建索引...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'rebuild_index' }),
})
message.success(res?.message || '索引重建完成')
} catch (err) {
message.error(err?.message || '重建索引失败')
}
}
</script>
<style scoped>
.aksrt-tools__section {
margin-bottom: 16px;
}
.aksrt-tools__health-card {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-tools__health-card strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__health-card p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-tools__health-card--warn {
border-color: #faad14;
background: #fffbe6;
}
.aksrt-tools__tool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s;
}
.aksrt-tools__tool-card:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
}
.aksrt-tools__tool-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.aksrt-tools__tool-body {
flex: 1;
min-width: 0;
}
.aksrt-tools__tool-body strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__tool-body p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -0,0 +1,39 @@
<template>
<div class="aksrt-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
{{ link.label }}
</n-button>
</n-space>
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
</n-card>
<slot />
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue'
defineProps({
title: { type: String, required: true },
desc: { type: String, default: '' },
note: { type: String, default: '' },
links: { type: Array, default: () => [] },
})
const router = useRouter()
function go(path) {
router.push(path)
}
</script>
@@ -0,0 +1,195 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版块管理" desc="创建、编辑和维护论坛版块信息,并观察版主申请积压情况。" />
<n-card size="small" class="aksrt-forum-boards__section">
<n-space justify="space-between" align="center">
<div class="aksrt-forum-boards__summary">共 {{ boards.length }} 个版块</div>
<n-button type="primary" @click="openCreate">新增版块</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-boards__section">
<n-data-table
:columns="columns"
:data="boards"
:loading="loading"
:bordered="false"
size="small"
:row-key="(row) => row.id"
/>
</n-card>
<n-modal :show="modalVisible" preset="card" style="width: 520px" :title="editingId ? '编辑版块' : '新增版块'" @update:show="modalVisible = $event">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="版块名称" path="name">
<n-input v-model:value="form.name" placeholder="输入版块名称" />
</n-form-item>
<n-form-item label="版块描述" path="description">
<n-input v-model:value="form.description" type="textarea" :rows="3" placeholder="描述主题方向和讨论边界" />
</n-form-item>
<n-form-item label="图标" path="icon">
<n-input v-model:value="form.icon" placeholder="例如:fas fa-comments" />
</n-form-item>
<n-form-item label="排序" path="sort_order">
<n-input-number v-model:value="form.sort_order" :min="0" style="width: 100%" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="modalVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const boards = ref([])
const modalVisible = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
name: '',
description: '',
icon: 'fas fa-comments',
sort_order: 0,
})
const rules = {
name: { required: true, message: '请输入版块名称', trigger: 'blur' },
}
const columns = computed(() => [
{ title: '排序', key: 'sort_order', width: 70 },
{ title: '版块名称', key: 'name', minWidth: 180 },
{ title: '描述', key: 'description', ellipsis: { tooltip: true } },
{
title: '治理状态',
key: 'governance',
width: 180,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NTag, { size: 'small', type: 'info' }, () => `${row.post_count || 0} 主题`),
h(NTag, { size: 'small', type: 'success' }, () => `${row.moderator_count || 0} 版主`),
h(NTag, { size: 'small', type: (row.pending_application_count || 0) > 0 ? 'warning' : 'default' }, () => `${row.pending_application_count || 0} 申请`),
]),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDelete(row) }, () => '删除'),
]),
},
])
function resetForm() {
form.name = ''
form.description = ''
form.icon = 'fas fa-comments'
form.sort_order = boards.value.length
}
async function loadBoards() {
loading.value = true
try {
const res = await api.request('/forum/boards')
boards.value = res?.data?.items || []
} catch (error) {
message.error(error?.message || '加载版块列表失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
resetForm()
modalVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
form.name = row.name || ''
form.description = row.description || ''
form.icon = row.icon || 'fas fa-comments'
form.sort_order = row.sort_order || 0
modalVisible.value = true
}
async function handleSave() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await api.request(`/forum/boards/${editingId.value}`, {
method: 'POST',
data: { ...form },
})
message.success('版块已更新')
} else {
await api.request('/forum/boards', {
method: 'POST',
data: { ...form },
})
message.success('版块已创建')
}
modalVisible.value = false
await loadBoards()
} catch (error) {
message.error(error?.message || '版块保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除版块',
content: `确定删除“${row.name}”吗?如果仍有主题,请先完成迁移。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/forum/boards/${row.id}`, { method: 'DELETE' })
message.success('版块已删除')
await loadBoards()
} catch (error) {
message.error(error?.message || '版块删除失败')
}
},
})
}
onMounted(loadBoards)
</script>
<style scoped>
.aksrt-forum-boards__section {
margin-bottom: 16px;
}
.aksrt-forum-boards__summary {
color: #6b7280;
font-size: 13px;
}
</style>
@@ -0,0 +1,223 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="论坛概览" desc="从待审核主题、版主申请和版块状态三个视角观察论坛治理负载。" />
<div class="aksrt-forum-dashboard__stats">
<div v-for="card in statCards" :key="card.key" class="aksrt-forum-dashboard__stat-card">
<div class="aksrt-forum-dashboard__stat-icon" :style="{ background: card.color }">
<i :class="card.icon"></i>
</div>
<div class="aksrt-forum-dashboard__stat-info">
<div class="aksrt-forum-dashboard__stat-value">{{ stats[card.key] || 0 }}</div>
<div class="aksrt-forum-dashboard__stat-label">{{ card.label }}</div>
</div>
</div>
</div>
<div class="aksrt-forum-dashboard__grid">
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>待审核主题</strong>
<n-button size="small" quaternary type="primary" @click="goTopics">进入主题治理</n-button>
</div>
</template>
<n-data-table
v-if="pendingTopics.length"
:columns="topicColumns"
:data="pendingTopics"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无待审核主题" />
</n-card>
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>待处理版主申请</strong>
<n-button size="small" quaternary type="primary" @click="goApplications">进入申请处理</n-button>
</div>
</template>
<n-data-table
v-if="pendingApplications.length"
:columns="applicationColumns"
:data="pendingApplications"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无待处理申请" />
</n-card>
</div>
<n-card size="small" class="aksrt-forum-dashboard__section">
<template #header>
<div class="aksrt-forum-dashboard__section-head">
<strong>版块概况</strong>
<n-button size="small" quaternary type="primary" @click="goBoards">进入版块管理</n-button>
</div>
</template>
<n-data-table
v-if="boards.length"
:columns="boardColumns"
:data="boards"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无版块" />
</n-card>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const stats = ref({})
const boards = ref([])
const pendingTopics = ref([])
const pendingApplications = ref([])
const statCards = [
{ key: 'board_count', label: '版块数', icon: 'fas fa-th-large', color: '#dbeafe' },
{ key: 'topic_count', label: '公开主题', icon: 'fas fa-list-alt', color: '#dcfce7' },
{ key: 'pending_topic_count', label: '待审核主题', icon: 'fas fa-hourglass-half', color: '#fef3c7' },
{ key: 'pending_application_count', label: '待处理申请', icon: 'fas fa-user-shield', color: '#fee2e2' },
]
const topicColumns = [
{ title: '标题', key: 'title', minWidth: 260 },
{ title: '作者', key: 'author_name', width: 110, render: (row) => row.author?.display_name || '-' },
{ title: '版块', key: 'board_name', width: 120, render: (row) => row.boards?.[0]?.name || '-' },
{ title: '时间', key: 'date', width: 160 },
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => goTopics() }, () => '治理'),
},
]
const applicationColumns = [
{ title: '版块', key: 'board_name', width: 140 },
{ title: '申请人', key: 'display_name', width: 140 },
{ title: '理由', key: 'reason', ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 160 },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => h(NTag, { size: 'small', type: 'warning' }, () => row.status_label || row.status || '-'),
},
]
const boardColumns = [
{ title: '版块名称', key: 'name', minWidth: 180 },
{ title: '主题数', key: 'post_count', width: 90 },
{ title: '版主数', key: 'moderator_count', width: 90 },
{ title: '待处理申请', key: 'pending_application_count', width: 120 },
]
async function loadOverview() {
try {
const res = await api.request('/forum/overview')
const data = res?.data || {}
stats.value = data.stats || {}
boards.value = data.boards || []
pendingTopics.value = data.pending_topics || []
pendingApplications.value = data.pending_applications || []
} catch (error) {
message.error(error?.message || '加载论坛概览失败')
}
}
function goBoards() {
window.location.hash = '#/forum/boards'
}
function goTopics() {
window.location.hash = '#/forum/topics'
}
function goApplications() {
window.location.hash = '#/forum/moderator-applications'
}
onMounted(loadOverview)
</script>
<style scoped>
.aksrt-forum-dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-forum-dashboard__stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px;
border-radius: 12px;
background: #fff;
border: 1px solid #eef2f7;
}
.aksrt-forum-dashboard__stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: #1d4ed8;
font-size: 18px;
}
.aksrt-forum-dashboard__stat-info {
display: flex;
flex-direction: column;
}
.aksrt-forum-dashboard__stat-value {
color: #111827;
font-size: 24px;
font-weight: 700;
}
.aksrt-forum-dashboard__stat-label {
color: #6b7280;
font-size: 13px;
}
.aksrt-forum-dashboard__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.aksrt-forum-dashboard__section {
margin-bottom: 16px;
}
.aksrt-forum-dashboard__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 1023px) {
.aksrt-forum-dashboard__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,201 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版主申请处理" desc="处理前台版主申请,完成审核、通过和拒绝反馈闭环。" />
<n-card size="small" class="aksrt-forum-apps__section">
<n-space wrap>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索版块 / 用户 / 理由" style="width: 220px" @keyup.enter="loadData" />
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="状态" style="width: 160px" />
<n-button type="primary" @click="loadData">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-apps__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:bordered="false"
size="small"
/>
</n-card>
<n-modal :show="reviewVisible" preset="card" title="处理版主申请" style="width: 520px" @update:show="reviewVisible = $event">
<n-space vertical :size="14">
<n-alert type="info" :show-icon="false">
<div>{{ activeApplication?.display_name || '-' }} / {{ activeApplication?.board_name || '-' }}</div>
<div class="aksrt-forum-apps__reason">{{ activeApplication?.reason || '暂无申请理由' }}</div>
</n-alert>
<n-radio-group v-model:value="reviewForm.status">
<n-radio-button value="approved">通过</n-radio-button>
<n-radio-button value="rejected">拒绝</n-radio-button>
</n-radio-group>
<n-input v-model:value="reviewForm.note" type="textarea" :rows="4" placeholder="记录审核结论和补充说明" />
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="reviewVisible = false">取消</n-button>
<n-button type="primary" :loading="reviewing" @click="submitReview">提交处理</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const loading = ref(false)
const items = ref([])
const boardOptions = ref([{ label: '全部版块', value: null }])
const statusOptions = ref([{ label: '全部状态', value: null }])
const reviewVisible = ref(false)
const reviewing = ref(false)
const activeApplication = ref(null)
const filters = reactive({
keyword: '',
board_id: null,
status: null,
})
const reviewForm = reactive({
status: 'approved',
note: '',
})
const columns = [
{
title: '版块 / 用户',
key: 'board_name',
minWidth: 220,
render: (row) => h('div', { class: 'aksrt-forum-apps__identity' }, [
h('div', { class: 'aksrt-forum-apps__board' }, row.board_name || '-'),
h('div', { class: 'aksrt-forum-apps__user' }, `${row.display_name || '-'} · ${row.user_email || '-'}`),
]),
},
{ title: '申请理由', key: 'reason', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: resolveStatusType(row.status) }, () => row.status_label || row.status || '-'),
},
{ title: '申请时间', key: 'created_at', width: 160 },
{ title: '处理时间', key: 'reviewed_at', width: 160, render: (row) => row.reviewed_at || '-' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => {
if (row.status !== 'pending') {
return '-'
}
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'small', quaternary: true, type: 'success', onClick: () => openReview(row, 'approved') }, () => '通过'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => openReview(row, 'rejected') }, () => '拒绝'),
])
},
},
]
function resolveStatusType(status) {
if (status === 'approved') return 'success'
if (status === 'rejected') return 'error'
return 'warning'
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/forum/moderator/applications', {
params: {
keyword: filters.keyword || '',
board_id: filters.board_id || '',
status: filters.status || '',
},
})
const data = res?.data || {}
items.value = data.items || []
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
statusOptions.value = data.options?.status || [{ label: '全部状态', value: null }]
} catch (error) {
message.error(error?.message || '加载版主申请失败')
} finally {
loading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.board_id = null
filters.status = null
loadData()
}
function openReview(row, status) {
activeApplication.value = row
reviewForm.status = status
reviewForm.note = row.review_note || ''
reviewVisible.value = true
}
async function submitReview() {
if (!activeApplication.value?.id) {
return
}
reviewing.value = true
try {
await api.request(`/forum/moderator/applications/${activeApplication.value.id}/review`, {
method: 'POST',
data: {
status: reviewForm.status,
note: reviewForm.note,
},
})
message.success('申请状态已更新')
reviewVisible.value = false
activeApplication.value = null
await loadData()
} catch (error) {
message.error(error?.message || '申请处理失败')
} finally {
reviewing.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.aksrt-forum-apps__section {
margin-bottom: 16px;
}
.aksrt-forum-apps__identity {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-forum-apps__board {
color: #111827;
font-weight: 600;
}
.aksrt-forum-apps__user,
.aksrt-forum-apps__reason {
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,363 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="版主管理" desc="按版块维护版主名单,支持角色分层和权限档位。" />
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen" style="margin-bottom:16px;">
<n-grid-item>
<n-statistic label="总版主数" :value="stats.total_moderators" />
</n-grid-item>
<n-grid-item>
<n-statistic label="总版块数" :value="stats.total_boards" />
</n-grid-item>
<n-grid-item>
<n-statistic label="待处理申请" :value="stats.pending_applications">
<template #suffix>
<n-tag v-if="stats.pending_applications > 0" type="warning" size="small">待处理</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="高级版主" :value="stats.senior_moderators" />
</n-grid-item>
</n-grid>
<n-card size="small" class="aksrt-forum-mod__toolbar">
<n-space justify="space-between" align="center">
<span class="aksrt-forum-mod__summary">已加载 {{ boards.length }} 个版块</span>
<n-space>
<n-button @click="goApplications">查看版主申请</n-button>
<n-button type="primary" @click="showBatchModal = true">批量管理</n-button>
</n-space>
</n-space>
</n-card>
<n-card v-for="board in boards" :key="board.board_id" size="small" class="aksrt-forum-mod__section">
<template #header>
<div class="aksrt-forum-mod__header">
<strong>{{ board.board_name }}</strong>
<n-space size="small">
<n-tag size="small" type="info">{{ board.topic_count }} 主题</n-tag>
<n-tag size="small" :type="board.pending_application_count > 0 ? 'warning' : 'default'">
{{ board.pending_application_count }} 申请待处理
</n-tag>
</n-space>
</div>
</template>
<!-- 版主列表 -->
<div class="aksrt-forum-mod__list">
<div v-for="moderator in board.moderators" :key="moderator.id" class="aksrt-forum-mod__item">
<n-avatar :src="moderator.avatar_url" :size="36" round />
<div class="aksrt-forum-mod__info">
<div class="aksrt-forum-mod__name-row">
<span class="aksrt-forum-mod__name">{{ moderator.display_name }}</span>
<n-tag :type="getRoleTagType(moderator.role)" size="tiny">{{ getRoleLabel(moderator.role) }}</n-tag>
</div>
<span class="aksrt-forum-mod__email">{{ moderator.user_email }}</span>
<div v-if="moderator.permissions?.length" class="aksrt-forum-mod__permissions">
<n-tag v-for="perm in moderator.permissions" :key="perm" size="tiny" type="default">{{ getPermissionLabel(perm) }}</n-tag>
</div>
</div>
<n-space size="small">
<n-select
v-model:value="moderator.role"
:options="roleOptions"
size="tiny"
style="width:100px;"
@update:value="handleRoleChange(board.board_id, moderator.id, $event)"
/>
<n-button size="tiny" quaternary type="error" @click="handleRemove(board.board_id, moderator.id)">移除</n-button>
</n-space>
</div>
<n-empty v-if="board.moderators.length === 0" description="暂无版主" size="small" />
</div>
<!-- 添加版主 -->
<div class="aksrt-forum-mod__add">
<n-input
v-model:value="board._newUser"
size="small"
style="width:140px"
placeholder="用户 ID"
@keyup.enter="handleAdd(board)"
/>
<n-select
v-model:value="board._newRole"
:options="roleOptions"
size="small"
style="width:100px;"
placeholder="角色"
/>
<n-button size="small" type="primary" :disabled="!board._newUser" @click="handleAdd(board)">添加版主</n-button>
</div>
</n-card>
<n-empty v-if="boards.length === 0 && !loading" description="暂无论坛版块" />
<!-- 批量管理弹窗 -->
<n-modal v-model:show="showBatchModal" preset="card" title="批量管理版主" style="max-width:560px;" :bordered="false">
<n-form label-placement="top">
<n-form-item label="选择版块">
<n-select v-model:value="batchForm.board_id" :options="boardOptions" placeholder="选择版块" />
</n-form-item>
<n-form-item label="操作">
<n-radio-group v-model:value="batchForm.action">
<n-space>
<n-radio value="add">批量添加</n-radio>
<n-radio value="remove">批量移除</n-radio>
<n-radio value="update_role">批量修改角色</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="用户 ID 列表">
<n-input v-model:value="batchForm.user_ids" type="textarea" :rows="3" placeholder="每行一个用户 ID" />
</n-form-item>
<n-form-item v-if="batchForm.action === 'update_role'" label="目标角色">
<n-select v-model:value="batchForm.target_role" :options="roleOptions" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showBatchModal = false">取消</n-button>
<n-button type="primary" :loading="batchLoading" @click="handleBatch">执行</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const message = useMessage()
const loading = ref(false)
const boards = ref([])
const stats = reactive({ total_moderators: 0, total_boards: 0, pending_applications: 0, senior_moderators: 0 })
// 批量管理
const showBatchModal = ref(false)
const batchLoading = ref(false)
const batchForm = reactive({ board_id: null, action: 'add', user_ids: '', target_role: 'moderator' })
// 角色选项
const roleOptions = [
{ label: '超级版主', value: 'super_moderator' },
{ label: '高级版主', value: 'senior_moderator' },
{ label: '版主', value: 'moderator' },
{ label: '实习版主', value: 'trainee' },
]
// 权限选项
const permissionLabels = {
manage_topics: '管理主题',
manage_comments: '管理评论',
manage_users: '管理用户',
pin_topics: '置顶主题',
close_topics: '关闭主题',
move_topics: '移动主题',
delete_topics: '删除主题',
ban_users: '封禁用户',
}
const boardOptions = computed(() => boards.value.map(b => ({ label: b.board_name, value: b.board_id })))
function getRoleLabel(role) {
const opt = roleOptions.find(r => r.value === role)
return opt?.label || role || '版主'
}
function getRoleTagType(role) {
const map = { super_moderator: 'error', senior_moderator: 'warning', moderator: 'info', trainee: 'default' }
return map[role] || 'default'
}
function getPermissionLabel(perm) {
return permissionLabels[perm] || perm
}
async function loadModerators() {
loading.value = true
try {
const res = await api.request('/forum/moderators')
boards.value = (res?.data?.boards || []).map(item => ({
...item,
_newUser: '',
_newRole: 'moderator',
}))
// 计算统计
stats.total_boards = boards.value.length
stats.total_moderators = boards.value.reduce((sum, b) => sum + (b.moderators?.length || 0), 0)
stats.pending_applications = boards.value.reduce((sum, b) => sum + (b.pending_application_count || 0), 0)
stats.senior_moderators = boards.value.reduce((sum, b) =>
sum + (b.moderators?.filter(m => ['super_moderator', 'senior_moderator'].includes(m.role))?.length || 0), 0)
} catch (error) {
message.error(error?.message || '加载版主列表失败')
} finally {
loading.value = false
}
}
async function handleAdd(board) {
const userId = parseInt(board._newUser || '', 10)
if (!userId || userId <= 0) {
message.warning('请输入有效的用户 ID')
return
}
try {
await api.request('/forum/moderators', {
method: 'POST',
data: {
board_id: board.board_id,
user_id: userId,
role: board._newRole || 'moderator',
action: 'add',
},
})
message.success('版主已添加')
board._newUser = ''
board._newRole = 'moderator'
await loadModerators()
} catch (error) {
message.error(error?.message || '添加版主失败')
}
}
async function handleRemove(boardId, userId) {
try {
await api.request('/forum/moderators', {
method: 'POST',
data: { board_id: boardId, user_id: userId, action: 'remove' },
})
message.success('版主已移除')
await loadModerators()
} catch (error) {
message.error(error?.message || '移除版主失败')
}
}
async function handleRoleChange(boardId, userId, newRole) {
try {
await api.request('/forum/moderators', {
method: 'POST',
data: { board_id: boardId, user_id: userId, role: newRole, action: 'update_role' },
})
message.success('角色已更新')
await loadModerators()
} catch (error) {
message.error(error?.message || '角色更新失败')
}
}
async function handleBatch() {
const userIds = batchForm.user_ids.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
if (!batchForm.board_id || userIds.length === 0) {
message.warning('请选择版块并输入用户 ID')
return
}
batchLoading.value = true
try {
await api.request('/forum/moderators/batch', {
method: 'POST',
data: {
board_id: batchForm.board_id,
user_ids: userIds,
action: batchForm.action,
target_role: batchForm.target_role,
},
})
message.success('批量操作完成')
showBatchModal.value = false
batchForm.user_ids = ''
await loadModerators()
} catch (error) {
message.error(error?.message || '批量操作失败')
} finally {
batchLoading.value = false
}
}
function goApplications() {
window.location.hash = '#/forum/moderator-applications'
}
onMounted(loadModerators)
</script>
<style scoped>
.aksrt-forum-mod__toolbar,
.aksrt-forum-mod__section {
margin-bottom: 16px;
}
.aksrt-forum-mod__summary {
color: var(--color-ink-3);
font-size: 13px;
}
.aksrt-forum-mod__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.aksrt-forum-mod__list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.aksrt-forum-mod__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: var(--surface-2);
border: 1px solid var(--color-line-soft);
}
.aksrt-forum-mod__info {
flex: 1;
min-width: 0;
}
.aksrt-forum-mod__name-row {
display: flex;
align-items: center;
gap: 6px;
}
.aksrt-forum-mod__name {
display: block;
color: var(--color-ink);
font-weight: 600;
}
.aksrt-forum-mod__email {
display: block;
color: var(--color-ink-3);
font-size: 12px;
}
.aksrt-forum-mod__permissions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.aksrt-forum-mod__add {
display: flex;
gap: 8px;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--color-line-soft);
}
</style>
@@ -0,0 +1,186 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="主题治理" desc="集中处理论坛主题的审核、可见性、版块移动和治理状态。" />
<n-card size="small" class="aksrt-forum-topics__section">
<n-space wrap>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索标题 / 作者 / 版块" style="width: 220px" @keyup.enter="loadData(1)" />
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="帖子状态" style="width: 160px" />
<n-select v-model:value="filters.review_status" :options="reviewOptions" clearable placeholder="审核状态" style="width: 160px" />
<n-select v-model:value="filters.visibility" :options="visibilityOptions" clearable placeholder="可见性" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-card>
<n-card size="small" class="aksrt-forum-topics__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<TopicModerationDrawer
v-model:show="detailVisible"
:topic-id="detailTopicId"
@updated="loadData(page)"
/>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import TopicModerationDrawer from './components/TopicModerationDrawer.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const detailVisible = ref(false)
const detailTopicId = ref(null)
const boardOptions = ref([])
const statusOptions = ref([{ label: '全部', value: 'all' }])
const reviewOptions = ref([])
const visibilityOptions = ref([])
const filters = reactive({
keyword: '',
board_id: null,
status: 'all',
review_status: null,
visibility: null,
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const columns = [
{
title: '主题',
key: 'title',
minWidth: 280,
render: (row) => h('div', { class: 'aksrt-forum-topics__title-cell' }, [
h('div', { class: 'aksrt-forum-topics__title' }, row.title || '-'),
h('div', { class: 'aksrt-forum-topics__subtitle' }, [
row.boards?.[0]?.name || '未分配版块',
' · ',
row.author?.display_name || '未知作者',
]),
]),
},
{
title: '可见性',
key: 'visibility',
width: 110,
render: (row) => h(NTag, { size: 'small', type: row.visibility?.key === 'public' ? 'default' : 'warning' }, () => row.visibility?.label || '-'),
},
{
title: '审核',
key: 'review_status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: resolveReviewType(row.review_status?.key) }, () => row.review_status?.label || '-'),
},
{ title: '回复', key: 'comment_count', width: 80 },
{ title: '浏览', key: 'views', width: 80 },
{ title: '发布时间', key: 'date', width: 160 },
{
title: '操作',
key: 'actions',
width: 110,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '治理'),
},
]
function resolveReviewType(status) {
if (status === 'approved') return 'success'
if (status === 'rejected') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/forum/topics', {
params: {
page: nextPage,
per_page: pagination.pageSize,
keyword: filters.keyword || '',
board_id: filters.board_id || '',
status: filters.status || 'all',
review_status: filters.review_status || '',
visibility: filters.visibility || '',
},
})
const data = res?.data || {}
items.value = data.items || []
pagination.itemCount = data.total || 0
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
statusOptions.value = data.options?.status || [{ label: '全部', value: 'all' }]
reviewOptions.value = [{ label: '全部审核', value: null }, ...(data.options?.review || [])]
visibilityOptions.value = [{ label: '全部可见性', value: null }, ...(data.options?.visibility || [])]
} catch (error) {
message.error(error?.message || '加载主题列表失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function resetFilters() {
filters.keyword = ''
filters.board_id = null
filters.status = 'all'
filters.review_status = null
filters.visibility = null
loadData(1)
}
function openDetail(row) {
detailTopicId.value = row.id
detailVisible.value = true
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-forum-topics__section {
margin-bottom: 16px;
}
.aksrt-forum-topics__title-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-forum-topics__title {
color: #111827;
font-weight: 600;
}
.aksrt-forum-topics__subtitle {
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<n-drawer :show="show" :width="620" @update:show="$emit('update:show', $event)">
<n-drawer-content title="主题治理" closable>
<n-spin :show="loading">
<template v-if="detail.topic">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="主题标题">{{ detail.topic.title || '-' }}</n-descriptions-item>
<n-descriptions-item label="所属版块">{{ primaryBoardName }}</n-descriptions-item>
<n-descriptions-item label="作者">{{ detail.topic.author?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="可见性">{{ detail.topic.visibility?.label || '-' }}</n-descriptions-item>
<n-descriptions-item label="审核状态">{{ detail.topic.review_status?.label || '-' }}</n-descriptions-item>
<n-descriptions-item label="主题状态">{{ detail.topic.status || '-' }}</n-descriptions-item>
<n-descriptions-item label="置顶">{{ stickySummary }}</n-descriptions-item>
<n-descriptions-item label="精华">{{ detail.topic.featured ? '是' : '否' }}</n-descriptions-item>
<n-descriptions-item label="帖子类型">{{ bbsTypeLabel }}</n-descriptions-item>
<n-descriptions-item v-if="detail.topic.bbs_type === 'question'" label="提问状态">{{ questionStatusLabel }}</n-descriptions-item>
<n-descriptions-item label="收藏">{{ detail.topic.favorite_count ?? 0 }}</n-descriptions-item>
<n-descriptions-item label="评分">{{ detail.topic.score ?? 0 }}</n-descriptions-item>
<n-descriptions-item label="专题">{{ detail.topic.special_topic || '-' }}</n-descriptions-item>
<n-descriptions-item label="投票">{{ voteSummary }}</n-descriptions-item>
<n-descriptions-item label="治理备注">{{ detail.topic.moderation_note || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-topic-drawer__excerpt">
<div class="aksrt-topic-drawer__section-title">内容摘要</div>
<p>{{ detail.topic.excerpt || '暂无摘要' }}</p>
</div>
<div class="aksrt-topic-drawer__actions">
<div class="aksrt-topic-drawer__section-title">执行治理动作</div>
<n-space vertical :size="12">
<n-select v-model:value="form.action" :options="actionOptions" />
<n-select
v-if="form.action === 'review'"
v-model:value="form.review_status"
:options="detail.options?.review || []"
placeholder="选择审核状态"
/>
<n-select
v-if="form.action === 'move_board'"
v-model:value="form.target_board_id"
:options="boardOptions"
placeholder="选择目标版块"
/>
<n-select
v-if="form.action === 'visibility'"
v-model:value="form.visibility"
:options="detail.options?.visibility || []"
placeholder="选择可见性"
/>
<n-select
v-if="form.action === 'sticky'"
v-model:value="form.sticky_value"
:options="stickyLevelOptions"
placeholder="选择置顶级别"
/>
<n-select
v-if="form.action === 'question_status'"
v-model:value="form.question_status_value"
:options="questionStatusOptions"
placeholder="选择提问状态"
/>
<n-select
v-if="booleanActions.includes(form.action) && form.action !== 'sticky'"
v-model:value="form.value"
:options="booleanOptions"
placeholder="选择状态"
/>
<n-input
v-model:value="form.note"
type="textarea"
:rows="3"
placeholder="记录本次处理原因,便于后续审计"
/>
<n-space>
<n-button type="primary" :loading="submitting" @click="handleSubmit">执行</n-button>
<n-button @click="loadDetail">刷新详情</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无主题详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
topicId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref(false)
const detail = ref({ topic: null, options: { boards: [], visibility: [], review: [] } })
// sticky 现在是 0~3 分级选择,不再是布尔动作(§7.3 置顶分级)。
const booleanActions = ['featured', 'locked', 'close', 'hide']
const form = reactive({
action: 'review',
review_status: 'pending',
target_board_id: null,
visibility: 'public',
value: '1',
sticky_value: 0,
question_status_value: 0,
note: '',
})
const actionOptions = [
{ label: '审核状态', value: 'review' },
{ label: '移动版块', value: 'move_board' },
{ label: '修改可见性', value: 'visibility' },
{ label: '置顶分级', value: 'sticky' },
{ label: '精华切换', value: 'featured' },
{ label: '锁定切换', value: 'locked' },
{ label: '关闭帖子', value: 'close' },
{ label: '隐藏帖子', value: 'hide' },
{ label: '提问状态', value: 'question_status' },
]
const booleanOptions = [
{ label: '启用', value: '1' },
{ label: '关闭', value: '0' },
]
// 置顶分级选项(对标 zibll topping 0/1/2/3)。
const stickyLevelOptions = [
{ label: '不置顶', value: 0 },
{ label: '版块置顶', value: 1 },
{ label: '超级置顶', value: 2 },
{ label: '全局置顶', value: 3 },
]
const questionStatusOptions = [
{ label: '未解决', value: 0 },
{ label: '已解决', value: 1 },
]
const primaryBoardName = computed(() => detail.value?.topic?.boards?.[0]?.name || '-')
const boardOptions = computed(() => (detail.value?.options?.boards || []).map((item) => ({
label: item.name,
value: item.id,
})))
// 置顶摘要:优先用后端返回的 sticky_label,否则按 sticky_level 映射。
const stickySummary = computed(() => {
const topic = detail.value?.topic
if (!topic) return '-'
const label = topic.sticky_label
if (label) return label
const level = topic.sticky_level ?? 0
const found = stickyLevelOptions.find((opt) => opt.value === level)
return found ? found.label : '不置顶'
})
const voteSummary = computed(() => {
const vote = detail.value?.topic?.vote
if (!vote?.enabled) {
return '未开启'
}
return `${vote.total_votes || 0} 票 / ${vote.multiple ? '多选' : '单选'}`
})
const bbsTypeLabel = computed(() => {
const t = detail.value?.topic?.bbs_type
return t === 'question' ? '提问' : '标准帖子'
})
const questionStatusLabel = computed(() => {
const s = detail.value?.topic?.question_status
return s === 1 ? '已解决' : '未解决'
})
watch(() => props.show, async (visible) => {
if (visible && props.topicId) {
await loadDetail()
} else if (!visible) {
detail.value = { topic: null, options: { boards: [], visibility: [], review: [] } }
resetForm()
}
})
function resetForm() {
form.action = 'review'
form.review_status = detail.value?.topic?.review_status?.key || 'pending'
form.target_board_id = detail.value?.topic?.boards?.[0]?.id || null
form.visibility = detail.value?.topic?.visibility?.key || 'public'
form.value = '1'
form.sticky_value = detail.value?.topic?.sticky_level ?? 0
form.question_status_value = detail.value?.topic?.question_status ?? 0
form.note = ''
}
async function loadDetail() {
if (!props.topicId) {
return
}
loading.value = true
try {
const res = await api.request(`/forum/topics/${props.topicId}`)
detail.value = res?.data || { topic: null, options: { boards: [], visibility: [], review: [] } }
resetForm()
} catch (error) {
message.error(error?.message || '加载主题详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.topicId) {
return
}
const payload = {
action: form.action,
note: form.note,
}
if (form.action === 'review') {
payload.review_status = form.review_status
} else if (form.action === 'move_board') {
payload.target_board_id = form.target_board_id
} else if (form.action === 'visibility') {
payload.visibility = form.visibility
} else if (form.action === 'sticky') {
// 置顶分级:发送 0~3 整数。
payload.value = form.sticky_value
} else if (form.action === 'question_status') {
payload.value = form.question_status_value
} else {
payload.value = form.value === '1'
}
submitting.value = true
try {
await api.request(`/forum/topics/${props.topicId}/moderate`, {
method: 'POST',
data: payload,
})
message.success('治理动作已执行')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '治理动作执行失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-topic-drawer__excerpt,
.aksrt-topic-drawer__actions {
margin-top: 18px;
}
.aksrt-topic-drawer__section-title {
margin-bottom: 8px;
font-weight: 600;
color: #1f2937;
}
.aksrt-topic-drawer__excerpt p {
margin: 0;
color: #4b5563;
line-height: 1.7;
}
</style>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="affiliate"
title="推广返佣"
desc="配置返佣比例、归因窗口和返佣说明。"
note="返佣逻辑复用用户邀请体系作为链路入口,订单归因和结算自动衔接当前策略字段。"
:links="[
{ label: '用户签到/邀请', path: '/users/signin' },
{ label: '创作分成', path: '/payment/revenue' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,453 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
<div class="aksrt-charge-card-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="操作台">
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
<n-button @click="batchVisible = true">批量生成</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
</n-card>
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
<n-form :model="createForm" label-placement="top">
<n-form-item label="卡号">
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡号" />
</n-form-item>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="面额">
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="过期时间">
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="备注">
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="createVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
</n-space>
</n-form>
</n-modal>
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
<n-form :model="batchForm" label-placement="top">
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="数量">
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="面额">
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="前缀">
<n-input v-model:value="batchForm.prefix" placeholder="CARD" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="随机长度">
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="过期时间">
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="batchVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
</n-space>
</n-form>
</n-modal>
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const rows = ref([])
const createVisible = ref(false)
const batchVisible = ref(false)
const drawerVisible = ref(false)
const currentCardId = ref(null)
const submittingCreate = ref(false)
const submittingBatch = ref(false)
const stats = reactive({
total: 0,
active: 0,
used: 0,
total_amount: 0,
used_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const createForm = reactive({
code: '',
amount: 10,
expiresAt: null,
note: '',
})
const batchForm = reactive({
count: 20,
amount: 10,
prefix: 'CARD',
length: 12,
expiresAt: null,
note: '',
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statCards = [
{ key: 'total', label: '总卡数' },
{ key: 'active', label: '未使用' },
{ key: 'used', label: '已使用' },
{ key: 'total_amount', label: '总面额' },
{ key: 'used_amount', label: '已兑换金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '未使用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已过期', value: 'expired' },
{ label: '已禁用', value: 'disabled' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '卡号', key: 'code', minWidth: 190, ellipsis: { tooltip: true } },
{ title: '面额', key: 'amount', width: 90 },
{ title: '余额', key: 'balance', width: 90 },
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '使用人',
key: 'used_by_user',
width: 160,
render: (row) => row.used_by_user?.display_name || '-',
},
{ title: '创建时间', key: 'created_at', width: 170 },
{
title: '过期时间',
key: 'expires_at',
width: 170,
render: (row) => row.expires_at || '-',
},
{
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]
if (row.status === 'active') {
actions.push(h(NButton, {
size: 'tiny',
type: 'warning',
quaternary: true,
onClick: () => handleDisable(row),
}, () => '禁用'))
}
return actions
}),
},
]
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
const seconds = `${date.getSeconds()}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function buildParams(page = pager.page, pageSize = pager.pageSize) {
const params = {
page,
per_page: pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange
params.date_from = formatDateValue(start).slice(0, 10)
params.date_to = formatDateValue(end).slice(0, 10)
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/charge-cards', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载充值卡台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = null
pager.page = 1
loadData()
}
async function handleCreate() {
submittingCreate.value = true
try {
await api.request('/payment/charge-cards', {
method: 'POST',
data: {
code: createForm.code,
amount: createForm.amount,
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
note: createForm.note,
},
})
message.success('充值卡已创建')
createVisible.value = false
createForm.code = ''
createForm.note = ''
await loadData()
} catch (error) {
message.error(error?.message || '创建失败')
} finally {
submittingCreate.value = false
}
}
async function handleBatchCreate() {
submittingBatch.value = true
try {
await api.request('/payment/charge-cards/batch', {
method: 'POST',
data: {
count: batchForm.count,
amount: batchForm.amount,
prefix: batchForm.prefix,
length: batchForm.length,
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
note: batchForm.note,
},
})
message.success('充值卡已批量生成')
batchVisible.value = false
await loadData()
} catch (error) {
message.error(error?.message || '批量生成失败')
} finally {
submittingBatch.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用充值卡 ${row.code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
message.success('充值卡已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '禁用失败')
}
},
})
}
function openDrawer(id) {
currentCardId.value = id
drawerVisible.value = true
}
async function handleExport() {
try {
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
const items = res?.data?.items || []
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
const lines = items.map((row) => [
row.id,
row.code,
row.amount,
row.balance,
row.status_label,
row.used_by_user?.display_name || '',
row.created_at || '',
row.expires_at || '',
row.note || '',
])
downloadCsv('charge-cards.csv', [header, ...lines])
message.success('导出完成')
} catch (error) {
message.error(error?.message || '导出失败')
}
}
function downloadCsv(filename, rows) {
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.aksrt-charge-card-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-charge-card-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,179 @@
<template>
<PaymentSectionPage
section="gateway"
title="收款接口"
desc="管理支付网关、查看连接状态、配置回调口令。"
note="当前支持人工确认与测试联调;回调校验口令在接入真实网关时直接复用。"
:links="[
{ label: '余额充值', path: '/payment/recharge' },
{ label: '付费内容', path: '/payment/paid' },
]"
>
<template #before-form>
<n-spin :show="loading">
<n-card class="aksrt-section-card" title="网关状态">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="gw in gateways" :key="gw.id">
<div class="aksrt-gateway__card">
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
<i :class="gw.id === 'balance' ? 'fas fa-wallet' : 'fas fa-credit-card'"></i>
</div>
<div>
<strong>{{ gw.name }}</strong>
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
</div>
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
{{ gw.configured ? '可用' : '不可用' }}
</n-tag>
</div>
</n-grid-item>
</n-grid>
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
</n-card>
<n-card class="aksrt-section-card" title="运行摘要">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong>{{ modeLabel }}</strong>
<p>当前模式</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong :class="overview.gateway?.test_enabled ? 'aksrt-text-warning' : ''">
{{ overview.gateway?.test_enabled ? '启用' : '关闭' }}
</strong>
<p>测试网关</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<strong :class="overview.gateway?.manual_review ? 'aksrt-text-info' : ''">
{{ overview.gateway?.manual_review ? '启用' : '关闭' }}
</strong>
<p>人工复核</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-gateway__stat">
<n-tag :type="overview.gateway?.callback_secret ? 'success' : 'warning'" size="small" round>
{{ overview.gateway?.callback_secret ? '已设置' : '未设置' }}
</n-tag>
<p>回调口令</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</n-spin>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const message = useMessage()
const gateways = ref([])
const overview = ref({})
const loading = ref(false)
const modeLabel = computed(() => {
const mode = overview.value.gateway?.mode
if (mode === 'test_gateway') return '测试网关'
if (mode === 'adapter_ready') return '适配器就绪'
return '人工确认'
})
onMounted(async () => {
loading.value = true
try {
const [gwRes, ovRes] = await Promise.all([
api.request('/shop/gateways').catch(() => null),
api.request('/payment/overview').catch(() => null),
])
if (gwRes?.data?.gateways) {
gateways.value = gwRes.data.gateways
}
if (ovRes?.data) {
overview.value = ovRes.data
}
} catch {
message.warning('部分支付接口暂不可用')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.aksrt-gateway__card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
border: 1px solid var(--color-line, #f0f0f0);
}
.aksrt-gateway__icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-3, #f0f0f0);
color: var(--color-ink-4, #8a909c);
font-size: 16px;
}
.aksrt-gateway__icon--active {
background: var(--color-primary-soft, #f0f5ff);
color: var(--color-primary, #1890ff);
}
.aksrt-gateway__card strong {
display: block;
font-size: 14px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-gateway__card p {
margin: 2px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-gateway__stat {
text-align: center;
padding: 16px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
}
.aksrt-gateway__stat strong {
display: block;
font-size: 18px;
font-weight: 600;
color: var(--color-ink, #1a1a2e);
}
.aksrt-gateway__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-text-warning {
color: var(--color-warning, #f59e0b);
}
.aksrt-text-info {
color: var(--color-info, #3b82f6);
}
</style>
@@ -0,0 +1,316 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
<div class="aksrt-rebate-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="筛选与列控制">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-checkbox-group v-model:value="visibleColumnKeys">
<n-space wrap>
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
{{ column.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-space>
</n-card>
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
<n-data-table
:columns="tableColumns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-card>
<RebateDetailDrawer
v-model:show="drawerVisible"
:rebate-id="currentRebateId"
@updated="loadData"
/>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
const message = useMessage()
const loading = ref(false)
const rows = ref([])
const drawerVisible = ref(false)
const currentRebateId = ref(null)
const stats = reactive({
total: 0,
pending: 0,
settled: 0,
total_amount: 0,
settled_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
type: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
types: [],
})
const statCards = [
{ key: 'total', label: '总记录' },
{ key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'total_amount', label: '总返佣' },
{ key: 'settled_amount', label: '已结算金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '待结算', value: 'pending' },
{ label: '已结算', value: 'settled' },
{ label: '已取消', value: 'cancelled' },
])
const typeOptions = computed(() => optionsState.types.length
? optionsState.types
: [
{ label: '全部类型', value: '' },
{ label: '订单返佣', value: 'order' },
{ label: '邀请返佣', value: 'invite' },
])
const columnChoices = [
{ label: '来源用户', value: 'source_user' },
{ label: '创建时间', value: 'created_at' },
{ label: '结算时间', value: 'settled_at' },
{ label: '备注', value: 'note' },
{ label: '处理记录数', value: 'history_count' },
]
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
const baseColumns = computed(() => {
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
{
title: '返佣对象',
key: 'user',
width: 160,
render: (row) => row.user?.display_name || '-',
},
{
title: '返佣金额',
key: 'amount',
width: 100,
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '类型',
key: 'type',
width: 100,
render: (row) => row.type_label || row.type,
},
]
if (visibleColumnKeys.value.includes('source_user')) {
columns.push({
title: '来源用户',
key: 'source_user',
width: 160,
render: (row) => row.source_user?.display_name || '-',
})
}
if (visibleColumnKeys.value.includes('created_at')) {
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
}
if (visibleColumnKeys.value.includes('settled_at')) {
columns.push({
title: '结算时间',
key: 'settled_at',
width: 170,
render: (row) => row.settled_at || '-',
})
}
if (visibleColumnKeys.value.includes('note')) {
columns.push({
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
})
}
if (visibleColumnKeys.value.includes('history_count')) {
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
}
columns.push({
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]),
})
return columns
})
const tableColumns = computed(() => baseColumns.value)
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildParams() {
const params = {
page: pager.page,
per_page: pager.pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (filters.type) params.type = filters.type
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
params.date_from = formatDateValue(filters.dateRange[0])
params.date_to = formatDateValue(filters.dateRange[1])
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/rebate/log', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
optionsState.types = data.options?.types || []
} catch (error) {
message.error(error?.message || '加载返佣台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.type = ''
filters.dateRange = null
pager.page = 1
loadData()
}
function openDrawer(id) {
currentRebateId.value = id
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-rebate-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-rebate-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="recharge"
title="余额充值"
desc="配置充值模式、套餐结构和帮助文案。"
note="当前支持人工确认与测试联调;接入真实网关时复用同一组字段。"
:links="[
{ label: '下载/付费内容中心', path: '/payment/paid' },
{ label: '收款接口', path: '/payment/gateway' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,249 @@
<template>
<PaymentSectionPage
section="revenue"
title="收益中心"
desc="查看收入流水、收益统计和提现记录。"
:links="[
{ label: 'VIP 会员', path: '/payment/vip' },
{ label: '推广返佣', path: '/payment/affiliate' },
]"
>
<template #before-form>
<n-card class="aksrt-section-card" title="收益统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.total.toFixed(2) }}</strong>
<p>总收益 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.pending.toFixed(2) }}</strong>
<p>待结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.settled.toFixed(2) }}</strong>
<p>已结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.count }}</strong>
<p>收益笔数</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card class="aksrt-section-card" title="收益趋势">
<template #header-extra>
<n-space size="small">
<n-button :type="granularity === 'day' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('day')">日</n-button>
<n-button :type="granularity === 'week' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('week')">周</n-button>
<n-button :type="granularity === 'month' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('month')">月</n-button>
</n-space>
</template>
<div v-if="timeSeries.length > 0" class="aksrt-revenue__chart">
<div class="aksrt-revenue__chart-bars">
<div
v-for="(item, index) in timeSeries"
:key="index"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
>
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
</div>
</div>
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>¥{{ periodTotal.toFixed(2) }}</strong></span>
<span>共 {{ periodCount }} 笔</span>
</div>
</div>
<n-empty v-else description="暂无趋势数据" />
</n-card>
<n-card class="aksrt-section-card" title="提现统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total }}</strong>
<p>提现笔数</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.pending }}</strong>
<p>待审核</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.completed }}</strong>
<p>已完成</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total_paid.toFixed(2) }}</strong>
<p>已提现 (元)</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const revenueStats = ref({ total: 0, pending: 0, settled: 0, count: 0 })
const withdrawalStats = ref({ total: 0, pending: 0, completed: 0, total_paid: 0 })
const timeSeries = ref([])
const granularity = ref('day')
const periodTotal = computed(() => timeSeries.value.reduce((s, i) => s + i.amount, 0))
const periodCount = computed(() => timeSeries.value.reduce((s, i) => s + i.count, 0))
const maxAmount = computed(() => Math.max(1, ...timeSeries.value.map(i => i.amount)))
function getBarHeight(amount) {
return Math.max(2, (amount / maxAmount.value) * 100)
}
function formatLabel(date) {
if (granularity.value === 'month') return date.slice(5)
if (granularity.value === 'week') return date.slice(5)
return date.slice(5)
}
function setGranularity(nextGranularity) {
granularity.value = nextGranularity
loadRevenue()
}
async function loadRevenue() {
try {
const params = new URLSearchParams({
time_series: '1',
granularity: granularity.value,
})
const res = await api.request(`/payment/revenue?${params}`)
if (res?.data) {
if (res.data.revenue) {
revenueStats.value = {
total: Number(res.data.revenue.total || 0),
pending: Number(res.data.revenue.pending || 0),
settled: Number(res.data.revenue.settled || 0),
count: Number(res.data.revenue.count || 0),
}
}
if (res.data.withdrawal) {
withdrawalStats.value = {
total: Number(res.data.withdrawal.total || 0),
pending: Number(res.data.withdrawal.pending || 0),
completed: Number(res.data.withdrawal.completed || 0),
total_paid: Number(res.data.withdrawal.total_paid || 0),
}
}
timeSeries.value = res.data.time_series || []
}
} catch {
// silent
}
}
onMounted(loadRevenue)
</script>
<style scoped>
.aksrt-revenue__stat {
text-align: center;
padding: 16px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
}
.aksrt-revenue__stat strong {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 180px;
padding: 0 4px;
border-bottom: 1px solid var(--color-line, #e5e7eb);
}
.aksrt-revenue__chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
min-width: 0;
cursor: default;
}
.aksrt-revenue__chart-bar {
width: 100%;
max-width: 32px;
min-height: 2px;
background: var(--color-primary, #88abda);
border-radius: 3px 3px 0 0;
transition: height 0.3s ease;
}
.aksrt-revenue__chart-bar-wrap:hover .aksrt-revenue__chart-bar {
background: var(--color-primary-hover, #7099cc);
}
.aksrt-revenue__chart-label {
font-size: 10px;
color: var(--color-ink-4, #8a909c);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}
.aksrt-revenue__chart-summary {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: var(--color-ink-3, #525866);
}
.aksrt-revenue__chart-summary strong {
color: var(--color-ink, #1a1a2e);
font-weight: 600;
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="aksrt-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
{{ link.label }}
</n-button>
</n-space>
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
</n-card>
<slot name="before-form" />
<AksFormRenderer
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="false"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema } from '@/schema'
const props = defineProps({
section: { type: String, required: true },
title: { type: String, required: true },
desc: { type: String, default: '' },
note: { type: String, default: '' },
links: { type: Array, default: () => [] },
})
const router = useRouter()
const message = useMessage()
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
const defaults = computed(() => {
const result = {}
;(pageMeta.value.groups || []).forEach((group) => {
;(group.fields || []).forEach((field) => {
if (field.default !== undefined) {
result[field.key] = field.default
}
})
})
return result
})
const {
formData,
loading,
saving,
loadError,
dirty,
save: doSave,
reload,
} = useSectionForm({
fetch: () => api.getSchemaPage('payment', props.section),
submit: (data) => api.saveSchemaPage('payment', props.section, data),
defaults: defaults.value,
onSaved: () => {
message.success('设置已保存')
},
})
function go(path) {
router.push(path)
}
async function handleSave() {
try {
await doSave()
} catch (error) {
message.error(error.message || '保存失败')
}
}
function handleReload() {
reload()
message.info('已放弃更改')
}
</script>
@@ -0,0 +1,16 @@
<template>
<PaymentSectionPage
section="vip"
title="VIP会员"
desc=”配置会员名称、展示徽标和套餐结构。”
note=”角色免扣下载的运行逻辑在”下载/付费内容中心”中生效;此处管理会员套餐和权益说明。”
:links="[
{ label: '下载/付费内容中心', path: '/payment/paid' },
{ label: '创作分成', path: '/payment/revenue' },
]"
/>
</template>
<script setup>
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
</script>
@@ -0,0 +1,89 @@
<template>
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
<n-drawer-content title="充值卡详情" closable>
<n-spin :show="loading">
<template v-if="detail.card">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用者">
{{ detail.card.used_by_user?.display_name || '-' }}
</n-descriptions-item>
</n-descriptions>
<div class="aksrt-charge-drawer__section">
<div class="aksrt-charge-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.card.note || '暂无备注' }}
</n-alert>
</div>
</template>
<n-empty v-else description="暂无充值卡详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
cardId: { type: [Number, String], default: null },
})
defineEmits(['update:show'])
const message = useMessage()
const loading = ref(false)
const detail = ref({ card: null })
watch(
() => [props.show, props.cardId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { card: null }
}
},
)
async function loadDetail() {
if (!props.cardId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
detail.value = res?.data || { card: null }
} catch (error) {
message.error(error?.message || '加载充值卡详情失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.aksrt-charge-drawer__section {
margin-top: 20px;
}
.aksrt-charge-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
</style>
@@ -0,0 +1,175 @@
<template>
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
<n-drawer-content title="返佣详情" closable>
<n-spin :show="loading">
<template v-if="detail.record">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.record.note || '暂无备注' }}
</n-alert>
</div>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理记录</div>
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
<div class="aksrt-rebate-drawer__timeline-head">
<strong>{{ entry.event || '-' }}</strong>
<span>{{ entry.time || '-' }}</span>
</div>
<p>{{ entry.note || '无备注' }}</p>
<small>{{ entry.operator_name || '系统' }}</small>
</div>
</div>
<n-empty v-else description="暂无处理记录" />
</div>
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理返佣</div>
<n-space vertical :size="12">
<n-input
v-model:value="form.note"
type="textarea"
:rows="3"
placeholder="记录结算说明或取消原因"
/>
<n-space>
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
结算返佣
</n-button>
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
取消返佣
</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无返佣详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
rebateId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref('')
const detail = ref({ record: null, history: [] })
const form = reactive({ note: '' })
watch(
() => [props.show, props.rebateId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { record: null, history: [] }
form.note = ''
}
},
)
async function loadDetail() {
if (!props.rebateId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/rebate/${props.rebateId}`)
detail.value = res?.data || { record: null, history: [] }
form.note = ''
} catch (error) {
message.error(error?.message || '加载返佣详情失败')
} finally {
loading.value = false
}
}
async function handleAction(action) {
if (!props.rebateId) {
return
}
submitting.value = action
try {
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
method: 'POST',
data: { note: form.note },
})
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '处理失败')
} finally {
submitting.value = ''
}
}
</script>
<style scoped>
.aksrt-rebate-drawer__section {
margin-top: 20px;
}
.aksrt-rebate-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-rebate-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-rebate-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-rebate-drawer__timeline-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.aksrt-rebate-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-rebate-drawer__timeline-item small {
color: #6b7280;
}
</style>
@@ -0,0 +1,283 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="附件治理"
desc="统一管理全站上传行为:头像、封面、投稿图片、评论图片、背景图和资源附件。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
<n-button quaternary @click="goModeration">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Summary -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>上传策略总览</h3>
</div>
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in summaryItems" :key="item.key">
<div class="aksrt-attach__summary">
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : '#8a909c'" />
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Avatar & Cover -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>头像与封面</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许用户上传头像">
<n-switch v-model:value="form.allow_avatar_upload" />
<template #feedback>
<span class="aksrt-hint-text">关闭后用户只能使用 Gravatar/Cravatar 自动头像。</span>
</template>
</n-form-item>
<n-form-item label="允许用户上传封面图">
<n-switch v-model:value="form.allow_cover_upload" />
<template #feedback>
<span class="aksrt-hint-text">控制用户中心公开主页封面图上传入口。</span>
</template>
</n-form-item>
<n-form-item label="允许用户上传背景图">
<n-switch v-model:value="form.allow_background_upload" />
<template #feedback>
<span class="aksrt-hint-text">控制用户中心公开可用的背景图上传入口。</span>
</template>
</n-form-item>
<n-form-item label="头像/封面大小上限 (MB)">
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Content uploads -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>内容上传</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许投稿插入图片">
<n-switch v-model:value="form.allow_submit_images" />
<template #feedback>
<span class="aksrt-hint-text">前台投稿编辑器中允许插入图片。</span>
</template>
</n-form-item>
<n-form-item label="投稿图片大小上限 (MB)">
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
</n-form-item>
<n-form-item label="允许评论插入图片">
<n-switch v-model:value="form.allow_comment_images" />
<template #feedback>
<span class="aksrt-hint-text">评论工具栏中允许插入图片链接或上传图片。</span>
</template>
</n-form-item>
<n-form-item label="评论图片大小上限 (MB)">
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Resource attachments -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>资源附件</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="允许资源附件上传">
<n-switch v-model:value="form.allow_resource_upload" />
<template #feedback>
<span class="aksrt-hint-text">后台资源编辑器中允许上传附件文件。</span>
</template>
</n-form-item>
<n-form-item label="资源附件大小上限 (MB)">
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- AI Moderation -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>审核联动</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="背景图接入 AI 审核">
<n-switch v-model:value="form.ai_moderate_background" />
<template #feedback>
<span class="aksrt-hint-text">命中高风险时会阻断上传,并保留上一次背景图。</span>
</template>
</n-form-item>
<n-form-item label="投稿图片接入 AI 审核">
<n-switch v-model:value="form.ai_moderate_submit" />
<template #feedback>
<span class="aksrt-hint-text">投稿中插入的图片也会经过内容安全审核。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const moderationSnapshot = ref({})
const form = reactive({
allow_avatar_upload: true,
allow_cover_upload: true,
allow_background_upload: false,
avatar_max_size: 2,
allow_submit_images: true,
submit_image_max_size: 5,
allow_comment_images: false,
comment_image_max_size: 2,
allow_resource_upload: true,
resource_max_size: 50,
ai_moderate_background: true,
ai_moderate_submit: false,
})
const summaryItems = computed(() => [
{ key: 'avatar', icon: 'fas fa-user-circle', label: '头像上传', enabled: form.allow_avatar_upload },
{ key: 'cover', icon: 'fas fa-image', label: '封面上传', enabled: form.allow_cover_upload },
{ key: 'background', icon: 'fas fa-photo-video', label: '背景图上传', enabled: form.allow_background_upload },
{ key: 'submit', icon: 'fas fa-edit', label: '投稿图片', enabled: form.allow_submit_images },
{ key: 'comment', icon: 'fas fa-comment-dots', label: '评论图片', enabled: form.allow_comment_images },
{ key: 'resource', icon: 'fas fa-file-archive', label: '资源附件', enabled: form.allow_resource_upload },
])
function go(path) {
router.push(path)
}
function goModeration() { router.push('/permissions/moderation') }
async function load() {
loading.value = true
try {
const [permissionRes, moderationRes] = await Promise.all([
api.getPermissionsAttachmentSettings(),
api.getPermissionsModerationSettings(),
])
const permission = permissionRes?.data ?? permissionRes ?? {}
const moderation = moderationRes?.data ?? moderationRes ?? {}
// Map permission fields
form.allow_avatar_upload = permission.allow_avatar_upload !== false
form.allow_cover_upload = permission.allow_cover_upload !== false
form.allow_background_upload = !!permission.allow_background_upload
form.avatar_max_size = Number(permission.avatar_max_size || 2)
form.allow_submit_images = permission.allow_submit_images !== false
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
form.allow_comment_images = !!permission.allow_comment_images
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
form.allow_resource_upload = permission.allow_resource_upload !== false
form.resource_max_size = Number(permission.resource_max_size || 50)
form.ai_moderate_background = moderation.ai_moderate_background !== false
form.ai_moderate_submit = !!moderation.ai_moderate_submit
moderationSnapshot.value = { ...moderation }
} catch (error) {
message.error(error.message || '加载附件权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await Promise.all([
api.savePermissionsAttachmentSettings({
allow_avatar_upload: form.allow_avatar_upload,
allow_cover_upload: form.allow_cover_upload,
allow_background_upload: form.allow_background_upload,
avatar_max_size: form.avatar_max_size,
allow_submit_images: form.allow_submit_images,
submit_image_max_size: form.submit_image_max_size,
allow_comment_images: form.allow_comment_images,
comment_image_max_size: form.comment_image_max_size,
allow_resource_upload: form.allow_resource_upload,
resource_max_size: form.resource_max_size,
}),
api.savePermissionsModerationSettings({
...moderationSnapshot.value,
ai_moderate_background: form.ai_moderate_background,
ai_moderate_submit: form.ai_moderate_submit,
}),
])
moderationSnapshot.value = {
...moderationSnapshot.value,
ai_moderate_background: form.ai_moderate_background,
ai_moderate_submit: form.ai_moderate_submit,
}
message.success('附件治理配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-attach__summary {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-attach__summary strong {
display: block;
font-size: 13px;
color: #1a1a2e;
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -0,0 +1,150 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="评论权限"
desc="管理评论入口的登录要求与回复权限,并查看当前验证码与审核策略状态。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
<n-button quaternary @click="go('/permissions/moderation')">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动状态</h3>
</div>
<n-space>
<n-tag :type="status.commentCaptchaEnabled ? 'success' : 'default'" round>
评论验证码:{{ status.commentCaptchaEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.moderationEnabled ? 'warning' : 'default'" round>
审核链路:{{ status.moderationEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.rateLimitEnabled ? 'warning' : 'default'" round>
评论限流:{{ status.rateLimitEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag :type="status.keywordRuleCount > 0 ? 'error' : 'default'" round>
敏感词:{{ status.keywordRuleCount }} 条
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>评论准入</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="评论需要登录">
<n-switch v-model:value="form.comment_require_login" />
<template #feedback>
<span class="aksrt-hint-text">开启后,游客只能浏览评论,不能直接提交评论。</span>
</template>
</n-form-item>
<n-form-item label="允许楼中回复">
<n-switch v-model:value="form.comment_allow_reply" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,前台评论列表将不再展示回复按钮。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
评论排序、分页方式、头像大小和空状态文案继续在“文章&列表 -> 评论设置”中维护;本页只负责准入与治理联动。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
comment_require_login: false,
comment_allow_reply: true,
})
const status = reactive({
commentCaptchaEnabled: false,
moderationEnabled: false,
rateLimitEnabled: false,
keywordRuleCount: 0,
})
function go(path) {
router.push(path)
}
function splitKeywords(value) {
return String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
}
async function load() {
loading.value = true
try {
const [commentData, captchaRes, moderationRes] = await Promise.all([
api.getSchemaPage('post_list', 'comment'),
api.getPermissionsLoginSettings(),
api.getPermissionsModerationSettings(),
])
const comment = commentData?.data ?? commentData ?? {}
const captcha = captchaRes?.data ?? captchaRes ?? {}
const moderation = moderationRes?.data ?? moderationRes ?? {}
const disabledScenarios = Array.isArray(captcha.disabled_scenarios) ? captcha.disabled_scenarios : []
form.comment_require_login = !!comment.comment_require_login
form.comment_allow_reply = comment.comment_allow_reply !== false
status.commentCaptchaEnabled = !!captcha.enabled && !disabledScenarios.includes('comment')
status.moderationEnabled = !!moderation.enabled
status.rateLimitEnabled = !!moderation.rate_limit_enabled
status.keywordRuleCount = splitKeywords(moderation.blocked_keywords).length
} catch (error) {
message.error(error.message || '加载评论权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('post_list', 'comment', {
comment_require_login: form.comment_require_login,
comment_allow_reply: form.comment_allow_reply,
})
message.success('评论权限已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,161 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="下载权限"
desc="管理资源下载所需的登录、积分与验证码门槛,并查看当前默认下载成本。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/payment/paid')">资源下载页</n-button>
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前门槛摘要</h3>
</div>
<n-space>
<n-tag :type="form.download_login ? 'warning' : 'default'" round>
登录要求:{{ form.download_login ? '已启用' : '已关闭' }}
</n-tag>
<n-tag :type="form.download_credit ? 'warning' : 'default'" round>
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
</n-tag>
<n-tag :type="form.download_captcha ? 'warning' : 'default'" round>
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
</n-tag>
<n-tag type="info" round>默认下载积分:{{ defaultCost }}</n-tag>
<n-tag type="info" round>Token TTL:10 分钟</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>下载门槛</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="下载需要登录">
<n-switch v-model:value="form.download_login" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,单个资源的登录要求仍可通过资源元字段单独覆盖。</span>
</template>
</n-form-item>
<n-form-item label="下载启用积分扣费">
<n-switch v-model:value="form.download_credit" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,资源价格将只作为展示信息,不会真正扣除积分。</span>
</template>
</n-form-item>
<n-form-item label="下载启用验证码">
<n-switch v-model:value="form.download_captcha" />
<template #feedback>
<span class="aksrt-hint-text">具体验证码服务商与场景白名单仍在“登录权限”页维护。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
默认下载积分仍由积分配置维护;本页只负责访问门槛。更完整的支付、VIP 与白名单策略将在 Phase 10 统一收口。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const defaultCost = ref(0)
const featureSnapshot = ref({
resource_cpt_enabled: true,
search_include_resource: true,
download_login: true,
download_credit: true,
download_captcha: false,
})
const form = reactive({
download_login: true,
download_credit: true,
download_captcha: false,
})
function go(path) {
router.push(path)
}
async function load() {
loading.value = true
try {
const [featureRes, creditRes] = await Promise.all([
api.getCmsFeatures(),
api.getCreditSettings(),
])
const features = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
const creditSettings = creditRes?.data ?? creditRes ?? {}
featureSnapshot.value = {
resource_cpt_enabled: features.resource_cpt_enabled !== false,
search_include_resource: features.search_include_resource !== false,
download_login: features.download_login !== false,
download_credit: features.download_credit !== false,
download_captcha: !!features.download_captcha,
}
form.download_login = featureSnapshot.value.download_login
form.download_credit = featureSnapshot.value.download_credit
form.download_captcha = featureSnapshot.value.download_captcha
defaultCost.value = Number(creditSettings.download_default_cost || 0)
} catch (error) {
message.error(error.message || '加载下载权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveCmsFeatures({
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
})
featureSnapshot.value = {
...featureSnapshot.value,
download_login: form.download_login,
download_credit: form.download_credit,
download_captcha: form.download_captcha,
}
message.success('下载权限已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,245 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="基本权限 / 能力矩阵"
desc="统一管理用户治理、投稿和评论的角色能力矩阵。"
>
<template #actions>
<n-space>
<n-button quaternary @click="resetDefaults">恢复默认</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 240px;">
<n-card class="aksrt-section-card" v-if="summary.cap_count">
<div class="aksrt-section-header">
<h3>矩阵概览</h3>
</div>
<n-space>
<n-tag round type="info">能力项:{{ summary.cap_count }}</n-tag>
<n-tag round type="info">分组:{{ summary.group_count }}</n-tag>
<n-tag round type="info">角色列:{{ summary.column_count }}</n-tag>
</n-space>
</n-card>
<n-card v-for="group in groups" :key="group.key" class="aksrt-section-card">
<template #header>
<div class="aksrt-cap-matrix__header">
<div>
<h3 class="aksrt-cap-matrix__title">{{ group.title }}</h3>
<p class="aksrt-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
</div>
</div>
</template>
<n-data-table
:columns="buildColumns(group)"
:data="group.items"
:bordered="true"
size="small"
:single-line="false"
:scroll-x="1200"
/>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存能力矩阵</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NInputNumber, NSwitch, NTag } from 'naive-ui'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const groups = ref([])
const columnsMeta = ref({})
const summary = reactive({
cap_count: 0,
group_count: 0,
column_count: 0,
})
const userCaps = reactive({})
const defaultsSnapshot = ref({})
onMounted(load)
async function load() {
loading.value = true
try {
const res = await api.getCapabilities()
const data = res?.data || {}
columnsMeta.value = data.columns || {}
groups.value = data.groups || []
Object.assign(summary, data.summary || { cap_count: 0, group_count: 0, column_count: 0 })
const snapshot = {}
for (const group of groups.value) {
for (const item of group.items || []) {
snapshot[item.cap_id] = { ...(item.values || {}) }
}
}
replaceReactive(userCaps, snapshot)
defaultsSnapshot.value = JSON.parse(JSON.stringify(snapshot))
} catch (error) {
message.error(error?.message || '加载能力矩阵失败')
} finally {
loading.value = false
}
}
function replaceReactive(target, source) {
Object.keys(target).forEach((key) => delete target[key])
Object.entries(source).forEach(([key, value]) => {
target[key] = { ...value }
})
}
function buildColumns(group) {
const cols = [
{
title: '能力项',
key: 'label',
fixed: 'left',
width: 280,
render: (row) => h('div', { class: 'aksrt-cap-matrix__cap' }, [
h('strong', row.label),
h('code', row.cap_id),
]),
},
]
const visibleColumns = new Set()
for (const item of group.items || []) {
for (const col of item.visible_columns || []) {
visibleColumns.add(col)
}
}
for (const col of Object.keys(columnsMeta.value || {})) {
if (!visibleColumns.has(col)) continue
cols.push({
title: columnsMeta.value[col],
key: col,
width: col === 'level' || col === 'vip' ? 110 : 100,
render: (row) => renderCapCell(row, col),
})
}
return cols
}
function renderCapCell(row, col) {
const hidden = !(row.visible_columns || []).includes(col)
if (hidden) {
return h('span', { class: 'aksrt-cap-matrix__empty' }, '—')
}
if (!userCaps[row.cap_id]) {
userCaps[row.cap_id] = { ...(row.values || {}) }
}
if (col === 'level' || col === 'vip') {
return h(NInputNumber, {
value: userCaps[row.cap_id]?.[col] ?? -1,
min: -1,
max: col === 'level' ? 10 : 2,
step: 1,
size: 'small',
style: 'width: 90px',
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value ?? -1),
})
}
return h(NSwitch, {
value: !!userCaps[row.cap_id]?.[col],
size: 'small',
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value),
})
}
function updateCapValue(capId, col, value) {
if (!userCaps[capId]) {
userCaps[capId] = {}
}
userCaps[capId][col] = value
}
function resetDefaults() {
replaceReactive(userCaps, JSON.parse(JSON.stringify(defaultsSnapshot.value || {})))
message.info('已恢复默认矩阵')
}
function expandAll() {
message.info('当前矩阵为平铺展示,无需展开')
}
function collapseAll() {
message.info('当前矩阵为平铺展示,无需折叠')
}
async function handleSave() {
saving.value = true
try {
await api.saveCapabilities(JSON.parse(JSON.stringify(userCaps)))
defaultsSnapshot.value = JSON.parse(JSON.stringify(userCaps))
message.success('能力矩阵已保存')
} catch (error) {
message.error(error?.message || '保存能力矩阵失败')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.aksrt-cap-matrix__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.aksrt-cap-matrix__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.aksrt-cap-matrix__desc {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-cap-matrix__cap {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-cap-matrix__cap code {
font-size: 11px;
color: #667085;
}
.aksrt-cap-matrix__empty {
color: #c0c4cc;
}
.aksrt-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,7 @@
<template>
<SchemaPage domain="post_list" section="comment" />
</template>
<script setup>
import SchemaPage from '@/pages/SchemaPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<SchemaPage domain="post_list" section="page" />
</template>
<script setup>
import SchemaPage from '@/pages/SchemaPage.vue'
</script>
@@ -0,0 +1,304 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="售后工作台" desc="把退款、换货、维修放在订单语境里处理,减少运营跳转。" />
<div class="aksrt-after-sale__stats">
<div v-for="item in statCards" :key="item.key" class="aksrt-after-sale__stat-card">
<div class="aksrt-after-sale__stat-icon" :style="{ background: item.color }">
<i :class="item.icon"></i>
</div>
<div class="aksrt-after-sale__stat-info">
<div class="aksrt-after-sale__stat-value">{{ stats[item.key] || 0 }}</div>
<div class="aksrt-after-sale__stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<n-card size="small" class="aksrt-after-sale__section">
<n-space wrap>
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="售后状态" style="width: 160px" />
<n-select v-model:value="filters.stage" :options="stageOptions" clearable placeholder="处理阶段" style="width: 160px" />
<n-select v-model:value="filters.type" :options="typeOptions" clearable placeholder="售后类型" style="width: 160px" />
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
<div class="aksrt-after-sale__toolbar">
<n-space>
<n-button type="primary" @click="showCreateModal = true">新建售后</n-button>
</n-space>
</div>
</n-card>
<n-card size="small" class="aksrt-after-sale__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<AfterSaleDetailDrawer
v-model:show="detailVisible"
:after-sale-id="detailId"
:status-options="detailStatusOptions"
@updated="loadData(page)"
/>
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
<div class="aksrt-modal aksrt-after-sale__modal">
<h3>新建售后工单</h3>
<div class="aksrt-form-group">
<label>订单号</label>
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
</div>
<div class="aksrt-form-group">
<label>售后类型</label>
<select v-model="createForm.type" class="aksrt-select">
<option v-for="item in typeOptions.filter((item) => item.value)" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="aksrt-form-group">
<label>金额</label>
<input v-model.number="createForm.amount" type="number" step="0.01" class="aksrt-input" placeholder="0.00">
</div>
<div class="aksrt-form-group">
<label>原因</label>
<textarea v-model="createForm.reason" class="aksrt-textarea" rows="3" placeholder="售后原因"></textarea>
</div>
<div class="aksrt-form-group">
<label>补充备注</label>
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="补充说明"></textarea>
</div>
<div class="aksrt-modal__actions">
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
{{ creating ? '提交中...' : '提交' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import AfterSaleDetailDrawer from './components/AfterSaleDetailDrawer.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const stats = ref({})
const detailVisible = ref(false)
const detailId = ref(null)
const statusOptions = ref([])
const stageOptions = ref([])
const typeOptions = ref([])
const showCreateModal = ref(false)
const creating = ref(false)
const filters = reactive({
status: null,
stage: null,
type: null,
order_number: '',
date_from: '',
date_to: '',
})
const createForm = reactive({
order_number: '',
type: 'refund',
amount: 0,
reason: '',
note: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const statCards = [
{ key: 'pending', label: '待处理', icon: 'fas fa-clock', color: '#f59e0b' },
{ key: 'processing', label: '处理中', icon: 'fas fa-spinner', color: '#2563eb' },
{ key: 'approved', label: '已通过', icon: 'fas fa-check', color: '#16a34a' },
{ key: 'rejected', label: '已拒绝', icon: 'fas fa-times', color: '#dc2626' },
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
]
const detailStatusOptions = computed(() => statusOptions.value.filter((item) => item.value))
const columns = [
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
{ title: '售后类型', key: 'type_label', width: 110 },
{ title: '阶段', key: 'stage', width: 120, render: (row) => row.stage?.label || '-' },
{ title: '物流联动', key: 'shipping_link', width: 170, render: (row) => row.order_context?.shipping?.latest?.status_label || row.order_context?.shipping?.latest?.status || '-' },
{ title: '申请人', key: 'applicant', width: 120, render: (row) => row.applicant?.display_name || '-' },
{ title: '金额', key: 'amount', width: 110, render: (row) => `¥${Number(row.amount || 0).toFixed(2)}` },
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
},
{ title: '创建时间', key: 'created_at', width: 160 },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
},
]
function resolveTagType(status) {
if (status === 'approved' || status === 'completed') return 'success'
if (status === 'processing') return 'info'
if (status === 'rejected' || status === 'cancelled') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/shop/after-sale', {
params: {
page: nextPage,
per_page: pagination.pageSize,
status: filters.status || '',
stage: filters.stage || '',
type: filters.type || '',
order_number: filters.order_number || '',
date_from: filters.date_from || '',
date_to: filters.date_to || '',
},
})
const data = res?.data || {}
items.value = data.items || []
stats.value = data.stats || {}
pagination.itemCount = data.total || 0
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
stageOptions.value = [{ label: '全部阶段', value: null }, ...(data.options?.stage || [])]
typeOptions.value = [{ label: '全部类型', value: null }, ...(data.options?.type || [])]
} catch (error) {
message.error(error?.message || '加载售后数据失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function openDetail(row) {
detailId.value = row.id
detailVisible.value = true
}
function resetFilters() {
filters.status = null
filters.stage = null
filters.type = null
filters.order_number = ''
filters.date_from = ''
filters.date_to = ''
loadData(1)
}
async function submitCreate() {
if (!createForm.order_number || !createForm.reason) {
message.warning('请填写订单号和售后原因')
return
}
creating.value = true
try {
await api.request('/shop/after-sale', {
method: 'POST',
data: { ...createForm },
})
message.success('售后工单已创建')
showCreateModal.value = false
createForm.order_number = ''
createForm.type = 'refund'
createForm.amount = 0
createForm.reason = ''
createForm.note = ''
loadData(1)
} catch (error) {
message.error(error?.message || '创建售后工单失败')
} finally {
creating.value = false
}
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-after-sale__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-after-sale__stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.aksrt-after-sale__stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.aksrt-after-sale__stat-value {
font-size: 22px;
font-weight: 700;
color: #111827;
}
.aksrt-after-sale__stat-label {
font-size: 12px;
color: #6b7280;
}
.aksrt-after-sale__section {
margin-bottom: 16px;
}
.aksrt-after-sale__toolbar {
margin-top: 14px;
}
.aksrt-after-sale__modal {
width: min(520px, calc(100vw - 32px));
}
</style>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="优惠券管理" desc="创建和管理优惠券,支持百分比和固定金额两种类型。" />
<!-- 操作栏 -->
<n-card size="small" class="aksrt-shop-coupons__section">
<n-space justify="space-between">
<n-space>
<n-input v-model:value="search" placeholder="搜索优惠码" clearable style="width:200px;" @clear="loadCoupons" @keyup.enter="loadCoupons">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态" clearable style="width:120px;" @update:value="loadCoupons" />
</n-space>
<n-button type="primary" @click="showCreate = true">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
新增优惠券
</n-button>
</n-space>
</n-card>
<!-- 优惠券列表 -->
<n-card size="small" class="aksrt-shop-coupons__section">
<n-data-table
:columns="columns"
:data="coupons"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 创建弹窗 -->
<n-modal :show="showCreate" @update:show="showCreate = $event" preset="card" title="新增优惠券" style="width:480px;" closable>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="优惠码" path="code">
<n-input v-model:value="form.code" placeholder="如 SAVE20" />
</n-form-item>
<n-form-item label="类型" path="type">
<n-select v-model:value="form.type" :options="typeOptions" />
</n-form-item>
<n-form-item :label="form.type === 'percent' ? '折扣比例 (%)' : '减免金额 (元)'" path="value">
<n-input-number v-model:value="form.value" :min="0" :max="form.type === 'percent' ? 100 : 99999" style="width:100%;" />
</n-form-item>
<n-form-item label="最低消费 (元)">
<n-input-number v-model:value="form.min_amount" :min="0" style="width:100%;" />
</n-form-item>
<n-form-item label="使用次数上限">
<n-input-number v-model:value="form.usage_limit" :min="0" style="width:100%;" />
<template #feedback>0 表示不限制</template>
</n-form-item>
<n-form-item label="过期时间">
<n-input v-model:value="form.expires_at" type="datetime-local" />
<template #feedback>留空表示永不过期</template>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showCreate = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleCreate">创建</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NButton, useMessage, useDialog } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const coupons = ref([])
const search = ref('')
const statusFilter = ref(null)
const showCreate = ref(false)
const formRef = ref(null)
const form = reactive({
code: '',
type: 'fixed',
value: 10,
min_amount: 0,
usage_limit: 0,
expires_at: '',
})
const rules = {
code: { required: true, message: '请输入优惠码', trigger: 'blur' },
value: { type: 'number', required: true, message: '请输入数值', trigger: 'blur' },
}
const statusOptions = [
{ label: '有效', value: 'active' },
{ label: '已禁用', value: 'disabled' },
]
const typeOptions = [
{ label: '固定金额', value: 'fixed' },
{ label: '百分比折扣', value: 'percent' },
]
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{ title: '优惠码', key: 'code', width: 140 },
{
title: '类型',
key: 'type',
width: 100,
render: (row) => h(NTag, { size: 'small', type: row.type === 'percent' ? 'info' : 'success' }, () => row.type === 'percent' ? `${row.value}%` : `¥${row.value}`),
},
{ title: '最低消费', key: 'min_amount', width: 100, render: (row) => row.min_amount > 0 ? `¥${row.min_amount}` : '—' },
{ title: '已用/上限', key: 'usage', width: 100, render: (row) => `${row.used_count}/${row.usage_limit || '∞'}` },
{
title: '状态',
key: 'status',
width: 80,
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'default' }, () => row.status === 'active' ? '有效' : '已禁用'),
},
{ title: '过期时间', key: 'expires_at', width: 160, render: (row) => row.expires_at || '永不过期' },
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => {
if (row.status !== 'active') return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
return h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDisable(row) }, () => '禁用')
},
},
]
async function loadCoupons() {
loading.value = true
try {
const params = new URLSearchParams({
page: String(pagination.page),
per_page: String(pagination.pageSize),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/coupons?' + params.toString())
if (res?.data) {
coupons.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadCoupons()
}
async function handleCreate() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
await api.request('/shop/coupons', {
method: 'POST',
body: JSON.stringify({ ...form }),
})
message.success('优惠券已创建')
showCreate.value = false
loadCoupons()
} catch {
message.error('创建失败')
} finally {
saving.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用优惠码「${row.code}」吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/shop/coupons/${row.id}/disable`, { method: 'POST' })
message.success('优惠券已禁用')
loadCoupons()
} catch {
message.error('操作失败')
}
},
})
}
onMounted(loadCoupons)
</script>
<style scoped>
.aksrt-shop-coupons__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,180 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="商城&商品" desc="商品管理、订单系统、库存管理等电商功能。" />
<div class="aksrt-shop-dashboard">
<!-- 统计卡片 -->
<div class="aksrt-shop-dashboard__stats">
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-box"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.product_count }}</span>
<span class="aksrt-shop-stat-card__label">商品数量</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-file-invoice"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.order_count }}</span>
<span class="aksrt-shop-stat-card__label">订单数量</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-coins"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ revenueDisplay }}</span>
<span class="aksrt-shop-stat-card__label">累计收入 (元)</span>
</div>
</div>
<div class="aksrt-shop-stat-card">
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-clock"></i></div>
<div class="aksrt-shop-stat-card__info">
<span class="aksrt-shop-stat-card__value">{{ overview.order_pending }}</span>
<span class="aksrt-shop-stat-card__label">待处理订单</span>
</div>
</div>
</div>
<!-- 最近商品 -->
<n-card class="aksrt-section-card" title="最近商品">
<n-data-table
v-if="products.length"
:columns="productColumns"
:data="products"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无商品" />
</n-card>
<!-- 最近订单 -->
<n-card class="aksrt-section-card" title="最近订单" style="margin-top:16px;">
<n-data-table
v-if="orders.length"
:columns="orderColumns"
:data="orders"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无订单" />
</n-card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, h } from 'vue'
import { NTag } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
const overview = ref({ product_count: 0, order_count: 0, order_pending: 0, revenue_total: 0 })
const products = ref([])
const orders = ref([])
const revenueDisplay = computed(() => (overview.value.revenue_total || 0).toFixed(2))
const statusMap = {
created: { label: '已创建', type: 'default' },
pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' },
delivered: { label: '已发货', type: 'info' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
expired: { label: '已过期', type: 'default' },
refunding: { label: '退款中', type: 'warning' },
refunded: { label: '已退款', type: 'error' },
}
const productColumns = [
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
{ title: '库存', key: 'stock', width: 80 },
{ title: '销量', key: 'sales', width: 80 },
{ title: '创建时间', key: 'created', width: 160 },
]
const orderColumns = [
{ title: '订单号', key: 'order_number', width: 180, ellipsis: { tooltip: true } },
{ title: '产品类型', key: 'product_type', width: 100 },
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const s = statusMap[row.status] || { label: row.status, type: 'default' }
return h(NTag, { size: 'small', type: s.type }, () => s.label)
},
},
{ title: '创建时间', key: 'created_at', width: 160 },
]
onMounted(async () => {
try {
const [overviewRes, productsRes, ordersRes] = await Promise.all([
api.request('/shop/overview'),
api.request('/shop/products?per_page=5'),
api.request('/shop/orders?per_page=5'),
])
if (overviewRes?.success && overviewRes.data) {
overview.value = { ...overview.value, ...overviewRes.data }
}
if (productsRes?.success && productsRes.data) {
products.value = productsRes.data.items || []
}
if (ordersRes?.success && ordersRes.data) {
orders.value = ordersRes.data.items || []
}
} catch {
// endpoints may not be available yet
}
})
</script>
<style scoped>
.aksrt-shop-dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-shop-stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-shop-stat-card__icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f0f5ff;
color: #1890ff;
font-size: 20px;
}
.aksrt-shop-stat-card__info {
display: flex;
flex-direction: column;
}
.aksrt-shop-stat-card__value {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-shop-stat-card__label {
font-size: 13px;
color: #7a7f8a;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="订单管理" desc="查看和管理所有交易订单。" />
<!-- 筛选栏 -->
<n-card size="small" class="aksrt-shop__section">
<n-space>
<n-input v-model:value="search" placeholder="搜索订单号" clearable style="width: 200px;" @clear="loadOrders" @keyup.enter="loadOrders">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="订单状态" clearable style="width: 140px;" @update:value="loadOrders" />
</n-space>
</n-card>
<!-- 订单列表 -->
<n-card size="small" class="aksrt-shop__section">
<n-data-table
:columns="columns"
:data="orders"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 订单详情抽屉 -->
<OrderDetailDrawer
v-model:show="detailVisible"
:order-number="detailOrderNumber"
@updated="loadOrders"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NButton } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import OrderDetailDrawer from './components/OrderDetailDrawer.vue'
import api from '@/api/index.js'
const loading = ref(false)
const orders = ref([])
const search = ref('')
const statusFilter = ref(null)
const detailVisible = ref(false)
const detailOrderNumber = ref('')
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
{ label: '已退款', value: 'refunded' },
]
const statusMap = {
created: { label: '已创建', type: 'default' },
pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' },
delivered: { label: '已发货', type: 'info' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
expired: { label: '已过期', type: 'default' },
refunding: { label: '退款中', type: 'warning' },
refunded: { label: '已退款', type: 'error' },
}
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{
title: '订单号',
key: 'order_number',
width: 180,
ellipsis: { tooltip: true },
render: (row) => h('a', {
href: 'javascript:;',
style: 'color:#1890ff;cursor:pointer;',
onClick: () => openDetail(row),
}, row.order_number),
},
{ title: '商品', key: 'product_name', ellipsis: { tooltip: true } },
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const s = statusMap[row.status] || { label: row.status, type: 'default' }
return h(NTag, { size: 'small', type: s.type }, () => s.label)
},
},
{ title: '买家', key: 'buyer_name', width: 120 },
{ title: '创建时间', key: 'created_at', width: 160 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => {
const btns = []
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'))
if (row.status === 'pending') {
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleCancel(row) }, () => '取消'))
}
if (row.status === 'paid') {
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => handleDeliver(row) }, () => '发货'))
}
return h('div', { style: 'display:flex;gap:4px;' }, btns)
},
},
]
async function loadOrders() {
loading.value = true
try {
const params = new URLSearchParams({
per_page: String(pagination.pageSize),
page: String(pagination.page),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/orders?' + params.toString())
if (res?.data) {
orders.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// Endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadOrders()
}
function openDetail(row) {
detailOrderNumber.value = row.order_number || ''
detailVisible.value = true
}
// Canonical: use order_number for status updates
async function updateOrderStatus(row, newStatus) {
try {
await api.request('/shop/orders/update-status', {
method: 'POST',
body: JSON.stringify({ order_number: row.order_number, status: newStatus }),
})
loadOrders()
} catch {
// silent
}
}
function handleCancel(row) { updateOrderStatus(row, 'cancelled') }
function handleDeliver(row) { updateOrderStatus(row, 'delivered') }
onMounted(loadOrders)
</script>
<style scoped>
.aksrt-shop__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="商品管理" desc="管理所有商品的上架、编辑和库存。" />
<!-- 操作栏 -->
<n-card size="small" class="aksrt-shop__section">
<n-space justify="space-between">
<n-space>
<n-input v-model:value="search" placeholder="搜索商品名称" clearable style="width: 240px;" @clear="loadProducts" @keyup.enter="loadProducts">
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
</n-input>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态筛选" clearable style="width: 140px;" @update:value="loadProducts" />
</n-space>
<n-button type="primary" @click="openCreate">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
新增商品
</n-button>
</n-space>
</n-card>
<!-- 商品列表 -->
<n-card size="small" class="aksrt-shop__section">
<n-data-table
:columns="columns"
:data="products"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 商品编辑抽屉 -->
<ProductEditorDrawer
v-model:show="drawerVisible"
:product-id="editingProductId"
@saved="loadProducts"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NSpace, NButton } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import ProductEditorDrawer from './components/ProductEditorDrawer.vue'
import api from '@/api/index.js'
const loading = ref(false)
const products = ref([])
const search = ref('')
const statusFilter = ref(null)
const drawerVisible = ref(false)
const editingProductId = ref(null)
const statusOptions = [
{ label: '已上架', value: 'publish' },
{ label: '已下架', value: 'draft' },
{ label: '全部', value: '' },
]
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
const columns = [
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
{ title: '库存', key: 'stock', width: 80 },
{ title: '销量', key: 'sales', width: 80 },
{
title: '状态',
key: 'status',
width: 80,
render: (row) => h(NTag, { size: 'small', type: row.status === 'publish' ? 'success' : 'default' }, () => row.status === 'publish' ? '已上架' : '已下架'),
},
{ title: '创建时间', key: 'created', width: 160 },
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
h(NButton, { size: 'tiny', quaternary: true, type: row.status === 'publish' ? 'warning' : 'success', onClick: () => handleToggle(row) }, () => row.status === 'publish' ? '下架' : '上架'),
]),
},
]
async function loadProducts() {
loading.value = true
try {
const params = new URLSearchParams({
per_page: String(pagination.pageSize),
page: String(pagination.page),
})
if (search.value) params.set('search', search.value)
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/shop/products?' + params.toString())
if (res?.data) {
products.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// Endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadProducts()
}
function openCreate() {
editingProductId.value = null
drawerVisible.value = true
}
function openEdit(row) {
editingProductId.value = row.id
drawerVisible.value = true
}
async function handleToggle(row) {
const newStatus = row.status === 'publish' ? 'draft' : 'publish'
try {
await api.request('/shop/products/' + row.id, {
method: 'POST',
body: JSON.stringify({ status: newStatus }),
})
loadProducts()
} catch {
// silent
}
}
onMounted(loadProducts)
</script>
<style scoped>
.aksrt-shop__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,364 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="物流工作台" desc="围绕订单上下文处理发货、跟踪、签收和异常退回。" />
<div class="aksrt-shipping__stats">
<div v-for="item in statCards" :key="item.key" class="aksrt-shipping__stat-card">
<div class="aksrt-shipping__stat-icon" :style="{ background: item.color }">
<i :class="item.icon"></i>
</div>
<div class="aksrt-shipping__stat-info">
<div class="aksrt-shipping__stat-value">{{ stats[item.key] || 0 }}</div>
<div class="aksrt-shipping__stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<n-card size="small" class="aksrt-shipping__section">
<n-space wrap>
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="物流状态" style="width: 150px" />
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.tracking_no" clearable placeholder="物流单号" style="width: 180px" @keyup.enter="loadData(1)" />
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
<n-button type="primary" @click="loadData(1)">查询</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
<div class="aksrt-shipping__toolbar">
<n-space>
<n-button type="primary" @click="showCreateModal = true">新建发货</n-button>
<n-button :disabled="selectedRowKeys.length === 0" @click="showBatchDialog = true">批量更新状态</n-button>
<n-popover trigger="click" placement="bottom-start">
<template #trigger>
<n-button>列控制</n-button>
</template>
<n-checkbox-group v-model:value="visibleColumnKeys">
<n-space vertical size="small">
<n-checkbox v-for="item in columnOptions" :key="item.key" :value="item.key">
{{ item.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-popover>
</n-space>
</div>
</n-card>
<n-card size="small" class="aksrt-shipping__section">
<n-data-table
:columns="columns"
:data="items"
:loading="loading"
:row-key="(row) => row.id"
:pagination="pagination"
@update:page="handlePageChange"
@update:checked-row-keys="selectedRowKeys = $event"
/>
</n-card>
<ShippingDetailDrawer
v-model:show="detailVisible"
:shipping-id="detailId"
@updated="loadData(page)"
/>
<ShippingBatchActionDialog
v-model:show="showBatchDialog"
:ids="selectedRowKeys"
:status-options="batchStatusOptions"
@updated="handleBatchUpdated"
/>
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
<div class="aksrt-modal aksrt-shipping__modal">
<h3>新建发货记录</h3>
<div class="aksrt-form-group">
<label>订单号</label>
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
</div>
<div class="aksrt-form-group">
<label>物流公司</label>
<select v-model="createForm.carrier" class="aksrt-select">
<option value="">请选择</option>
<option v-for="item in carrierOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="aksrt-form-group">
<label>物流单号</label>
<input v-model="createForm.tracking_no" type="text" class="aksrt-input" placeholder="输入物流单号">
</div>
<div class="aksrt-form-group">
<label>备注</label>
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="发货备注"></textarea>
</div>
<div class="aksrt-modal__actions">
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
{{ creating ? '提交中...' : '提交' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api/index.js'
import ShippingDetailDrawer from './components/ShippingDetailDrawer.vue'
import ShippingBatchActionDialog from './components/ShippingBatchActionDialog.vue'
const message = useMessage()
const loading = ref(false)
const page = ref(1)
const items = ref([])
const stats = ref({})
const detailVisible = ref(false)
const detailId = ref(null)
const selectedRowKeys = ref([])
const showBatchDialog = ref(false)
const showCreateModal = ref(false)
const creating = ref(false)
const carrierOptions = ref([])
const statusOptions = ref([])
const filters = reactive({
status: null,
order_number: '',
tracking_no: '',
date_from: '',
date_to: '',
})
const createForm = reactive({
order_number: '',
carrier: '',
tracking_no: '',
note: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const statCards = [
{ key: 'shipped', label: '已发货', icon: 'fas fa-box', color: '#0f766e' },
{ key: 'in_transit', label: '运输中', icon: 'fas fa-truck', color: '#2563eb' },
{ key: 'delivered', label: '已签收', icon: 'fas fa-check-circle', color: '#16a34a' },
{ key: 'returned', label: '已退回', icon: 'fas fa-undo', color: '#dc2626' },
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
]
const batchStatusOptions = computed(() => statusOptions.value.filter((item) => item.value !== 'shipped'))
const columnOptions = [
{ key: 'order_number', label: '订单号' },
{ key: 'carrier_label', label: '物流公司' },
{ key: 'tracking_no', label: '物流单号' },
{ key: 'status_label', label: '状态' },
{ key: 'operator', label: '处理人' },
{ key: 'address', label: '收货信息' },
{ key: 'created_at', label: '创建时间' },
]
const visibleColumnKeys = ref(columnOptions.map((item) => item.key))
const baseColumns = [
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
{ title: '物流公司', key: 'carrier_label', width: 120 },
{ title: '物流单号', key: 'tracking_no', width: 180, render: (row) => h('code', row.tracking_no) },
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
},
{ title: '处理人', key: 'operator', width: 120, render: (row) => row.operator?.display_name || '-' },
{ title: '收货信息', key: 'address', ellipsis: { tooltip: true }, render: (row) => row.order_context?.address?.full_text || '-' },
{ title: '创建时间', key: 'created_at', width: 160 },
]
const columns = computed(() => {
const visible = new Set(visibleColumnKeys.value)
const filteredColumns = baseColumns.filter((column) => visible.has(column.key))
return [
{ type: 'selection', width: 40 },
...filteredColumns,
{
title: '操作',
key: 'actions',
width: 160,
render: (row) => h('div', { class: 'aksrt-shipping__action-buttons' }, [
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
h(NButton, { size: 'small', quaternary: true, onClick: () => copyAddress(row) }, () => '复制地址'),
]),
},
]
})
function resolveTagType(status) {
if (status === 'delivered') return 'success'
if (status === 'in_transit') return 'info'
if (status === 'returned') return 'error'
return 'warning'
}
async function loadData(nextPage = 1) {
loading.value = true
page.value = nextPage
pagination.page = nextPage
try {
const res = await api.request('/shop/shipping', {
params: {
page: nextPage,
per_page: pagination.pageSize,
status: filters.status || '',
order_number: filters.order_number || '',
tracking_no: filters.tracking_no || '',
date_from: filters.date_from || '',
date_to: filters.date_to || '',
},
})
const data = res?.data || {}
items.value = data.items || []
stats.value = data.stats || {}
pagination.itemCount = data.total || 0
carrierOptions.value = data.options?.carrier || []
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
} catch (error) {
message.error(error?.message || '加载物流数据失败')
} finally {
loading.value = false
}
}
function handlePageChange(nextPage) {
loadData(nextPage)
}
function openDetail(row) {
detailId.value = row.id
detailVisible.value = true
}
function handleBatchUpdated() {
selectedRowKeys.value = []
loadData(page.value)
}
function resetFilters() {
filters.status = null
filters.order_number = ''
filters.tracking_no = ''
filters.date_from = ''
filters.date_to = ''
loadData(1)
}
async function copyAddress(row) {
const text = row.order_context?.address?.full_text || ''
if (!text) {
message.warning('当前记录没有地址信息')
return
}
try {
await navigator.clipboard.writeText(text)
message.success('地址已复制')
} catch {
message.warning('复制失败,请手动复制')
}
}
async function submitCreate() {
if (!createForm.order_number || !createForm.carrier || !createForm.tracking_no) {
message.warning('请填写订单号、物流公司和物流单号')
return
}
creating.value = true
try {
await api.request('/shop/shipping', {
method: 'POST',
data: { ...createForm },
})
message.success('发货记录已创建')
showCreateModal.value = false
createForm.order_number = ''
createForm.carrier = ''
createForm.tracking_no = ''
createForm.note = ''
loadData(1)
} catch (error) {
message.error(error?.message || '创建发货记录失败')
} finally {
creating.value = false
}
}
onMounted(() => loadData(1))
</script>
<style scoped>
.aksrt-shipping__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-shipping__stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.aksrt-shipping__stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.aksrt-shipping__stat-value {
font-size: 22px;
font-weight: 700;
color: #111827;
}
.aksrt-shipping__stat-label {
font-size: 12px;
color: #6b7280;
}
.aksrt-shipping__section {
margin-bottom: 16px;
}
.aksrt-shipping__toolbar {
margin-top: 14px;
}
.aksrt-shipping__action-buttons {
display: flex;
gap: 4px;
}
.aksrt-shipping__modal {
width: min(520px, calc(100vw - 32px));
}
</style>
@@ -0,0 +1,138 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="提现管理" desc="审核和管理创作者提现申请。" />
<!-- 筛选栏 -->
<n-card size="small" class="aksrt-shop__section">
<n-space>
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="提现状态" clearable style="width: 140px;" @update:value="loadWithdrawals" />
</n-space>
</n-card>
<!-- 提现列表 -->
<n-card size="small" class="aksrt-shop__section">
<n-data-table
:columns="columns"
:data="withdrawals"
:loading="loading"
:bordered="false"
size="small"
:pagination="pagination"
@update:page="handlePageChange"
/>
</n-card>
<!-- 审核弹窗 -->
<WithdrawalReviewModal
v-model:show="reviewVisible"
:withdrawal="reviewItem"
@updated="loadWithdrawals"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { NTag, NButton } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import WithdrawalReviewModal from './components/WithdrawalReviewModal.vue'
import api from '@/api/index.js'
const loading = ref(false)
const withdrawals = ref([])
const statusFilter = ref(null)
const reviewVisible = ref(false)
const reviewItem = ref({})
const statusOptions = [
{ label: '待审核', value: 'pending' },
{ label: '已批准', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已打款', value: 'completed' },
]
const statusMap = {
pending: { label: '待审核', type: 'warning' },
approved: { label: '已批准', type: 'info' },
processing: { label: '处理中', type: 'info' },
completed: { label: '已打款', type: 'success' },
rejected: { label: '已拒绝', type: 'error' },
}
// Unified pagination: page + per_page → items + total + page + total_pages
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50],
})
const columns = [
{ title: '申请人', key: 'user_name', width: 120 },
{ title: '金额', key: 'amount', width: 120, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
{ title: '提现方式', key: 'method', width: 100 },
{ title: '收款账号', key: 'account_info', width: 160, ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const s = statusMap[row.status] || { label: row.status, type: 'default' }
return h(NTag, { size: 'small', type: s.type }, () => s.label)
},
},
{ title: '申请时间', key: 'created_at', width: 160 },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => {
if (row.status !== 'pending') {
return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
}
return h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openReview(row) }, () => '审核')
},
},
]
async function loadWithdrawals() {
loading.value = true
try {
const params = new URLSearchParams({
per_page: String(pagination.pageSize),
page: String(pagination.page),
})
if (statusFilter.value) params.set('status', statusFilter.value)
const res = await api.request('/payment/withdrawals?' + params.toString())
if (res?.data) {
withdrawals.value = res.data.items || []
pagination.itemCount = res.data.total || 0
}
} catch {
// Endpoint may not exist yet
} finally {
loading.value = false
}
}
function handlePageChange(page) {
pagination.page = page
loadWithdrawals()
}
function openReview(row) {
reviewItem.value = row
reviewVisible.value = true
}
onMounted(loadWithdrawals)
</script>
<style scoped>
.aksrt-shop__section {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,283 @@
<template>
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
<n-drawer-content title="售后详情" closable>
<n-spin :show="loading">
<!-- 售后状态头 -->
<div class="aksrt-aftersale__status-header" :class="'aksrt-aftersale__status-header--' + (detail.record?.status || 'default')">
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
<span class="aksrt-aftersale__order-no">订单号:{{ detail.record?.order_number || '-' }}</span>
</div>
<!-- 售后信息 -->
<n-descriptions bordered :column="2" size="small" label-placement="left" style="margin-top:16px;">
<n-descriptions-item label="售后类型">{{ detail.record?.type_label || detail.record?.type || '-' }}</n-descriptions-item>
<n-descriptions-item label="申请阶段">
<n-tag size="small">{{ detail.record?.stage?.label || '-' }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="申请金额">
<n-text type="error" strong>¥{{ Number(detail.record?.amount || 0).toFixed(2) }}</n-text>
</n-descriptions-item>
<n-descriptions-item label="退款金额">
<n-text type="error" strong>¥{{ Number(detail.record?.refund_amount || 0).toFixed(2) }}</n-text>
</n-descriptions-item>
<n-descriptions-item label="申请原因" :span="2">{{ detail.record?.reason || '-' }}</n-descriptions-item>
<n-descriptions-item label="申请人">{{ detail.record?.applicant?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="申请时间">{{ detail.record?.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="处理人">{{ detail.record?.operator?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="处理时间">{{ detail.record?.processed_at || '-' }}</n-descriptions-item>
<n-descriptions-item v-if="detail.record?.operator_note" label="处理备注" :span="2">{{ detail.record.operator_note }}</n-descriptions-item>
</n-descriptions>
<!-- 退款渠道 -->
<n-card v-if="detail.record?.refund_channel" size="small" title="退款渠道" :bordered="false" style="margin-top:16px;">
<n-descriptions :column="2" size="small" label-placement="left">
<n-descriptions-item label="退款方式">{{ detail.record.refund_channel.method_label || detail.record.refund_channel.method || '-' }}</n-descriptions-item>
<n-descriptions-item label="退款状态">
<n-tag :type="detail.record.refund_channel.status === 'completed' ? 'success' : 'warning'" size="small">
{{ detail.record.refund_channel.status_label || detail.record.refund_channel.status || '-' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="退款单号">{{ detail.record.refund_channel.refund_id || '-' }}</n-descriptions-item>
<n-descriptions-item label="退款时间">{{ detail.record.refund_channel.completed_at || '-' }}</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 退货地址 -->
<n-card v-if="detail.record?.return_address" size="small" title="退货地址" :bordered="false" style="margin-top:12px;">
<n-descriptions :column="1" size="small" label-placement="left">
<n-descriptions-item label="收货人">{{ detail.record.return_address.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="电话">{{ detail.record.return_address.phone || '-' }}</n-descriptions-item>
<n-descriptions-item label="地址">{{ detail.record.return_address.full_address || '-' }}</n-descriptions-item>
</n-descriptions>
<n-button size="small" @click="copyReturnAddress" style="margin-top:8px;">
<i class="fas fa-copy" style="margin-right:4px;" /> 复制地址
</n-button>
</n-card>
<!-- 回寄物流 -->
<n-card v-if="detail.record?.return_shipping" size="small" title="回寄物流" :bordered="false" style="margin-top:12px;">
<n-descriptions :column="2" size="small" label-placement="left">
<n-descriptions-item label="物流公司">{{ detail.record.return_shipping.company || '-' }}</n-descriptions-item>
<n-descriptions-item label="物流单号">{{ detail.record.return_shipping.tracking_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="回寄时间">{{ detail.record.return_shipping.shipped_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag size="small" :type="detail.record.return_shipping.status === 'received' ? 'success' : 'info'">
{{ detail.record.return_shipping.status_label || detail.record.return_shipping.status || '-' }}
</n-tag>
</n-descriptions-item>
</n-descriptions>
<n-timeline v-if="detail.record.return_shipping.tracking_detail?.length" style="margin-top:12px;">
<n-timeline-item
v-for="(item, i) in detail.record.return_shipping.tracking_detail"
:key="i"
:title="item.description"
:content="item.time"
:type="i === 0 ? 'success' : 'default'"
/>
</n-timeline>
</n-card>
<!-- 售后联动 -->
<n-card size="small" title="售后联动" :bordered="false" style="margin-top:12px;">
<div class="aksrt-aftersale__context-item">
关联物流:
<span>
{{ detail.record?.order_context?.shipping?.latest?.status_label || detail.record?.order_context?.shipping?.latest?.status || '暂无物流记录' }}
<template v-if="detail.record?.order_context?.shipping?.latest?.tracking_no">
/ {{ detail.record.order_context.shipping.latest.carrier_label || '-' }}
/ {{ detail.record.order_context.shipping.latest.tracking_no }}
</template>
</span>
</div>
<div v-if="detail.record?.order_context?.address?.full_text" class="aksrt-aftersale__context-item">
回寄参考地址:
<span>{{ detail.record.order_context.address.full_text }}</span>
</div>
</n-card>
<!-- 状态更新 -->
<n-card size="small" title="状态更新" :bordered="false" style="margin-top:16px;">
<n-space vertical>
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="处理备注" />
<n-space>
<n-select v-model:value="nextStatus" :options="actionOptions" placeholder="选择状态" style="width:160px;" />
<n-button type="primary" :disabled="!nextStatus" :loading="updating" @click="handleStatusUpdate">更新状态</n-button>
</n-space>
</n-space>
</n-card>
<!-- 操作时间线 -->
<div v-if="detail.timeline?.length" class="aksrt-aftersale__timeline">
<div class="aksrt-aftersale__section-title">操作时间线</div>
<n-timeline size="small">
<n-timeline-item
v-for="(item, index) in detail.timeline"
:key="index"
:time="item.time"
:title="item.label"
:content="item.detail || ''"
:type="item.type === 'refund' ? 'warning' : item.type === 'shipping' ? 'info' : 'success'"
/>
</n-timeline>
</div>
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
afterSaleId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const updating = ref(false)
const detail = ref({ record: null, timeline: [] })
const nextStatus = ref(null)
const note = ref('')
const statusMap = {
pending: { label: '待处理', type: 'warning' },
processing: { label: '处理中', type: 'info' },
approved: { label: '已通过', type: 'success' },
rejected: { label: '已拒绝', type: 'error' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
}
const statusLabel = computed(() => {
const s = detail.value?.record?.status
return (statusMap[s] || { label: s || '-' }).label
})
const statusType = computed(() => {
const s = detail.value?.record?.status
return (statusMap[s] || { type: 'default' }).type
})
const actionOptions = computed(() => {
const status = detail.value?.record?.status
if (status === 'pending') {
return [
{ label: '处理中', value: 'processing' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已取消', value: 'cancelled' },
]
}
if (status === 'processing') {
return [
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已取消', value: 'cancelled' },
]
}
if (status === 'approved') {
return [
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
]
}
return []
})
watch(() => props.show, async (visible) => {
if (visible && props.afterSaleId) {
await loadDetail()
} else if (!visible) {
detail.value = { record: null, timeline: [] }
nextStatus.value = null
note.value = ''
}
})
async function loadDetail() {
loading.value = true
try {
const res = await api.request(`/shop/after-sale/${props.afterSaleId}`)
detail.value = res?.data || { record: null, timeline: [] }
} catch (error) {
message.error(error?.message || '加载售后详情失败')
} finally {
loading.value = false
}
}
async function handleStatusUpdate() {
if (!nextStatus.value) return
updating.value = true
try {
await api.request(`/shop/after-sale/${props.afterSaleId}/update-status`, {
method: 'POST',
data: { status: nextStatus.value, operator_note: note.value },
})
message.success('售后状态已更新')
nextStatus.value = null
note.value = ''
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '状态更新失败')
} finally {
updating.value = false
}
}
async function copyReturnAddress() {
const addr = detail.value?.record?.return_address
if (!addr) return
const text = `${addr.name} ${addr.phone} ${addr.full_address}`
try {
await navigator.clipboard.writeText(text)
message.success('退货地址已复制')
} catch {
message.warning('复制失败')
}
}
</script>
<style scoped>
.aksrt-aftersale__status-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: var(--surface-2);
}
.aksrt-aftersale__status-header--pending { background: #fff7e6; }
.aksrt-aftersale__status-header--processing { background: #e6f7ff; }
.aksrt-aftersale__status-header--approved { background: #f6ffed; }
.aksrt-aftersale__status-header--rejected { background: #fff1f0; }
.aksrt-aftersale__status-header--completed { background: #f6ffed; }
.aksrt-aftersale__status-header--cancelled { background: #f5f5f5; }
.aksrt-aftersale__order-no {
font-size: 13px;
color: var(--color-ink-3);
}
.aksrt-aftersale__context-item {
margin-top: 8px;
color: var(--color-ink-2);
line-height: 1.6;
}
.aksrt-aftersale__timeline {
margin-top: 18px;
}
.aksrt-aftersale__section-title {
margin-bottom: 8px;
font-weight: 600;
color: var(--color-ink);
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="aksrt-order-context">
<n-card size="small" title="订单上下文" :bordered="false">
<template v-if="context?.exists">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="订单号">{{ context.summary?.order_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="订单状态">{{ context.summary?.status_label || context.summary?.status || '-' }}</n-descriptions-item>
<n-descriptions-item label="买家">
{{ context.buyer?.display_name || '-' }}
<span v-if="context.buyer?.user_login" class="aksrt-order-context__muted">({{ context.buyer.user_login }})</span>
</n-descriptions-item>
<n-descriptions-item v-if="context.buyer?.user_email" label="联系邮箱">{{ context.buyer.user_email }}</n-descriptions-item>
<n-descriptions-item label="支付方式">{{ context.payment_method?.label || context.payment_method?.value || '-' }}</n-descriptions-item>
<n-descriptions-item label="配送方式">{{ context.shipping_method?.label || context.shipping_method?.value || '-' }}</n-descriptions-item>
<n-descriptions-item label="收货地址">{{ context.address?.full_text || '-' }}</n-descriptions-item>
<n-descriptions-item v-if="context.coupon?.code || context.coupon?.label" label="优惠信息">
{{ context.coupon?.label || context.coupon?.code }}
<span v-if="context.coupon?.discount" class="aksrt-order-context__muted">减免 ¥{{ formatAmount(context.coupon.discount) }}</span>
</n-descriptions-item>
<n-descriptions-item label="金额">
¥{{ formatAmount(context.pricing?.amount) }}
<span class="aksrt-order-context__muted">
小计 ¥{{ formatAmount(context.pricing?.subtotal) }} / 运费 ¥{{ formatAmount(context.pricing?.shipping_fee) }} / 优惠 ¥{{ formatAmount(context.pricing?.discount) }}
</span>
</n-descriptions-item>
<n-descriptions-item label="物流联动">
<template v-if="context.shipping?.latest?.status_label || context.shipping?.count">
{{ context.shipping?.latest?.status_label || context.shipping?.latest?.status || '-' }}
<span v-if="context.shipping?.latest?.tracking_no" class="aksrt-order-context__muted">
{{ context.shipping.latest.carrier_label || context.shipping.latest.carrier || '' }} / {{ context.shipping.latest.tracking_no }}
</span>
</template>
<template v-else>-</template>
</n-descriptions-item>
<n-descriptions-item label="售后联动">
<template v-if="context.after_sale?.latest?.status_label || context.after_sale?.count">
{{ context.after_sale?.latest?.status_label || context.after_sale?.latest?.status || '-' }}
<span v-if="context.after_sale?.latest?.stage?.label" class="aksrt-order-context__muted">
{{ context.after_sale.latest.stage.label }}
</span>
</template>
<template v-else>-</template>
</n-descriptions-item>
<n-descriptions-item v-if="context.customer_note" label="用户备注">{{ context.customer_note }}</n-descriptions-item>
</n-descriptions>
<div v-if="Array.isArray(context.items) && context.items.length" class="aksrt-order-context__items">
<div v-for="(item, index) in context.items" :key="index" class="aksrt-order-context__item">
<div class="aksrt-order-context__item-title">{{ item.product_name || `商品 #${item.product_id || index + 1}` }}</div>
<div class="aksrt-order-context__item-meta">数量 {{ item.quantity || 1 }} / 小计 ¥{{ formatAmount(item.line_total) }}</div>
</div>
</div>
</template>
<n-empty v-else description="未找到订单上下文" size="small" />
</n-card>
</div>
</template>
<script setup>
defineProps({
context: {
type: Object,
default: () => ({}),
},
})
function formatAmount(value) {
return Number(value || 0).toFixed(2)
}
</script>
<style scoped>
.aksrt-order-context {
margin-top: 16px;
}
.aksrt-order-context__muted {
display: inline-block;
margin-left: 8px;
color: #8c8c8c;
font-size: 12px;
}
.aksrt-order-context__items {
display: grid;
gap: 8px;
margin-top: 12px;
}
.aksrt-order-context__item {
padding: 10px 12px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fafc;
}
.aksrt-order-context__item-title {
font-weight: 600;
color: #1f2937;
}
.aksrt-order-context__item-meta {
margin-top: 4px;
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,440 @@
<template>
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
<n-drawer-content title="订单详情" closable>
<n-spin :show="loading">
<!-- 订单状态头 -->
<div class="aksrt-order-drawer__status-header" :class="'aksrt-order-drawer__status-header--' + order.status">
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
<span class="aksrt-order-drawer__order-no">订单号:{{ order.order_number || '-' }}</span>
</div>
<!-- 核心信息 -->
<n-descriptions bordered :column="2" label-placement="left" size="small" style="margin-top:16px;">
<n-descriptions-item label="商品" :span="2">{{ order.product_name || order.product_type || '-' }}</n-descriptions-item>
<n-descriptions-item label="订单金额">
<n-text type="error" strong>¥{{ (order.amount || 0).toFixed(2) }}</n-text>
</n-descriptions-item>
<n-descriptions-item label="支付方式">{{ order.payment_method?.label || order.payment_method || '-' }}</n-descriptions-item>
<n-descriptions-item label="小计">¥{{ (order.subtotal || 0).toFixed(2) }}</n-descriptions-item>
<n-descriptions-item label="运费">¥{{ (order.shipping_fee || 0).toFixed(2) }}</n-descriptions-item>
<n-descriptions-item label="优惠">¥{{ (order.discount || 0).toFixed(2) }}</n-descriptions-item>
<n-descriptions-item v-if="order.payment_transaction_id" label="交易号" :span="2">{{ order.payment_transaction_id }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ order.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item v-if="order.paid_at" label="支付时间">{{ order.paid_at }}</n-descriptions-item>
<n-descriptions-item v-if="order.completed_at" label="完成时间">{{ order.completed_at }}</n-descriptions-item>
<n-descriptions-item v-if="order.note" label="备注" :span="2">{{ order.note }}</n-descriptions-item>
</n-descriptions>
<!-- 买家信息 -->
<n-card size="small" title="买家信息" :bordered="false" style="margin-top:16px;">
<n-descriptions :column="2" label-placement="left" size="small">
<n-descriptions-item label="用户 ID">{{ order.user_id || '-' }}</n-descriptions-item>
<n-descriptions-item label="用户名">{{ buyer.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="邮箱">{{ buyer.email || '-' }}</n-descriptions-item>
<n-descriptions-item label="手机">{{ buyer.phone || '-' }}</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 收货地址 -->
<n-card v-if="address" size="small" title="收货地址" :bordered="false" style="margin-top:12px;">
<n-descriptions :column="1" label-placement="left" size="small">
<n-descriptions-item label="收货人">{{ address.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="电话">{{ address.phone || '-' }}</n-descriptions-item>
<n-descriptions-item label="地址">{{ address.full_address || '-' }}</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 物流信息 -->
<n-card v-if="shipping" size="small" title="物流信息" :bordered="false" style="margin-top:12px;">
<n-descriptions :column="2" label-placement="left" size="small">
<n-descriptions-item label="物流公司">{{ shipping.company || '-' }}</n-descriptions-item>
<n-descriptions-item label="物流单号">{{ shipping.tracking_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="发货时间">{{ shipping.shipped_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag :type="shipping.status === 'delivered' ? 'success' : 'info'" size="small">
{{ shipping.status_label || shipping.status || '-' }}
</n-tag>
</n-descriptions-item>
</n-descriptions>
<n-timeline v-if="shipping.tracking_detail?.length" style="margin-top:12px;">
<n-timeline-item
v-for="(item, i) in shipping.tracking_detail"
:key="i"
:title="item.description"
:content="item.time"
:type="i === 0 ? 'success' : 'default'"
/>
</n-timeline>
</n-card>
<!-- 售后信息 -->
<n-card v-if="afterSale" size="small" title="售后信息" :bordered="false" style="margin-top:12px;">
<n-descriptions :column="2" label-placement="left" size="small">
<n-descriptions-item label="售后类型">{{ afterSale.type_label || afterSale.type || '-' }}</n-descriptions-item>
<n-descriptions-item label="售后状态">
<n-tag :type="afterSale.status === 'completed' ? 'success' : 'warning'" size="small">
{{ afterSale.status_label || afterSale.status || '-' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="退款金额">¥{{ (afterSale.refund_amount || 0).toFixed(2) }}</n-descriptions-item>
<n-descriptions-item label="申请时间">{{ afterSale.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="原因" :span="2">{{ afterSale.reason || '-' }}</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 操作时间线 -->
<n-card v-if="timeline.length" size="small" title="操作时间线" :bordered="false" style="margin-top:16px;">
<n-timeline>
<n-timeline-item
v-for="(entry, index) in timeline"
:key="index"
:type="entry.type === 'shipping' ? 'info' : entry.type === 'after_sale' ? 'warning' : 'success'"
:title="entry.title"
:content="entry.detail || ''"
:time="entry.time"
/>
</n-timeline>
</n-card>
<!-- 操作按钮 -->
<div v-if="order.order_number" class="aksrt-order-drawer__actions">
<n-divider />
<n-space wrap>
<n-button v-if="order.status === 'pending'" type="success" size="small" @click="updateStatus('paid')">
<template #icon><i class="fas fa-check" /></template>
确认支付
</n-button>
<n-button v-if="order.status === 'pending'" type="error" size="small" @click="updateStatus('cancelled')">
<template #icon><i class="fas fa-times" /></template>
取消订单
</n-button>
<n-button v-if="order.status === 'paid'" type="info" size="small" @click="showShipModal = true">
<template #icon><i class="fas fa-truck" /></template>
发货
</n-button>
<n-button v-if="order.status === 'delivered'" type="success" size="small" @click="updateStatus('completed')">
<template #icon><i class="fas fa-check-double" /></template>
确认完成
</n-button>
<n-button v-if="['paid', 'delivered', 'completed'].includes(order.status)" type="warning" size="small" @click="showRefundModal = true">
<template #icon><i class="fas fa-undo" /></template>
申请退款
</n-button>
<n-button v-if="order.status === 'completed' && !afterSale" type="default" size="small" @click="showAfterSaleModal = true">
<template #icon><i class="fas fa-headset" /></template>
创建售后
</n-button>
</n-space>
</div>
</n-spin>
<template #footer>
<n-button @click="$emit('update:show', false)">关闭</n-button>
</template>
</n-drawer-content>
</n-drawer>
<!-- 发货弹窗 -->
<n-modal v-model:show="showShipModal" preset="card" title="发货" style="max-width:480px;" :bordered="false">
<n-form label-placement="top">
<n-form-item label="物流公司">
<n-select v-model:value="shipForm.company" :options="shippingCompanies" placeholder="选择物流公司" />
</n-form-item>
<n-form-item label="物流单号">
<n-input v-model:value="shipForm.tracking_number" placeholder="填写物流单号" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="shipForm.note" type="textarea" :rows="2" placeholder="发货备注(可选)" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showShipModal = false">取消</n-button>
<n-button type="info" :loading="shipLoading" @click="submitShip">确认发货</n-button>
</n-space>
</template>
</n-modal>
<!-- 退款弹窗 -->
<n-modal v-model:show="showRefundModal" preset="card" title="申请退款" style="max-width:420px;" :bordered="false">
<n-form label-placement="top">
<n-form-item label="退款金额 (元)">
<n-input-number v-model:value="refundAmount" :min="0" :max="order.amount || 0" :precision="2" style="width:100%;" />
<n-text depth="3" style="font-size:12px;margin-top:4px;">订单总额 ¥{{ (order.amount || 0).toFixed(2) }},支持部分退款</n-text>
</n-form-item>
<n-form-item label="退款原因">
<n-input v-model:value="refundReason" type="textarea" :rows="3" placeholder="填写退款原因(可选)" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showRefundModal = false">取消</n-button>
<n-button type="warning" :loading="refundLoading" @click="submitRefund">确认退款</n-button>
</n-space>
</template>
</n-modal>
<!-- 售后弹窗 -->
<n-modal v-model:show="showAfterSaleModal" preset="card" title="创建售后" style="max-width:480px;" :bordered="false">
<n-form label-placement="top">
<n-form-item label="售后类型">
<n-select v-model:value="afterSaleForm.type" :options="afterSaleTypes" />
</n-form-item>
<n-form-item label="退款金额">
<n-input-number v-model:value="afterSaleForm.refund_amount" :min="0" :max="order.amount || 0" :precision="2" style="width:100%;" />
</n-form-item>
<n-form-item label="原因">
<n-input v-model:value="afterSaleForm.reason" type="textarea" :rows="3" placeholder="售后原因" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showAfterSaleModal = false">取消</n-button>
<n-button type="warning" :loading="afterSaleLoading" @click="submitAfterSale">提交售后</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
orderNumber: { type: String, default: '' },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const order = ref({})
const buyer = ref({})
const address = ref(null)
const shipping = ref(null)
const afterSale = ref(null)
const timeline = ref([])
// 发货
const showShipModal = ref(false)
const shipLoading = ref(false)
const shipForm = ref({ company: '', tracking_number: '', note: '' })
const shippingCompanies = [
{ label: '顺丰速运', value: 'sf' },
{ label: '圆通速递', value: 'yt' },
{ label: '中通快递', value: 'zt' },
{ label: '韵达快递', value: 'yd' },
{ label: '申通快递', value: 'sto' },
{ label: '百世快递', value: 'ht' },
{ label: '极兔速递', value: 'jt' },
{ label: '邮政快递', value: 'ems' },
{ label: '其他', value: 'other' },
]
// 退款
const showRefundModal = ref(false)
const refundAmount = ref(0)
const refundReason = ref('')
const refundLoading = ref(false)
// 售后
const showAfterSaleModal = ref(false)
const afterSaleLoading = ref(false)
const afterSaleForm = ref({ type: 'refund', refund_amount: 0, reason: '' })
const afterSaleTypes = [
{ label: '仅退款', value: 'refund' },
{ label: '退货退款', value: 'return_refund' },
{ label: '换货', value: 'exchange' },
{ label: '维修', value: 'repair' },
]
const statusMap = {
created: { label: '已创建', type: 'default' },
pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' },
delivered: { label: '已发货', type: 'info' },
completed: { label: '已完成', type: 'success' },
cancelled: { label: '已取消', type: 'default' },
expired: { label: '已过期', type: 'default' },
refunding: { label: '退款中', type: 'warning' },
refunded: { label: '已退款', type: 'error' },
}
const statusLabel = computed(() => (statusMap[order.value.status] || { label: order.value.status }).label)
const statusType = computed(() => (statusMap[order.value.status] || { type: 'default' }).type)
watch(() => props.show, async (val) => {
if (val && props.orderNumber) {
await loadOrder()
} else if (!val) {
resetState()
}
})
async function loadOrder() {
loading.value = true
try {
const res = await api.request(`/shop/orders/${props.orderNumber}`)
if (res?.data) {
order.value = res.data
buyer.value = res.buyer || {}
address.value = res.address || null
shipping.value = res.shipping || null
afterSale.value = res.after_sale || null
timeline.value = res.timeline || []
refundAmount.value = res.data.amount || 0
}
} catch {
message.error('加载订单详情失败')
} finally {
loading.value = false
}
}
function resetState() {
order.value = {}
buyer.value = {}
address.value = null
shipping.value = null
afterSale.value = null
timeline.value = []
showRefundModal.value = false
showShipModal.value = false
showAfterSaleModal.value = false
}
async function refreshOrder() {
try {
const res = await api.request(`/shop/orders/${props.orderNumber}`)
if (res?.data) {
order.value = res.data
buyer.value = res.buyer || {}
address.value = res.address || null
shipping.value = res.shipping || null
afterSale.value = res.after_sale || null
timeline.value = res.timeline || []
}
} catch { /* silent */ }
}
async function updateStatus(status) {
try {
await api.request('/shop/orders/update-status', {
method: 'POST',
data: { order_number: props.orderNumber, status },
})
message.success('状态已更新')
await refreshOrder()
emit('updated')
} catch {
message.error('操作失败')
}
}
async function submitShip() {
shipLoading.value = true
try {
await api.request('/shop/orders/ship', {
method: 'POST',
data: {
order_number: props.orderNumber,
company: shipForm.value.company,
tracking_number: shipForm.value.tracking_number,
note: shipForm.value.note,
},
})
message.success('发货成功')
showShipModal.value = false
shipForm.value = { company: '', tracking_number: '', note: '' }
await refreshOrder()
emit('updated')
} catch (err) {
message.error(err?.message || '发货失败')
} finally {
shipLoading.value = false
}
}
async function submitRefund() {
refundLoading.value = true
try {
await api.request('/shop/orders/update-status', {
method: 'POST',
data: {
order_number: props.orderNumber,
status: 'refunding',
refund_amount: refundAmount.value,
refund_reason: refundReason.value,
},
})
message.success('退款申请已提交')
showRefundModal.value = false
refundReason.value = ''
await refreshOrder()
emit('updated')
} catch (err) {
message.error(err?.message || '退款操作失败')
} finally {
refundLoading.value = false
}
}
async function submitAfterSale() {
afterSaleLoading.value = true
try {
await api.request('/shop/orders/after-sale', {
method: 'POST',
data: {
order_number: props.orderNumber,
type: afterSaleForm.value.type,
refund_amount: afterSaleForm.value.refund_amount,
reason: afterSaleForm.value.reason,
},
})
message.success('售后申请已提交')
showAfterSaleModal.value = false
afterSaleForm.value = { type: 'refund', refund_amount: 0, reason: '' }
await refreshOrder()
emit('updated')
} catch (err) {
message.error(err?.message || '售后申请失败')
} finally {
afterSaleLoading.value = false
}
}
</script>
<style scoped>
.aksrt-order-drawer__status-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: var(--surface-2);
}
.aksrt-order-drawer__status-header--pending { background: #fff7e6; }
.aksrt-order-drawer__status-header--paid { background: #f6ffed; }
.aksrt-order-drawer__status-header--delivered { background: #e6f7ff; }
.aksrt-order-drawer__status-header--completed { background: #f6ffed; }
.aksrt-order-drawer__status-header--cancelled { background: #f5f5f5; }
.aksrt-order-drawer__status-header--refunding { background: #fff7e6; }
.aksrt-order-drawer__status-header--refunded { background: #fff1f0; }
.aksrt-order-drawer__order-no {
font-size: 13px;
color: var(--color-ink-3);
}
.aksrt-order-drawer__actions {
margin-top: 16px;
}
.aksrt-order-drawer__muted {
font-size: 12px;
color: var(--color-ink-4);
margin-left: 8px;
}
</style>
@@ -0,0 +1,263 @@
<template>
<n-drawer :show="show" :width="560" @update:show="$emit('update:show', $event)">
<n-drawer-content :title="isEdit ? '编辑商品' : '新增商品'" closable>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<!-- 基础信息 -->
<n-divider title-placement="left">基础信息</n-divider>
<n-form-item label="商品名称" path="title">
<n-input v-model:value="form.title" placeholder="输入商品名称" />
</n-form-item>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="价格 (元)" path="price">
<n-input-number v-model:value="form.price" :min="0" :precision="2" style="width:100%;" placeholder="0.00" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="库存" path="stock">
<n-input-number v-model:value="form.stock" :min="0" style="width:100%;" placeholder="0" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="商品简介" path="excerpt">
<n-input v-model:value="form.excerpt" type="textarea" :rows="3" placeholder="简短描述(可选)" />
</n-form-item>
<n-form-item label="商品详情" path="content">
<n-input v-model:value="form.content" type="textarea" :rows="6" placeholder="详细描述(支持 HTML)" />
</n-form-item>
<!-- 商品素材 -->
<n-divider title-placement="left">商品素材</n-divider>
<n-form-item label="主图 URL" path="image">
<n-input v-model:value="form.image" placeholder="https://example.com/image.jpg" />
</n-form-item>
<n-form-item label="图集 URL">
<n-input v-model:value="form.galleryText" type="textarea" :rows="3" placeholder="每行一个图片 URL" />
</n-form-item>
<!-- 分类与标签 -->
<n-divider title-placement="left">分类与标签</n-divider>
<n-form-item label="分类">
<n-select
v-model:value="form.categories"
:options="categoryOptions"
multiple
filterable
placeholder="选择分类(可多选)"
:loading="loadingTaxonomy"
/>
</n-form-item>
<n-form-item label="标签">
<n-select
v-model:value="form.tags"
:options="tagOptions"
multiple
filterable
tag
placeholder="输入标签后回车"
:loading="loadingTaxonomy"
/>
</n-form-item>
<!-- 发货与售后 -->
<n-divider title-placement="left">发货与售后</n-divider>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="发货方式" path="shipping_mode">
<n-select v-model:value="form.shipping_mode" :options="shippingModeOptions" placeholder="选择发货方式" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="额外运费 (元)" path="shipping_fee">
<n-input-number v-model:value="form.shipping_fee" :min="0" :precision="2" style="width:100%;" placeholder="0.00" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="售后说明" path="after_sale_policy">
<n-input v-model:value="form.after_sale_policy" type="textarea" :rows="3" placeholder="售后政策说明(可选)" />
</n-form-item>
<n-form-item label="支持退款">
<n-switch v-model:value="form.support_refund" />
</n-form-item>
<!-- 发布设置 -->
<n-divider title-placement="left">发布设置</n-divider>
<n-form-item label="状态" path="status">
<n-select v-model:value="form.status" :options="statusOptions" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? '保存修改' : '创建商品' }}
</n-button>
</n-space>
</template>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
productId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'saved'])
const message = useMessage()
const formRef = ref(null)
const saving = ref(false)
const loadingTaxonomy = ref(false)
const isEdit = computed(() => !!props.productId)
const form = reactive({
title: '',
price: 0,
stock: 0,
excerpt: '',
content: '',
status: 'publish',
image: '',
galleryText: '',
categories: [],
tags: [],
shipping_mode: 'digital',
shipping_fee: 0,
after_sale_policy: '',
support_refund: true,
})
const rules = {
title: { required: true, message: '请输入商品名称', trigger: 'blur' },
price: { type: 'number', required: true, message: '请输入价格', trigger: 'blur' },
}
const statusOptions = [
{ label: '上架', value: 'publish' },
{ label: '下架', value: 'draft' },
]
const shippingModeOptions = [
{ label: '自动发货(虚拟商品)', value: 'digital' },
{ label: '手动发货', value: 'manual' },
{ label: '快递发货', value: 'express' },
]
const categoryOptions = ref([])
const tagOptions = ref([])
// Load taxonomy options
async function loadTaxonomy() {
loadingTaxonomy.value = true
try {
const res = await api.request('/shop/products/taxonomy')
if (res?.data) {
categoryOptions.value = (res.data.categories || []).map(c => ({ label: c.name, value: c.id }))
tagOptions.value = (res.data.tags || []).map(t => ({ label: t.name, value: t.name }))
}
} catch {
// Taxonomy endpoint may not exist yet — silent fallback
} finally {
loadingTaxonomy.value = false
}
}
// Load product data when editing
watch(() => props.show, async (val) => {
if (val) {
loadTaxonomy()
if (props.productId) {
try {
const res = await api.request(`/shop/products/${props.productId}`)
if (res?.data) {
const gallery = Array.isArray(res.data.gallery) ? res.data.gallery : []
Object.assign(form, {
title: res.data.title || '',
price: res.data.price || 0,
stock: res.data.stock || 0,
excerpt: res.data.excerpt || '',
content: res.data.content || '',
status: res.data.status || 'publish',
image: res.data.image || '',
galleryText: gallery.join('\n'),
categories: Array.isArray(res.data.categories) ? res.data.categories : [],
tags: Array.isArray(res.data.tags) ? res.data.tags : [],
shipping_mode: res.data.shipping_mode || 'digital',
shipping_fee: res.data.shipping_fee || 0,
after_sale_policy: res.data.after_sale_policy || '',
support_refund: res.data.support_refund !== false,
})
}
} catch {
message.error('加载商品失败')
}
} else {
// Reset form for create
Object.assign(form, {
title: '', price: 0, stock: 0, excerpt: '', content: '', status: 'publish',
image: '', galleryText: '', categories: [], tags: [],
shipping_mode: 'digital', shipping_fee: 0, after_sale_policy: '', support_refund: true,
})
}
}
})
async function handleSave() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload = {
...form,
gallery: form.galleryText
.split('\n')
.map(s => s.trim())
.filter(Boolean),
}
delete payload.galleryText
if (isEdit.value) {
await api.request(`/shop/products/${props.productId}`, {
method: 'POST',
body: JSON.stringify(payload),
})
message.success('商品已更新')
} else {
await api.request('/shop/products', {
method: 'POST',
body: JSON.stringify(payload),
})
message.success('商品已创建')
}
emit('update:show', false)
emit('saved')
} catch (err) {
message.error(err?.message || '操作失败')
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,94 @@
<template>
<n-modal :show="show" preset="dialog" title="批量更新物流状态" @update:show="$emit('update:show', $event)">
<div class="aksrt-shipping-batch__body">
<div class="aksrt-shipping-batch__summary">已选 {{ ids.length }} 条记录</div>
<n-form label-placement="top">
<n-form-item label="目标状态">
<n-select v-model:value="form.status" :options="statusOptions" placeholder="选择状态" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="form.note" type="textarea" :rows="3" placeholder="批量操作备注" />
</n-form-item>
</n-form>
</div>
<template #action>
<n-space justify="end">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="submitting" @click="handleSubmit">提交</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { reactive, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
ids: { type: Array, default: () => [] },
statusOptions: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const form = reactive({
status: null,
note: '',
})
const submitting = reactive({ value: false })
watch(() => props.show, (visible) => {
if (visible) {
form.status = null
form.note = ''
}
})
async function handleSubmit() {
if (!form.status || !props.ids.length) {
message.warning('请选择目标状态并至少勾选一条记录')
return
}
submitting.value = true
try {
const res = await api.request('/shop/shipping/batch-update-status', {
method: 'POST',
data: {
ids: props.ids,
status: form.status,
note: form.note,
},
})
const updatedIds = Array.isArray(res?.data?.updated_ids) ? res.data.updated_ids : []
const failed = Array.isArray(res?.data?.failed) ? res.data.failed : []
if (failed.length) {
message.warning(`已更新 ${updatedIds.length} 条,另有 ${failed.length} 条因状态限制未执行`)
} else {
message.success(`已批量更新 ${updatedIds.length} 条记录`)
}
emit('update:show', false)
emit('updated')
} catch (error) {
message.error(error?.message || '批量操作失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-shipping-batch__body {
padding-top: 8px;
}
.aksrt-shipping-batch__summary {
margin-bottom: 12px;
color: #475569;
font-size: 13px;
}
</style>
@@ -0,0 +1,237 @@
<template>
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
<n-drawer-content title="物流详情" closable>
<n-spin :show="loading">
<!-- 物流状态头 -->
<div class="aksrt-shipping__status-header" :class="'aksrt-shipping__status-header--' + (detail.record?.status || 'default')">
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
<span class="aksrt-shipping__order-no">订单号:{{ detail.record?.order_number || '-' }}</span>
</div>
<!-- 物流信息 -->
<n-descriptions bordered :column="2" size="small" label-placement="left" style="margin-top:16px;">
<n-descriptions-item label="物流公司">{{ detail.record?.carrier_label || detail.record?.carrier || '-' }}</n-descriptions-item>
<n-descriptions-item label="物流单号">
<n-text code>{{ detail.record?.tracking_no || '-' }}</n-text>
<n-button v-if="detail.record?.tracking_no" text size="small" @click="copyTrackingNo" style="margin-left:8px;">
<i class="fas fa-copy" />
</n-button>
</n-descriptions-item>
<n-descriptions-item label="发货时间">{{ detail.record?.shipped_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="处理人">{{ detail.record?.operator?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="发货类型">
<n-tag size="small" :type="detail.record?.ship_type === 'express' ? 'info' : 'default'">
{{ detail.record?.ship_type_label || '快递发货' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="备注">{{ detail.record?.note || '-' }}</n-descriptions-item>
</n-descriptions>
<!-- 收货地址 -->
<n-card v-if="detail.record?.order_context?.address" size="small" title="收货地址" :bordered="false" style="margin-top:16px;">
<n-descriptions :column="1" size="small" label-placement="left">
<n-descriptions-item label="收货人">{{ detail.record.order_context.address.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="电话">{{ detail.record.order_context.address.phone || '-' }}</n-descriptions-item>
<n-descriptions-item label="地址">{{ detail.record.order_context.address.full_address || detail.record.order_context.address.full_text || '-' }}</n-descriptions-item>
</n-descriptions>
<n-button size="small" @click="copyAddress" style="margin-top:8px;">
<i class="fas fa-copy" style="margin-right:4px;" /> 复制地址
</n-button>
</n-card>
<!-- 物流追踪 -->
<n-card v-if="detail.tracking_detail?.length" size="small" title="物流追踪" :bordered="false" style="margin-top:16px;">
<n-timeline>
<n-timeline-item
v-for="(item, i) in detail.tracking_detail"
:key="i"
:title="item.description || item.status"
:content="item.time"
:type="i === 0 ? 'success' : 'default'"
/>
</n-timeline>
</n-card>
<!-- 状态更新 -->
<n-card size="small" title="状态更新" :bordered="false" style="margin-top:16px;">
<n-space vertical>
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="状态变更备注" />
<n-space>
<n-select v-model:value="nextStatus" :options="actionOptions" placeholder="选择状态" style="width:160px;" />
<n-button type="primary" :disabled="!nextStatus" :loading="updating" @click="handleStatusUpdate">更新状态</n-button>
</n-space>
</n-space>
</n-card>
<!-- 操作时间线 -->
<div v-if="detail.timeline?.length" class="aksrt-shipping__timeline">
<div class="aksrt-shipping__section-title">操作时间线</div>
<n-timeline size="small">
<n-timeline-item
v-for="(item, index) in detail.timeline"
:key="index"
:time="item.time"
:title="item.label"
:content="item.detail || ''"
/>
</n-timeline>
</div>
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
shippingId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const updating = ref(false)
const detail = ref({ record: null, timeline: [], tracking_detail: [] })
const nextStatus = ref(null)
const note = ref('')
const statusMap = {
pending: { label: '待发货', type: 'warning' },
shipped: { label: '已发货', type: 'info' },
in_transit: { label: '运输中', type: 'info' },
delivered: { label: '已签收', type: 'success' },
returned: { label: '已退回', type: 'warning' },
lost: { label: '已丢失', type: 'error' },
}
const statusLabel = computed(() => {
const s = detail.value?.record?.status
return (statusMap[s] || { label: s || '-' }).label
})
const statusType = computed(() => {
const s = detail.value?.record?.status
return (statusMap[s] || { type: 'default' }).type
})
const actionOptions = computed(() => {
const status = detail.value?.record?.status
if (status === 'shipped' || status === 'pending') {
return [
{ label: '运输中', value: 'in_transit' },
{ label: '已签收', value: 'delivered' },
{ label: '已退回', value: 'returned' },
{ label: '已丢失', value: 'lost' },
]
}
if (status === 'in_transit') {
return [
{ label: '已签收', value: 'delivered' },
{ label: '已退回', value: 'returned' },
{ label: '已丢失', value: 'lost' },
]
}
return []
})
watch(() => props.show, async (visible) => {
if (visible && props.shippingId) {
await loadDetail()
} else if (!visible) {
detail.value = { record: null, timeline: [], tracking_detail: [] }
nextStatus.value = null
note.value = ''
}
})
async function loadDetail() {
loading.value = true
try {
const res = await api.request(`/shop/shipping/${props.shippingId}`)
detail.value = res?.data || { record: null, timeline: [], tracking_detail: [] }
} catch (error) {
message.error(error?.message || '加载物流详情失败')
} finally {
loading.value = false
}
}
async function handleStatusUpdate() {
if (!nextStatus.value) return
updating.value = true
try {
await api.request(`/shop/shipping/${props.shippingId}/update-status`, {
method: 'POST',
data: { status: nextStatus.value, note: note.value },
})
message.success('物流状态已更新')
nextStatus.value = null
note.value = ''
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '状态更新失败')
} finally {
updating.value = false
}
}
async function copyTrackingNo() {
const text = detail.value?.record?.tracking_no || ''
if (!text) return
try {
await navigator.clipboard.writeText(text)
message.success('物流单号已复制')
} catch {
message.warning('复制失败')
}
}
async function copyAddress() {
const addr = detail.value?.record?.order_context?.address
const text = addr?.full_address || addr?.full_text || ''
if (!text) return
try {
await navigator.clipboard.writeText(text)
message.success('地址已复制')
} catch {
message.warning('复制失败')
}
}
</script>
<style scoped>
.aksrt-shipping__status-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: var(--surface-2);
}
.aksrt-shipping__status-header--shipped { background: #e6f7ff; }
.aksrt-shipping__status-header--in_transit { background: #e6f7ff; }
.aksrt-shipping__status-header--delivered { background: #f6ffed; }
.aksrt-shipping__status-header--returned { background: #fff7e6; }
.aksrt-shipping__status-header--lost { background: #fff1f0; }
.aksrt-shipping__order-no {
font-size: 13px;
color: var(--color-ink-3);
}
.aksrt-shipping__timeline {
margin-top: 18px;
}
.aksrt-shipping__section-title {
margin-bottom: 8px;
font-weight: 600;
color: var(--color-ink);
}
</style>
@@ -0,0 +1,68 @@
<template>
<n-modal :show="show" @update:show="$emit('update:show', $event)" preset="card" :title="modalTitle" style="width:480px;" closable>
<n-form label-placement="top">
<n-form-item label="申请人">
<n-input :value="withdrawal.user_name || '-'" disabled />
</n-form-item>
<n-form-item label="提现金额">
<n-input :value="'¥' + (withdrawal.amount || 0).toFixed(2)" disabled />
</n-form-item>
<n-form-item label="提现方式">
<n-input :value="withdrawal.method || '-'" disabled />
</n-form-item>
<n-form-item label="收款账号">
<n-input :value="withdrawal.account || withdrawal.account_info || '-'" disabled />
</n-form-item>
<n-form-item label="审核备注">
<n-input v-model:value="note" type="textarea" :rows="3" placeholder="填写审核说明(可选)" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button type="error" :loading="loading" @click="handleAction('rejected')">拒绝</n-button>
<n-button type="success" :loading="loading" @click="handleAction('approved')">批准</n-button>
<n-button type="info" :loading="loading" @click="handleAction('completed')">打款</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/index.js'
const props = defineProps({
show: { type: Boolean, default: false },
withdrawal: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const note = ref('')
const modalTitle = computed(() => {
const id = props.withdrawal?.id
return id ? `审核提现 #${id}` : '审核提现'
})
async function handleAction(status) {
loading.value = true
try {
await api.request(`/payment/withdrawals/${props.withdrawal.id}`, {
method: 'POST',
body: JSON.stringify({ status, note: note.value }),
})
message.success('操作成功')
emit('update:show', false)
emit('updated')
} catch {
message.error('操作失败')
} finally {
loading.value = false
}
}
</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,274 @@
<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" size="small">{{ info.settingsMigrationVersion || '未迁移' }}</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>
<router-link to="/tools/migration" class="aksrt-theme-info__link">
<FaIcon icon="fas fa-database" :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: '',
settingsMigrationVersion: '',
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 || '',
settingsMigrationVersion: data.settings_migration_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,279 @@
<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/migration')">迁移状态</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,191 @@
<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-button quaternary @click="go('/tools/migration')">迁移状态</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,111 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="迁移状态"
desc="查看数据库 schema 版本、设置迁移版本和迁移日志,方便发布前确认历史兼容层状态。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/tools/health')">健康检查</n-button>
<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>
<n-tag round type="info">DB Schema:{{ status.schema_version || '0.0.0' }}</n-tag>
<n-tag round type="info">Settings Migration:{{ status.migration_version || '0.0.0' }}</n-tag>
<n-tag round :type="logItems.length ? 'success' : 'default'">迁移日志:{{ logItems.length }} 条</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
迁移说明文档位于 `docs/migration-map.md`。如果这里的 schema/version 与文档记录不一致,发布前应先补齐说明。
</n-alert>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>迁移日志</h3>
</div>
<div v-if="logItems.length" class="aksrt-migration-log">
<div v-for="(item, index) in logItems" :key="index" class="aksrt-migration-log__item">
<pre>{{ stringifyItem(item) }}</pre>
</div>
</div>
<n-empty v-else description="暂无迁移日志" />
</n-card>
</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 status = ref({})
const logItems = computed(() => {
return Array.isArray(status.value.migration_log) ? status.value.migration_log : []
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
function stringifyItem(item) {
if (typeof item === 'string') {
return item
}
return JSON.stringify(item, null, 2)
}
async function load() {
loading.value = true
try {
status.value = extractData(await api.getMigrationStatus())
} catch (error) {
message.error(error.message || '加载迁移状态失败')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-migration-log {
display: grid;
gap: 12px;
}
.aksrt-migration-log__item pre {
margin: 0;
padding: 12px 14px;
border-radius: 12px;
background: #101828;
color: #d0d7e2;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,201 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="更新中心"
desc="查看版本状态、检测更新并浏览更新日志与教程入口。"
>
<template #actions>
<n-space>
<n-button quaternary :loading="checking" @click="handleCheck">检测更新</n-button>
<n-button quaternary @click="go('/docs')">文档中心</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 220px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>版本状态</h3>
</div>
<n-space>
<n-tag round type="info">当前版本:{{ updateInfo.current_version || '0.0.0' }}</n-tag>
<n-tag round :type="updateInfo.update_available ? 'warning' : 'success'">
{{ updateInfo.update_available ? `发现新版本 ${updateInfo.remote_version}` : '已是最新版本' }}
</n-tag>
<n-tag round type="default">上次检测:{{ formatDate(updateInfo.last_check) }}</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card" v-if="updateInfo.update_available">
<div class="aksrt-section-header">
<h3>可用更新</h3>
</div>
<n-alert type="warning" style="margin-bottom: 12px;">
检测到新版本 {{ updateInfo.remote_version }},当前版本为 {{ updateInfo.current_version }}。
</n-alert>
<n-space>
<n-button v-if="updateInfo.download_url" type="primary" tag="a" :href="updateInfo.download_url" target="_blank">
下载更新
</n-button>
<n-button @click="handleDismiss" :loading="dismissing">忽略此版本</n-button>
</n-space>
<div v-if="updateInfo.changelog" class="aksrt-update__changelog">
<h4>更新日志</h4>
<pre>{{ updateInfo.changelog }}</pre>
</div>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>常用教程入口</h3>
</div>
<div class="aksrt-update__grid">
<a v-for="item in guides" :key="item.key" class="aksrt-update__guide" :href="item.url" target="_blank" rel="noreferrer">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</a>
</div>
</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 checking = ref(false)
const dismissing = ref(false)
const updateInfo = ref({
current_version: '0.0.0',
remote_version: '',
update_available: false,
download_url: '',
changelog: '',
last_check: 0,
})
const guides = [
{ key: 'rewrite', title: '伪静态配置', desc: '检查固定链接与重写规则', url: '/wp-admin/options-permalink.php' },
{ key: 'docs-mode', title: '文档模式', desc: '配置文档分类与专题模板', url: '#/extensions/docs-mode' },
{ key: 'oauth', title: '第三方登录', desc: '检查 GitHub / Gitee / QQ / Google 登录', url: '#/users/oauth' },
{ key: 'backup', title: '设置快照', desc: '创建与恢复站点配置快照', url: '#/tools/backup' },
{ key: 'migration', title: '迁移状态', desc: '确认 schema 与设置迁移版本', url: '#/tools/migration' },
{ key: 'health', title: '健康检查', desc: '查看 PHP / WP / 数据表状态', url: '#/tools/health' },
{ key: 'meilisearch', title: 'Meilisearch', desc: '配置搜索索引与同步策略', url: '#/global/search' },
{ key: 'i18n', title: '多语言翻译', desc: '检查语言切换与术语配置', url: '#/global/i18n' },
{ key: 'cdn', title: '静态资源与 CDN', desc: '核对前端资源地址与缓存策略', url: '#/global/common' },
{ key: 'forum', title: '论坛体系', desc: '检查版块、话题与版主管理', url: '#/forum' },
{ key: 'ip-geo', title: 'IP 归属地', desc: '验证 IP 查询服务与密钥配置', url: '#/extensions/security' },
{ key: 'payment', title: '支付与会员', desc: '核对网关、会员与充值配置', url: '#/payment/gateway' },
{ key: 'credits', title: '积分与签到', desc: '检查积分规则、签到与邀请奖励', url: '#/users/signin' },
{ key: 'badge', title: '等级与徽章', desc: '维护等级、认证与徽章体系', url: '#/users/badge' },
]
onMounted(load)
function go(path) {
router.push(path)
}
function formatDate(value) {
if (!value) return '未检测'
const timestamp = Number(value)
if (!Number.isNaN(timestamp) && timestamp > 0) {
return new Date(timestamp * 1000).toLocaleString()
}
return String(value)
}
async function load() {
loading.value = true
try {
const res = await api.getUpdateInfo()
updateInfo.value = res?.data || {}
} catch (error) {
message.error(error?.message || '加载更新状态失败')
} finally {
loading.value = false
}
}
async function handleCheck() {
checking.value = true
try {
const res = await api.detectUpdate()
updateInfo.value = res?.data || {}
message.success('更新检测完成')
} catch (error) {
message.error(error?.message || '检测更新失败')
} finally {
checking.value = false
}
}
async function handleDismiss() {
if (!updateInfo.value.remote_version) return
dismissing.value = true
try {
await api.dismissUpdate(updateInfo.value.remote_version)
message.success('已忽略此版本')
await load()
} catch (error) {
message.error(error?.message || '忽略版本失败')
} finally {
dismissing.value = false
}
}
</script>
<style scoped>
.aksrt-update__changelog {
margin-top: 16px;
}
.aksrt-update__changelog pre {
margin: 8px 0 0;
padding: 12px 14px;
border-radius: 12px;
background: #101828;
color: #d0d7e2;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.aksrt-update__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.aksrt-update__guide {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
border: 1px solid #eef1f6;
border-radius: 12px;
text-decoration: none;
color: inherit;
background: #fafafa;
}
.aksrt-update__guide:hover {
border-color: #1890ff;
background: #f5faff;
}
.aksrt-update__guide span {
font-size: 12px;
color: #667085;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户徽章"
desc="维护公开徽章字典、展示数量和前台展示位。具体授予逻辑可以后续再接业务,但字段和展示契约已经固定。"
>
<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_badge_enabled ? 'success' : 'default'">
徽章系统:{{ form.user_badge_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_badge_public_display ? 'info' : 'default'">
公开展示:{{ form.user_badge_public_display ? '开启' : '关闭' }}
</n-tag>
<n-tag round type="info">
最多显示:{{ form.user_badge_limit }} 枚
</n-tag>
<n-tag round :type="notificationSummary.dynamicBadge ? 'success' : 'default'">
通知红点联动:{{ notificationSummary.dynamicBadge ? '启用' : '关闭' }}
</n-tag>
<n-tag round type="info">
字典条目:{{ badgeCount }}
</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_badge_enabled" />
</n-form-item>
<n-form-item label="公开页显示徽章" v-if="form.user_badge_enabled">
<n-switch v-model:value="form.user_badge_public_display" />
</n-form-item>
<n-form-item label="最多显示数量" v-if="form.user_badge_enabled">
<n-input-number v-model:value="form.user_badge_limit" :min="1" :max="8" style="width: 180px;" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>徽章字典</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="徽章列表">
<n-input
v-model:value="form.user_badge_dictionary"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="founder|创始成员|warning&#10;staff|站务团队|info"
:disabled="!form.user_badge_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个徽章,格式 `slug|显示文案|色调`,色调支持 `neutral / info / success / warning / brand`。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
前台从 `aksrt_user_badges` user meta 读取已授予徽章列表,并在公开主页和本人中心头部展示。这里只负责定义合法徽章字典和展示上限。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
user_badge_enabled: false,
user_badge_public_display: true,
user_badge_limit: 3,
user_badge_dictionary: 'founder|创始成员|warning\nstaff|站务团队|info',
})
const notificationSummary = reactive({
dynamicBadge: false,
})
const badgeCount = computed(() => {
return String(form.user_badge_dictionary || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [schemaRes, notificationRes] = await Promise.all([
api.getSchemaPage('users', 'badge'),
api.getNotificationSettings(),
])
const schemaData = extractData(schemaRes)
const notificationData = extractData(notificationRes)
form.user_badge_enabled = !!schemaData.user_badge_enabled
form.user_badge_public_display = schemaData.user_badge_public_display !== false
form.user_badge_limit = Number(schemaData.user_badge_limit || 3)
form.user_badge_dictionary = schemaData.user_badge_dictionary || 'founder|创始成员|warning\nstaff|站务团队|info'
notificationSummary.dynamicBadge = !!notificationData.user_badge_enabled
} catch (error) {
message.error(error.message || '加载用户徽章配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'badge', {
user_badge_enabled: form.user_badge_enabled,
user_badge_public_display: form.user_badge_public_display,
user_badge_limit: Number(form.user_badge_limit || 3),
user_badge_dictionary: form.user_badge_dictionary,
})
message.success('用户徽章配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
+210
View File
@@ -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,195 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户功能"
desc="统一配置用户中心和公开主页的功能模块,让前台页面和后台字段使用同一份契约。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/page-display/user-home')">公开主页</n-button>
<n-button quaternary @click="go('/permissions/attachment')">附件权限</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动摘要</h3>
</div>
<n-space>
<n-tag type="info" round>固定入口:个人信息 / 账号设置 / 消息通知</n-tag>
<n-tag round :type="form.user_cover_upload ? 'success' : 'default'">
背景图编辑位:{{ form.user_cover_upload ? '已展示' : '已隐藏' }}
</n-tag>
<n-tag round :type="backgroundUploadAllowed ? 'success' : 'warning'">
背景图上传权限:{{ backgroundUploadAllowed ? '已开放' : '未开放' }}
</n-tag>
<n-tag round :type="homeSummary.sidebar ? 'success' : 'default'">
公开主页侧栏:{{ homeSummary.sidebar ? '开启' : '关闭' }}
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>用户中心面板</h3>
</div>
<n-form label-placement="left" label-width="170" class="aksrt-form">
<n-form-item label="固定面板">
<n-space>
<n-tag round>个人信息</n-tag>
<n-tag round>账号设置</n-tag>
<n-tag round>消息通知</n-tag>
</n-space>
</n-form-item>
<n-form-item label="可切换业务面板">
<n-checkbox-group v-model:value="form.dynamicTabs">
<n-space vertical>
<n-checkbox value="posts">我的文章</n-checkbox>
<n-checkbox value="comments">我的评论</n-checkbox>
<n-checkbox value="favorites">我的收藏</n-checkbox>
<n-checkbox value="credits">账户余额 / 签到</n-checkbox>
</n-space>
</n-checkbox-group>
<template #feedback>
<span class="aksrt-hint-text">“个人信息”始终保留在首位;积分面板仅在积分系统启用时显示。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>公开资料编辑位</h3>
</div>
<n-form :model="form" label-placement="left" label-width="170" class="aksrt-form">
<n-form-item label="头像编辑入口">
<n-switch v-model:value="form.user_avatar_upload" />
<template #feedback>
<span class="aksrt-hint-text">开启后,用户中心会显示跳转到 WordPress 原生资料页的头像修改入口。</span>
</template>
</n-form-item>
<n-form-item label="背景图编辑位">
<n-switch v-model:value="form.user_cover_upload" />
<template #feedback>
<span class="aksrt-hint-text">这里只控制前台入口显示;真正的上传权限仍由“附件权限”页治理。</span>
</template>
</n-form-item>
<n-form-item label="公开社交链接">
<n-switch v-model:value="form.user_social_links" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,用户中心联系方式编辑区和公开主页联系卡片会一起隐藏。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
公开主页的封面、侧栏、关注按钮和公开 Tab 仍在“页面&显示 / 用户主页”维护;本页负责“用户中心能否编辑哪些内容”。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const backgroundUploadAllowed = ref(false)
const homeSummary = reactive({
tabs: [],
sidebar: true,
})
const form = reactive({
dynamicTabs: ['posts', 'comments', 'favorites'],
user_avatar_upload: true,
user_cover_upload: true,
user_social_links: true,
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
function parseTabs(value) {
const items = Array.isArray(value)
? value
: String(value || '')
.split(/[\s,]+/)
.filter(Boolean)
return items
.map((item) => String(item).trim())
.filter((item) => ['posts', 'comments', 'favorites', 'credits'].includes(item))
}
async function load() {
loading.value = true
try {
const [featureRes, homeRes, permissionRes] = await Promise.all([
api.getSchemaPage('users', 'features'),
api.getSchemaPage('page_display', 'user-home'),
api.getPermissionsAttachmentSettings(),
])
const featureData = extractData(featureRes)
const homeData = extractData(homeRes)
const permissionData = extractData(permissionRes)
form.dynamicTabs = parseTabs(featureData.user_center_tabs || 'profile,posts,comments,favorites')
form.user_avatar_upload = featureData.user_avatar_upload !== false
form.user_cover_upload = featureData.user_cover_upload !== false
form.user_social_links = featureData.user_social_links !== false
homeSummary.tabs = parseTabs(homeData.user_home_tabs || 'posts,comments,favorites')
homeSummary.sidebar = homeData.user_home_sidebar !== false
backgroundUploadAllowed.value = !!permissionData.allow_background_upload
} catch (error) {
message.error(error.message || '加载用户功能配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'features', {
user_center_tabs: ['profile', ...form.dynamicTabs].join(','),
user_avatar_upload: form.user_avatar_upload,
user_cover_upload: form.user_cover_upload,
user_social_links: form.user_social_links,
})
message.success('用户功能配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,512 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="邀请码运营" desc="统一管理用户邀请码与独立邀请码,支持批量生成、批量禁用、备注追踪和筛选导出。" />
<div class="aksrt-invite-ops__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-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-card title="批量生成邀请码" size="small">
<n-form :model="generateForm" label-placement="top">
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="生成数量">
<n-input-number v-model:value="generateForm.count" :min="1" :max="100" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="随机长度">
<n-input-number v-model:value="generateForm.length" :min="4" :max="12" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="前缀">
<n-input v-model:value="generateForm.prefix" placeholder="AK" />
</n-form-item>
<n-form-item label="批次备注">
<n-input
v-model:value="generateForm.note"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="例如:618 拉新活动 / 渠道合作批次"
/>
</n-form-item>
<n-space>
<n-button type="primary" :loading="generating" @click="handleGenerate">批量生成</n-button>
<n-button @click="loadData">刷新列表</n-button>
</n-space>
</n-form>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="批次输出" size="small">
<n-alert type="info" :show-icon="false">
独立邀请码会接入注册校验链路;导出前建议补齐批次备注,便于渠道回溯。
</n-alert>
<div v-if="generatedCodes.length" class="aksrt-invite-ops__generated">
<n-tag type="success" round :bordered="false">批次 {{ generatedBatchId || '-' }}</n-tag>
<n-input
type="textarea"
:value="generatedCodes.join('\n')"
readonly
:autosize="{ minRows: 7, maxRows: 10 }"
/>
</div>
<n-empty v-else description="生成后的邀请码会显示在这里" />
</n-card>
</n-grid-item>
</n-grid>
<n-card title="邀请码台账" size="small" class="aksrt-invite-ops__table-card">
<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" placeholder="搜索邀请码、批次、用户、备注" clearable />
</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.source" :options="sourceOptions" 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>
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('disable')">批量禁用</n-button>
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('delete')">批量删除</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.row_key"
:checked-row-keys="checkedRowKeys"
@update:checked-row-keys="handleCheckedRowKeysChange"
/>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const generating = ref(false)
const rows = ref([])
const checkedRowKeys = ref([])
const generatedCodes = ref([])
const generatedBatchId = ref('')
const stats = reactive({
total_codes: 0,
active_codes: 0,
used_codes: 0,
disabled_codes: 0,
user_codes: 0,
standalone_codes: 0,
total_invites: 0,
total_rewarded: 0,
})
const generateForm = reactive({
count: 20,
length: 6,
prefix: 'AK',
note: '',
})
const filters = reactive({
keyword: '',
status: '',
source: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
sources: [],
})
const statCards = [
{ key: 'total_codes', label: '总邀请码' },
{ key: 'active_codes', label: '当前可用' },
{ key: 'used_codes', label: '已使用' },
{ key: 'disabled_codes', label: '已禁用' },
{ key: 'standalone_codes', label: '独立邀请码' },
{ key: 'total_rewarded', label: '已完成兑现' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '可用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已禁用', value: 'disabled' },
])
const sourceOptions = computed(() => optionsState.sources.length
? optionsState.sources
: [
{ label: '全部来源', value: '' },
{ label: '用户邀请码', value: 'user' },
{ label: '独立邀请码', value: 'standalone' },
])
const checkedRows = computed(() => rows.value.filter((row) => checkedRowKeys.value.includes(row.row_key)))
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 = [
{
type: 'selection',
disabled: (row) => !row.can_bulk,
},
{
title: '来源',
key: 'source_type',
width: 120,
render: (row) => h(NTag, { size: 'small', round: true, type: row.source_type === 'standalone' ? 'info' : 'default' }, () => row.source_label),
},
{
title: '邀请码',
key: 'invite_code',
width: 180,
ellipsis: { tooltip: true },
},
{
title: '归属/使用人',
key: 'owner',
width: 180,
render: (row) => row.user?.display_name || row.invitee?.display_name || '-',
},
{
title: '状态',
key: 'status_label',
width: 110,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '注册状态',
key: 'registration_status',
width: 110,
render: (row) => row.registration_status || '-',
},
{
title: '批次备注',
key: 'note',
minWidth: 180,
ellipsis: { tooltip: true },
render: (row) => row.note || row.batch_id || '-',
},
{
title: '创建时间',
key: 'created_at',
width: 170,
},
{
title: '使用时间',
key: 'used_at',
width: 170,
render: (row) => row.used_at || '-',
},
{
title: '操作',
key: 'actions',
width: 170,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
quaternary: true,
type: 'warning',
onClick: () => handleDisable(row),
}, () => '禁用'),
]
if (row.source_type === 'standalone') {
actions.push(
h(NButton, {
size: 'tiny',
quaternary: true,
type: 'error',
onClick: () => handleDelete(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')
return `${year}-${month}-${day}`
}
function buildQuery(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 (filters.source) params.source = filters.source
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
params.date_from = formatDateValue(filters.dateRange[0])
params.date_to = formatDateValue(filters.dateRange[1])
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/invite/codes', { params: buildQuery() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
optionsState.sources = data.options?.sources || []
checkedRowKeys.value = []
} catch (error) {
message.error(error?.message || '加载邀请码台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.source = ''
filters.dateRange = null
pager.page = 1
loadData()
}
function handleCheckedRowKeysChange(keys) {
checkedRowKeys.value = keys
}
async function handleGenerate() {
generating.value = true
try {
const res = await api.request('/invite/codes', {
method: 'POST',
data: {
count: generateForm.count,
length: generateForm.length,
prefix: generateForm.prefix,
note: generateForm.note,
},
})
generatedCodes.value = res?.data?.codes || []
generatedBatchId.value = res?.data?.batch_id || ''
message.success(res?.message || '邀请码已生成')
await loadData()
} catch (error) {
message.error(error?.message || '生成失败')
} finally {
generating.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用邀请码 ${row.invite_code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
if (row.source_type === 'user') {
await api.request(`/invite/codes/${row.user?.id}/disable`, { method: 'POST' })
} else {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action: 'disable',
targets: [{ source_type: 'standalone', code: row.invite_code }],
},
})
}
message.success('邀请码已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '操作失败')
}
},
})
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定要删除独立邀请码 ${row.invite_code} 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action: 'delete',
targets: [{ source_type: 'standalone', code: row.invite_code }],
},
})
message.success('邀请码已删除')
await loadData()
} catch (error) {
message.error(error?.message || '删除失败')
}
},
})
}
async function handleBulkAction(action) {
if (!checkedRows.value.length) {
return
}
try {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action,
targets: checkedRows.value.map((row) => ({
source_type: row.source_type,
user_id: row.user?.id || 0,
code: row.invite_code,
})),
},
})
message.success(action === 'delete' ? '批量删除完成' : '批量禁用完成')
await loadData()
} catch (error) {
message.error(error?.message || '批量操作失败')
}
}
async function handleExport() {
try {
const res = await api.request('/invite/codes', { params: buildQuery(1, 200) })
const items = res?.data?.items || []
const header = ['来源', '邀请码', '归属用户', '使用人', '状态', '注册状态', '批次', '备注', '创建时间', '使用时间']
const lines = items.map((row) => [
row.source_label || '',
row.invite_code || '',
row.user?.display_name || '',
row.invitee?.display_name || '',
row.status_label || '',
row.registration_status || '',
row.batch_id || '',
row.note || '',
row.created_at || '',
row.used_at || '',
])
downloadCsv('invite-codes.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-invite-ops__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-invite-ops__generated {
display: grid;
gap: 12px;
margin-top: 16px;
}
.aksrt-invite-ops__table-card {
margin-top: 16px;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户等级"
desc="配置等级来源、展示前缀和阈值规则。前台公开主页会按照这里的规则输出等级身份芯片。"
>
<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_enabled ? 'success' : 'default'">
等级系统:{{ form.user_level_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="creditsSummary.enabled ? 'info' : 'warning'">
积分系统:{{ creditsSummary.enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="info">
当前货币:{{ creditsSummary.currencyName }}
</n-tag>
<n-tag round :type="form.user_level_source === 'credits_balance' ? 'success' : 'warning'">
来源:{{ form.user_level_source === 'credits_balance' ? '积分余额' : '手动 Meta' }}
</n-tag>
<n-tag round type="info">
阈值条目:{{ thresholdCount }}
</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_enabled" />
</n-form-item>
<n-form-item label="公开页显示等级" v-if="form.user_level_enabled">
<n-switch v-model:value="form.user_level_public_display" />
</n-form-item>
<n-form-item label="等级前缀" v-if="form.user_level_enabled">
<n-input v-model:value="form.user_level_prefix" maxlength="12" placeholder="Lv." />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>等级规则</h3>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="等级来源">
<n-radio-group v-model:value="form.user_level_source" :disabled="!form.user_level_enabled">
<n-space size="large">
<n-radio value="credits_balance">积分余额</n-radio>
<n-radio value="manual_meta">手动 Meta</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="等级阈值">
<n-input
v-model:value="form.user_level_thresholds"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="1|0&#10;2|30&#10;3|100&#10;4|300"
:disabled="!form.user_level_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个等级,格式 `等级|所需积分`。手动 Meta 模式下仍建议保留完整字典,方便后续切换回自动计算。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
手动模式读取 `aksrt_user_level` user meta;自动模式则根据当前积分余额和上方阈值表实时计算等级,不需要单独写库。
</n-alert>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
user_level_enabled: false,
user_level_public_display: true,
user_level_source: 'credits_balance',
user_level_prefix: 'Lv.',
user_level_thresholds: '1|0\n2|30\n3|100\n4|300',
})
const creditsSummary = reactive({
enabled: true,
currencyName: '积分',
})
const thresholdCount = computed(() => {
return String(form.user_level_thresholds || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [schemaRes, creditRes] = await Promise.all([
api.getSchemaPage('users', 'level'),
api.getCreditSettings(),
])
const schemaData = extractData(schemaRes)
const creditData = extractData(creditRes)
form.user_level_enabled = !!schemaData.user_level_enabled
form.user_level_public_display = schemaData.user_level_public_display !== false
form.user_level_source = schemaData.user_level_source || 'credits_balance'
form.user_level_prefix = schemaData.user_level_prefix || 'Lv.'
form.user_level_thresholds = schemaData.user_level_thresholds || '1|0\n2|30\n3|100\n4|300'
creditsSummary.enabled = creditData.enabled !== false
creditsSummary.currencyName = creditData.currency_name || '积分'
} catch (error) {
message.error(error.message || '加载用户等级配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'level', {
user_level_enabled: form.user_level_enabled,
user_level_public_display: form.user_level_public_display,
user_level_source: form.user_level_source,
user_level_prefix: form.user_level_prefix,
user_level_thresholds: form.user_level_thresholds,
})
message.success('用户等级配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -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|垃圾信息&#10;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,308 @@
<template>
<UsersSectionPage
title="签到奖励"
desc="直接管理签到与邀请奖励的真实运行配置,保存后会立即影响前台签到、邀请链接和注册页邀请码行为。"
:links="[
{ label: '注册登录', path: '/users/auth' },
{ label: '用户积分', path: '/users/credits' },
]"
>
<template #before-form>
<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.credits_enabled ? 'success' : 'warning'">
积分系统:{{ form.credits_enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round :type="form.signin_enabled ? 'success' : 'default'">
签到能力:{{ form.signin_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.invite_enabled ? 'success' : 'default'">
邀请奖励:{{ form.invite_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.invite_registration_enabled ? 'info' : 'default'">
邀请注册:{{ form.invite_registration_enabled ? '开启' : '关闭' }}
</n-tag>
<n-tag round :type="captchaSummary.register ? 'info' : 'default'">
注册验证码:{{ captchaSummary.register ? '启用' : '未启用' }}
</n-tag>
<n-tag round :type="captchaSummary.signin ? 'info' : 'default'">
签到验证码:{{ captchaSummary.signin ? '启用' : '未启用' }}
</n-tag>
</n-space>
</n-card>
<n-card v-if="!form.credits_enabled" class="aksrt-section-card">
<n-alert type="warning" :show-icon="false">
当前积分系统处于关闭状态。即使保存签到和邀请奖励配置,前台也不会真正发放奖励,直到“用户积分”页重新启用积分系统。
</n-alert>
</n-card>
</n-spin>
</template>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>每日签到</h3>
</div>
<n-form-item label="启用签到奖励">
<n-switch v-model:value="form.signin_enabled" />
</n-form-item>
<n-form-item label="基础奖励" v-if="form.signin_enabled">
<n-input-number v-model:value="form.signin_base" :min="0" :max="100000" style="width: 180px;" />
<template #feedback>
<span class="aksrt-hint-text">每次签到基础发放 {{ currencyLabel }} 数量。</span>
</template>
</n-form-item>
<n-form-item label="连续签到加成" v-if="form.signin_enabled">
<n-input
v-model:value="form.signin_streak_text"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="3|2&#10;7|5&#10;30|20"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个周期,格式 `天数|额外奖励`。例如 `7|5` 表示连续 7 天额外获得 5 {{ currencyLabel }}。</span>
</template>
</n-form-item>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>邀请奖励与注册页邀请码</h3>
</div>
<n-form-item label="启用邀请奖励">
<n-switch v-model:value="form.invite_enabled" />
</n-form-item>
<n-form-item label="开放邀请注册">
<n-switch v-model:value="form.invite_registration_enabled" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,邀请链接入口、注册页邀请码字段和个人中心邀请模块会一起隐藏。</span>
</template>
</n-form-item>
<template v-if="form.invite_registration_enabled">
<n-form-item label="邀请码长度">
<n-input-number v-model:value="form.invite_code_length" :min="4" :max="12" style="width: 180px;" />
</n-form-item>
<n-form-item label="注册页邀请码字段">
<n-radio-group v-model:value="form.invite_register_field">
<n-space size="large">
<n-radio value="hidden">隐藏</n-radio>
<n-radio value="optional">选填</n-radio>
<n-radio value="required">必填</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="无效邀请码处理" v-if="form.invite_register_field === 'required'">
<n-switch v-model:value="form.invite_reject_invalid" />
<template #feedback>
<span class="aksrt-hint-text">开启后,必填模式下无效邀请码会直接拒绝注册。关闭则忽略邀请码错误并继续注册。</span>
</template>
</n-form-item>
</template>
<template v-if="form.invite_enabled">
<n-form-item label="邀请人奖励">
<n-input-number v-model:value="form.inviter_amount" :min="0" :max="100000" style="width: 180px;" />
</n-form-item>
<n-form-item label="被邀请人奖励">
<n-input-number v-model:value="form.invitee_amount" :min="0" :max="100000" style="width: 180px;" />
</n-form-item>
<n-form-item label="验证邮箱后发放">
<n-switch v-model:value="form.require_verify" />
</n-form-item>
<n-form-item label="同 IP 日上限">
<n-input-number v-model:value="form.ip_daily_cap" :min="0" :max="1000" style="width: 180px;" />
</n-form-item>
</template>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-form>
</UsersSectionPage>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import UsersSectionPage from '@/pages/users/UsersSectionPage.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const creditSnapshot = ref({})
const authSnapshot = ref({})
const captchaSummary = reactive({
register: false,
signin: false,
})
const form = reactive({
credits_enabled: true,
currency_name: '',
signin_enabled: true,
signin_base: 1,
signin_streak_text: '3|2\n7|5\n30|20',
invite_enabled: true,
inviter_amount: 5,
invitee_amount: 2,
require_verify: true,
ip_daily_cap: 3,
invite_registration_enabled: true,
invite_code_length: 6,
invite_register_field: 'optional',
invite_reject_invalid: true,
})
const currencyLabel = computed(() => form.currency_name || '积分')
function extractData(response) {
return response?.data ?? response ?? {}
}
function formatStreak(streak) {
const entries = Object.entries(streak || {})
.map(([day, bonus]) => [Number(day), Number(bonus)])
.filter(([day, bonus]) => Number.isFinite(day) && day > 0 && Number.isFinite(bonus) && bonus >= 0)
.sort((left, right) => left[0] - right[0])
return entries.map(([day, bonus]) => `${day}|${bonus}`).join('\n')
}
function parseStreakText(value) {
const rows = String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
const streak = {}
rows.forEach((row) => {
const parts = row.split('|').map((item) => item.trim())
if (parts.length !== 2) {
return
}
const day = Number(parts[0])
const bonus = Number(parts[1])
if (!Number.isFinite(day) || !Number.isFinite(bonus) || day <= 0 || bonus < 0) {
return
}
streak[Math.trunc(day)] = Math.trunc(bonus)
})
return streak
}
async function load() {
loading.value = true
try {
const [creditRes, authRes, captchaRes] = await Promise.all([
api.getCreditSettings(),
api.getAuthSettings(),
api.getPermissionsLoginSettings(),
])
const creditData = extractData(creditRes)
const authData = extractData(authRes)
const captchaData = extractData(captchaRes)
const dailySignin = creditData.daily_signin || {}
const inviteReward = creditData.invite_reward || {}
const disabledScenarios = Array.isArray(captchaData.disabled_scenarios)
? captchaData.disabled_scenarios
: []
creditSnapshot.value = creditData
authSnapshot.value = authData
form.credits_enabled = creditData.enabled !== false
form.currency_name = creditData.currency_name || ''
form.signin_enabled = dailySignin.enabled !== false
form.signin_base = Number(dailySignin.base || 0)
form.signin_streak_text = formatStreak(dailySignin.streak || { 3: 2, 7: 5, 30: 20 })
form.invite_enabled = inviteReward.enabled !== false
form.inviter_amount = Number(inviteReward.inviter_amount || 0)
form.invitee_amount = Number(inviteReward.invitee_amount || 0)
form.require_verify = inviteReward.require_verify !== false
form.ip_daily_cap = Number(inviteReward.ip_daily_cap || 0)
form.invite_registration_enabled = authData.invite_registration_enabled !== false
form.invite_code_length = Number(authData.invite_code_length || 6)
form.invite_register_field = authData.invite_register_field || 'optional'
form.invite_reject_invalid = authData.invite_reject_invalid !== false
captchaSummary.register = !!captchaData.enabled && !disabledScenarios.includes('register')
captchaSummary.signin = !!captchaData.enabled && !disabledScenarios.includes('signin')
} catch (error) {
message.error(error.message || '加载签到奖励配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const nextCredit = {
...creditSnapshot.value,
daily_signin: {
...(creditSnapshot.value.daily_signin || {}),
enabled: form.signin_enabled,
base: Number(form.signin_base || 0),
streak: parseStreakText(form.signin_streak_text),
},
invite_reward: {
...(creditSnapshot.value.invite_reward || {}),
enabled: form.invite_enabled,
inviter_amount: Number(form.inviter_amount || 0),
invitee_amount: Number(form.invitee_amount || 0),
require_verify: form.require_verify,
ip_daily_cap: Number(form.ip_daily_cap || 0),
register_field: form.invite_register_field,
reject_invalid: form.invite_reject_invalid,
},
}
const nextAuth = {
...authSnapshot.value,
invite_registration_enabled: form.invite_registration_enabled,
invite_code_length: Number(form.invite_code_length || 6),
invite_register_field: form.invite_register_field,
invite_reject_invalid: form.invite_reject_invalid,
}
await Promise.all([
api.saveCreditSettings(nextCredit),
api.saveAuthSettings(nextAuth),
])
creditSnapshot.value = nextCredit
authSnapshot.value = nextAuth
message.success('签到奖励配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,338 @@
<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_verify_enabled ? 'success' : 'default'">
认证结构:{{ form.user_verify_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="authSummary.emailVerificationEnabled ? 'info' : 'default'">
邮箱验证:{{ authSummary.emailVerificationEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="warning">
待审核:{{ counts.pending }}
</n-tag>
<n-tag round type="success">
已通过:{{ counts.verified }}
</n-tag>
<n-tag round type="default">
已驳回:{{ counts.rejected }}
</n-tag>
</n-space>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" title="审核说明">
<n-alert type="info" :show-icon="false">
人工审核台读取 `aksrt_user_verify_status`、`aksrt_user_verify_reason` 和审核日志。开启“邮箱验证通过视为已认证”后,公开页仍会优先展示邮件验证结果。
</n-alert>
</n-card>
</n-grid-item>
</n-grid>
<n-card class="aksrt-verify-page__card" size="small" title="公开展示与审核开关">
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="启用身份认证结构">
<n-switch v-model:value="form.user_verify_enabled" />
</n-form-item>
<n-form-item label="公开显示认证徽标">
<n-switch v-model:value="form.user_verify_public_display" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item label="认证徽标文案">
<n-input v-model:value="form.user_verify_badge_text" :disabled="!form.user_verify_enabled" maxlength="20" />
</n-form-item>
<n-form-item label="邮箱验证通过视为已认证">
<n-switch v-model:value="form.user_verify_accept_email_verified" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item label="启用人工审核台">
<n-switch v-model:value="form.user_verify_require_manual_review" :disabled="!form.user_verify_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 class="aksrt-verify-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-button type="primary" block @click="handleSearch">筛选记录</n-button>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="requests"
:loading="reviewLoading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.user_id"
/>
</n-space>
</n-card>
<VerifyReviewDrawer
v-model:show="drawerVisible"
:user-id="currentUserId"
@updated="loadRequests"
/>
</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 VerifyReviewDrawer from './components/VerifyReviewDrawer.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const reviewLoading = ref(false)
const requests = ref([])
const drawerVisible = ref(false)
const currentUserId = ref(null)
const form = reactive({
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_badge_text: '已认证',
user_verify_accept_email_verified: true,
user_verify_require_manual_review: false,
})
const authSummary = reactive({
emailVerificationEnabled: false,
})
const counts = reactive({
pending: 0,
verified: 0,
rejected: 0,
total: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statusOptions = computed(() => optionsState.statuses.length
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
: [
{ label: '全部状态', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'verified' },
{ label: '已驳回', value: 'rejected' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (page) => {
pager.page = page
loadRequests()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadRequests()
},
}))
const columns = [
{
title: '用户',
key: 'display_name',
width: 160,
},
{
title: '邮箱',
key: 'email',
minWidth: 220,
ellipsis: { tooltip: true },
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', verified: 'success', rejected: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
},
},
{
title: '邮箱验证',
key: 'email_verified',
width: 100,
render: (row) => row.email_verified ? '已完成' : '未完成',
},
{
title: '申请时间',
key: 'requested_at',
width: 170,
},
{
title: '处理时间',
key: 'reviewed_at',
width: 170,
render: (row) => row.reviewed_at || '-',
},
{
title: '历史',
key: 'history_count',
width: 80,
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.user_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 buildParams() {
const params = {
page: pager.page,
per_page: pager.pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (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, authRes] = await Promise.all([
api.getSchemaPage('users', 'verify'),
api.getAuthSettings(),
])
const schemaData = extractData(schemaRes)
const authData = extractData(authRes)
form.user_verify_enabled = schemaData.user_verify_enabled !== false
form.user_verify_public_display = schemaData.user_verify_public_display !== false
form.user_verify_badge_text = schemaData.user_verify_badge_text || '已认证'
form.user_verify_accept_email_verified = schemaData.user_verify_accept_email_verified !== false
form.user_verify_require_manual_review = !!schemaData.user_verify_require_manual_review
authSummary.emailVerificationEnabled = !!authData.registration_email_verification_enabled
await loadRequests()
} catch (error) {
message.error(error?.message || '加载身份认证页失败')
} finally {
loading.value = false
}
}
async function loadRequests() {
reviewLoading.value = true
try {
const res = await api.request('/verify/requests', { params: buildParams() })
const data = res?.data || {}
requests.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(counts, data.counts || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载认证审核台失败')
} finally {
reviewLoading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'verify', {
user_verify_enabled: form.user_verify_enabled,
user_verify_public_display: form.user_verify_public_display,
user_verify_badge_text: form.user_verify_badge_text,
user_verify_accept_email_verified: form.user_verify_accept_email_verified,
user_verify_require_manual_review: form.user_verify_require_manual_review,
})
message.success('身份认证配置已保存')
} catch (error) {
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
function handleSearch() {
pager.page = 1
loadRequests()
}
function openDrawer(userId) {
currentUserId.value = userId
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-verify-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,258 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="微信模板消息" desc="配置微信公众号模板消息推送。" />
<!-- 状态卡片 -->
<div class="aksrt-wechat__status">
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.configured ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.configured ? 'fas fa-check' : 'fas fa-exclamation'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">配置状态</div>
<div class="aksrt-wechat__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.has_token ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.has_token ? 'fas fa-key' : 'fas fa-key'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">Access Token</div>
<div class="aksrt-wechat__status-value">{{ status.has_token ? '已获取' : '未获取' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.has_template ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.has_template ? 'fas fa-file-alt' : 'fas fa-file-alt'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">模板 ID</div>
<div class="aksrt-wechat__status-value">{{ status.has_template ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon aksrt-wechat__status-icon--info">
<i class="fas fa-users"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">绑定用户</div>
<div class="aksrt-wechat__status-value">{{ status.bound_users || 0 }} 人</div>
</div>
</div>
</div>
<!-- 配置表单 -->
<div class="aksrt-wechat__form-card">
<h3>基本配置</h3>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用微信模板消息
</label>
</div>
<div class="aksrt-form-group">
<label>AppID</label>
<input v-model="form.app_id" type="text" class="aksrt-input" placeholder="微信公众号 AppID">
</div>
<div class="aksrt-form-group">
<label>AppSecret</label>
<input v-model="form.app_secret" type="password" class="aksrt-input" placeholder="微信公众号 AppSecret">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group">
<label>模板 ID</label>
<input v-model="form.template_id" type="text" class="aksrt-input" placeholder="微信模板消息模板 ID">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<!-- 通知类型 -->
<div class="aksrt-wechat__form-card">
<h3>支持的通知类型</h3>
<p class="aksrt-form-hint">以下事件类型可触发微信模板消息推送(需用户绑定微信且开启对应订阅):</p>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>事件类型</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="evt in eventTypes" :key="evt.key">
<td><code>{{ evt.key }}</code></td>
<td>{{ evt.desc }}</td>
<td>
<span class="aksrt-badge" :class="evt.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ evt.ready ? '可用' : '待接入' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
import api from '@/api';
export default {
name: 'UsersWechatTemplate',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
configured: false,
has_token: false,
has_template: false,
bound_users: 0,
});
const form = reactive({
enabled: false,
app_id: '',
app_secret: '',
template_id: '',
});
const saving = ref(false);
// Event type definitions — readiness driven by backend allowed_types
const eventTypeDefs = [
{ key: 'comment_reply', desc: '评论回复通知' },
{ key: 'system_notice', desc: '系统公告通知' },
{ key: 'order_paid', desc: '订单支付通知' },
{ key: 'credits_change', desc: '积分变动通知' },
{ key: 'forum_reply', desc: '论坛回复通知' },
];
const allowedTypes = ref([]);
const eventTypes = computed(() =>
eventTypeDefs.map(et => ({
...et,
ready: allowedTypes.value.length === 0 || allowedTypes.value.includes(et.key),
}))
);
async function loadStatus() {
try {
const json = await api.request('/wechat/status');
if (json.success) {
status.value = json.data;
form.enabled = json.data.enabled || false;
form.app_id = json.data.appid || '';
form.app_secret = json.data.app_secret === '***' ? '' : '';
form.template_id = json.data.template_id || '';
// Use backend allowed_types for readiness (empty = all allowed)
allowedTypes.value = json.data.allowed_types || [];
}
} catch (e) {
console.error('Load WeChat status failed:', e);
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = { ...form };
// 如果密码为空且状态显示已配置,发送掩码保留原值
if (!payload.app_secret && status.value.configured) {
payload.app_secret = '***';
}
const json = await api.request('/wechat/settings', {
method: 'POST',
data: payload,
});
if (json.success) {
alert('设置已保存');
loadStatus();
} else {
alert(json.message || '保存失败');
}
} catch (e) {
console.error('Save failed:', e);
alert('保存失败: ' + (e.message || '未知错误'));
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, eventTypes, saveSettings };
},
};
</script>
<style scoped>
.aksrt-wechat__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-wechat__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-wechat__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-wechat__status-icon--ok {
background: #d4edda;
color: #155724;
}
.aksrt-wechat__status-icon--warn {
background: #fff3cd;
color: #856404;
}
.aksrt-wechat__status-icon--info {
background: #d1ecf1;
color: #0c5460;
}
.aksrt-wechat__status-value {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-wechat__status-label {
font-size: 12px;
color: #888;
}
.aksrt-wechat__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-wechat__form-card h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
}
.aksrt-form-hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
</style>
@@ -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>
@@ -0,0 +1,180 @@
<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.request">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="用户">{{ detail.request.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="邮箱">{{ detail.request.email || '-' }}</n-descriptions-item>
<n-descriptions-item label="当前状态">{{ detail.request.status || '-' }}</n-descriptions-item>
<n-descriptions-item label="邮箱验证">
{{ detail.request.email_verified ? '已完成' : '未完成' }}
</n-descriptions-item>
<n-descriptions-item label="申请时间">{{ detail.request.requested_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="最近处理">{{ detail.request.reviewed_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">当前备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.request.reason || '暂无备注' }}
</n-alert>
</div>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">审核历史</div>
<div v-if="detail.history?.length" class="aksrt-verify-drawer__timeline">
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-verify-drawer__timeline-item">
<div class="aksrt-verify-drawer__timeline-head">
<strong>{{ entry.status || '-' }}</strong>
<span>{{ entry.time || '-' }}</span>
</div>
<p>{{ entry.reason || '无备注' }}</p>
<small>{{ entry.reviewer_name || '系统' }}</small>
</div>
</div>
<n-empty v-else description="暂无审核历史" />
</div>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">执行审核</div>
<n-space vertical :size="12">
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
<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 { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
userId: { 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({ request: null, history: [], options: { statuses: [] } })
const form = reactive({
status: 'verified',
note: '',
})
watch(
() => [props.show, props.userId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { request: null, history: [], options: { statuses: [] } }
form.status = 'verified'
form.note = ''
}
},
)
async function loadDetail() {
if (!props.userId) {
return
}
loading.value = true
try {
const res = await api.request(`/verify/requests/${props.userId}`)
detail.value = res?.data || { request: null, history: [], options: { statuses: [] } }
form.status = detail.value?.request?.status === 'rejected' ? 'verified' : 'verified'
form.note = ''
} catch (error) {
message.error(error?.message || '加载认证详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.userId) {
return
}
submitting.value = true
try {
await api.request(`/verify/requests/${props.userId}/review`, {
method: 'POST',
data: {
status: form.status,
note: form.note,
},
})
message.success('认证审核已提交')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '审核失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-verify-drawer__section {
margin-top: 20px;
}
.aksrt-verify-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-verify-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-verify-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-verify-drawer__timeline-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.aksrt-verify-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-verify-drawer__timeline-item small {
color: #6b7280;
}
</style>