feat(admin-frontend): 新增系统与订阅管理后台页面

扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐
管理页、节点管理页及多个结构化占位页

补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、
失败作业详情与流量排行 limit 联动能力

同步后端 traffic rank limit 支持与知识库归档、设计约束、
验证配置及视觉验收产物
This commit is contained in:
yinjianm
2026-04-24 15:32:09 +08:00
parent 9ce345eb76
commit 16203b14f6
74 changed files with 6737 additions and 119 deletions
@@ -0,0 +1,677 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheckFilled, Message, RefreshRight, Setting, WarningFilled } from '@element-plus/icons-vue'
import { fetchAdminConfig, getPlans, saveAdminConfig, setTelegramWebhook, testAdminMail } from '@/api/admin'
import type { AdminPlanListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
import {
createSystemConfigFormState,
getSystemConfigFieldOptions,
normalizeSystemConfigMappings,
serializeSystemConfigForm,
systemConfigSections,
type SystemConfigFieldSchema,
type SystemConfigFieldValue,
type SystemConfigSectionKey,
} from '@/utils/systemConfig'
const loading = ref(true)
const reloading = ref(false)
const saving = ref(false)
const errorMessage = ref('')
const activeSection = ref<SystemConfigSectionKey>('site')
const auxiliaryAction = ref<'mail' | 'telegram' | null>(null)
const plans = ref<AdminPlanListItem[]>([])
const lastLoadedAt = ref<string | null>(null)
const form = reactive(createSystemConfigFormState())
const sectionRefs = new Map<SystemConfigSectionKey, HTMLElement>()
const originalSnapshot = ref(JSON.stringify(serializeSystemConfigForm(form)))
const resolvedSections = computed(() => systemConfigSections.map((section) => ({
...section,
fields: section.fields.map((field) => ({
...field,
options: getSystemConfigFieldOptions(field, plans.value),
})),
})))
const currentSnapshot = computed(() => JSON.stringify(serializeSystemConfigForm(form)))
const isDirty = computed(() => currentSnapshot.value !== originalSnapshot.value)
const saveStatusText = computed(() => {
if (saving.value) return '配置保存中'
if (isDirty.value) return '存在未保存改动'
return '已与服务端同步'
})
const summaryCards = computed(() => [
{
label: '站点名称',
value: String(form.app_name || '未命名站点'),
},
{
label: '后台路径',
value: form.secure_path ? `/${form.secure_path}` : '未设置',
},
{
label: '注册状态',
value: Boolean(form.stop_register) ? '暂停注册' : '开放注册',
},
])
function applyFormState() {
originalSnapshot.value = JSON.stringify(serializeSystemConfigForm(form))
lastLoadedAt.value = new Date().toISOString()
}
function assignFormState(nextState: Record<string, SystemConfigFieldValue>) {
Object.keys(nextState).forEach((key) => {
form[key] = nextState[key]
})
applyFormState()
}
async function loadPage(mode: 'initial' | 'reload' = 'initial') {
if (mode === 'initial') {
loading.value = true
} else {
reloading.value = true
}
errorMessage.value = ''
try {
const [configResult, plansResult] = await Promise.allSettled([
fetchAdminConfig(),
getPlans(),
])
if (configResult.status === 'rejected') {
throw configResult.reason
}
const nextState = normalizeSystemConfigMappings(configResult.value.data)
assignFormState(nextState)
if (plansResult.status === 'fulfilled') {
plans.value = plansResult.value.data ?? []
} else {
plans.value = []
ElMessage.warning('试用套餐列表加载失败,注册试用下拉选项将暂时不可用')
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '系统配置加载失败'
} finally {
loading.value = false
reloading.value = false
}
}
function getFieldValue(key: string): SystemConfigFieldValue {
return form[key]
}
function updateField(key: string, value: SystemConfigFieldValue) {
form[key] = value
}
function resolveNumberValue(field: SystemConfigFieldSchema): number | undefined {
const value = getFieldValue(field.key)
if (typeof value === 'number') return value
if (value === null || value === '') return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
function resolveTextValue(field: SystemConfigFieldSchema): string {
const value = getFieldValue(field.key)
if (value === null || value === undefined) return ''
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
function registerSection(key: SystemConfigSectionKey) {
return (element: Element | ComponentPublicInstance | null) => {
if (element instanceof HTMLElement) {
sectionRefs.set(key, element)
return
}
if (element && '$el' in element && element.$el instanceof HTMLElement) {
sectionRefs.set(key, element.$el)
}
}
}
function jumpToSection(key: SystemConfigSectionKey) {
activeSection.value = key
sectionRefs.get(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
async function handleSave() {
if (saving.value) return
saving.value = true
try {
const payload = serializeSystemConfigForm(form)
await saveAdminConfig(payload)
originalSnapshot.value = JSON.stringify(payload)
ElMessage.success('系统配置已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '系统配置保存失败')
} finally {
saving.value = false
}
}
function ensureSavedBeforeAuxiliaryAction(): boolean {
if (isDirty.value) {
ElMessage.warning('请先保存当前配置,再执行辅助操作')
return false
}
return true
}
async function handleTestMail() {
if (!ensureSavedBeforeAuxiliaryAction()) return
auxiliaryAction.value = 'mail'
try {
await testAdminMail()
ElMessage.success('测试邮件已触发,请检查当前管理员邮箱')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '测试邮件发送失败')
} finally {
auxiliaryAction.value = null
}
}
async function handleSetWebhook() {
if (!ensureSavedBeforeAuxiliaryAction()) return
auxiliaryAction.value = 'telegram'
try {
await setTelegramWebhook({
telegram_bot_token: String(form.telegram_bot_token || ''),
})
ElMessage.success('Telegram Webhook 设置成功')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : 'Telegram Webhook 设置失败')
} finally {
auxiliaryAction.value = null
}
}
onMounted(() => {
void loadPage()
})
</script>
<template>
<div class="config-page">
<section class="config-hero">
<div class="config-copy">
<p class="config-kicker">System Settings</p>
<h1>系统设置</h1>
<p>
管理系统核心配置包括站点安全订阅邀请佣金节点邮件与通知相关设置
</p>
<div class="config-status">
<ElIcon :class="{ danger: isDirty }">
<WarningFilled v-if="isDirty" />
<CircleCheckFilled v-else />
</ElIcon>
<span>{{ saveStatusText }}</span>
<small v-if="lastLoadedAt">最近加载于 {{ formatDateTime(lastLoadedAt) }}</small>
</div>
</div>
<div class="hero-side">
<div class="hero-actions">
<ElButton :loading="reloading" @click="loadPage('reload')">
<ElIcon><RefreshRight /></ElIcon>
重新拉取
</ElButton>
<ElButton type="primary" :loading="saving" :disabled="!isDirty" @click="handleSave">
保存配置
</ElButton>
</div>
<div class="hero-summary">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</div>
</section>
<section v-if="loading" class="loading-shell">
<ElSkeleton :rows="4" animated />
<ElSkeleton :rows="6" animated />
</section>
<section v-else class="config-shell">
<aside class="config-nav">
<button
v-for="section in resolvedSections"
:key="section.key"
type="button"
class="nav-item"
:class="{ active: section.key === activeSection }"
@click="jumpToSection(section.key)"
>
<span>{{ section.navLabel }}</span>
<small>{{ section.fields.length }} </small>
</button>
</aside>
<div class="config-content">
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
class="config-error"
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="loadPage('reload')">重新加载</ElButton>
</template>
</ElAlert>
<article
v-for="section in resolvedSections"
:key="section.key"
:ref="registerSection(section.key)"
class="config-section"
>
<header class="section-header">
<div class="section-copy">
<p>{{ section.navLabel }}</p>
<h2>{{ section.title }}</h2>
<span>{{ section.description }}</span>
</div>
<div v-if="section.key === 'email' || section.key === 'telegram'" class="section-actions">
<ElButton
v-if="section.key === 'email'"
:loading="auxiliaryAction === 'mail'"
@click="handleTestMail"
>
<ElIcon><Message /></ElIcon>
发送测试邮件
</ElButton>
<ElButton
v-if="section.key === 'telegram'"
:loading="auxiliaryAction === 'telegram'"
@click="handleSetWebhook"
>
<ElIcon><Setting /></ElIcon>
设置 Webhook
</ElButton>
</div>
</header>
<ElForm label-position="top" class="config-form">
<div class="config-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="config-field"
:class="{ 'is-full': field.fullWidth }"
>
<ElFormItem :label="field.label">
<ElSwitch
v-if="field.type === 'switch'"
:model-value="Boolean(getFieldValue(field.key))"
@update:model-value="updateField(field.key, $event)"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
:model-value="resolveNumberValue(field)"
:min="field.min"
:max="field.max"
:step="field.step ?? 1"
controls-position="right"
class="field-number"
@update:model-value="updateField(field.key, $event ?? (field.nullable ? null : field.defaultValue ?? 0))"
/>
<ElSelect
v-else-if="field.type === 'select'"
:model-value="getFieldValue(field.key)"
:multiple="field.multiple"
:allow-create="field.allowCreate"
:filterable="field.multiple || field.allowCreate"
:clearable="!field.multiple"
collapse-tags
collapse-tags-tooltip
class="field-select"
@update:model-value="updateField(field.key, $event as SystemConfigFieldValue)"
>
<ElOption
v-for="option in field.options"
:key="`${field.key}-${option.value}`"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput
v-else-if="field.type === 'textarea'"
:model-value="resolveTextValue(field)"
type="textarea"
:rows="field.rows ?? 4"
:placeholder="field.placeholder"
:autosize="field.rows ? undefined : { minRows: 4, maxRows: 10 }"
@update:model-value="updateField(field.key, $event)"
/>
<ElInput
v-else
:model-value="resolveTextValue(field)"
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:placeholder="field.placeholder"
clearable
@update:model-value="updateField(field.key, $event)"
/>
<p v-if="field.helper" class="field-helper">
{{ field.helper }}
</p>
</ElFormItem>
</div>
</div>
</ElForm>
</article>
</div>
</section>
</div>
</template>
<style scoped>
.config-page {
display: grid;
gap: 24px;
}
.config-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 34px;
border-radius: 28px;
background: #000000;
}
.config-copy {
display: grid;
gap: 12px;
max-width: 620px;
}
.config-kicker {
margin: 0;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.config-copy h1 {
margin: 0;
color: #ffffff;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
}
.config-copy > p:last-of-type {
margin: 0;
color: rgba(255, 255, 255, 0.72);
line-height: 1.6;
}
.config-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
color: rgba(255, 255, 255, 0.72);
}
.config-status :deep(.el-icon) {
color: #2997ff;
}
.config-status :deep(.el-icon.danger) {
color: #f59e0b;
}
.config-status small {
color: rgba(255, 255, 255, 0.52);
}
.hero-side {
display: grid;
gap: 16px;
min-width: 360px;
}
.hero-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.hero-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.hero-summary article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-summary span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-summary strong {
color: #ffffff;
font-size: 20px;
line-height: 1.2;
}
.loading-shell {
display: grid;
gap: 16px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.config-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.config-nav,
.config-content {
display: grid;
gap: 18px;
}
.config-nav {
position: sticky;
top: 0;
padding: 18px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.nav-item {
display: grid;
gap: 4px;
width: 100%;
padding: 14px 16px;
border: 1px solid transparent;
border-radius: 18px;
background: transparent;
color: var(--xboard-text-secondary);
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.nav-item span {
color: inherit;
font-weight: 600;
}
.nav-item small {
color: var(--xboard-text-muted);
font-size: 12px;
}
.nav-item.active {
border-color: rgba(0, 113, 227, 0.14);
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
}
.config-error {
margin-bottom: 2px;
}
.config-section {
display: grid;
gap: 22px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.section-copy {
display: grid;
gap: 8px;
}
.section-copy p {
margin: 0;
color: var(--xboard-text-muted);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.section-copy h2 {
margin: 0;
color: var(--xboard-text-strong);
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.28px;
}
.section-copy span {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.section-actions {
display: flex;
gap: 12px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 16px;
}
.config-field.is-full {
grid-column: 1 / -1;
}
.field-number,
.field-select {
width: 100%;
}
.field-helper {
margin-top: 8px;
color: var(--xboard-text-muted);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 1180px) {
.config-hero,
.config-shell,
.section-header {
grid-template-columns: 1fr;
flex-direction: column;
}
.hero-side {
min-width: 0;
}
.hero-actions,
.section-actions {
justify-content: flex-start;
}
.config-shell {
display: grid;
}
.config-nav {
position: static;
grid-auto-flow: column;
grid-auto-columns: minmax(180px, 1fr);
overflow-x: auto;
}
}
@media (max-width: 767px) {
.config-grid,
.hero-summary {
grid-template-columns: 1fr;
}
.config-hero {
padding: 28px 24px;
}
.config-section {
padding: 22px;
}
}
</style>