feat: add OAuth outbound transport plugin system

This commit is contained in:
shaw
2026-08-24 09:03:37 +08:00
parent d45135d87d
commit 40ea3aebad
98 changed files with 7681 additions and 141 deletions
+11 -2
View File
@@ -35,6 +35,7 @@ import affiliatesAPI from './affiliates'
import riskControlAPI from './riskControl'
import adminComplianceAPI from './compliance'
import auditAPI from './audit'
import pluginsAPI from './plugins'
/**
* Unified admin API object for convenient access
@@ -71,7 +72,8 @@ export const adminAPI = {
affiliates: affiliatesAPI,
riskControl: riskControlAPI,
compliance: adminComplianceAPI,
audit: auditAPI
audit: auditAPI,
plugins: pluginsAPI
}
export {
@@ -106,7 +108,8 @@ export {
affiliatesAPI,
riskControlAPI,
adminComplianceAPI,
auditAPI
auditAPI,
pluginsAPI
}
export default adminAPI
@@ -118,3 +121,9 @@ export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
export type {
PluginInstallation,
PluginCompatibility,
PluginUISession,
PluginTestResult
} from './plugins'
+156
View File
@@ -0,0 +1,156 @@
import { apiClient } from '../client'
export interface PluginCapability {
id: string
platform: string
account_type: string
}
export interface PluginRequirements {
sub2api: string
recommended_sub2api_version?: string
tested_sub2api_versions?: string[]
plugin_protocol: number
transport_api: number
ui_bridge: number
}
export interface PluginManifest {
schema_version: number
id: string
name: string
version: string
description?: string
author?: string
requires: PluginRequirements
capabilities: PluginCapability[]
ui: { entrypoint: string }
}
export interface PluginCompatibility {
compatible: boolean
tested: boolean
status: 'compatible' | 'untested' | 'incompatible'
message: string
current_sub2api_version: string
required_sub2api_version: string
recommended_sub2api_version: string
plugin_protocol: number
transport_api: number
ui_bridge: number
}
export interface PluginBinding {
id: number
plugin_id: number
capability: string
platform: string
account_type: string
enabled: boolean
rollout_percent: number
}
export interface PluginInstallation {
id: number
plugin_key: string
name: string
version: string
description: string
author: string
manifest: PluginManifest
binary_sha256: string
signature_status: 'trusted' | 'unsigned'
state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible'
last_error: string
installed_at: string
enabled_at?: string
updated_at: string
bindings: PluginBinding[]
compatibility: PluginCompatibility
runtime_healthy: boolean
runtime_message: string
}
export interface PluginTestResult {
success: boolean
message: string
latency_ms: number
}
export interface PluginUISession {
url: string
bridge_token: string
ui_bridge_version: number
expires_at: string
}
export async function list(): Promise<PluginInstallation[]> {
const { data } = await apiClient.get<PluginInstallation[]>('/admin/plugins')
return data
}
export async function upload(file: File): Promise<PluginInstallation> {
const form = new FormData()
form.append('plugin', file)
const { data } = await apiClient.post<PluginInstallation>('/admin/plugins/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000
})
return data
}
export async function enable(
id: number,
rolloutPercent: number,
acceptUntested: boolean
): Promise<PluginInstallation> {
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/enable`, {
rollout_percent: rolloutPercent,
accept_untested: acceptUntested
})
return data
}
export async function disable(id: number): Promise<PluginInstallation> {
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/disable`)
return data
}
export async function remove(id: number): Promise<void> {
await apiClient.delete(`/admin/plugins/${id}`)
}
export async function getConfig(id: number): Promise<Record<string, unknown>> {
const { data } = await apiClient.get<Record<string, unknown>>(`/admin/plugins/${id}/config`)
return data
}
export async function saveConfig(
id: number,
config: Record<string, unknown>
): Promise<Record<string, unknown>> {
const { data } = await apiClient.put<Record<string, unknown>>(`/admin/plugins/${id}/config`, config)
return data
}
export async function test(id: number): Promise<PluginTestResult> {
const { data } = await apiClient.post<PluginTestResult>(`/admin/plugins/${id}/test`)
return data
}
export async function createUISession(id: number): Promise<PluginUISession> {
const { data } = await apiClient.post<PluginUISession>(`/admin/plugins/${id}/ui-session`)
return data
}
export default {
list,
upload,
enable,
disable,
remove,
getConfig,
saveConfig,
test,
createUISession
}
+2
View File
@@ -727,6 +727,7 @@ export interface SystemSettings {
model_plaza_enabled: boolean;
model_plaza_require_auth: boolean;
model_plaza_description: string;
plugin_management_enabled: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
@@ -1026,6 +1027,7 @@ export interface UpdateSettingsRequest {
model_plaza_enabled?: boolean;
model_plaza_require_auth?: boolean;
model_plaza_description?: string;
plugin_management_enabled?: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
@@ -193,6 +193,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { sanitizeUrl } from '@/utils/url'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
@@ -472,6 +473,10 @@ const ServerIcon = {
)
}
const PluginIcon = {
render: () => h(Icon, { name: 'cube' })
}
const BellIcon = {
render: () =>
h(
@@ -685,6 +690,7 @@ const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
const flagBatchImageAccess = () => canUseBatchImage.value
@@ -769,6 +775,7 @@ const adminNavItems = computed((): NavItem[] => {
},
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/plugins', label: t('nav.plugins'), icon: PluginIcon, featureFlag: flagPluginManagement },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
...plugins,
}
@@ -0,0 +1,50 @@
export default {
plugins: {
title: 'Plugin Management',
description: 'Install and manage isolated OAuth outbound transport plugins. API Key flows are unchanged.',
upload: 'Install plugin',
uploadHint: 'Only .s2plugin packages are accepted; trusted publisher signatures are required by default.',
runtimeNotice: 'Plugin installation, enable/disable, and configuration are handled dynamically by the Sub2API host and normally do not require a host restart. Restart only when the host version or host configuration changes according to your deployment process.',
menuNotice: 'The Plugin Management switch in System Settings controls only sidebar visibility; it does not stop loaded or running plugins.',
empty: 'No plugins installed',
emptyHint: 'Select a local .s2plugin package. Sub2API never downloads third-party plugins automatically.',
configure: 'Configure',
enable: 'Enable',
disable: 'Disable',
test: 'Test',
uninstall: 'Uninstall',
rollout: 'OAuth traffic percentage',
compatibility: 'Version compatibility',
currentVersion: 'Current Sub2API',
requiredVersion: 'Required range',
recommendedVersion: 'Recommended version',
signature: 'Package signature',
trusted: 'Verified',
unsigned: 'Unsigned',
runtime: 'Runtime',
healthy: 'Healthy',
unhealthy: 'Not running',
compatible: 'Compatible',
untested: 'Untested version',
incompatible: 'Incompatible',
enabled: 'Enabled',
disabled: 'Disabled',
error: 'Error',
starting: 'Starting',
configTitle: '{name} configuration',
loadingUI: 'Loading plugin configuration UI...',
uiUnavailable: 'Unable to load the plugin configuration UI',
uploadSuccess: 'Plugin installed and kept disabled',
enableSuccess: 'Plugin enabled',
disableSuccess: 'Plugin disabled',
uninstallSuccess: 'Plugin uninstalled',
testSuccess: 'Plugin test passed',
confirmDisable: 'Disable this plugin? New OAuth requests immediately return to the built-in Sub2API path.',
confirmUninstall: 'Uninstall this plugin? It must be disabled first. Installed files and configuration will be removed.',
confirmUntested: 'This plugin is compatible but has not declared the current Sub2API version as tested. Enable it anyway?',
fileRequired: 'Select a .s2plugin file',
bridgeRejected: 'Plugin UI message validation failed',
onlyOpenAI: 'Initial capability: OpenAI OAuth outbound transport only',
noAccountCoupling: 'The scope is platform and account type. Account records are not changed and no per-account toggle is required.'
}
}
@@ -52,6 +52,12 @@ export default {
priceDescription: 'Pricing notes (Markdown)',
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
},
pluginManagement: {
title: 'Plugin Management',
description: 'Controls whether the plugin management entry appears in the admin sidebar. This switch does not control plugin runtime state.',
enabled: 'Show Plugin Management',
enabledHint: 'Turning this off only hides the sidebar entry; loaded or running plugins are not stopped.',
},
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
+1
View File
@@ -176,6 +176,7 @@ export default {
modelPlaza: 'Model Plaza',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
plugins: 'Plugins',
proxies: 'Proxies',
redeemCodes: 'Redeem Codes',
ops: 'Ops',
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
...plugins,
}
@@ -0,0 +1,50 @@
export default {
plugins: {
title: '插件管理',
description: '安装和管理独立运行的 OAuth 出站传输插件。API Key 流程不受影响。',
upload: '安装插件',
uploadHint: '仅接受 .s2plugin 包;默认要求可信发布者签名。',
runtimeNotice: '插件安装、启用、停用和配置由 Sub2API 宿主动态处理,通常不需要重启宿主实例。只有宿主版本或宿主配置本身变化时,才按部署方式执行重启。',
menuNotice: '系统设置中的“插件管理”开关仅控制侧边栏菜单显示,不会停止已经加载或正在运行的插件。',
empty: '尚未安装插件',
emptyHint: '选择本机的 .s2plugin 文件进行安装。Sub2API 不会自动下载第三方插件。',
configure: '配置',
enable: '启用',
disable: '停用',
test: '测试',
uninstall: '卸载',
rollout: 'OAuth 流量比例',
compatibility: '版本兼容性',
currentVersion: '当前 Sub2API',
requiredVersion: '要求范围',
recommendedVersion: '建议版本',
signature: '包签名',
trusted: '已验证',
unsigned: '未签名',
runtime: '运行状态',
healthy: '运行正常',
unhealthy: '未运行',
compatible: '兼容',
untested: '未验证版本',
incompatible: '不兼容',
enabled: '已启用',
disabled: '已停用',
error: '异常',
starting: '启动中',
configTitle: '{name} 配置',
loadingUI: '正在加载插件配置界面...',
uiUnavailable: '无法加载插件配置界面',
uploadSuccess: '插件安装成功,当前保持停用',
enableSuccess: '插件已启用',
disableSuccess: '插件已停用',
uninstallSuccess: '插件已卸载',
testSuccess: '插件测试通过',
confirmDisable: '确定停用此插件吗?新的 OAuth 请求会立即恢复 Sub2API 原有路径。',
confirmUninstall: '确定卸载此插件吗?插件必须先停用。此操作会移除安装文件和配置。',
confirmUntested: '该插件兼容当前版本范围,但未声明已测试当前 Sub2API 版本。确定承担风险并启用吗?',
fileRequired: '请选择 .s2plugin 文件',
bridgeRejected: '插件 UI 消息校验失败',
onlyOpenAI: '初期能力:仅 OpenAI OAuth 出站传输',
noAccountCoupling: '作用域为平台与账号类型,不修改账号数据,也不需要在账号页逐个开启。'
}
}
@@ -52,6 +52,12 @@ export default {
priceDescription: '价格说明(Markdown)',
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
},
pluginManagement: {
title: '插件管理',
description: '控制管理员侧边栏是否显示插件管理入口。此开关不控制插件运行状态。',
enabled: '显示插件管理菜单',
enabledHint: '关闭后仅隐藏侧边栏菜单;已加载或正在运行的插件不会因此停止。',
},
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
+1
View File
@@ -176,6 +176,7 @@ export default {
modelPlaza: '模型广场',
subscriptions: '订阅管理',
accounts: '账号管理',
plugins: '插件管理',
proxies: 'IP管理',
redeemCodes: '兑换码',
ops: '运维监控',
+12
View File
@@ -524,6 +524,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.accounts.description'
}
},
{
path: '/admin/plugins',
name: 'AdminPlugins',
component: () => import('@/views/admin/PluginsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Plugin Management',
titleKey: 'admin.plugins.title',
descriptionKey: 'admin.plugins.description'
}
},
{
path: '/admin/announcements',
name: 'AdminAnnouncements',
@@ -57,6 +57,7 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
plugin_management_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
+1
View File
@@ -373,6 +373,7 @@ export const useAppStore = defineStore('app', () => {
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
plugin_management_enabled: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
+1
View File
@@ -273,6 +273,7 @@ export interface PublicSettings {
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
plugin_management_enabled: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
+5
View File
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-in',
label: 'Model Plaza',
}),
pluginManagement: defineFlag({
key: 'plugin_management_enabled',
mode: 'opt-in',
label: 'Plugin Management',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
+671
View File
@@ -0,0 +1,671 @@
<template>
<AppLayout>
<div class="space-y-6">
<section
class="flex flex-col gap-4 border-b border-gray-200 pb-5 dark:border-dark-700 sm:flex-row sm:items-end sm:justify-between"
>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.plugins.title") }}
</h2>
<p class="mt-1 max-w-3xl text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.description") }}
</p>
<div
class="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.onlyOpenAI")
}}</span>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.noAccountCoupling")
}}</span>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<input
ref="fileInput"
class="hidden"
type="file"
accept=".s2plugin,application/zip"
@change="handleFileSelected"
/>
<button
type="button"
class="btn btn-primary"
:disabled="uploading"
@click="fileInput?.click()"
>
<Icon name="upload" size="sm" />
{{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
</button>
<button
type="button"
class="btn btn-secondary"
:disabled="loading"
:title="t('common.refresh')"
@click="loadPlugins"
>
<Icon name="refresh" size="sm" />
<span class="sr-only">{{ t("common.refresh") }}</span>
</button>
</div>
</section>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.uploadHint") }}
</p>
<div
class="border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
>
<p>{{ t("admin.plugins.runtimeNotice") }}</p>
<p class="mt-1">{{ t("admin.plugins.menuNotice") }}</p>
</div>
<div
v-if="loading"
class="flex min-h-48 items-center justify-center text-sm text-gray-500"
>
{{ t("common.loading") }}
</div>
<div
v-else-if="plugins.length === 0"
class="flex min-h-56 flex-col items-center justify-center border border-dashed border-gray-300 px-6 text-center dark:border-dark-600"
>
<Icon name="cube" size="xl" class="text-gray-400" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.empty") }}
</p>
<p class="mt-1 max-w-lg text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.emptyHint") }}
</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article
v-for="plugin in plugins"
:key="plugin.id"
class="card overflow-hidden border border-gray-200 dark:border-dark-700"
>
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 p-5 dark:border-dark-700"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3
class="truncate text-base font-semibold text-gray-900 dark:text-white"
>
{{ plugin.name }}
</h3>
<span class="font-mono text-xs text-gray-500"
>v{{ plugin.version }}</span
>
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="stateClass(plugin.state)"
>
{{ t(`admin.plugins.${plugin.state}`) }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{{ plugin.plugin_key
}}<span v-if="plugin.author"> · {{ plugin.author }}</span>
</p>
<p
v-if="plugin.description"
class="mt-2 text-sm text-gray-600 dark:text-gray-300"
>
{{ plugin.description }}
</p>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="openConfiguration(plugin)"
>
<Icon name="cog" size="sm" />
{{ t("admin.plugins.configure") }}
</button>
</div>
<div class="grid grid-cols-1 gap-x-6 gap-y-4 p-5 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.compatibility") }}
</p>
<div class="mt-2 flex items-center gap-2">
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="compatibilityClass(plugin.compatibility.status)"
>
{{ t(`admin.plugins.${plugin.compatibility.status}`) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
plugin.compatibility.message
}}</span>
</div>
<dl
class="mt-3 grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-xs"
>
<dt class="text-gray-500">
{{ t("admin.plugins.currentVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.current_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.requiredVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.required_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.recommendedVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.recommended_sub2api_version || "-" }}
</dd>
</dl>
</div>
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.runtime") }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span
class="rounded px-2 py-0.5"
:class="
plugin.runtime_healthy
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
"
>
{{
plugin.runtime_healthy
? t("admin.plugins.healthy")
: t("admin.plugins.unhealthy")
}}
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ t("admin.plugins.signature") }}:
{{ t(`admin.plugins.${plugin.signature_status}`) }}
</span>
</div>
<p
v-if="plugin.last_error"
class="mt-3 break-words text-xs text-red-600 dark:text-red-400"
>
{{ plugin.last_error }}
</p>
<p
v-else-if="plugin.runtime_message"
class="mt-3 break-words text-xs text-gray-500"
>
{{ plugin.runtime_message }}
</p>
</div>
<div class="md:col-span-2">
<label
class="flex items-center justify-between gap-4 text-xs font-medium text-gray-600 dark:text-gray-300"
>
<span>{{ t("admin.plugins.rollout") }}</span>
<span class="w-11 text-right font-mono"
>{{
rolloutValues[plugin.id] ?? currentRollout(plugin)
}}%</span
>
</label>
<input
:value="rolloutValues[plugin.id] ?? currentRollout(plugin)"
type="range"
min="1"
max="100"
step="1"
class="mt-2 w-full accent-primary-600"
:disabled="hasEnabledBinding(plugin)"
@input="setRollout(plugin.id, $event)"
/>
</div>
</div>
<div
class="flex flex-wrap justify-end gap-2 border-t border-gray-100 px-5 py-4 dark:border-dark-700"
>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="testPlugin(plugin)"
>
<Icon name="beaker" size="sm" />
{{ t("admin.plugins.test") }}
</button>
<button
v-if="hasEnabledBinding(plugin)"
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="disablePlugin(plugin)"
>
<Icon name="ban" size="sm" />
{{ t("admin.plugins.disable") }}
</button>
<button
v-else
type="button"
class="btn btn-primary btn-sm"
:disabled="
busyID === plugin.id ||
plugin.state === 'starting' ||
!plugin.compatibility.compatible
"
@click="enablePlugin(plugin)"
>
<Icon name="play" size="sm" />
{{ t("admin.plugins.enable") }}
</button>
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="busyID === plugin.id || hasEnabledBinding(plugin)"
@click="uninstallPlugin(plugin)"
>
<Icon name="trash" size="sm" />
{{ t("admin.plugins.uninstall") }}
</button>
</div>
</article>
</div>
<BaseDialog
:show="configPlugin !== null"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
width="full"
@close="closeConfiguration"
>
<div
class="relative min-h-[520px] overflow-hidden bg-gray-50 dark:bg-dark-900"
:style="{ height: `${iframeHeight}px` }"
>
<div
v-if="uiLoading"
class="absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-500"
>
{{ t("admin.plugins.loadingUI") }}
</div>
<div
v-if="uiError"
class="absolute inset-0 z-20 flex flex-col items-center justify-center p-8 text-center"
>
<Icon name="exclamationTriangle" size="xl" class="text-amber-500" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.uiUnavailable") }}
</p>
<p class="mt-1 max-w-xl text-sm text-gray-500">{{ uiError }}</p>
</div>
<iframe
v-if="uiSession"
ref="pluginFrame"
:src="uiSession.url"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
class="h-full w-full border-0 bg-white dark:bg-dark-900"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
@load="uiLoading = false"
/>
</div>
</BaseDialog>
<TotpStepUpDialog :controller="pluginStepUp" />
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
adminAPI,
type PluginInstallation,
type PluginUISession,
} from "@/api/admin";
import { useAppStore } from "@/stores";
import AppLayout from "@/components/layout/AppLayout.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import Icon from "@/components/icons/Icon.vue";
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
import {
isStepUpBlocked,
isStepUpCancelled,
stepUpBlockReason,
useStepUp,
} from "@/composables/useStepUp";
interface PluginBridgeMessage {
source?: string;
bridge_token?: string;
type?: string;
request_id?: string;
config?: unknown;
height?: unknown;
level?: unknown;
message?: unknown;
}
const { t } = useI18n();
const appStore = useAppStore();
const pluginStepUp = useStepUp();
const plugins = ref<PluginInstallation[]>([]);
const loading = ref(false);
const uploading = ref(false);
const busyID = ref<number | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const rolloutValues = ref<Record<number, number>>({});
const configPlugin = ref<PluginInstallation | null>(null);
const uiSession = ref<PluginUISession | null>(null);
const pluginFrame = ref<HTMLIFrameElement | null>(null);
const uiLoading = ref(false);
const uiError = ref("");
const iframeHeight = ref(640);
function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
return String(
(error as { message?: unknown }).message || t("common.unknownError"),
);
}
return t("common.unknownError");
}
function reportSensitiveActionError(error: unknown): void {
if (isStepUpCancelled(error)) return;
if (isStepUpBlocked(error)) {
appStore.showError(
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
? t("stepUp.adminApiKeyForbidden")
: t("stepUp.notEnabled"),
);
return;
}
appStore.showError(errorMessage(error));
}
async function loadPlugins(): Promise<void> {
loading.value = true;
try {
plugins.value = await adminAPI.plugins.list();
for (const plugin of plugins.value) {
rolloutValues.value[plugin.id] = currentRollout(plugin);
}
} catch (error: unknown) {
appStore.showError(errorMessage(error));
} finally {
loading.value = false;
}
}
async function handleFileSelected(event: Event): Promise<void> {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = "";
if (!file || !file.name.toLowerCase().endsWith(".s2plugin")) {
appStore.showError(t("admin.plugins.fileRequired"));
return;
}
uploading.value = true;
try {
await pluginStepUp.run(() => adminAPI.plugins.upload(file));
appStore.showSuccess(t("admin.plugins.uploadSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
uploading.value = false;
}
}
function currentRollout(plugin: PluginInstallation): number {
return (
plugin.bindings.find(
(binding) => binding.capability === "openai.oauth.outbound_transport.v1",
)?.rollout_percent || 100
);
}
function hasEnabledBinding(plugin: PluginInstallation): boolean {
return plugin.bindings.some((binding) => binding.enabled);
}
function setRollout(id: number, event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
rolloutValues.value[id] = Math.min(100, Math.max(1, value));
}
async function enablePlugin(plugin: PluginInstallation): Promise<void> {
let acceptUntested = false;
if (!plugin.compatibility.tested) {
acceptUntested = window.confirm(t("admin.plugins.confirmUntested"));
if (!acceptUntested) return;
}
busyID.value = plugin.id;
try {
await pluginStepUp.run(() =>
adminAPI.plugins.enable(
plugin.id,
rolloutValues.value[plugin.id] || 100,
acceptUntested,
),
);
appStore.showSuccess(t("admin.plugins.enableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function disablePlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmDisable"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.disable(plugin.id));
appStore.showSuccess(t("admin.plugins.disableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function uninstallPlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmUninstall"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.remove(plugin.id));
appStore.showSuccess(t("admin.plugins.uninstallSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function testPlugin(plugin: PluginInstallation): Promise<void> {
busyID.value = plugin.id;
try {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(plugin.id),
);
if (result.success)
appStore.showSuccess(result.message || t("admin.plugins.testSuccess"));
else appStore.showError(result.message || t("common.error"));
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function openConfiguration(plugin: PluginInstallation): Promise<void> {
configPlugin.value = plugin;
uiSession.value = null;
uiLoading.value = true;
uiError.value = "";
iframeHeight.value = 640;
try {
uiSession.value = await adminAPI.plugins.createUISession(plugin.id);
} catch (error: unknown) {
uiLoading.value = false;
uiError.value = errorMessage(error);
}
}
function closeConfiguration(): void {
configPlugin.value = null;
uiSession.value = null;
uiLoading.value = false;
uiError.value = "";
}
function postBridgeResult(
request: PluginBridgeMessage,
payload: Record<string, unknown>,
): void {
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
pluginFrame.value.contentWindow.postMessage(
{
source: "sub2api-plugin-host",
bridge_token: uiSession.value.bridge_token,
type: `${request.type}.result`,
request_id: request.request_id,
...payload,
},
"*",
);
}
async function handleBridgeMessage(event: MessageEvent): Promise<void> {
if (
!uiSession.value ||
!configPlugin.value ||
event.source !== pluginFrame.value?.contentWindow
)
return;
const message = event.data as PluginBridgeMessage;
if (
!message ||
message.source !== "sub2api-plugin-ui" ||
message.bridge_token !== uiSession.value.bridge_token
)
return;
try {
switch (message.type) {
case "sub2api.plugin.ready":
uiLoading.value = false;
break;
case "config.load": {
const config = await adminAPI.plugins.getConfig(configPlugin.value.id);
postBridgeResult(message, { ok: true, config });
break;
}
case "config.save": {
if (
!message.config ||
typeof message.config !== "object" ||
Array.isArray(message.config)
) {
throw new Error(t("admin.plugins.bridgeRejected"));
}
const config = await pluginStepUp.run(() =>
adminAPI.plugins.saveConfig(
configPlugin.value!.id,
message.config as Record<string, unknown>,
),
);
postBridgeResult(message, { ok: true, config });
appStore.showSuccess(t("common.saved"));
break;
}
case "config.test": {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(configPlugin.value!.id),
);
postBridgeResult(message, { ok: result.success, result });
if (result.success)
appStore.showSuccess(
result.message || t("admin.plugins.testSuccess"),
);
else appStore.showError(result.message || t("common.error"));
break;
}
case "ui.resize": {
const height = Number(message.height);
if (Number.isFinite(height))
iframeHeight.value = Math.min(960, Math.max(520, Math.round(height)));
break;
}
case "ui.notify": {
const text =
typeof message.message === "string"
? message.message.slice(0, 500)
: "";
if (!text) break;
if (message.level === "error") appStore.showError(text);
else if (message.level === "success") appStore.showSuccess(text);
else appStore.showInfo(text);
break;
}
}
} catch (error: unknown) {
if (isStepUpBlocked(error)) reportSensitiveActionError(error);
postBridgeResult(message, {
ok: false,
error: isStepUpCancelled(error) ? t("common.cancel") : errorMessage(error),
});
}
}
function stateClass(state: PluginInstallation["state"]): string {
if (state === "enabled")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (state === "error" || state === "incompatible")
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
if (state === "starting")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300";
}
function compatibilityClass(
status: PluginInstallation["compatibility"]["status"],
): string {
if (status === "compatible")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (status === "untested")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
}
onMounted(() => {
window.addEventListener("message", handleBridgeMessage);
void loadPlugins();
});
onBeforeUnmount(() => {
window.removeEventListener("message", handleBridgeMessage);
});
</script>
+27
View File
@@ -7188,6 +7188,30 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.pluginManagement.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between gap-4">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.pluginManagement.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.enabledHint') }}
</p>
</div>
<Toggle v-model="form.plugin_management_enabled" />
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -9752,6 +9776,8 @@ const form = reactive<SettingsForm>({
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
// Plugin management menu visibility; plugin runtime is unaffected.
plugin_management_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -11408,6 +11434,7 @@ async function saveSettings() {
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
plugin_management_enabled: form.plugin_management_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
@@ -0,0 +1,169 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginsView from '../PluginsView.vue'
const {
listPlugins,
uploadPlugin,
enablePlugin,
savePluginConfig,
createUISession,
stepUpRun,
} = vi.hoisted(() => ({
listPlugins: vi.fn(),
uploadPlugin: vi.fn(),
enablePlugin: vi.fn(),
savePluginConfig: vi.fn(),
createUISession: vi.fn(),
stepUpRun: vi.fn((action: () => Promise<unknown>) => action()),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
plugins: {
list: listPlugins,
upload: uploadPlugin,
enable: enablePlugin,
disable: vi.fn(),
remove: vi.fn(),
getConfig: vi.fn().mockResolvedValue({}),
saveConfig: savePluginConfig,
test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
createUISession,
},
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('@/composables/useStepUp', () => ({
useStepUp: () => ({ run: stepUpRun }),
isStepUpBlocked: () => false,
isStepUpCancelled: () => false,
stepUpBlockReason: () => '',
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
const plugin = {
id: 7,
plugin_key: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
description: '',
author: 'test',
manifest: {
schema_version: 1,
id: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
requires: {
sub2api: '>=0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
capabilities: [],
ui: { entrypoint: 'ui/index.html' },
},
binary_sha256: 'a'.repeat(64),
signature_status: 'trusted' as const,
state: 'disabled' as const,
last_error: '',
installed_at: '2026-08-22T00:00:00Z',
updated_at: '2026-08-22T00:00:00Z',
bindings: [
{
id: 1,
plugin_id: 7,
capability: 'openai.oauth.outbound_transport.v1',
platform: 'openai',
account_type: 'oauth',
enabled: false,
rollout_percent: 100,
},
],
compatibility: {
compatible: true,
tested: true,
status: 'compatible' as const,
message: '',
current_sub2api_version: '0.1.0',
required_sub2api_version: '>=0.1.0',
recommended_sub2api_version: '0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
runtime_healthy: false,
runtime_message: '',
}
function mountView() {
return mount(PluginsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
BaseDialog: { template: '<div><slot /></div>' },
Icon: true,
TotpStepUpDialog: true,
},
},
})
}
describe('管理员插件页二次验证', () => {
beforeEach(() => {
vi.clearAllMocks()
stepUpRun.mockImplementation((action: () => Promise<unknown>) => action())
listPlugins.mockResolvedValue([plugin])
uploadPlugin.mockResolvedValue(plugin)
enablePlugin.mockResolvedValue(plugin)
savePluginConfig.mockResolvedValue({ enabled: true })
createUISession.mockResolvedValue({
url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
bridge_token: 'bridge',
ui_bridge_version: 1,
expires_at: '2026-08-22T01:00:00Z',
})
})
it('启用插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
expect(button).toBeDefined()
await button!.trigger('click')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
})
it('上传插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
})
await input.trigger('change')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(uploadPlugin).toHaveBeenCalledTimes(1)
})
})