mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat: add OAuth outbound transport plugin system
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
|
||||
|
||||
@@ -176,6 +176,7 @@ export default {
|
||||
modelPlaza: '模型广场',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
plugins: '插件管理',
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user