feat: complete unified plugin admin v1.1.0
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit ada4ab3c21
69 changed files with 6681 additions and 901 deletions
+27
View File
@@ -0,0 +1,27 @@
<template>
<t-config-provider>
<div v-if="!authState.ready" class="boot-screen"><t-loading size="32px" text="正在连接控制面" /></div>
<router-view v-else-if="authState.user" />
<Login v-else />
</t-config-provider>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import router from './router'
import { authState, hydrateSession, loadSubscriptionState } from './services/api'
import Login from './views/Login.vue'
onMounted(async () => {
await hydrateSession()
// The first router pass happens while authState.ready is false. Re-check a
// deep link after hydration so a direct subscription URL cannot bypass the
// route-level module gate.
const current = router.currentRoute.value
if (!authState.user && current.path !== '/login') {
await router.replace('/login')
} else if (authState.user && current.path.startsWith('/modules/subscription')) {
if (!(await loadSubscriptionState(true))) await router.replace('/overview')
}
})
</script>
@@ -0,0 +1,194 @@
<template>
<div v-if="sceneId && prefix" class="captcha-action">
<button
:id="buttonId"
type="button"
class="captcha-action__trigger"
:class="{ 'is-verified': state === 'verified' }"
:disabled="state === 'verified' || state === 'verifying'"
@click="handleTrigger"
>
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
</button>
<div :id="elementId" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
interface AliyunInitOptions {
SceneId: string
prefix: string
mode: 'popup' | 'embed'
element: string
button: string
captchaVerifyCallback: (param: string) => { captchaResult: boolean; bizResult?: boolean }
onBizResultCallback: (result: boolean) => void
getInstance: (instance: unknown) => void
slideStyle?: { width: number; height: number }
language?: string
}
declare global {
interface Window {
initAliyunCaptcha?: (options: AliyunInitOptions) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
const props = withDefaults(defineProps<{ sceneId: string; prefix: string; region?: string }>(), { region: 'cn' })
const emit = defineEmits<{ verify: [param: string]; expire: []; error: [] }>()
const uid = Math.random().toString(36).slice(2, 10)
const buttonId = `plugin-aliyun-captcha-button-${uid}`
const elementId = `plugin-aliyun-captcha-element-${uid}`
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
const scriptSrc = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
let readyPromise: Promise<void> | null = null
let cachedParam: string | null = null
let pending: { resolve: (value: string | null) => void } | null = null
let pendingTimer: number | null = null
let initialized = false
function loadScript(): Promise<void> {
if (window.initAliyunCaptcha) return Promise.resolve()
if (readyPromise) return readyPromise
window.AliyunCaptchaConfig = { region: props.region || 'cn', prefix: props.prefix }
readyPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src*="aliyunCaptcha/AliyunCaptcha"]')
if (existing) {
existing.addEventListener('load', () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable')), { once: true })
existing.addEventListener('error', () => reject(new Error('Failed to load Aliyun Captcha SDK')), { once: true })
return
}
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable'))
script.onerror = () => reject(new Error('Failed to load Aliyun Captcha SDK'))
document.head.appendChild(script)
}).catch((error) => {
readyPromise = null
throw error
})
return readyPromise
}
function resolveParam(param: string) {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
const value = param.trim()
if (!value) {
state.value = 'idle'
pending?.resolve(null)
pending = null
emit('error')
return
}
cachedParam = value
state.value = 'verified'
emit('verify', value)
pending?.resolve(value)
pending = null
}
function initCaptcha() {
if (initialized) return
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
window.initAliyunCaptcha({
SceneId: props.sceneId,
prefix: props.prefix,
mode: 'popup',
element: `#${elementId}`,
button: `#${buttonId}`,
captchaVerifyCallback: (param) => {
resolveParam(param)
return { captchaResult: true }
},
onBizResultCallback: () => undefined,
getInstance: () => undefined,
slideStyle: { width: 360, height: 40 },
language: 'cn',
})
initialized = true
}
function handleTrigger() {
if (state.value === 'verified' || state.value === 'verifying') return
state.value = 'verifying'
void verify().catch(() => {
state.value = 'idle'
emit('error')
})
}
async function verify(): Promise<string | null> {
if (cachedParam) {
const value = cachedParam
cachedParam = null
return value
}
await loadScript()
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
initCaptcha()
return new Promise<string | null>((resolve) => {
pending = { resolve }
if (pendingTimer !== null) window.clearTimeout(pendingTimer)
pendingTimer = window.setTimeout(() => {
pendingTimer = null
state.value = 'idle'
pending?.resolve(null)
pending = null
}, 15000)
document.getElementById(buttonId)?.click()
})
}
function reset() {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
cachedParam = null
state.value = 'idle'
pending?.resolve(null)
pending = null
document.getElementById('aliyunCaptcha-mask')?.remove()
document.getElementById('aliyunCaptcha-window-popup')?.remove()
emit('expire')
}
defineExpose({ verify, reset })
onMounted(() => {
if (!props.sceneId || !props.prefix) return
void loadScript().then(initCaptcha).catch(() => emit('error'))
})
onUnmounted(() => {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
pending?.resolve(null)
pending = null
document.getElementById('aliyunCaptcha-mask')?.remove()
document.getElementById('aliyunCaptcha-window-popup')?.remove()
})
</script>
<style scoped>
.captcha-action { width: 100%; }
.captcha-action__trigger {
display: inline-flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
}
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
.captcha-action__trigger.is-verified { border-color: #00a870; background: #f0fff8; color: #087f5b; }
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
.captcha-action__dot.is-verified { background: #00a870; }
</style>
@@ -0,0 +1,117 @@
<template>
<div v-if="enabled" class="captcha-challenge" aria-live="polite">
<TurnstileWidget
v-if="provider === 'turnstile' && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onExpire"
@error="onError"
/>
<TencentCaptchaGate
v-else-if="provider === 'tencent' && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
@verify="onTencentVerify"
@expire="onExpire"
@error="onError"
/>
<AliyunCaptchaWidget
v-else-if="provider === 'aliyun' && aliyunSceneId && aliyunPrefix"
ref="aliyunRef"
:scene-id="aliyunSceneId"
:prefix="aliyunPrefix"
:region="aliyunRegion"
@verify="onAliyunVerify"
@expire="onExpire"
@error="onError"
/>
<div v-else class="captcha-challenge__missing">验证码配置不完整,请联系管理员</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import TurnstileWidget from './TurnstileWidget.vue'
import TencentCaptchaGate from './TencentCaptchaGate.vue'
import AliyunCaptchaWidget from './AliyunCaptchaWidget.vue'
import type { TencentCaptchaProof } from '../services/tencentCaptcha'
export interface CaptchaProof { token: string; randstr: string; provider: 'turnstile' | 'tencent' | 'aliyun' }
const props = defineProps<{
enabled: boolean
provider: string
turnstileSiteKey?: string
tencentAppId?: string
tencentRegion?: string
aliyunSceneId?: string
aliyunPrefix?: string
aliyunRegion?: string
}>()
const emit = defineEmits<{ verify: [proof: CaptchaProof]; expire: []; error: [] }>()
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
const turnstileToken = ref('')
const tencentProof = ref<TencentCaptchaProof | null>(null)
const aliyunToken = ref('')
const provider = computed(() => props.provider)
function onTurnstileVerify(token: string) {
turnstileToken.value = token
emit('verify', { token, randstr: '', provider: 'turnstile' })
}
function onTencentVerify(proof: TencentCaptchaProof) {
tencentProof.value = proof
emit('verify', { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' })
}
function onAliyunVerify(token: string) {
aliyunToken.value = token
emit('verify', { token, randstr: '', provider: 'aliyun' })
}
function onExpire() {
turnstileToken.value = ''
tencentProof.value = null
aliyunToken.value = ''
emit('expire')
}
function onError() {
onExpire()
emit('error')
}
async function verifyAction(): Promise<CaptchaProof | null> {
if (!props.enabled) return null
if (provider.value === 'turnstile') {
return turnstileToken.value ? { token: turnstileToken.value, randstr: '', provider: 'turnstile' } : null
}
if (provider.value === 'tencent') {
const proof = tencentProof.value || await tencentRef.value?.verify() || null
return proof ? { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' } : null
}
if (provider.value === 'aliyun') {
const token = aliyunToken.value || await aliyunRef.value?.verify() || ''
return token ? { token, randstr: '', provider: 'aliyun' } : null
}
return null
}
function reset() {
turnstileToken.value = ''
tencentProof.value = null
aliyunToken.value = ''
turnstileRef.value?.reset()
tencentRef.value?.reset()
aliyunRef.value?.reset()
}
defineExpose({ verifyAction, reset })
</script>
<style scoped>
.captcha-challenge { width: 100%; margin: 2px 0 10px; }
.captcha-challenge__missing { padding: 10px 12px; border: 1px solid #f3c7c4; border-radius: 6px; color: #a61b1b; background: #fff7f7; font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,6 @@
<template>
<header class="page-header"><div><p class="eyebrow">{{ eyebrow }}</p><h1>{{ title }}</h1><p class="page-subtitle">{{ subtitle }}</p></div><div class="page-actions"><slot name="actions" /></div></header>
</template>
<script setup lang="ts">
defineProps<{ eyebrow: string; title: string; subtitle: string }>()
</script>
@@ -0,0 +1,134 @@
<template>
<div class="captcha-action">
<div v-if="isInternational" ref="internationalContainerRef" class="captcha-action__international" />
<button
v-else
type="button"
class="captcha-action__trigger"
:disabled="state === 'verified' || state === 'verifying'"
@click="handleTrigger"
>
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { loadTencentCaptcha, normalizeTencentCaptchaRegion, type TencentCaptchaProof } from '../services/tencentCaptcha'
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const emit = defineEmits<{ verify: [proof: TencentCaptchaProof]; expire: []; error: [] }>()
const isInternational = computed(() => normalizeTencentCaptchaRegion(props.region) === 'intl')
const internationalContainerRef = ref<HTMLElement | null>(null)
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let resolvePending: ((proof: TencentCaptchaProof | null) => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
function handleResult(result: { ret: number; ticket?: string | null; randstr?: string | null; errorCode?: number }) {
if (result.ret === 2) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
return
}
const ticket = result.ticket?.trim() || ''
const randstr = result.randstr?.trim() || ''
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('error')
return
}
const proof = { ticket, randstr }
cachedProof = proof
state.value = 'verified'
emit('verify', proof)
resolvePending?.(proof)
resolvePending = null
pending = null
}
async function ensureInstance() {
const Constructor = await loadTencentCaptcha(normalizeTencentCaptchaRegion(props.region))
if (instance) return
if (isInternational.value) {
if (!internationalContainerRef.value) throw new Error('Tencent Captcha container unavailable')
instance = new Constructor(internationalContainerRef.value, props.appId, handleResult, { enableAutoCheck: false, type: 'popup' })
} else {
instance = new Constructor(props.appId, handleResult, { userLanguage: 'zh-cn' })
}
}
function handleTrigger() {
if (state.value === 'verified' || state.value === 'verifying') return
state.value = 'verifying'
void verify()
}
async function verify(): Promise<TencentCaptchaProof | null> {
if (cachedProof) {
const proof = cachedProof
cachedProof = null
return proof
}
if (pending) return pending
const verification = new Promise<TencentCaptchaProof | null>((resolve) => { resolvePending = resolve })
pending = verification
try {
await ensureInstance()
instance?.show()
} catch (error) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('error')
throw error
}
return verification
}
function reset() {
instance?.destroy()
instance = null
cachedProof = null
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('expire')
}
defineExpose({ verify, reset })
onMounted(() => {
if (!props.appId) return
// Preload the international checkbox so the first click is visible and
// never gets swallowed by the asynchronous SDK initialization.
if (isInternational.value) void ensureInstance().catch(() => emit('error'))
})
onBeforeUnmount(reset)
</script>
<style scoped>
.captcha-action { width: 100%; }
.captcha-action__international { display: flex; width: 100%; min-height: 60px; align-items: center; justify-content: center; }
.captcha-action__trigger {
display: inline-flex;
width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
}
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
.captcha-action__dot.is-verified { background: #00a870; }
</style>
@@ -0,0 +1,32 @@
<template><div ref="element" class="trend-chart" aria-label="近期操作趋势" /></template>
<script setup lang="ts">
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
echarts.use([BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
const props = defineProps<{ labels: string[]; values: number[] }>()
const element = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let observer: ResizeObserver | null = null
function render() {
if (!element.value) return
chart ||= echarts.init(element.value)
chart.setOption({
animationDuration: 350,
grid: { left: 36, right: 16, top: 20, bottom: 24 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', boundaryGap: false, data: props.labels, axisLine: { lineStyle: { color: '#d7dce3' } }, axisLabel: { color: '#718096', fontSize: 11 } },
yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#edf0f4' } }, axisLabel: { color: '#718096', fontSize: 11 } },
series: [{ name: '操作数', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: props.values, lineStyle: { color: '#0052d9', width: 2 }, itemStyle: { color: '#0052d9' }, areaStyle: { color: 'rgba(0,82,217,.10)' } }],
})
}
onMounted(() => { render(); if (element.value) { observer = new ResizeObserver(() => chart?.resize()); observer.observe(element.value) } })
watch(() => [props.labels, props.values], render, { deep: true })
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose(); chart = null })
</script>
@@ -0,0 +1,119 @@
<template>
<div v-if="siteKey" class="captcha-turnstile">
<div ref="containerRef" class="captcha-turnstile__container" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
interface TurnstileRenderOptions {
sitekey: string
callback: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
}
interface TurnstileAPI {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileAPI
__pluginTurnstileReady?: () => void
}
}
const props = withDefaults(
defineProps<{ siteKey: string; theme?: 'light' | 'dark' | 'auto'; size?: 'normal' | 'compact' | 'flexible' }>(),
{ theme: 'auto', size: 'flexible' }
)
const emit = defineEmits<{ verify: [token: string]; expire: []; error: [] }>()
const containerRef = ref<HTMLElement | null>(null)
let widgetId: string | null = null
let scriptPromise: Promise<void> | null = null
function loadScript(): Promise<void> {
if (window.turnstile) return Promise.resolve()
if (scriptPromise) return scriptPromise
scriptPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src*="challenges.cloudflare.com/turnstile"]')
if (existing) {
const onLoad = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
existing.addEventListener('load', onLoad, { once: true })
if (window.turnstile) resolve()
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
script.async = true
script.defer = true
script.onload = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
script.onerror = () => reject(new Error('Failed to load Turnstile SDK'))
document.head.appendChild(script)
}).catch((error) => {
scriptPromise = null
throw error
})
return scriptPromise
}
function renderWidget() {
if (!window.turnstile || !containerRef.value || !props.siteKey) return
if (widgetId) {
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
widgetId = null
}
containerRef.value.innerHTML = ''
widgetId = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey,
theme: props.theme,
size: props.size,
callback: (token) => emit('verify', token),
'expired-callback': () => emit('expire'),
'error-callback': () => emit('error'),
})
}
function reset() {
if (window.turnstile && widgetId) {
try { window.turnstile.reset(widgetId) } catch { /* SDK cleanup is best effort. */ }
}
}
defineExpose({ reset })
onMounted(async () => {
if (!props.siteKey) return
try {
await loadScript()
renderWidget()
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error')
}
})
watch(() => props.siteKey, (value) => {
if (value && window.turnstile) renderWidget()
})
onUnmounted(() => {
if (window.turnstile && widgetId) {
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
}
widgetId = null
})
</script>
<style scoped>
.captcha-turnstile { width: 100%; min-height: 66px; }
.captcha-turnstile__container { width: 100%; min-height: 66px; }
.captcha-turnstile__container :deep(iframe) { display: block; max-width: 100%; }
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,94 @@
<template>
<t-layout class="app-shell">
<div v-if="mobileOpen" class="mobile-scrim" @click="mobileOpen = false" />
<t-aside :class="['app-aside', { 'app-aside-mobile-open': mobileOpen }]" width="252px">
<div class="brand">
<div class="brand-mark"><ControlPlatformIcon size="22px" /></div>
<div>
<div class="brand-title">Sub2API</div>
<div class="brand-subtitle">插件控制面</div>
</div>
</div>
<div class="nav-caption">控制台</div>
<t-menu :value="activeMenu" class="main-menu" @change="onMenuChange">
<t-menu-item value="/overview"><template #icon><DashboardIcon /></template>概览</t-menu-item>
<t-menu-item value="/plugins"><template #icon><AppIcon /></template>已安装插件</t-menu-item>
<t-menu-item value="/marketplace"><template #icon><ShopIcon /></template>插件市场</t-menu-item>
<t-menu-item value="/operations"><template #icon><HistoryIcon /></template>操作记录</t-menu-item>
<template v-if="subscriptionEnabled">
<t-divider class="nav-divider" />
<div class="nav-caption nav-caption-module">业务模块</div>
</template>
<t-menu-item v-if="subscriptionEnabled" value="/modules/subscription/overview">
<template #icon><ChartLineIcon /></template>订阅管理
</t-menu-item>
</t-menu>
<div class="aside-footer">
<t-tag theme="success" variant="light" class="status-tag"><CheckCircleIcon size="14px" /> Core 会话正常</t-tag>
<span class="version-text">Plugin Admin v1.1.0</span>
</div>
</t-aside>
<t-layout class="main-layout">
<t-header class="app-header">
<div class="header-left">
<t-button class="mobile-menu-button" variant="text" shape="square" aria-label="打开导航" @click="mobileOpen = true">
<template #icon><MenuIcon /></template>
</t-button>
<div class="breadcrumbs"><span>插件管理</span><ChevronRightIcon size="15px" /><strong>{{ pageTitle }}</strong></div>
</div>
<div class="header-right">
<t-tag theme="success" variant="light" class="secure-tag"><CheckCircleIcon size="14px" /> 安全会话</t-tag>
<t-dropdown :options="userOptions" trigger="click" @click="onUserAction">
<t-button variant="text" class="user-button">
<template #icon><UserCircleIcon /></template>
<span class="user-name">{{ displayName }}</span><ChevronDownIcon size="15px" />
</t-button>
</t-dropdown>
</div>
</t-header>
<t-content class="app-content"><router-view /></t-content>
</t-layout>
</t-layout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
AppIcon, ChartLineIcon, CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, ControlPlatformIcon,
DashboardIcon, HistoryIcon, MenuIcon, ShopIcon, UserCircleIcon,
} from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import { authState, loadSubscriptionState, logout, moduleState } from '../services/api'
const route = useRoute()
const router = useRouter()
const mobileOpen = ref(false)
const subscriptionEnabled = computed(() => moduleState.subscriptionEnabled)
const pageTitle = computed(() => String(route.meta.title || (route.path.startsWith('/modules/subscription') ? '订阅管理' : '概览')))
const activeMenu = computed(() => route.path.startsWith('/plugins') ? '/plugins' : route.path.startsWith('/modules/subscription') ? '/modules/subscription/overview' : route.path)
const displayName = computed(() => String(authState.user?.email || authState.user?.username || '管理员'))
const userOptions = [{ content: '退出登录', value: 'logout' }]
async function loadModules() {
await loadSubscriptionState(true)
}
function onMenuChange(value: string | number) {
const target = String(value)
if (target === '/modules/subscription/overview' && !subscriptionEnabled.value) {
MessagePlugin.warning('请先安装并启用订阅模块')
return
}
router.push(target)
mobileOpen.value = false
}
async function onUserAction(data: any) {
if (String(data?.value || '') !== 'logout') return
await logout()
router.push('/login')
}
onMounted(loadModules)
</script>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
import './styles.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(TDesign)
app.use(createPinia())
app.use(router)
app.mount('#app')
+43
View File
@@ -0,0 +1,43 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Shell from './layouts/Shell.vue'
import Login from './views/Login.vue'
import Overview from './views/Overview.vue'
import Plugins from './views/Plugins.vue'
import PluginDetail from './views/PluginDetail.vue'
import Marketplace from './views/Marketplace.vue'
import Operations from './views/Operations.vue'
import Subscription from './views/Subscription.vue'
import { authState, loadSubscriptionState } from './services/api'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login, meta: { public: true } },
{
path: '/', component: Shell,
children: [
{ path: '', redirect: '/overview' },
{ path: 'overview', component: Overview, meta: { title: '概览' } },
{ path: 'plugins', component: Plugins, meta: { title: '已安装插件' } },
{ path: 'plugins/:id/:tab?', component: PluginDetail, meta: { title: '插件详情' } },
{ path: 'marketplace', component: Marketplace, meta: { title: '插件市场' } },
{ path: 'operations', component: Operations, meta: { title: '操作记录' } },
{ path: 'modules/subscription/:tab?', component: Subscription, meta: { title: '订阅管理' } },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/overview' },
],
})
router.beforeEach(async (to) => {
if (!authState.ready) return true
if (!authState.user && !to.meta.public) return '/login'
if (authState.user && to.path === '/login') return '/overview'
if (authState.user && to.path.startsWith('/modules/subscription')) {
const enabled = await loadSubscriptionState()
if (!enabled) return '/overview'
}
return true
})
export default router
@@ -0,0 +1,212 @@
import { reactive } from 'vue'
export interface User {
id?: string | number
email?: string
username?: string
role?: string
status?: string
[key: string]: unknown
}
export interface CaptchaConfig {
enabled: boolean
provider: '' | 'turnstile' | 'tencent' | 'aliyun' | string
turnstile_enabled: boolean
turnstile_site_key: string
tencent_captcha_enabled: boolean
tencent_captcha_app_id: string
tencent_captcha_region: string
aliyun_captcha_enabled: boolean
aliyun_captcha_scene_id: string
aliyun_captcha_prefix: string
aliyun_captcha_region: string
}
export const authState = reactive<{
ready: boolean
user: User | null
csrf: string
pendingToken: string
}>({ ready: false, user: null, csrf: '', pendingToken: '' })
export const moduleState = reactive<{
subscriptionEnabled: boolean
loaded: boolean
loading: boolean
}>({ subscriptionEnabled: false, loaded: false, loading: false })
const basePath = () => {
const meta = document.querySelector('meta[name="plugin-base-path"]')
return (meta?.getAttribute('content') || '').replace(/\/$/, '')
}
export class ApiError extends Error {
status: number
constructor(message: string, status = 0) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
const method = (options.method || 'GET').toUpperCase()
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
if (authState.csrf && method !== 'GET') headers.set('X-CSRF-Token', authState.csrf)
const response = await fetch(`${basePath()}${path}`, { ...options, method, headers, credentials: 'same-origin' })
const data = await response.json().catch(() => ({}))
if (!response.ok) {
if (response.status === 401 && !path.startsWith('/login')) {
authState.user = null
authState.csrf = ''
}
throw new ApiError(data?.error || `请求失败 (${response.status})`, response.status)
}
return data as T
}
export async function hydrateSession() {
try {
const data = await request<{ user: User; csrf_token: string }>('/api/me')
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
} catch (_) {
authState.user = null
authState.csrf = ''
moduleState.loaded = false
moduleState.subscriptionEnabled = false
} finally {
authState.ready = true
}
}
export async function getCaptchaConfig(): Promise<CaptchaConfig> {
const data = await request<Partial<CaptchaConfig>>('/api/captcha-config')
return {
enabled: data.enabled === true,
provider: data.provider || '',
turnstile_enabled: data.turnstile_enabled === true,
turnstile_site_key: String(data.turnstile_site_key || ''),
tencent_captcha_enabled: data.tencent_captcha_enabled === true,
tencent_captcha_app_id: String(data.tencent_captcha_app_id || ''),
tencent_captcha_region: String(data.tencent_captcha_region || 'cn'),
aliyun_captcha_enabled: data.aliyun_captcha_enabled === true,
aliyun_captcha_scene_id: String(data.aliyun_captcha_scene_id || ''),
aliyun_captcha_prefix: String(data.aliyun_captcha_prefix || ''),
aliyun_captcha_region: String(data.aliyun_captcha_region || 'cn'),
}
}
export async function loadSubscriptionState(force = false): Promise<boolean> {
if (moduleState.loaded && !force) return moduleState.subscriptionEnabled
if (moduleState.loading) {
// A concurrent router/Shell request should observe the same eventual
// server decision instead of issuing an unbounded request fan-out.
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (!moduleState.loading) {
window.clearInterval(timer)
resolve()
}
}, 20)
})
return moduleState.subscriptionEnabled
}
moduleState.loading = true
try {
const data = await request<any>('/api/subscription/status')
const payload = unwrap<any>(data) || {}
moduleState.subscriptionEnabled = payload.enabled === true
} catch (_) {
moduleState.subscriptionEnabled = false
} finally {
moduleState.loaded = true
moduleState.loading = false
}
return moduleState.subscriptionEnabled
}
export async function login(email: string, password: string, extra: Record<string, unknown> = {}) {
const data = await request<{ requires_2fa?: boolean; pending_token?: string; user?: User; csrf_token?: string }>('/login', {
method: 'POST',
body: JSON.stringify({ email, password, ...extra }),
})
if (data.requires_2fa) {
authState.pendingToken = data.pending_token || ''
return { requires2fa: true }
}
authState.pendingToken = ''
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
moduleState.loaded = false
return { requires2fa: false }
}
export async function verify2fa(code: string) {
const data = await request<{ user?: User; csrf_token?: string }>('/login/2fa', {
method: 'POST',
body: JSON.stringify({ pending_token: authState.pendingToken, totp_code: code }),
})
authState.pendingToken = ''
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
moduleState.loaded = false
}
export async function logout() {
try {
await request('/logout', { method: 'POST' })
} finally {
authState.user = null
authState.csrf = ''
authState.pendingToken = ''
moduleState.loaded = false
moduleState.subscriptionEnabled = false
}
}
export function unwrap<T = any>(response: any): T {
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && ('code' in response || 'message' in response)) return response.data as T
return response as T
}
export function listFrom(value: any): any[] {
const data = unwrap<any>(value)
if (Array.isArray(data)) return data
if (Array.isArray(data?.items)) return data.items
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.rows)) return data.rows
return []
}
export function formatDate(value: unknown) {
if (!value) return '-'
const date = new Date(String(value))
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
export function stateLabel(value: unknown) {
const labels: Record<string, string> = {
healthy: '运行中', enabled: '运行中', disabled: '已停用', incompatible: 'Core 不兼容', error: '启用失败',
starting: '启动中', draining: '停用中', upgrading: '升级中', rollback_pending: '等待回滚',
}
return labels[String(value)] || String(value || '未知')
}
export function operationSucceeded(value: unknown) {
return ['completed', 'success', 'succeeded'].includes(String(value || '').toLowerCase())
}
export function operationTheme(value: unknown) {
const state = String(value || '').toLowerCase()
if (operationSucceeded(state)) return 'success'
if (state === 'failed' || state === 'error') return 'danger'
return 'warning'
}
export function operationKey(kind: string, id = '') {
return `${kind}-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
@@ -0,0 +1,8 @@
import { AppIcon, CheckCircleIcon, CloudDownloadIcon, DeleteIcon, HistoryIcon, RollbackIcon, SettingIcon, UploadIcon } from 'tdesign-icons-vue-next'
export const actionLabels: Record<string, string> = { install: '入库', marketplace_install: '市场入库', enable: '启用', disable: '停用', upgrade: '升级', rollback: '回滚', uninstall: '卸载', menu_preview: '菜单预览', menu_apply: '应用菜单', config: '保存配置' }
export function actionLabel(action: unknown) { return actionLabels[String(action)] || String(action || '操作') }
export function actionIcon(action: unknown) {
const icons: Record<string, any> = { install: CloudDownloadIcon, marketplace_install: CloudDownloadIcon, enable: CheckCircleIcon, disable: SettingIcon, upgrade: UploadIcon, rollback: RollbackIcon, uninstall: DeleteIcon, menu_preview: AppIcon, menu_apply: AppIcon, config: SettingIcon }
return icons[String(action)] || HistoryIcon
}
@@ -0,0 +1,89 @@
export type TencentCaptchaRegion = 'cn' | 'intl'
export interface TencentCaptchaProof {
ticket: string
randstr: string
}
interface TencentCaptchaResult {
ret: number
ticket?: string | null
randstr?: string | null
errorCode?: number
}
interface TencentCaptchaInstance {
show(): void
destroy(): void
}
interface TencentCaptchaConstructor {
new (appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
new (element: HTMLElement, appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
}
declare global {
interface Window {
TencentCaptcha?: TencentCaptchaConstructor
TCaptchaGlobal?: boolean
}
}
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js',
}
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
let loadedRegion: TencentCaptchaRegion | null = null
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
return value === 'intl' ? 'intl' : 'cn'
}
function existingRegion(): TencentCaptchaRegion {
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
}
export function loadTencentCaptcha(region: TencentCaptchaRegion = 'cn'): Promise<TencentCaptchaConstructor> {
if (window.TencentCaptcha && (loadedRegion === region || existingRegion() === region)) {
return Promise.resolve(window.TencentCaptcha)
}
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
}
if (scriptPromise && loadedRegion === region) return scriptPromise
loadedRegion = region
scriptPromise = new Promise<TencentCaptchaConstructor>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC[region]}"]`)
if (existing) {
const finish = () => window.TencentCaptcha && existingRegion() === region
? resolve(window.TencentCaptcha)
: reject(new Error('Tencent Captcha SDK unavailable'))
if (window.TencentCaptcha) finish()
else existing.addEventListener('load', finish, { once: true })
existing.addEventListener('error', () => reject(new Error('Failed to load Tencent Captcha SDK')), { once: true })
return
}
const script = document.createElement('script')
script.src = SCRIPT_SRC[region]
script.async = true
script.onload = () => {
if (window.TencentCaptcha && existingRegion() === region) resolve(window.TencentCaptcha)
else reject(new Error('Tencent Captcha SDK unavailable'))
}
script.onerror = () => reject(new Error('Failed to load Tencent Captcha SDK'))
document.head.appendChild(script)
}).catch((error) => {
scriptPromise = null
loadedRegion = null
throw error
})
return scriptPromise
}
export function resetTencentCaptchaLoaderForTest() {
scriptPromise = null
loadedRegion = null
}
+240
View File
@@ -0,0 +1,240 @@
:root {
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #1f2937;
background: #f3f6fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
--app-blue: #0052d9;
--app-ink: #1f2937;
--app-muted: #718096;
--app-border: #e5e7eb;
--app-panel: #fff;
}
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { overflow-x: hidden; background: #f3f6fa; }
button, input, textarea, select { font: inherit; }
a, button { -webkit-tap-highlight-color: transparent; }
.mono, code, .numeric, .stat-card strong { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-variant-numeric: tabular-nums; }
.wrap-anywhere { overflow-wrap: anywhere; word-break: break-word; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.boot-screen { min-height: 100vh; display: grid; place-items: center; color: var(--app-muted); }
/* Login */
.login-page { position: relative; min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; background: radial-gradient(circle at 12% 10%, #e8f1ff 0, transparent 34%), #f5f7fb; }
.login-accent { position: absolute; inset: 0 0 auto; height: 4px; background: var(--app-blue); }
.login-layout { width: min(1060px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 80px; align-items: center; }
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 auto; border-radius: 10px; color: #fff; background: var(--app-blue); box-shadow: 0 8px 20px rgb(0 82 217 / 20%); }
.brand-title { color: #111827; font-size: 18px; font-weight: 700; letter-spacing: .01em; }
.brand-subtitle { margin-top: 2px; color: var(--app-muted); font-size: 12px; }
.brand-login { margin-bottom: 72px; }
.login-intro .eyebrow { margin-bottom: 14px; }
.eyebrow { margin: 0 0 7px; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; line-height: 1.3; }
.login-intro h1 { max-width: 560px; margin: 0 0 18px; color: #111827; font-size: clamp(32px, 4vw, 48px); line-height: 1.12; letter-spacing: 0; }
.login-lead { max-width: 560px; margin: 0; color: #5f6b7a; font-size: 16px; line-height: 1.75; }
.login-points { display: grid; gap: 13px; margin-top: 34px; }
.login-points div { display: flex; align-items: center; gap: 10px; color: #4b5563; font-size: 13px; }
.login-points svg { color: #00a870; flex: 0 0 auto; }
.login-card { border-radius: 10px !important; box-shadow: 0 18px 50px rgb(15 23 42 / 9%); }
.login-card .t-card__body { padding: 30px; }
.login-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.login-card h2 { margin: 0; color: #111827; font-size: 25px; line-height: 1.25; }
.login-card > .t-card__body > .muted { margin: 0 0 24px; }
.muted, .secondary-text { color: var(--app-muted); font-size: 13px; line-height: 1.55; }
.login-form { display: grid; gap: 4px; }
.login-form .t-form__item { margin-bottom: 14px; }
.login-form .t-button { height: 42px; margin-top: 5px; }
.login-form .captcha-challenge + .t-button { margin-top: 2px; }
.login-error { margin-top: 18px; }
.login-security { display: flex; align-items: center; gap: 7px; justify-content: center; margin-top: 24px; padding-top: 18px; border-top: 1px solid #edf0f4; color: #8792a2; font-size: 11px; }
/* App shell */
.app-shell { min-height: 100vh; }
.app-aside { position: relative; z-index: 30; display: flex; flex-direction: column; min-height: 100vh; border-right: 1px solid #e6eaf0; background: #fff; }
.brand { padding: 23px 22px 20px; }
.app-aside .brand-mark { width: 36px; height: 36px; border-radius: 8px; box-shadow: none; }
.nav-caption { padding: 17px 22px 8px; color: #9aa5b4; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.nav-caption-module { padding-top: 5px; }
.main-menu { border-right: 0 !important; }
.main-menu .t-menu__item { min-height: 44px; margin: 2px 10px; border-radius: 5px; color: #4b5563; font-size: 14px; }
.main-menu .t-menu__item.t-is-active { color: var(--app-blue); background: #e8f1ff; }
.main-menu .t-menu__item:hover { background: #f4f7fb; }
.main-menu .t-menu__item.t-is-disabled { opacity: .55; }
.nav-divider { margin: 10px 22px 0 !important; }
.aside-footer { display: grid; gap: 10px; margin-top: auto; padding: 18px 22px 22px; border-top: 1px solid #edf0f4; }
.status-tag { justify-self: start; display: inline-flex; align-items: center; gap: 6px; }
.version-text { color: #9aa5b4; font-size: 11px; }
.main-layout { min-width: 0; min-height: 100vh; }
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 64px; padding: 0 34px; border-bottom: 1px solid #e6eaf0; background: #fff; }
.header-left, .header-right, .breadcrumbs, .user-button { display: flex; align-items: center; min-width: 0; }
.header-left, .header-right { gap: 13px; }
.breadcrumbs { gap: 9px; color: #8a96a5; font-size: 13px; }
.breadcrumbs strong { overflow: hidden; color: #1f2937; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.secure-tag { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.user-button { gap: 6px; color: #374151; }
.user-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-menu-button { display: none; }
.app-content { min-width: 0; padding: 32px 34px 56px; }
.page-view { width: min(1400px, 100%); min-width: 0; margin: 0 auto; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; padding-bottom: 21px; border-bottom: 0; }
.page-header h1 { margin: 0 0 5px; color: #111827; font-size: 28px; font-weight: 700; line-height: 1.25; letter-spacing: 0; }
.page-subtitle { max-width: 720px; margin: 0; color: #718096; font-size: 13px; line-height: 1.6; }
.page-actions, .toolbar-actions, .table-actions, .detail-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.page-actions { flex: 0 0 auto; }
.page-alert { margin-bottom: 16px; }
.page-toolbar, .section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-toolbar { align-items: flex-end; }
.section-toolbar h2 { margin: 0 0 4px; color: #1f2937; font-size: 20px; }
.toolbar-hint { color: #9aa5b4; font-size: 12px; }
/* Cards and statistics */
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.stat-card { min-width: 0; border-top: 3px solid #94a3b8 !important; border-radius: 7px !important; }
.stat-card .t-card__body { padding: 18px; }
.stat-card.stat-success { border-top-color: #00a870 !important; }
.stat-card.stat-warning { border-top-color: #ed7b2f !important; }
.stat-card.stat-danger { border-top-color: #d54941 !important; }
.stat-card.stat-primary { border-top-color: var(--app-blue) !important; }
.stat-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 15px; color: #637083; font-size: 12px; }
.stat-card-top svg { color: var(--app-blue); }
.stat-card strong { display: block; margin-bottom: 7px; color: #111827; font-size: 29px; font-weight: 700; line-height: 1; }
.stat-card small { color: #96a0ae; font-size: 11px; }
.stat-success .stat-card-top svg { color: #00a870; }
.stat-warning .stat-card-top svg { color: #ed7b2f; }
.stat-danger .stat-card-top svg { color: #d54941; }
.overview-grid, .detail-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(340px, .8fr); gap: 16px; margin-bottom: 16px; }
.panel-card, .table-card, .filter-card, .empty-card { min-width: 0; border-radius: 7px !important; }
.panel-card .t-card__header, .table-card .t-card__header { padding: 18px 20px 0; }
.panel-card .t-card__title, .table-card .t-card__title { color: #1f2937; font-size: 16px; font-weight: 600; }
.panel-card .t-card__body { padding: 18px 20px 20px; }
.panel-card .t-card__actions { color: var(--app-blue); }
.trend-chart { width: 100%; height: 255px; min-height: 220px; }
.guidance-card { margin-bottom: 0; }
.guidance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; }
.guidance-item { display: grid; grid-template-columns: 31px minmax(0, 1fr); gap: 10px; }
.guidance-number { color: var(--app-blue); font-size: 12px; font-weight: 700; line-height: 1.7; }
.guidance-item strong { color: #344054; font-size: 13px; }
.guidance-item p { margin: 5px 0 0; color: #7c8795; font-size: 12px; line-height: 1.55; }
.table-action { display: inline-flex; align-items: center; gap: 7px; color: #344054; }
.table-actions { justify-content: flex-start; flex-wrap: nowrap; }
.table-actions .t-button { flex: 0 0 auto; }
.cell-note { display: block; margin-top: 3px; color: #9aa5b4; font-size: 11px; }
.plugin-cell { display: flex; align-items: center; gap: 11px; min-width: 0; }
.plugin-cell > div:last-child { display: grid; gap: 3px; min-width: 0; }
.plugin-cell strong { overflow: hidden; color: #263244; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.plugin-avatar, .market-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 7px; color: var(--app-blue); background: #e8f1ff; }
.empty-card { padding: 34px; }
.filter-card { margin-bottom: 16px; }
.filter-card .t-card__body { padding: 17px 20px; }
.filter-grid { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 12px; }
.filter-grid .t-form__item { margin-bottom: 0; }
/* Detail */
.detail-back { margin-bottom: 14px; }
.detail-back .t-link { font-size: 13px; }
.detail-tabs { margin-bottom: 22px; }
.detail-tabs .t-tabs__nav { overflow-x: auto; }
.detail-tabs .t-tabs__nav-container { min-width: max-content; }
.detail-tabs .t-tabs__content { display: none; }
.detail-content { min-width: 0; }
.detail-actions { margin-bottom: 16px; }
.description-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; margin: 0; }
.description-grid > div { min-width: 0; }
.description-grid dt { margin-bottom: 5px; color: #8a96a5; font-size: 12px; }
.description-grid dd { min-width: 0; color: #344054; font-size: 13px; line-height: 1.5; }
.inline-alert { margin-top: 20px; }
.narrow-content { max-width: 900px; }
.tag-gap { margin-left: 8px; }
.preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.preview-grid h3 { margin: 0 0 8px; font-size: 13px; }
.preview-grid pre { min-height: 210px; max-height: 420px; margin: 0; padding: 13px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 5px; color: #344054; background: #f7f9fc; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.loading-panel { display: grid; place-items: center; min-height: 260px; }
.pagination-row { display: flex; justify-content: flex-end; padding-top: 17px; }
.drawer-description { margin-bottom: 20px; }
/* Marketplace */
.market-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.market-card { min-width: 0; border-radius: 7px !important; }
.market-card .t-card__header { padding: 20px 20px 0; }
.market-card .t-card__body { padding: 16px 20px; }
.market-card .t-card__footer { padding: 0 20px 20px; }
.market-heading { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.market-title { min-width: 0; flex: 1 1 auto; }
.market-title h2 { margin: 0 0 4px; overflow: hidden; color: #263244; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.market-heading > .t-tag { flex: 0 0 auto; }
.market-description { min-height: 42px; margin: 0 0 18px; color: #687585; font-size: 13px; line-height: 1.6; }
.market-meta { gap: 13px 20px; margin-bottom: 17px; }
.market-hash { display: grid; gap: 5px; padding-top: 13px; border-top: 1px solid #edf0f4; }
.market-hash span { color: #9aa5b4; font-size: 11px; }
.market-hash code { color: #556274; font-size: 11px; overflow-wrap: anywhere; }
/* TDesign table containment */
.table-card { overflow: hidden; }
.table-card .t-card__body { min-width: 0; padding: 0 0 18px; }
.table-card .t-table { min-width: 680px; }
.table-card .t-table__content { overflow-x: auto; }
.table-card .t-table__header th { background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 600; }
.table-card .t-table__body td { color: #344054; font-size: 13px; }
.table-card .t-table__pagination { padding: 15px 20px 0; }
@media (max-width: 1100px) {
.login-layout { gap: 44px; }
.app-header { padding: 0 24px; }
.app-content { padding: 28px 24px 44px; }
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-grid, .detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.login-page { padding: 26px 16px; place-items: start center; }
.login-layout { grid-template-columns: 1fr; gap: 28px; margin-top: 16px; }
.brand-login { margin-bottom: 34px; }
.login-intro h1 { font-size: 33px; }
.login-lead { font-size: 14px; }
.login-points { display: none; }
.login-card .t-card__body { padding: 22px 18px; }
.app-aside { position: fixed; inset: 0 auto 0 0; width: min(280px, calc(100vw - 48px)); min-height: 100vh; transform: translateX(-105%); transition: transform .2s ease; box-shadow: 12px 0 32px rgb(15 23 42 / 16%); }
.app-aside.app-aside-mobile-open { transform: translateX(0); }
.mobile-scrim { position: fixed; z-index: 20; inset: 0; background: rgb(15 23 42 / 32%); }
.mobile-menu-button { display: inline-flex; }
.app-header { height: 58px; padding: 0 14px; }
.header-left, .header-right { gap: 8px; }
.secure-tag { display: none; }
.user-name { max-width: 108px; }
.app-content { padding: 22px 14px 36px; }
.page-header { align-items: flex-start; flex-direction: column; gap: 14px; margin-bottom: 18px; }
.page-header h1 { font-size: 23px; }
.page-actions { width: 100%; justify-content: flex-start; }
.page-actions .t-button { flex: 1 1 auto; min-width: 0; }
.page-toolbar, .section-toolbar { align-items: flex-start; flex-direction: column; gap: 8px; }
.section-toolbar .t-button, .toolbar-actions { width: 100%; justify-content: flex-start; }
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.stat-card .t-card__body { padding: 13px; }
.stat-card strong { font-size: 23px; }
.stat-card-top { margin-bottom: 11px; }
.overview-grid, .detail-grid, .market-grid { grid-template-columns: 1fr; gap: 12px; }
.guidance-grid { grid-template-columns: 1fr; gap: 15px; }
.panel-card .t-card__header, .table-card .t-card__header { padding: 15px 15px 0; }
.panel-card .t-card__body { padding: 15px; }
.filter-card .t-card__body { padding: 14px; }
.filter-grid { grid-template-columns: 1fr; gap: 10px; }
.filter-grid .t-button { justify-self: start; }
.description-grid { grid-template-columns: 1fr; gap: 13px; }
.detail-tabs { margin-bottom: 17px; }
.detail-tabs .t-tabs__nav-item { padding: 0 12px; }
.preview-grid { grid-template-columns: 1fr; }
.market-card .t-card__header { padding: 16px 15px 0; }
.market-card .t-card__body { padding: 14px 15px; }
.market-card .t-card__footer { padding: 0 15px 15px; }
.table-actions { flex-wrap: wrap; }
.table-card .t-table { min-width: 720px; }
.pagination-row { justify-content: flex-start; overflow-x: auto; }
}
@media (max-width: 430px) {
.app-content { padding-left: 10px; padding-right: 10px; }
.page-actions .t-button { padding-left: 10px; padding-right: 10px; font-size: 12px; }
.breadcrumbs span { display: none; }
.breadcrumbs strong { max-width: 150px; }
.user-name { max-width: 90px; }
.stat-card small { font-size: 10px; }
}
@@ -0,0 +1,181 @@
<template>
<main class="login-page">
<div class="login-accent" />
<section class="login-layout">
<div class="login-intro">
<div class="brand brand-login"><div class="brand-mark"><ControlPlatformIcon size="24px" /></div><div><div class="brand-title">Sub2API</div><div class="brand-subtitle">插件控制面</div></div></div>
<p class="eyebrow">BUSINESS PLUGIN CONTROL PLANE</p>
<h1>统一管理你的业务插件</h1>
<p class="login-lead">使用 Core 管理员会话进入插件市场、生命周期和业务模块。所有插件操作都在同一安全控制面完成。</p>
<div class="login-points">
<div><CheckCircleIcon size="18px" /><span>一次登录,访问已启用业务模块</span></div>
<div><CheckCircleIcon size="18px" /><span>凭据仅保存在服务端会话</span></div>
<div><CheckCircleIcon size="18px" /><span>每次变更都有可追溯操作记录</span></div>
</div>
</div>
<t-card class="login-card" :bordered="true">
<div class="login-card-heading"><div><p class="eyebrow">ADMIN ACCESS</p><h2>管理员登录</h2></div><t-tag theme="primary" variant="light">仅管理员</t-tag></div>
<p class="muted">使用 Sub2API Core 的管理员账号登录,普通账号没有访问权限。</p>
<t-alert v-if="captchaConfigError" class="login-error" theme="warning" :message="captchaConfigError" />
<t-form v-if="!twoFactor" ref="loginForm" class="login-form" :data="credentials" :rules="rules" @submit="submitLogin">
<t-form-item label="邮箱" name="email"><t-input v-model="credentials.email" type="text" aria-label="邮箱" autocomplete="username" placeholder="admin@example.com" /></t-form-item>
<t-form-item label="密码" name="password"><t-input v-model="credentials.password" type="password" aria-label="密码" autocomplete="current-password" placeholder="输入管理员密码" /></t-form-item>
<CaptchaChallenge
v-if="captchaLoaded && captchaConfig.enabled"
ref="captchaRef"
:enabled="captchaConfig.enabled"
:provider="captchaConfig.provider"
:turnstile-site-key="captchaConfig.turnstile_site_key"
:tencent-app-id="captchaConfig.tencent_captcha_app_id"
:tencent-region="captchaConfig.tencent_captcha_region"
:aliyun-scene-id="captchaConfig.aliyun_captcha_scene_id"
:aliyun-prefix="captchaConfig.aliyun_captcha_prefix"
:aliyun-region="captchaConfig.aliyun_captcha_region"
@verify="onCaptchaVerify"
@expire="onCaptchaExpire"
@error="onCaptchaError"
/>
<t-button theme="primary" type="submit" block :loading="loading" :disabled="!captchaLoaded || Boolean(captchaConfigError)"><template #icon><UserCircleIcon /></template>登录</t-button>
</t-form>
<t-form v-else class="login-form" :data="twoFactorData" @submit="submit2fa">
<t-alert theme="info" title="需要二次验证">请输入 Core 返回的 6 位验证码。</t-alert>
<t-form-item label="验证码" name="totp_code"><t-input v-model="twoFactorData.totp_code" inputmode="numeric" maxlength="6" placeholder="000000" /></t-form-item>
<t-button theme="primary" type="submit" block :loading="loading"><template #icon><CheckCircleIcon /></template>验证并继续</t-button>
<t-button variant="text" block @click="resetTwoFactor">返回账号登录</t-button>
</t-form>
<t-alert v-if="errorMessage" class="login-error" theme="error" :message="errorMessage" />
<div class="login-security"><LockOnIcon size="15px" /> HttpOnly 会话 · CSRF 保护 · 8 小时绝对有效期</div>
</t-card>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircleIcon, ControlPlatformIcon, LockOnIcon, UserCircleIcon } from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import CaptchaChallenge, { type CaptchaProof } from '../components/CaptchaChallenge.vue'
import { getCaptchaConfig, login, verify2fa, type CaptchaConfig } from '../services/api'
const router = useRouter()
const loading = ref(false)
const twoFactor = ref(false)
const errorMessage = ref('')
const captchaLoaded = ref(false)
const captchaConfigError = ref('')
const captchaRef = ref<InstanceType<typeof CaptchaChallenge> | null>(null)
const captchaProof = ref<CaptchaProof | null>(null)
const captchaConfig = reactive<CaptchaConfig>({
enabled: false,
provider: '',
turnstile_enabled: false,
turnstile_site_key: '',
tencent_captcha_enabled: false,
tencent_captcha_app_id: '',
tencent_captcha_region: 'cn',
aliyun_captcha_enabled: false,
aliyun_captcha_scene_id: '',
aliyun_captcha_prefix: '',
aliyun_captcha_region: 'cn',
})
const credentials = reactive({ email: '', password: '' })
const twoFactorData = reactive({ totp_code: '' })
const rules = {
email: [{ required: true, message: '请输入管理员邮箱', type: 'error' as const }],
password: [{ required: true, message: '请输入密码', type: 'error' as const }],
}
async function submitLogin() {
if (!credentials.email.trim() || !credentials.password) { errorMessage.value = '请输入邮箱和密码'; return }
loading.value = true
errorMessage.value = ''
try {
const proof = captchaConfig.enabled ? (await captchaRef.value?.verifyAction()) || null : null
if (captchaConfig.enabled && !proof) {
errorMessage.value = '请先完成安全验证'
return
}
captchaProof.value = proof
const extra = proof?.provider === 'tencent'
? { tencent_captcha_ticket: proof.token, tencent_captcha_randstr: proof.randstr }
: proof?.provider === 'aliyun'
? { turnstile_token: proof.token }
: proof?.provider === 'turnstile'
? { turnstile_token: proof.token }
: {}
const result = await login(credentials.email.trim(), credentials.password, extra)
if (result.requires2fa) twoFactor.value = true
else {
MessagePlugin.success('登录成功')
router.push('/overview')
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
} finally {
captchaRef.value?.reset()
captchaProof.value = null
loading.value = false
}
}
async function submit2fa() {
if (!/^\d{6}$/.test(twoFactorData.totp_code)) {
errorMessage.value = '请输入 6 位数字验证码'
return
}
loading.value = true
errorMessage.value = ''
try {
await verify2fa(twoFactorData.totp_code)
MessagePlugin.success('登录成功')
router.push('/overview')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '验证码校验失败'
} finally {
loading.value = false
}
}
function resetTwoFactor() {
twoFactor.value = false
twoFactorData.totp_code = ''
errorMessage.value = ''
}
function onCaptchaVerify(proof: CaptchaProof) {
captchaProof.value = proof
errorMessage.value = ''
}
function onCaptchaExpire() {
captchaProof.value = null
}
function onCaptchaError() {
captchaProof.value = null
errorMessage.value = '安全验证加载失败,请刷新页面重试'
}
onMounted(async () => {
captchaConfigError.value = ''
try {
const loaded = await getCaptchaConfig()
Object.assign(captchaConfig, loaded)
const enabledProviders = [
loaded.turnstile_enabled,
loaded.tencent_captcha_enabled,
loaded.aliyun_captcha_enabled,
].filter(Boolean).length
if (enabledProviders > 1) {
captchaConfigError.value = 'Core 同时启用了多个验证码服务,请保留一个服务商后重试'
} else if (loaded.enabled && !loaded.provider) {
captchaConfigError.value = '验证码配置不完整,请联系管理员'
}
} catch (error) {
captchaConfigError.value = error instanceof Error ? `验证码配置加载失败:${error.message}` : '验证码配置加载失败,请刷新页面重试'
} finally {
captchaLoaded.value = true
}
})
</script>
@@ -0,0 +1,35 @@
<template>
<div class="page-view">
<PageHeader eyebrow="MARKETPLACE" title="插件市场" subtitle="从受控目录查看可用版本,下载后先入库,再由管理员手动启用。">
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新市场</t-button></template>
</PageHeader>
<t-alert theme="info" class="page-alert" message="归档地址只在控制面服务端访问,浏览器不会接触下载 URL。" />
<t-alert v-if="error" theme="error" class="page-alert" :message="error" />
<section v-if="items.length" class="market-grid">
<t-card v-for="item in items" :key="`${item.plugin_id}-${item.version}`" class="market-card" :bordered="true">
<template #header><div class="market-heading"><div class="market-icon"><CloudDownloadIcon size="20px" /></div><div class="market-title"><h2>{{ item.name || item.plugin_id }}</h2><span class="secondary-text mono">{{ item.plugin_id }} · v{{ item.version || '-' }}</span></div><t-tag :theme="marketState(item).theme as any" variant="light">{{ marketState(item).label }}</t-tag></div></template>
<p class="market-description">{{ item.description || '由受控插件目录提供的 Business Plugin' }}</p>
<dl class="description-grid market-meta"><div><dt>发布者</dt><dd>{{ item.publisher || item.publisher_key_id || '-' }}</dd></div><div><dt>兼容性</dt><dd>{{ item.compatibility?.status || 'unknown' }}</dd></div><div><dt>发布时间</dt><dd>{{ formatDate(item.published_at) }}</dd></div><div><dt>包大小</dt><dd>{{ formatSize(item.archive_size) }}</dd></div></dl>
<div class="market-hash"><span>SHA-256</span><code>{{ item.archive_sha256 || '-' }}</code></div>
<template #footer><t-button v-if="!item.installed" theme="primary" block :loading="busy === item.plugin_id" @click="install(item)"><template #icon><CloudDownloadIcon /></template>下载并入库</t-button><t-button v-else variant="outline" block @click="router.push(`/plugins/${encodeURIComponent(item.plugin_id)}/revisions`)">查看已安装版本</t-button></template>
</t-card>
</section>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="暂无可用插件" /></t-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { CloudDownloadIcon, RefreshIcon } from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, operationKey, request } from '../services/api'
const router = useRouter(); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
function marketState(item: any) { if (!item.installed) return { label: '可入库', theme: 'primary' }; return String(item.installed_version).replace(/^v/, '') === String(item.version).replace(/^v/, '') ? { label: '已入库', theme: 'success' } : { label: '已有版本', theme: 'warning' } }
function formatSize(value: unknown) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return '-'; if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${Math.ceil(n / 1024)} KB` }
async function load() { loading.value = true; error.value = ''; try { const [market, installed] = await Promise.all([request<any>('/api/marketplace'), request<any>('/api/plugins')]); const map = new Map(listFrom(installed).map((p) => [p.plugin_id, p])); items.value = listFrom(market).map((item) => ({ ...item, installed: item.installed || map.has(item.plugin_id), installed_version: item.installed_version || map.get(item.plugin_id)?.version })) } catch (e) { error.value = e instanceof Error ? e.message : '市场加载失败' } finally { loading.value = false } }
async function install(item: any) { busy.value = item.plugin_id; try { const result = await request<any>('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('marketplace-install', `${item.plugin_id}-${item.version}`) }, body: JSON.stringify({ plugin_id: item.plugin_id, version: item.version }) }); MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`); await load() } catch (e) { error.value = e instanceof Error ? e.message : '插件入库失败' } finally { busy.value = '' } }
onMounted(load)
</script>
@@ -0,0 +1,30 @@
<template>
<div class="page-view">
<PageHeader eyebrow="OPERATIONS" title="操作记录" subtitle="按插件和操作类型筛选控制面审计结果。">
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></template>
</PageHeader>
<t-card class="filter-card" :bordered="true"><div class="filter-grid"><t-select v-model="filters.plugin" label="插件" placeholder="全部插件" clearable><t-option value="" label="全部插件" /> <t-option v-for="plugin in plugins" :key="plugin.plugin_id" :value="plugin.plugin_id" :label="plugin.name || plugin.plugin_id" /></t-select><t-select v-model="filters.action" label="操作类型" placeholder="全部操作" clearable><t-option value="" label="全部操作" /><t-option v-for="action in actionSet" :key="action" :value="action" :label="actionLabel(action)" /></t-select><t-button variant="text" @click="clearFilters">清除筛选</t-button></div></t-card>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<t-card class="table-card" :bordered="true"><t-table :data="filteredItems" :columns="columns" row-key="operation_id" :loading="loading" :hover="true"><template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template><template #plugin_id="{ row }"><span class="mono wrap-anywhere">{{ row.plugin_id || '-' }}</span></template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #request_id="{ row }"><span class="secondary-text mono wrap-anywhere">{{ row.request_id || '-' }}</span></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template><template #operation_id="{ row }"><t-link v-if="row.operation_id" theme="primary" @click="openDetail(row.operation_id)">查看详情</t-link></template></t-table><t-empty v-if="!filteredItems.length" description="还没有操作记录" /></t-card>
<t-drawer v-model:visible="detailVisible" header="操作详情" size="min(520px, 100vw)"><dl v-if="detail" class="description-grid drawer-description"><div><dt>操作 ID</dt><dd class="mono wrap-anywhere">{{ detail.id }}</dd></div><div><dt>操作类型</dt><dd>{{ actionLabel(detail.kind) }}</dd></div><div><dt>插件</dt><dd class="mono wrap-anywhere">{{ detail.plugin_id || '-' }}</dd></div><div><dt>状态</dt><dd>{{ detail.state || '-' }}</dd></div><div><dt>请求 ID</dt><dd class="mono wrap-anywhere">{{ detail.request_id || '-' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(detail.updated_at) }}</dd></div></dl><t-alert v-if="detail?.error" theme="error" :message="detail.error" /><t-alert v-if="detail?.warning" theme="warning" :message="detail.warning" /></t-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshIcon } from 'tdesign-icons-vue-next'
import { useRouter } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import { actionIcon, actionLabel } from '../services/labels'
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
const router = useRouter(); const plugins = ref<any[]>([]); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const filters = ref({ plugin: '', action: '' }); const detailVisible = ref(false); const detail = ref<any>(null)
const columns = [{ colKey: 'action', title: '操作', width: 170 }, { colKey: 'plugin_id', title: '插件', minWidth: 220 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }, { colKey: 'operation_id', title: '', width: 100, fixed: 'right' as const }]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const actionSet = computed(() => [...new Set(items.value.map((item) => item.action).filter(Boolean))].sort())
const filteredItems = computed(() => items.value.filter((item) => (!filters.value.plugin || item.plugin_id === filters.value.plugin) && (!filters.value.action || item.action === filters.value.action)).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()))
function clearFilters() { filters.value = { plugin: '', action: '' } }
async function load() { loading.value = true; error.value = ''; try { const [audit, pluginData] = await Promise.all([request<any>('/api/audit'), request<any>('/api/plugins')]); items.value = listFrom(audit); plugins.value = listFrom(pluginData) } catch (e) { error.value = e instanceof Error ? e.message : '操作记录加载失败' } finally { loading.value = false } }
async function openDetail(id: string) { detailVisible.value = true; detail.value = await request(`/api/operations/${encodeURIComponent(id)}`).catch(() => null) }
onMounted(load)
</script>
@@ -0,0 +1,86 @@
<template>
<div class="page-view">
<PageHeader eyebrow="OVERVIEW" title="概览" subtitle="查看插件控制面的总体状态、运行健康和最近活动。">
<template #actions><t-button theme="primary" @click="router.push('/plugins')"><template #icon><AppIcon /></template>管理插件</t-button></template>
</PageHeader>
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
<section class="stat-grid" aria-label="插件统计">
<t-card v-for="stat in stats" :key="stat.label" class="stat-card" :class="`stat-${stat.theme}`" :bordered="true">
<div class="stat-card-top"><span>{{ stat.label }}</span><component :is="stat.icon" size="18px" /></div>
<strong>{{ stat.value }}</strong><small>{{ stat.caption }}</small>
</t-card>
</section>
<section class="overview-grid">
<t-card class="panel-card" title="近期操作" :bordered="true">
<template #actions><t-link theme="primary" @click="router.push('/operations')">查看全部</t-link></template>
<t-table v-if="recent.length" :data="recent" :columns="recentColumns" row-key="id" size="small" :hover="true">
<template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template>
<template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template>
<template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template>
</t-table>
<t-empty v-else description="还没有操作记录" />
</t-card>
<t-card class="panel-card" title="操作趋势" :bordered="true">
<template #actions><t-tag theme="primary" variant="light">最近 7 天</t-tag></template>
<TrendChart :labels="trendLabels" :values="trendValues" />
</t-card>
</section>
<t-card class="panel-card guidance-card" title="生命周期提示" :bordered="true">
<div class="guidance-grid">
<div v-for="(item, index) in guidance" :key="item.title" class="guidance-item"><span class="guidance-number">{{ String(index + 1).padStart(2, '0') }}</span><div><strong>{{ item.title }}</strong><p>{{ item.body }}</p></div></div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { AppIcon, CheckCircleIcon, ErrorCircleIcon, TimeIcon, HistoryIcon } from 'tdesign-icons-vue-next'
import { useRouter } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import TrendChart from '../components/TrendChart.vue'
import { actionIcon, actionLabel } from '../services/labels'
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
const router = useRouter()
const plugins = ref<any[]>([])
const recent = ref<any[]>([])
const error = ref('')
const recentColumns = [
{ colKey: 'action', title: '操作', width: 150 },
{ colKey: 'plugin_id', title: '插件', ellipsis: true },
{ colKey: 'result', title: '结果', width: 90 },
{ colKey: 'time', title: '时间', width: 170 },
]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const stats = computed(() => {
const healthy = plugins.value.filter((p) => ['healthy', 'enabled'].includes(p.state)).length
const staged = plugins.value.filter((p) => ['disabled', 'incompatible'].includes(p.state) || p.installation_status === 'staged').length
const attention = plugins.value.filter((p) => ['error', 'starting', 'upgrading', 'rollback_pending'].includes(p.state)).length
return [
{ label: '已登记插件', value: plugins.value.length, caption: '全部已校验版本', theme: 'neutral', icon: AppIcon },
{ label: '运行中', value: healthy, caption: '健康检查通过', theme: 'success', icon: CheckCircleIcon },
{ label: '待启用', value: staged, caption: '已入库,等待启动', theme: 'warning', icon: TimeIcon },
{ label: '需要关注', value: attention, caption: '失败或迁移中', theme: 'danger', icon: ErrorCircleIcon },
]
})
const guidance = [
{ title: '入库不等于启用', body: '上传或市场下载只完成验签、哈希和兼容性校验。' },
{ title: '启用前先配置', body: '服务地址和菜单地址在插件详情的配置页维护。' },
{ title: '高风险操作可追溯', body: '升级、回滚和卸载均保留操作 ID 与审计结果。' },
]
const trendLabels = computed(() => ['-6 天', '-5 天', '-4 天', '-3 天', '-2 天', '昨天', '今天'])
const trendValues = computed(() => {
const values = new Array(7).fill(0)
recent.value.forEach((item) => { const date = new Date(item.time); if (!Number.isNaN(date.getTime())) { const days = Math.floor((Date.now() - date.getTime()) / 86400000); if (days >= 0 && days < 7) values[6 - days] += 1 } })
return values
})
async function load() {
try {
const [pluginData, auditData] = await Promise.all([request('/api/plugins'), request('/api/audit')])
plugins.value = listFrom(pluginData)
recent.value = listFrom(auditData).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()).slice(0, 8)
} catch (e) { error.value = e instanceof Error ? e.message : '概览数据加载失败' }
}
onMounted(load)
</script>
@@ -0,0 +1,96 @@
<template>
<div class="page-view">
<div class="detail-back"><t-link theme="primary" @click="router.push('/plugins')"><template #prefix-icon><ChevronLeftIcon /></template>返回已安装插件</t-link></div>
<PageHeader eyebrow="PLUGIN DETAIL" :title="plugin?.name || String(route.params.id)" :subtitle="`${String(route.params.id)} · v${plugin?.version || '-'}`">
<template #actions><t-tag v-if="plugin" :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template>
</PageHeader>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<t-tabs v-model="activeTab" class="detail-tabs" @change="onTabChange">
<t-tab-panel v-for="tab in tabs" :key="tab.value" :value="tab.value" :label="tab.label" />
</t-tabs>
<div v-if="loading" class="loading-panel"><t-loading text="加载中" /></div>
<template v-else-if="plugin">
<section v-if="activeTab === 'overview'" class="detail-content">
<div class="detail-actions"><t-button v-if="isHealthy(plugin)" variant="outline" :loading="busy === 'disable'" @click="mutate('disable')"><template #icon><PauseIcon /></template>停用插件</t-button><t-button v-else theme="primary" :disabled="plugin.state === 'incompatible'" :loading="busy === 'enable'" @click="mutate('enable')"><template #icon><PlayCircleIcon /></template>启用插件</t-button><t-button theme="danger" variant="outline" :disabled="isHealthy(plugin)" :loading="busy === 'uninstall'" @click="mutate('uninstall')"><template #icon><DeleteIcon /></template>卸载插件</t-button></div>
<div class="detail-grid">
<t-card title="健康状态" class="panel-card" :bordered="true"><template #actions><t-tag :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template><dl class="description-grid"><div><dt>安装状态</dt><dd>{{ plugin.installation_status || '-' }}</dd></div><div><dt>服务地址</dt><dd class="mono wrap-anywhere">{{ plugin.endpoint || '未配置' }}</dd></div><div><dt>活动 revision</dt><dd class="mono">{{ plugin.active_revision || '-' }}</dd></div><div><dt>待切换 revision</dt><dd class="mono">{{ plugin.pending_revision || '-' }}</dd></div></dl><t-alert v-if="plugin.last_error" theme="error" :message="plugin.last_error" class="inline-alert" /><t-alert v-else theme="success" message="最近一次检查未发现错误。" class="inline-alert" /></t-card>
<t-card title="插件信息" class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>能力</dt><dd class="wrap-anywhere">{{ (plugin.capabilities || []).join(', ') || '未声明' }}</dd></div><div><dt>Core 兼容性</dt><dd>{{ plugin.compatibility?.status || 'unknown' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(plugin.updated_at) }}</dd></div><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '未声明' }}</dd></div></dl></t-card>
</div>
</section>
<section v-else-if="activeTab === 'revisions'" class="detail-content">
<div class="section-toolbar"><div><h2>版本与升级</h2><p class="secondary-text">新版本先校验并进入候选 revision,健康检查通过后才会切换。</p></div><input ref="upgradeInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onUpgradeSelected" /><t-button theme="primary" :loading="busy === 'upgrade'" @click="upgradeInput?.click()"><template #icon><UploadIcon /></template>上传升级</t-button></div>
<t-card class="table-card" :bordered="true"><t-table v-if="visibleRevisions.length" :data="visibleRevisions" :columns="revisionColumns" row-key="id" :hover="true"><template #version="{ row }"><div><strong>v{{ row.version || '-' }}</strong><t-tag v-if="row.id === plugin.active_revision" theme="success" variant="light" class="tag-gap">当前活动</t-tag><div class="secondary-text mono wrap-anywhere">{{ row.id }}</div></div></template><template #hash="{ row }"><span class="mono wrap-anywhere">{{ row.archive_sha256 || '-' }}</span></template><template #verified="{ row }"><span class="secondary-text">{{ formatDate(row.verified_at) }}</span></template><template #actions="{ row }"><t-button v-if="row.id !== plugin.active_revision && !row.retired" size="medium" variant="outline" :loading="busy === row.id" @click="mutate('rollback', row.id)"><template #icon><RollbackIcon /></template>回滚到此版本</t-button></template></t-table><t-empty v-else description="暂无可用版本记录" /></t-card>
</section>
<section v-else-if="activeTab === 'config'" class="detail-content narrow-content">
<div class="section-toolbar"><div><h2>配置</h2><p class="secondary-text">服务地址和菜单地址由控制面保存,密钥不会回显到浏览器。</p></div></div>
<t-card class="panel-card" :bordered="true"><t-form :data="configForm" :rules="configRules" @submit="saveConfig"><t-form-item label="服务地址" name="service_url"><t-input v-model="configForm.service_url" placeholder="http://127.0.0.1:18090" /></t-form-item><t-form-item v-if="!isSubscription" label="菜单地址" name="public_url"><t-input v-model="configForm.public_url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/" /></t-form-item><t-alert v-if="isSubscription" theme="info" message="订阅模块的菜单固定进入 Plugin Admin 统一 Shell;这里只需配置服务地址用于健康检查。" class="inline-alert" /><t-alert v-else theme="info" message="敏感配置只在服务端加密保存,页面不会回显原值。" class="inline-alert" /><t-button theme="primary" type="submit" :loading="busy === 'config'"><template #icon><SaveIcon /></template>保存配置</t-button></t-form></t-card>
</section>
<section v-else-if="activeTab === 'menu'" class="detail-content narrow-content">
<div class="section-toolbar"><div><h2>菜单接入</h2><p class="secondary-text">先预览菜单变更,再应用到 Core 管理员导航。</p></div><div class="toolbar-actions"><t-button variant="outline" :loading="busy === 'menu-preview'" @click="menuPreview"><template #icon><ViewListIcon /></template>预览菜单</t-button><t-button theme="primary" :loading="busy === 'menu-apply'" @click="mutate('menu-apply')"><template #icon><CheckIcon /></template>应用菜单</t-button></div></div>
<t-card class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '-' }}</dd></div><div><dt>菜单名称</dt><dd>{{ plugin.menu?.label || '-' }}</dd></div><div><dt>可见性</dt><dd>{{ plugin.menu?.visibility || 'admin' }}</dd></div><div><dt>排序</dt><dd>{{ plugin.menu?.sort_order ?? '-' }}</dd></div></dl><t-alert theme="info" message="应用菜单只会更新 Core 的 custom_menu_items,不会修改 Core 源码。" class="inline-alert" /></t-card>
</section>
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>操作历史</h2><p class="secondary-text">只显示当前插件的生命周期和菜单操作。</p></div><t-button variant="outline" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="pluginAudit" :columns="auditColumns" row-key="operation_id" :hover="true"><template #action="{ row }">{{ actionLabel(row.action) }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!pluginAudit.length" description="还没有操作记录" /></t-card></section>
</template>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="插件不存在或已卸载" /></t-card>
<t-dialog v-model:visible="previewVisible" header="菜单变更预览" width="min(880px, calc(100vw - 32px))" :footer="false"><div class="preview-grid"><div><h3>当前菜单</h3><pre>{{ JSON.stringify(menuCurrent, null, 2) }}</pre></div><div><h3>应用后菜单</h3><pre>{{ JSON.stringify(menuNext, null, 2) }}</pre></div></div></t-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CheckIcon, ChevronLeftIcon, DeleteIcon, PauseIcon, PlayCircleIcon, RefreshIcon, RollbackIcon, SaveIcon, UploadIcon, ViewListIcon } from 'tdesign-icons-vue-next'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { actionLabel } from '../services/labels'
import { formatDate, operationKey, operationSucceeded, operationTheme, request, stateLabel } from '../services/api'
const route = useRoute(); const router = useRouter()
const plugin = ref<any | null>(null); const pluginAudit = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
const tabs = [{ value: 'overview', label: '运行概况' }, { value: 'revisions', label: '版本与升级' }, { value: 'config', label: '配置' }, { value: 'menu', label: '菜单接入' }, { value: 'operations', label: '操作历史' }]
const activeTab = ref(String(route.params.tab || 'overview')); const upgradeInput = ref<HTMLInputElement | null>(null)
const configForm = reactive({ service_url: '', public_url: '' }); const configRules = { service_url: [{ required: true, message: '请输入服务地址', type: 'error' as const }] }
const previewVisible = ref(false); const menuCurrent = ref<any[]>([]); const menuNext = ref<any[]>([])
const revisionColumns = [{ colKey: 'version', title: '版本', minWidth: 250 }, { colKey: 'hash', title: 'SHA-256', minWidth: 300 }, { colKey: 'verified', title: '验证时间', width: 180 }, { colKey: 'actions', title: '操作', width: 160 }]
const auditColumns = [{ colKey: 'action', title: '操作', width: 180 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 250 }, { colKey: 'time', title: '时间', width: 180 }]
const isHealthy = (item: any) => ['healthy', 'enabled'].includes(item.state)
const tagTheme = (state: string) => isHealthy({ state }) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
const isSubscription = computed(() => plugin.value?.plugin_id === 'qiu.subscription-admin')
const visibleRevisions = computed(() => (plugin.value?.revisions || []).filter((revision: any) => !revision.retired))
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
function onTabChange(value: string | number) { activeTab.value = String(value); router.replace(`/plugins/${encodeURIComponent(String(route.params.id))}/${String(value)}`) }
async function load() {
loading.value = true; error.value = ''
try {
const id = encodeURIComponent(String(route.params.id)); plugin.value = await request(`/api/plugins/${id}`)
const audits = await request<any>('/api/audit'); pluginAudit.value = (Array.isArray(audits.items) ? audits.items : []).filter((item: any) => item.plugin_id === plugin.value.plugin_id).sort((a: any, b: any) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime())
if (['config', 'menu'].includes(activeTab.value)) { const cfg = await request<any>(`/api/plugins/${id}/config`); configForm.service_url = cfg.endpoint || ''; configForm.public_url = typeof cfg.config?.public_url === 'string' ? cfg.config.public_url : '' }
} catch (e) { error.value = e instanceof Error ? e.message : '插件详情加载失败' }
finally { loading.value = false }
}
async function waitOperation(id: string) { for (let i = 0; i < 20 && id; i += 1) { const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null); if (op?.state && op.state !== 'running') return op; await new Promise((resolve) => window.setTimeout(resolve, 200)) } return null }
async function mutate(action: string, revision = '') {
const id = encodeURIComponent(String(route.params.id)); const endpoint = action === 'rollback' ? `/api/plugins/${id}/rollback` : `/api/plugins/${id}/${action}`
if (['disable', 'uninstall'].includes(action)) { const dialog = DialogPlugin.confirm({ header: action === 'uninstall' ? '确认卸载插件' : '确认停用插件', body: action === 'uninstall' ? '卸载会移除插件文件,但不会删除 Core 数据。' : '停用后 Core 管理员菜单会被移除。', confirmBtn: action === 'uninstall' ? '卸载' : '停用', cancelBtn: '取消', onConfirm: () => { dialog.hide(); performMutation(endpoint, action, revision) } }); return }
await performMutation(endpoint, action, revision)
}
async function performMutation(endpoint: string, action: string, revision = '') {
busy.value = action === 'rollback' ? revision : action
try {
const body = action === 'rollback' && revision ? JSON.stringify({ revision }) : undefined
const result = await request<any>(endpoint, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, revision) }, body })
const final = await waitOperation(result.operation_id)
const state = final?.state || result.state
if (state === 'failed') throw new Error(final?.error || result.error || `${actionLabel(action)}失败`)
if (state && !operationSucceeded(state)) MessagePlugin.warning(`${actionLabel(action)}操作仍在处理中`)
else MessagePlugin.success(`${actionLabel(action)}已完成`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' } finally { busy.value = '' }
}
async function saveConfig() { if (!configForm.service_url.trim()) { error.value = '请输入服务地址'; return }; busy.value = 'config'; try { const payload = isSubscription.value ? { service_url: configForm.service_url } : configForm; const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/config`, { method: 'PUT', headers: { 'Idempotency-Key': operationKey('config', String(route.params.id)) }, body: JSON.stringify(payload) }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '配置保存失败'); MessagePlugin.success('配置已保存'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '配置保存失败' } finally { busy.value = '' } }
async function menuPreview() { busy.value = 'menu-preview'; try { const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/menu-preview`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('menu-preview', String(route.params.id)) } }); menuCurrent.value = result.current || []; menuNext.value = result.next || []; previewVisible.value = true } catch (e) { error.value = e instanceof Error ? e.message : '菜单预览失败' } finally { busy.value = '' } }
async function onUpgradeSelected(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; busy.value = 'upgrade'; try { const body = new FormData(); body.append('package', file); const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('upgrade', String(route.params.id)) }, body }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '升级失败'); MessagePlugin.success('升级已完成'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '升级上传失败' } finally { busy.value = '' } }
watch(() => route.params.tab, (value) => { activeTab.value = String(value || 'overview'); load() })
onMounted(load)
</script>
@@ -0,0 +1,110 @@
<template>
<div class="page-view">
<PageHeader eyebrow="INSTALLED PLUGINS" title="已安装插件" subtitle="查看插件状态、版本和生命周期。高风险操作进入插件详情页。">
<template #actions>
<input ref="packageInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onPackageSelected" />
<t-button theme="primary" :loading="busy === 'upload'" @click="packageInput?.click()"><template #icon><UploadIcon /></template>上传并入库</t-button>
<t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button>
</template>
</PageHeader>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<div class="page-toolbar"><span class="secondary-text">共 {{ plugins.length }} 个插件</span><span class="toolbar-hint">下载包仅入库,不会自动启动</span></div>
<t-card v-if="plugins.length" class="table-card" :bordered="true">
<t-table :data="plugins" :columns="columns" row-key="plugin_id" :loading="loading" :hover="true" stripe>
<template #plugin="{ row }"><div class="plugin-cell"><div class="plugin-avatar"><AppIcon size="19px" /></div><div><strong>{{ row.name || row.plugin_id }}</strong><span class="secondary-text mono">{{ row.plugin_id }}</span></div></div></template>
<template #version="{ row }"><span class="mono">v{{ row.version || '-' }}</span></template>
<template #state="{ row }"><t-tag :theme="tagTheme(row.state)" variant="light">{{ stateLabel(row.state) }}</t-tag><span class="cell-note">{{ row.installation_status === 'staged' ? '待启用' : '' }}</span></template>
<template #compatibility="{ row }"><t-tag :theme="row.compatibility?.compatible ? 'success' : 'warning'" variant="light">{{ row.compatibility?.status || 'unknown' }}</t-tag></template>
<template #updated="{ row }"><span class="secondary-text">{{ formatDate(row.updated_at) }}</span></template>
<template #actions="{ row }"><div class="table-actions"><t-button size="medium" variant="outline" @click="router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/overview`)">查看详情</t-button><t-button size="medium" :theme="isHealthy(row) ? 'default' : 'primary'" :variant="isHealthy(row) ? 'outline' : 'base'" :loading="busy === row.plugin_id" :disabled="isTransitioning(row) || row.state === 'incompatible'" @click="mutate(isHealthy(row) ? 'disable' : 'enable', row.plugin_id)">{{ isHealthy(row) ? '停用' : '启用' }}</t-button><t-dropdown :options="moreOptions(row)" trigger="click" @click="(data: any) => onMore(String(data?.value || ''), row)"><t-button size="medium" variant="text" shape="square" aria-label="更多操作"><template #icon><MoreIcon /></template></t-button></t-dropdown></div></template>
</t-table>
</t-card>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="还没有登记业务插件" /></t-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AppIcon, MoreIcon, RefreshIcon, UploadIcon } from 'tdesign-icons-vue-next'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, operationKey, request, stateLabel } from '../services/api'
const router = useRouter()
const plugins = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const busy = ref('')
const packageInput = ref<HTMLInputElement | null>(null)
const columns = [
{ colKey: 'plugin', title: '插件', minWidth: 230 },
{ colKey: 'version', title: '版本', width: 110 },
{ colKey: 'state', title: '状态', width: 150 },
{ colKey: 'compatibility', title: 'Core 兼容性', width: 145 },
{ colKey: 'updated', title: '最近更新', width: 180 },
{ colKey: 'actions', title: '操作', width: 250, fixed: 'right' as const },
]
const healthyStates = ['healthy', 'enabled']
const isHealthy = (row: any) => healthyStates.includes(row.state)
const isTransitioning = (row: any) => ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(row.state)
const tagTheme = (state: string) => healthyStates.includes(state) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
async function load() {
loading.value = true; error.value = ''
try { plugins.value = listFrom(await request('/api/plugins')).sort((a, b) => String(a.name || a.plugin_id).localeCompare(String(b.name || b.plugin_id))) }
catch (e) { error.value = e instanceof Error ? e.message : '插件列表加载失败' }
finally { loading.value = false }
}
async function waitOperation(id: string) {
if (!id) return null
for (let i = 0; i < 8; i += 1) {
const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null)
if (op && op.state && op.state !== 'running') return op
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
return null
}
async function mutate(action: string, id: string) {
busy.value = id; error.value = ''
try {
const result = await request<any>(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, id) } })
const final = await waitOperation(result.operation_id)
MessagePlugin.success(`${action === 'enable' ? '启用' : action === 'disable' ? '停用' : action}操作已完成${final?.state ? `:${final.state}` : ''}`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' }
finally { busy.value = '' }
}
function moreOptions(row: any) {
return [
{ content: '配置', value: 'config' },
{ content: '版本与升级', value: 'revisions' },
{ content: '菜单接入', value: 'menu' },
{ content: '卸载插件', value: 'uninstall', disabled: isHealthy(row) },
]
}
function onMore(value: string, row: any) {
if (value === 'config' || value === 'revisions' || value === 'menu') router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/${value}`)
if (value === 'uninstall') {
const dialog = DialogPlugin.confirm({ header: '确认卸载插件', body: '卸载会移除插件文件,但不会删除 Core 数据。', confirmBtn: '卸载', cancelBtn: '取消', onConfirm: () => { dialog.hide(); mutate('uninstall', row.plugin_id) } })
}
}
async function onPackageSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
busy.value = 'upload'; error.value = ''
try {
const body = new FormData(); body.append('package', file)
const result = await request<any>('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('install') }, body })
MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '上传失败' }
finally { busy.value = '' }
}
onMounted(load)
</script>
@@ -0,0 +1,55 @@
<template>
<div class="page-view">
<PageHeader eyebrow="BUSINESS MODULE · SUBSCRIPTION" title="订阅管理" subtitle="订阅是插件控制面中的业务模块,沿用当前管理员会话,不需要再次登录。">
<template #actions><t-tag theme="success" variant="light"><CheckCircleIcon size="14px" /> 共享控制面会话</t-tag></template>
</PageHeader>
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
<t-tabs v-model="activeTab" class="detail-tabs" @change="changeTab"><t-tab-panel value="overview" label="概览" /><t-tab-panel value="plans" label="套餐" /><t-tab-panel value="subscriptions" label="用户订阅" /><t-tab-panel value="audit" label="操作记录" /></t-tabs>
<section v-if="activeTab === 'overview'" class="detail-content">
<div class="stat-grid module-stats"><t-card class="stat-card stat-neutral" :bordered="true"><div class="stat-card-top"><span>Core 连接</span><LinkIcon size="18px" /></div><strong>{{ status?.core_base_configured ? '正常' : '未配置' }}</strong><small>服务端 allowlist</small></t-card><t-card class="stat-card stat-primary" :bordered="true"><div class="stat-card-top"><span>套餐数量</span><CatalogIcon size="18px" /></div><strong>{{ plans.length }}</strong><small>来自 Core 套餐目录</small></t-card><t-card class="stat-card stat-success" :bordered="true"><div class="stat-card-top"><span>订阅记录</span><ChartLineIcon size="18px" /></div><strong>{{ subscriptionTotal }}</strong><small>当前筛选范围</small></t-card><t-card class="stat-card stat-warning" :bordered="true"><div class="stat-card-top"><span>会话模式</span><LockOnIcon size="18px" /></div><strong>共享</strong><small>不创建模块 Cookie</small></t-card></div>
<t-card class="panel-card module-note" title="模块边界" :bordered="true"><t-alert theme="info" message="浏览器只请求 Plugin Admin 的同源 BFF。Core access token、refresh token 和服务密钥始终保存在服务端会话。" /><div class="module-flow"><span>统一登录</span><ChevronRightIcon /><span>订阅模块</span><ChevronRightIcon /><span>Core 只读接口</span></div></t-card>
</section>
<section v-else-if="activeTab === 'plans'" class="detail-content"><div class="section-toolbar"><div><h2>套餐目录</h2><p class="secondary-text">套餐数据由 Core 返回,订阅模块只负责展示和查询。</p></div><t-button variant="outline" :loading="loading" @click="loadPlans"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="plans" :columns="planColumns" row-key="id" :loading="loading" :hover="true"><template #name="{ row }"><div><strong>{{ row.name || row.title || `套餐 ${row.id || '-'}` }}</strong><span class="secondary-text">{{ row.description || 'Core 套餐' }}</span></div></template><template #price="{ row }"><span class="numeric">{{ formatMoney(row.price ?? row.amount) }}</span></template><template #status="{ row }"><t-tag :theme="row.enabled === false ? 'default' : 'success'" variant="light">{{ row.enabled === false ? '已停用' : '可用' }}</t-tag></template></t-table><t-empty v-if="!plans.length" description="暂无套餐数据" /></t-card></section>
<section v-else-if="activeTab === 'subscriptions'" class="detail-content"><div class="section-toolbar"><div><h2>用户订阅</h2><p class="secondary-text">服务端分页和筛选参数沿用订阅模块 API。</p></div><t-button variant="outline" :loading="loading" @click="loadSubscriptions"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="filter-card" :bordered="true"><div class="filter-grid subscription-filter"><t-input v-model="filters.user_id" label="用户 ID" placeholder="输入用户 ID" clearable /><t-input v-model="filters.group_id" label="分组 ID" placeholder="输入分组 ID" clearable /><t-select v-model="filters.status" label="状态" placeholder="全部状态" clearable><t-option value="active" label="有效" /><t-option value="expired" label="已过期" /><t-option value="revoked" label="已撤销" /></t-select><t-button theme="primary" @click="applyFilters"><template #icon><SearchIcon /></template>查询</t-button></div></t-card><t-card class="table-card" :bordered="true"><t-table :data="subscriptions" :columns="subscriptionColumns" row-key="id" :loading="loading" :hover="true"><template #user="{ row }"><div><strong>{{ row.user?.username || row.user?.email || row.user_id || '-' }}</strong><span class="secondary-text">用户 ID {{ row.user_id || '-' }}</span></div></template><template #plan="{ row }">{{ row.plan?.name || row.plan_name || row.group?.name || row.group_id || '-' }}</template><template #status="{ row }"><t-tag :theme="subscriptionTheme(row.status)" variant="light">{{ subscriptionLabel(row.status) }}</t-tag></template><template #period="{ row }"><span class="secondary-text">{{ formatDate(row.start_at || row.started_at) }} 至 {{ formatDate(row.expire_at || row.expires_at) }}</span></template></t-table><t-empty v-if="!subscriptions.length" description="暂无订阅记录" /><div class="pagination-row"><t-pagination v-model="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :show-jumper="true" @change="onPageChange" /></div></t-card></section>
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>模块操作记录</h2><p class="secondary-text">只记录通过统一控制面发起的订阅查询。</p></div><t-button variant="outline" :loading="loading" @click="loadAudit"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="audit" :columns="auditColumns" row-key="request"><template #action="{ row }">{{ row.action || '-' }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!audit.length" description="还没有模块操作记录" /></t-card></section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CatalogIcon, ChartLineIcon, CheckCircleIcon, ChevronRightIcon, LinkIcon, LockOnIcon, RefreshIcon, SearchIcon } from 'tdesign-icons-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, loadSubscriptionState, operationSucceeded, operationTheme, request, unwrap } from '../services/api'
const route = useRoute(); const router = useRouter(); const activeTab = ref(String(route.params.tab || 'overview')); const loading = ref(false); const error = ref(''); const status = ref<any>(null); const plans = ref<any[]>([]); const subscriptions = ref<any[]>([]); const audit = ref<any[]>([]); const pagination = reactive({ current: 1, pageSize: 20, total: 0 }); const filters = reactive({ user_id: '', group_id: '', status: '' })
const planColumns = [{ colKey: 'name', title: '套餐', minWidth: 260 }, { colKey: 'price', title: '价格', width: 150 }, { colKey: 'status', title: '状态', width: 120 }]
const subscriptionColumns = [{ colKey: 'user', title: '用户', minWidth: 220 }, { colKey: 'plan', title: '套餐/分组', minWidth: 200 }, { colKey: 'status', title: '状态', width: 120 }, { colKey: 'period', title: '有效期', minWidth: 270 }]
const auditColumns = [{ colKey: 'action', title: '操作', minWidth: 240 }, { colKey: 'result', title: '结果', width: 110 }, { colKey: 'request', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const subscriptionTotal = computed(() => pagination.total || subscriptions.value.length)
function formatMoney(value: unknown) { const n = Number(value); return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '-' }
function subscriptionTheme(statusValue: unknown) { return statusValue === 'active' || statusValue === 'enabled' ? 'success' : statusValue === 'revoked' ? 'danger' : 'warning' }
function subscriptionLabel(statusValue: unknown) { return ({ active: '有效', enabled: '有效', expired: '已过期', revoked: '已撤销' } as Record<string, string>)[String(statusValue)] || String(statusValue || '未知') }
function changeTab(value: string | number) { activeTab.value = String(value); router.replace(`/modules/subscription/${String(value)}`); loadForTab() }
function extractPage(value: any) { const data = unwrap<any>(value) || {}; const rows = listFrom(value); return { rows, total: Number(data.total ?? data.pagination?.total ?? rows.length) || rows.length } }
async function loadStatus() { status.value = await request('/api/subscription/status').then(unwrap).catch(() => null) }
async function loadPlans() { loading.value = true; error.value = ''; try { plans.value = listFrom(await request('/api/subscription/plans')) } catch (e) { error.value = e instanceof Error ? e.message : '套餐加载失败' } finally { loading.value = false } }
async function loadSubscriptions() { loading.value = true; error.value = ''; try { const query = new URLSearchParams({ page: String(pagination.current), page_size: String(pagination.pageSize) }); Object.entries(filters).forEach(([key, value]) => { if (value) query.set(key, value) }); const result = extractPage(await request(`/api/subscription/subscriptions?${query.toString()}`)); subscriptions.value = result.rows; pagination.total = result.total } catch (e) { error.value = e instanceof Error ? e.message : '订阅加载失败' } finally { loading.value = false } }
async function loadAudit() { loading.value = true; error.value = ''; try { audit.value = listFrom(await request('/api/subscription/audit')) } catch (e) { error.value = e instanceof Error ? e.message : '审计加载失败' } finally { loading.value = false } }
function applyFilters() { pagination.current = 1; loadSubscriptions() }
function onPageChange(info: any) { pagination.current = Number(info?.current || info?.page || info || 1); pagination.pageSize = Number(info?.pageSize || pagination.pageSize); loadSubscriptions() }
function loadForTab() { if (activeTab.value === 'plans') loadPlans(); else if (activeTab.value === 'subscriptions') loadSubscriptions(); else if (activeTab.value === 'audit') loadAudit(); else loadStatus() }
watch(() => route.params.tab, async (value) => {
activeTab.value = String(value || 'overview')
if (await loadSubscriptionState()) loadForTab()
})
onMounted(async () => {
if (!(await loadSubscriptionState())) {
await router.replace('/overview')
return
}
await loadStatus()
loadForTab()
})
</script>