导入 AKSRT 主题开发源码

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

No files matched your search

@@ -0,0 +1,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>