Merge upstream/main into feat/one-click-update

This commit is contained in:
zhiqicloud
2026-05-22 15:28:27 +08:00
269 changed files with 31793 additions and 6990 deletions

View File

@@ -1,12 +1,16 @@
import apiClient from './client'
import type { ModelTestCapabilities } from './endpoints/types'
import axios from 'axios'
import axios, { type AxiosRequestConfig } from 'axios'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
export interface SystemDataImportOptions {
onUploadProgress?: AxiosRequestConfig['onUploadProgress']
}
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
return null
@@ -919,11 +923,11 @@ export const adminApi = {
},
// 导入配置
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
async importConfig(data: ConfigImportRequest, options: SystemDataImportOptions = {}): Promise<ConfigImportResponse> {
const response = await apiClient.post<ConfigImportResponse>(
'/api/admin/system/config/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
return response.data
},
@@ -935,27 +939,27 @@ export const adminApi = {
},
// 导入用户数据
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
async importUsers(data: UsersImportRequest, options: SystemDataImportOptions = {}): Promise<UsersImportResponse> {
const response = await apiClient.post<UsersImportResponse>(
'/api/admin/system/users/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
return response.data
},
// 导出聚合数据(配置数据 + 用户数据)
// 导出完整备份(配置数据 + 用户数据)
async exportAggregateData(): Promise<AggregateExportData> {
const response = await apiClient.get<AggregateExportData>('/api/admin/system/data/export')
return response.data
},
// 导入聚合数据(配置数据 + 用户数据)
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
// 导入完整备份(配置数据 + 用户数据)
async importAggregateData(data: AggregateImportRequest, options: SystemDataImportOptions = {}): Promise<AggregateImportResponse> {
const response = await apiClient.post<AggregateImportResponse>(
'/api/admin/system/data/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
return response.data
},
@@ -978,6 +982,23 @@ export const adminApi = {
return response.data
},
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | 'bark' | {
channel?: 'all' | 'email' | 'server_chan' | 'bark'
item_key?: string
} = 'all'): Promise<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}> {
const payload = typeof options === 'string' ? { channel: options } : options
const response = await apiClient.post<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}>('/api/admin/system/important-notification/test', payload)
return response.data
},
// 邮件模板相关
// 获取所有邮件模板
async getEmailTemplates(): Promise<EmailTemplatesResponse> {

View File

@@ -286,74 +286,6 @@ export async function refreshProviderQuota(
return response.data
}
export type ProviderKeyBalanceStatus =
| 'success'
| 'pending'
| 'auth_failed'
| 'auth_expired'
| 'rate_limited'
| 'network_error'
| 'parse_error'
| 'not_configured'
| 'not_supported'
| 'already_done'
| 'unknown_error'
export interface ProviderKeyBalanceInfo {
total_granted: number | null
total_used: number | null
total_available: number | null
expires_at: string | null
currency: string
extra: Record<string, unknown>
}
export interface ProviderKeyBalanceResult {
status: ProviderKeyBalanceStatus
action_type: 'query_balance'
data: ProviderKeyBalanceInfo | null
message: string | null
executed_at: string
response_time_ms: number | null
cache_ttl_seconds: number
saved_to_key?: boolean
saved_key_id?: string | null
save_message?: string | null
}
export interface ProviderKeyBalanceQuery {
key_id?: string
api_key?: string
auth_type?: 'api_key' | 'bearer' | 'service_account' | 'oauth'
api_formats?: string[]
architecture_id?: 'new_api' | 'sub2api' | 'generic_api'
custom_base_url?: string
new_api_user_id?: string
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token'
custom_endpoint?: string
custom_method?: 'GET' | 'POST'
custom_currency?: string
custom_quota_divisor?: number
custom_balance_path?: string
custom_used_path?: string
custom_granted_path?: string
auto_refresh_interval_minutes?: number
save_balance_secret?: boolean
save_result?: boolean
}
export async function queryProviderKeyBalance(
providerId: string,
data: ProviderKeyBalanceQuery,
): Promise<ProviderKeyBalanceResult> {
const response = await client.post<ProviderKeyBalanceResult>(
`/api/admin/endpoints/providers/${providerId}/key-balance`,
data,
{ timeout: 60 * 1000 },
)
return response.data
}
/**
* 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro

View File

@@ -79,6 +79,137 @@ export interface OAuthBatchImportTaskStatusResponse {
updated_at: number
}
export type BatchImportCredentialsNormalization =
| { ok: true; isBatch: boolean; credentials: string }
| { ok: false; message: string }
function getImportCredentialLines(text: string): Array<{ lineNumber: number; text: string }> {
return text
.split('\n')
.map((line, index) => ({ lineNumber: index + 1, text: line.trim() }))
.filter(line => line.text && !line.text.startsWith('#'))
}
function jsonParseErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function normalizeBatchImportItem(
value: unknown,
location: string,
): { ok: true; value: string | Record<string, unknown> } | { ok: false; message: string } {
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed) return { ok: true, value: trimmed }
return { ok: false, message: `${location} 不能为空字符串` }
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return { ok: true, value: value as Record<string, unknown> }
}
return { ok: false, message: `${location} 必须是 JSON 对象或字符串` }
}
function normalizeBatchImportArray(items: unknown[]): BatchImportCredentialsNormalization {
if (items.length === 0) {
return { ok: false, message: 'JSON 数组不能为空' }
}
const normalized: Array<string | Record<string, unknown>> = []
for (const [index, item] of items.entries()) {
const result = normalizeBatchImportItem(item, `JSON 数组第 ${index + 1}`)
if (!result.ok) return result
normalized.push(result.value)
}
return {
ok: true,
isBatch: true,
credentials: JSON.stringify(normalized),
}
}
function parseImportCredentialLines(
lines: Array<{ lineNumber: number; text: string }>,
): BatchImportCredentialsNormalization {
const normalized: Array<string | Record<string, unknown>> = []
for (const line of lines) {
const firstChar = line.text[0]
if (firstChar === '{' || firstChar === '[') {
let parsed: unknown
try {
parsed = JSON.parse(line.text)
} catch (error) {
return {
ok: false,
message: `JSON Lines 格式无效,请检查第 ${line.lineNumber} 行: ${jsonParseErrorMessage(error)}`,
}
}
if (Array.isArray(parsed)) {
for (const [index, item] of parsed.entries()) {
const result = normalizeBatchImportItem(item, `${line.lineNumber} 行数组第 ${index + 1}`)
if (!result.ok) return result
normalized.push(result.value)
}
continue
}
const result = normalizeBatchImportItem(parsed, `${line.lineNumber}`)
if (!result.ok) return result
normalized.push(result.value)
continue
}
normalized.push(line.text)
}
return normalizeBatchImportArray(normalized)
}
export function normalizeBatchImportCredentials(text: string): BatchImportCredentialsNormalization {
const trimmed = text.trim()
if (!trimmed) {
return { ok: false, message: '请输入凭据数据' }
}
const firstChar = trimmed[0]
if (firstChar === '[') {
try {
const parsed: unknown = JSON.parse(trimmed)
if (!Array.isArray(parsed)) {
return { ok: false, message: 'JSON 批量凭据必须是数组' }
}
return normalizeBatchImportArray(parsed)
} catch (error) {
return { ok: false, message: `JSON 数组格式无效: ${jsonParseErrorMessage(error)}` }
}
}
if (firstChar === '{') {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return { ok: true, isBatch: false, credentials: trimmed }
}
return { ok: false, message: '单条 JSON 凭据必须是对象' }
} catch (error) {
const lines = getImportCredentialLines(trimmed)
if (lines.length > 1) {
return parseImportCredentialLines(lines)
}
return { ok: false, message: `JSON 格式无效: ${jsonParseErrorMessage(error)}` }
}
}
const lines = getImportCredentialLines(trimmed)
if (lines.length > 1) {
return parseImportCredentialLines(lines)
}
return { ok: true, isBatch: false, credentials: trimmed }
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
@@ -102,8 +233,14 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken(
providerId: string,
data: {
api_key?: string
apiKey?: string
token?: string
auth_token?: string
authToken?: string
refresh_token?: string
access_token?: string
password?: string
expires_at?: number
name?: string
proxy_node_id?: string
@@ -150,7 +287,8 @@ export async function getBatchImportOAuthTaskStatus(
export interface DeviceAuthorizeRequest {
start_url?: string
region?: string
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github'
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
login_option?: 'google' | 'github' | 'default'
redirect_uri?: string
proxy_node_id?: string
}
@@ -170,6 +308,7 @@ export interface DeviceAuthorizeResponse {
export interface DevicePollRequest {
session_id: string
callback_url?: string
token?: string
}
export interface DevicePollResponse {

View File

@@ -5,6 +5,7 @@ import type {
FailoverRulesConfig,
PoolAdvancedConfig,
ProviderConfig,
ProviderType,
ProviderWithEndpointsSummary,
ProxyConfig,
} from './types'
@@ -92,7 +93,7 @@ export async function updateProvider(
providerId: string,
data: Partial<{
name: string
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
provider_type: ProviderType
description: string | null
website: string
provider_priority: number
@@ -127,7 +128,7 @@ export async function updateProvider(
export async function createProvider(
data: {
name: string
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
provider_type?: ProviderType
description?: string
website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'

View File

@@ -370,6 +370,32 @@ export interface KiroUpstreamMetadata {
banned_at?: number // 封禁时间Unix 时间戳,秒)
}
// Windsurf 上游配额信息
export interface WindsurfUpstreamMetadata {
updated_at?: number
plan_name?: string
daily_remaining_percent?: number | null
weekly_remaining_percent?: number | null
daily_reset_at?: number | null
weekly_reset_at?: number | null
prompt_used?: number | null
prompt_limit?: number | null
prompt_remaining?: number | null
flex_used?: number | null
flex_limit?: number | null
flex_remaining?: number | null
allowed_models_count?: number | null
models?: Array<{
model_uid?: string | null
label?: string | null
provider?: string | null
supports_images?: boolean | null
credit_multiplier?: number | null
}> | null
rate_limit?: Record<string, unknown> | null
last_error?: string | null
}
export interface ChatGPTWebUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
plan_type?: string | null
@@ -402,46 +428,13 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface BalanceQueryUpstreamMetadata {
updated_at?: number
architecture_id?: string | null
status?: string | null
executed_at?: string | null
response_time_ms?: number | null
total_available?: number | null
total_used?: number | null
total_granted?: number | null
currency?: string | null
plan_name?: string | null
query_config?: {
custom_base_url?: string | null
new_api_user_id?: string | null
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token' | string | null
custom_endpoint?: string | null
custom_method?: 'GET' | 'POST' | string | null
custom_currency?: string | null
custom_quota_divisor?: number | null
custom_balance_path?: string | null
custom_used_path?: string | null
custom_granted_path?: string | null
auto_refresh_interval_minutes?: number | null
has_saved_secret?: boolean | null
} | null
extra?: Record<string, unknown> | null
}
export interface ProviderKeyBalanceSummary extends BalanceQueryUpstreamMetadata {
key_id?: string | null
key_name?: string | null
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
windsurf?: WindsurfUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
balance_query?: BalanceQueryUpstreamMetadata
}
// 按格式的健康度数据
@@ -569,7 +562,7 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制null/undefined 表示不限制
@@ -718,8 +711,8 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
key_balance_summary?: ProviderKeyBalanceSummary | null
kiro_simulated_cache_enabled?: boolean
ops_quota_alert_enabled?: boolean
created_at: string
updated_at: string
}

View File

@@ -66,6 +66,8 @@ export interface QuotaStatusSnapshot {
plan_type?: string | null
pool_tier?: string | null
credits?: QuotaCreditsSnapshot | null
allowed_models_count?: number | null
rate_limit?: Record<string, unknown> | null
windows?: QuotaWindowSnapshot[] | null
}

View File

@@ -1,5 +1,7 @@
import apiClient from './client'
const MODULE_MANAGEMENT_ORDER_CONFIG_KEY = 'module_management.extension_order'
export interface ModuleStatus {
name: string
available: boolean
@@ -75,6 +77,20 @@ const CHAT_PII_REDACTION_DEFAULT_CONFIG: ChatPiiRedactionConfig = {
placeholder_prefix: 'AETHER',
}
export function normalizeModuleManagementOrder(value: unknown): string[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const order: string[] = []
for (const item of value) {
if (typeof item !== 'string') continue
const name = item.trim()
if (!name || seen.has(name)) continue
seen.add(name)
order.push(name)
}
return order
}
function cloneDefaultChatPiiRedactionRules(): ChatPiiRedactionRule[] {
return CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule }))
}
@@ -189,6 +205,31 @@ export const modulesApi = {
return response.data
},
async getModuleManagementOrder(): Promise<string[]> {
try {
const response = await apiClient.get<{ key: string; value: unknown }>(
`/api/admin/system/configs/${MODULE_MANAGEMENT_ORDER_CONFIG_KEY}`
)
return normalizeModuleManagementOrder(response.data.value)
} catch (err) {
const status = (err as { response?: { status?: number } }).response?.status
if (status === 404) return []
throw err
}
},
async updateModuleManagementOrder(order: string[]): Promise<string[]> {
const normalized = normalizeModuleManagementOrder(order)
const response = await apiClient.put<{ key: string; value: unknown }>(
`/api/admin/system/configs/${MODULE_MANAGEMENT_ORDER_CONFIG_KEY}`,
{
value: normalized,
description: '模块管理扩展模块展示顺序',
},
)
return normalizeModuleManagementOrder(response.data.value)
},
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),

View File

@@ -12,13 +12,7 @@ import client from './client'
// ==================== Types ====================
/** 认证类型 */
export type ConnectorAuthType =
| 'api_key'
| 'refresh_token'
| 'session_login'
| 'oauth'
| 'cookie'
| 'none'
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
/** 操作类型 */
export type ProviderActionType =
@@ -133,12 +127,19 @@ export interface ActionConfigRequest {
}
/** 保存配置请求 */
export interface QuotaAlertConfig {
enabled: boolean
threshold_amount: number
fetch_interval_seconds: number
}
export interface SaveConfigRequest {
architecture_id: string
base_url?: string
connector: ConnectorConfigRequest
actions: Record<string, ActionConfigRequest>
schedule: Record<string, string>
quota_alert?: QuotaAlertConfig
}
/** 连接请求 */
@@ -196,6 +197,7 @@ export interface ProviderOpsConfigResponse {
config: Record<string, unknown>
credentials: Record<string, unknown>
}
quota_alert?: QuotaAlertConfig
}
/**

View File

@@ -1,4 +1,4 @@
import { Mail, Shield, AlertTriangle } from 'lucide-vue-next'
import { Mail, Shield, AlertTriangle, BellRing } from 'lucide-vue-next'
import type { LucideIcon } from 'lucide-vue-next'
export interface BuiltinTool {
@@ -15,6 +15,12 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
href: '/admin/email',
icon: Mail,
},
{
name: '通知服务',
description: '管理通知项、模板和推送服务策略',
href: '/admin/notification-service',
icon: BellRing,
},
{
name: 'IP 安全',
description: '管理 IP 黑白名单,控制系统访问权限',

View File

@@ -11,6 +11,9 @@ const authStoreMock = vi.hoisted(() => ({
}))
const routerPushMock = vi.hoisted(() => vi.fn())
const routeMock = vi.hoisted(() => ({
query: {},
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
warning: vi.fn(),
@@ -27,6 +30,7 @@ const oauthApiMocks = vi.hoisted(() => ({
}))
vi.mock('vue-router', () => ({
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
@@ -157,6 +161,7 @@ beforeEach(() => {
authStoreMock.error = ''
authStoreMock.canAccessAdmin = false
authStoreMock.login.mockReset()
routeMock.query = {}
routerPushMock.mockReset()
toastMocks.success.mockReset()
toastMocks.warning.mockReset()

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@
]"
@click="switchMode('oauth')"
>
{{ isKiroProvider ? '设备授权' : '获取授权' }}
{{ isDeviceBrowserProvider ? (isWindsurfProvider ? '浏览器登录' : '设备授权') : '获取授权' }}
</button>
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
@@ -93,8 +93,132 @@
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
<template v-if="isWindsurfProvider">
<div class="space-y-4">
<div class="grid grid-cols-3 gap-1.5">
<button
v-for="opt in ([
{ key: 'default', label: '默认' },
{ key: 'google', label: 'Google' },
{ key: 'github', label: 'GitHub' },
] as const)"
:key="opt.key"
class="h-8 text-xs font-medium rounded-md border transition-colors"
:class="device.auth_type === opt.key
? 'border-primary bg-primary/5 text-foreground'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
@click="selectWindsurfLoginOption(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div
v-if="device.status === 'error' || device.status === 'expired'"
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
>
<div class="flex flex-col items-center text-center space-y-3">
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle class="w-5 h-5 text-destructive" />
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-destructive">
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
</p>
<p class="text-xs text-muted-foreground">
{{ device.error || '请重试' }}
</p>
</div>
<Button
size="sm"
variant="outline"
@click="resetDevice"
>
重新开始
</Button>
</div>
</div>
<div
v-else-if="device.starting && !device.session_id"
class="flex items-center justify-center py-12"
>
<div class="text-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
<p class="text-xs text-muted-foreground">
正在准备登录...
</p>
</div>
</div>
<div
v-else
class="space-y-4"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
<span class="text-xs font-medium">前往登录</span>
</div>
<div class="flex gap-2 pl-6">
<Button
size="sm"
:disabled="device.starting || device.completing || !device.verification_uri_complete"
@click="openDeviceVerificationUrl"
>
<ExternalLink class="w-3 h-3 mr-1" />
打开
</Button>
<Button
size="sm"
variant="outline"
:disabled="device.starting || device.completing || !device.verification_uri_complete"
@click="copyToClipboard(device.verification_uri_complete)"
>
<Copy class="w-3 h-3 mr-1" />
复制
</Button>
<Button
v-if="!device.session_id"
size="sm"
variant="outline"
:disabled="device.starting"
@click="startDeviceAuth"
>
开始
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
<span class="text-xs font-medium">粘贴回调 URL 或 token</span>
</div>
<div class="pl-6">
<Textarea
v-model="device.callback_url"
:disabled="device.completing"
:placeholder="deviceCallbackPlaceholder"
class="min-h-[150px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
</div>
<div
v-if="device.session_id && device.status === 'pending'"
class="pl-6 flex items-center gap-1.5 text-[11px] text-muted-foreground"
>
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
<span>会话剩余 {{ deviceCountdownFormatted }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Kiro: 设备授权模式 -->
<template v-if="isKiroProvider">
<template v-else-if="isKiroProvider">
<div class="space-y-3">
<!-- 授权类型切换 -->
<div class="grid grid-cols-2 gap-1.5">
@@ -198,7 +322,7 @@
<Textarea
v-model="device.callback_url"
:disabled="device.completing"
:placeholder="kiroSocialCallbackPlaceholder"
:placeholder="deviceCallbackPlaceholder"
class="h-full min-h-0 overflow-y-auto text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
@@ -457,7 +581,70 @@
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<div
v-if="isWindsurfProvider"
class="grid grid-cols-2 gap-1.5 rounded-lg border border-border p-0.5 bg-muted/30"
>
<button
v-for="method in ([
{ key: 'email_password', label: '邮箱密码' },
{ key: 'token_json', label: 'Token / JSON' },
] as const)"
:key="method.key"
class="h-8 text-xs font-medium rounded-md transition-colors"
:class="windsurfImportMethod === method.key
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
:disabled="importing"
@click="setWindsurfImportMethod(method.key)"
>
{{ method.label }}
</button>
</div>
<div
v-if="isWindsurfEmailPasswordImport"
class="space-y-3"
>
<div class="space-y-1.5">
<label class="text-xs font-medium">邮箱</label>
<input
v-model="windsurfEmail"
type="email"
autocomplete="username"
:disabled="importing"
placeholder="you@example.com"
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
spellcheck="false"
>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">密码</label>
<input
v-model="windsurfPassword"
type="password"
autocomplete="current-password"
:disabled="importing"
placeholder="Windsurf 密码"
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground">名称(可选)</label>
<input
v-model="windsurfAccountName"
type="text"
autocomplete="off"
:disabled="importing"
placeholder="未填写时使用邮箱"
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
spellcheck="false"
>
</div>
</div>
<JsonImportInput
v-else
v-model="importText"
:disabled="importing"
:reset-key="importInputResetKey"
@@ -472,7 +659,7 @@
/>
<div
v-if="importTask"
v-if="importTask && !isWindsurfEmailPasswordImport"
class="rounded-xl border border-border bg-muted/20 p-3 space-y-2"
>
<div class="flex items-center justify-between text-xs">
@@ -527,15 +714,15 @@
取消
</Button>
<Button
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
v-if="mode === 'oauth' && showAuthorizationMode && !isDeviceBrowserProvider"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
{{ oauth.completing ? '验证中...' : '验证' }}
</Button>
<Button
v-if="mode === 'oauth' && isKiroSocialManualCallbackMode"
:disabled="!canCompleteKiroSocialDeviceAuth"
v-if="mode === 'oauth' && isManualDeviceCallbackMode"
:disabled="!canCompleteDeviceAuth"
@click="completeDeviceAuth"
>
{{ device.completing ? '验证中...' : '验证' }}
@@ -545,7 +732,7 @@
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : importButtonLabel }}
{{ importButtonText }}
</Button>
</template>
</Dialog>
@@ -577,6 +764,7 @@ import {
getBatchImportOAuthTaskStatus,
startDeviceAuthorize,
pollDeviceAuthorize,
normalizeBatchImportCredentials,
getAwsRegions,
} from '@/api/endpoints'
import type {
@@ -649,6 +837,7 @@ function getSelectedNodeLabel(): string {
// 模式
type DialogMode = 'oauth' | 'import'
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
type WindsurfImportMethod = 'email_password' | 'token_json'
// OAuth 状态
interface OAuthState {
@@ -678,7 +867,8 @@ let oauthInitRequestId = 0
let oauthCompleteRequestId = 0
// 设备授权状态
type DeviceAuthType = 'google' | 'github' | 'builder_id' | 'identity_center'
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
type WindsurfLoginOption = 'default' | 'google' | 'github'
interface DeviceAuthState {
auth_type: DeviceAuthType
@@ -736,11 +926,17 @@ const importInputResetKey = ref(0)
const importTask = ref<OAuthBatchImportTaskStatusResponse | null>(null)
let importPollTimer: ReturnType<typeof setTimeout> | null = null
const importPolling = ref(false)
const windsurfImportMethod = ref<WindsurfImportMethod>('email_password')
const windsurfEmail = ref('')
const windsurfPassword = ref('')
const windsurfAccountName = ref('')
const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -752,14 +948,20 @@ const isKiroSocialManualCallbackMode = computed(() =>
isKiroProvider.value && isSocialDeviceAuth.value
)
const isKiroSocialManualCallbackPending = computed(() =>
isKiroSocialManualCallbackMode.value
const isManualDeviceCallbackMode = computed(() =>
isKiroSocialManualCallbackMode.value || isWindsurfProvider.value
)
const isManualDeviceCallbackPending = computed(() =>
isManualDeviceCallbackMode.value
&& device.value.session_id.length > 0
&& device.value.status === 'pending'
)
const kiroSocialCallbackPlaceholder = computed(() =>
`http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
const deviceCallbackPlaceholder = computed(() =>
isWindsurfProvider.value
? `粘贴包含 token=...&state=... 的回调 URLsession token/apiKey 也可直接粘贴,普通 token 请用导入授权`
: `http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
)
const deviceCountdownFormatted = computed(() => {
@@ -779,13 +981,18 @@ const canCompleteOAuth = computed(() => {
return !oauthBusy.value
})
const canCompleteKiroSocialDeviceAuth = computed(() => {
if (!isKiroSocialManualCallbackPending.value) return false
const canCompleteDeviceAuth = computed(() => {
if (!isManualDeviceCallbackPending.value) return false
if (!device.value.callback_url.trim()) return false
return !device.value.starting && !device.value.completing
})
const canImport = computed(() => {
if (isWindsurfEmailPasswordImport.value) {
return windsurfEmail.value.trim().length > 0
&& windsurfPassword.value.trim().length > 0
&& !importing.value
}
return importText.value.trim().length > 0 && !importing.value
})
@@ -800,7 +1007,9 @@ const importDropHint = computed(() => (
const importManualPlaceholder = computed(() => (
isGrokProvider.value
? '粘贴 Grok sso/session token支持每行一个或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON'
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
: isWindsurfProvider.value
? '粘贴 show-auth-token Token、API key 或 JSON 内容'
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
))
const importManualDescription = computed(() => (
isGrokProvider.value
@@ -814,6 +1023,18 @@ const importFileToggleText = computed(() => (
isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入'
))
const providerCredentialActionLabel = computed(() => (isGrokProvider.value ? '导入' : '授权'))
const isWindsurfEmailPasswordImport = computed(() =>
isWindsurfProvider.value && windsurfImportMethod.value === 'email_password'
)
const importButtonText = computed(() => {
if (importing.value) {
return importTask.value && !isWindsurfEmailPasswordImport.value
? `导入中 ${importTask.value.progress_percent}%`
: '导入中...'
}
return isWindsurfEmailPasswordImport.value ? '登录并导入' : importButtonLabel.value
})
function stopImportPolling() {
if (importPollTimer) {
@@ -957,6 +1178,7 @@ function resetDeviceRuntimeState() {
}
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
if (!isKiroProvider.value) return false
if (device.value.starting) {
return !isSocialDeviceAuth.value
}
@@ -967,6 +1189,14 @@ function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
return true
}
function selectWindsurfLoginOption(loginOption: WindsurfLoginOption) {
if (!isWindsurfProvider.value) return
if (device.value.auth_type === loginOption && device.value.session_id && device.value.status === 'pending') return
deviceAuthRequestId += 1
resetDeviceRuntimeState()
device.value.auth_type = loginOption
}
function selectDeviceAuthType(authType: DeviceAuthType) {
if (device.value.auth_type === authType) return
if (isKiroDeviceAuthOptionDisabled(authType)) return
@@ -985,11 +1215,11 @@ function resetDevice() {
totp.stop()
const { auth_type, start_url, region, totp_secret } = device.value
device.value = createInitialDeviceState()
device.value.auth_type = auth_type
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
device.value.start_url = start_url
device.value.region = region
device.value.totp_secret = totp_secret
if (device.value.auth_type === 'google' || device.value.auth_type === 'github') {
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
void ensureKiroSocialDeviceAuth()
}
}
@@ -1003,10 +1233,17 @@ function resetForm() {
stopDevicePolling()
totp.stop()
device.value = createInitialDeviceState()
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
}
importText.value = ''
importing.value = false
importTask.value = null
importInputResetKey.value += 1
windsurfImportMethod.value = 'email_password'
windsurfEmail.value = ''
windsurfPassword.value = ''
windsurfAccountName.value = ''
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = defaultMode.value
@@ -1046,7 +1283,7 @@ function openAuthorizationUrl() {
async function initOAuth() {
if (!props.providerId) return
if (!showAuthorizationMode.value) return
if (isKiroProvider.value) return
if (isDeviceBrowserProvider.value) return
if (oauth.value.starting) return
const requestId = ++oauthInitRequestId
@@ -1095,35 +1332,12 @@ async function handleCompleteOAuth() {
}
}
// 检测是否为批量导入格式
function isBatchImport(text: string): boolean {
const trimmed = text.trim()
// JSON 数组(含单元素数组)
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) && parsed.length >= 1
} catch {
return false
}
}
// 单个 JSON 对象(可能是 pretty-printed 多行)不算批量导入
if (trimmed.startsWith('{')) {
try {
JSON.parse(trimmed)
return false // 可解析的单个 JSON 对象,走单条导入
} catch {
// 解析失败:可能是多个 JSON 对象JSON Lines 格式),继续检查
}
}
// 多行文本(纯 Token 一行一个)
const lines = trimmed.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
return lines.length > 1
}
function parseImportText(text: string): {
api_key?: string
token?: string
refresh_token?: string
access_token?: string
password?: string
expires_at?: number
name?: string
email?: string
@@ -1154,6 +1368,35 @@ function parseImportText(text: string): {
}
}
if (isWindsurfProvider.value) {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
const token = normalizeStringField(obj.token) ?? normalizeStringField(obj.auth_token) ?? normalizeStringField(obj.authToken)
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken)
const email = normalizeStringField(obj.email)
const password = normalizeStringField(obj.password)
if (apiKey || token || refreshToken || accessToken || (email && password)) {
return {
api_key: apiKey,
token,
refresh_token: refreshToken,
access_token: accessToken,
email,
password,
name: normalizeStringField(obj.name) ?? email,
}
}
}
} catch {
// Not JSON: treat as token copied from show-auth-token.
}
return { token: trimmed }
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
@@ -1316,8 +1559,47 @@ function handleImportInputError(payload: { message: string; title?: string }) {
showError(payload.message, payload.title)
}
function setWindsurfImportMethod(method: WindsurfImportMethod) {
if (!isWindsurfProvider.value || importing.value) return
windsurfImportMethod.value = method
importTask.value = null
}
async function handleWindsurfEmailPasswordImport() {
if (!props.providerId) return
const email = windsurfEmail.value.trim()
const password = windsurfPassword.value.trim()
if (!email || !password) {
showError('请输入邮箱和密码', '格式错误')
return
}
importing.value = true
try {
const result = await importProviderRefreshToken(props.providerId, {
email,
password,
name: windsurfAccountName.value.trim() || email,
proxy_node_id: selectedProxyNodeId.value || undefined,
})
success(getOAuthSuccessMessage('导入', result))
emit('saved')
handleClose()
} catch (err: unknown) {
const errorMessage = parseApiError(err, '导入失败')
showError(errorMessage, '错误')
} finally {
importing.value = false
}
}
async function handleImport() {
if (!canImport.value || !props.providerId) return
if (isWindsurfEmailPasswordImport.value) {
await handleWindsurfEmailPasswordImport()
return
}
const inputText = importText.value.trim()
if (!inputText) {
@@ -1325,13 +1607,19 @@ async function handleImport() {
return
}
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
if (!normalizedCredentials.ok) {
showError(normalizedCredentials.message, '格式错误')
return
}
importing.value = true
let keepImporting = false
try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// Kiro 的单条 JSON 凭据也必须走 batch-import 路径,后端需要完整 auth_config。
if (isKiroProvider.value || isBatchImport(inputText)) {
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
if (isKiroProvider.value || normalizedCredentials.isBatch) {
const task = await startBatchImportOAuthTask(props.providerId, normalizedCredentials.credentials, proxyNodeId)
importTask.value = {
task_id: task.task_id,
provider_id: props.providerId,
@@ -1356,7 +1644,7 @@ async function handleImport() {
scheduleImportPoll(task.task_id, 400)
} else {
// 单条导入
const parsed = parseImportText(inputText)
const parsed = parseImportText(normalizedCredentials.credentials)
if (!parsed) {
showError('无法解析输入内容,请检查格式', '格式错误')
return
@@ -1413,12 +1701,18 @@ async function startDeviceAuth() {
device.value.starting = true
device.value.error = ''
try {
const isWindsurf = isWindsurfProvider.value
const isBuilderID = requestedAuthType === 'builder_id'
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
const authTypeForRequest = isWindsurf
? 'browser'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
const resp = await startDeviceAuthorize(props.providerId, {
auth_type: requestedAuthType,
start_url: isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined)),
region: isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined),
auth_type: authTypeForRequest,
login_option: isWindsurf ? windsurfLoginOption : undefined,
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
@@ -1428,7 +1722,7 @@ async function startDeviceAuth() {
device.value.verification_uri_complete = resp.verification_uri_complete
device.value.expires_at = Date.now() + resp.expires_in * 1000
device.value.interval = resp.interval || 5
device.value.callback_required = resp.callback_required === true || isSocial
device.value.callback_required = resp.callback_required === true || isSocial || isWindsurf
device.value.status = 'pending'
startCountdown()
if (!device.value.callback_required) {
@@ -1464,7 +1758,7 @@ function scheduleDevicePoll() {
}
async function completeDeviceAuth() {
if (device.value.completing || !canCompleteKiroSocialDeviceAuth.value) return
if (device.value.completing || !canCompleteDeviceAuth.value) return
device.value.completing = true
try {
await pollDevice(true)
@@ -1473,13 +1767,39 @@ async function completeDeviceAuth() {
}
}
function normalizeWindsurfSubmittedCredential(value: string): { callback_url?: string, token?: string } {
const trimmed = value.trim()
if (!trimmed) return {}
if (/^https?:\/\//i.test(trimmed)) {
return { callback_url: trimmed }
}
const query = trimmed.replace(/^[?#&]+/, '')
const params = new URLSearchParams(query)
const hasTokenParam = ['token', 'auth_token', 'access_token'].some(key => params.has(key))
const hasStateParam = params.has('state')
if (hasTokenParam && hasStateParam) {
return { callback_url: `https://windsurf.com/show-auth-token?${query}` }
}
if (hasTokenParam) {
return { token: params.get('token') || params.get('auth_token') || params.get('access_token') || trimmed }
}
return { token: trimmed }
}
async function pollDevice(withCallback = false) {
if (!props.providerId || !device.value.session_id || device.value.status !== 'pending') return
try {
const submittedCredential = withCallback ? device.value.callback_url.trim() : ''
const windsurfSubmitted = isWindsurfProvider.value
? normalizeWindsurfSubmittedCredential(submittedCredential)
: {}
const result = await pollDeviceAuthorize(props.providerId, {
session_id: device.value.session_id,
callback_url: withCallback ? device.value.callback_url.trim() : undefined,
callback_url: withCallback ? (windsurfSubmitted.callback_url || (!isWindsurfProvider.value ? submittedCredential : undefined)) : undefined,
token: withCallback ? windsurfSubmitted.token : undefined,
})
switch (result.status) {
@@ -1535,7 +1855,9 @@ watch(() => props.open, (newOpen) => {
if (!showAuthorizationMode.value) {
return
}
if (isKiroProvider.value) {
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
initOAuth()
@@ -1552,7 +1874,11 @@ watch(
mode.value = 'import'
return
}
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
? device.value.auth_type
: 'default'
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}
},

View File

@@ -1,10 +1,10 @@
<template>
<Dialog
:open="open"
:title="dialogTitle"
description="独立配置上游余额/用量查询凭据,不影响模型调用 Key"
title="用户认证"
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
:icon="KeyRound"
size="4xl"
size="md"
@update:open="$emit('update:open', $event)"
>
<form
@@ -23,30 +23,38 @@
</div>
<div
v-else
class="space-y-5"
class="space-y-4"
>
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<Label>预设模板</Label>
<span class="text-xs text-muted-foreground">留空则自动使用供应商配置</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="arch in architectures"
:key="arch.architecture_id"
type="button"
class="h-8 rounded-md border px-3 text-xs font-medium transition-colors"
:class="selectedArchitectureId === arch.architecture_id
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'"
@click="selectArchitecturePreset(arch.architecture_id)"
<!-- 认证模板 + 认证方式并排 -->
<div class="flex gap-3">
<div
class="space-y-2"
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }"
>
<Label>认证模板</Label>
<Select
v-model="selectedArchitectureId"
@update:model-value="handleArchitectureChange"
>
{{ formatArchitectureLabel(arch) }}
</button>
<SelectTrigger>
<SelectValue placeholder="选择认证模板" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="arch in architectures"
:key="arch.architecture_id"
:value="arch.architecture_id"
>
{{ arch.display_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div
v-if="currentAuthTypes.length > 1"
class="grid gap-2 sm:max-w-xs"
class="space-y-2"
style="flex: 1"
>
<Label>认证方式</Label>
<Select
@@ -69,15 +77,6 @@
</div>
</div>
<div class="space-y-1">
<div class="text-sm font-semibold text-foreground">
凭证配置
</div>
<div class="text-xs text-muted-foreground">
不同模板支持的凭据类型不同API Key访问令牌和 Refresh Token 会分别保留
</div>
</div>
<!-- 动态表单字段 -->
<template v-if="currentSchema">
<template
@@ -240,6 +239,47 @@
</template>
</template>
</template>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
额度提醒
</Label>
<p class="mt-1 text-xs text-muted-foreground">
余额低于阈值时通过通知服务发送提醒
</p>
</div>
<Switch v-model="quotaAlert.enabled" />
</div>
<div
v-if="quotaAlert.enabled"
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
>
<div class="space-y-2">
<Label>提醒阈值</Label>
<Input
v-model.number="quotaAlert.threshold_amount"
type="number"
min="0"
step="0.0001"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label>获取频率(秒)</Label>
<Input
v-model.number="quotaAlert.fetch_interval_seconds"
type="number"
min="30"
max="86400"
step="1"
placeholder="30"
/>
</div>
</div>
</div>
</div>
</form>
@@ -263,23 +303,13 @@
:disabled="isVerifying || !canVerify"
@click="handleVerify"
>
<Loader2
v-if="isVerifying"
class="h-3.5 w-3.5 animate-spin"
/>
<Play
v-else
class="h-3.5 w-3.5"
/>
{{ isVerifying ? '测试中...' : '测试脚本' }}
{{ isVerifying ? '验证中...' : '验证' }}
</Button>
<Button
variant="outline"
:disabled="isSaving || isVerifying"
@click="handleFormat"
:disabled="isSaving || !canSave"
@click="handleSave"
>
<Wand2 class="h-3.5 w-3.5" />
格式化
{{ isSaving ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@@ -287,20 +317,6 @@
>
取消
</Button>
<Button
:disabled="isSaving || !canSave"
@click="handleSave"
>
<Loader2
v-if="isSaving"
class="h-3.5 w-3.5 animate-spin"
/>
<Save
v-else
class="h-3.5 w-3.5"
/>
{{ isSaving ? '保存中...' : '保存配置' }}
</Button>
</div>
</div>
</template>
@@ -309,7 +325,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { KeyRound, Loader2, Play, Save, Wand2 } from 'lucide-vue-next'
import { KeyRound } from 'lucide-vue-next'
import {
Dialog,
Button,
@@ -330,6 +346,7 @@ import {
getProviderOpsConfig,
deleteProviderOpsConfig,
type ArchitectureInfo,
type QuotaAlertConfig,
} from '@/api/providerOps'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
@@ -350,7 +367,6 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
open: boolean
providerId: string
providerName?: string
providerWebsite?: string
currentConfig?: Record<string, unknown> | null
}>()
@@ -401,12 +417,12 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
const dialogTitle = computed(() => (
props.providerName
? `配置用量查询 - ${props.providerName}`
: '配置用量查询'
))
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
})
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
// 当前架构支持的认证方式
const currentAuthTypes = computed(() => {
@@ -453,8 +469,15 @@ const canVerify = computed(() => {
})
// 保存按钮是否可用:验证成功且表单未变动
const quotaAlertChanged = computed(() => {
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
})
const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value
return (
(verifyStatus.value === 'success' && !formChanged.value)
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
)
})
// 字段分组
@@ -473,26 +496,6 @@ function handleArchitectureChange() {
formChanged.value = true
}
function selectArchitecturePreset(architectureId: string) {
if (selectedArchitectureId.value === architectureId) return
selectedArchitectureId.value = architectureId
handleArchitectureChange()
}
function formatArchitectureLabel(arch: ArchitectureInfo): string {
const labels: Record<string, string> = {
generic_api: '通用模板',
new_api: 'NewAPI',
sub2api: 'Sub2API',
anyrouter: 'AnyRouter',
done_hub: 'Done Hub',
yescode: 'YesCode',
cubence: 'Cubence',
nekocode: 'NekoCode',
}
return labels[arch.architecture_id] || arch.display_name
}
function handleAuthTypeChange() {
resetFormData()
verifyStatus.value = null
@@ -530,9 +533,7 @@ function resetFormData() {
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = key === 'base_url'
? (props.providerWebsite || (prop as Record<string, unknown>)['x-default-value'] || '')
: ((prop as Record<string, unknown>)['x-default-value'] ?? '')
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
@@ -549,20 +550,9 @@ function formatQuota(quota: number): string {
return quota.toLocaleString()
}
function handleFormat() {
const normalized: Record<string, unknown> = { ...formData.value }
for (const [key, value] of Object.entries(normalized)) {
if (typeof value !== 'string') continue
normalized[key] = key === 'base_url'
? value.trim().replace(/\/+$/, '')
: value.trim()
}
if (!normalized.base_url && props.providerWebsite) {
normalized.base_url = props.providerWebsite.replace(/\/+$/, '')
}
formData.value = normalized
verifyStatus.value = null
formChanged.value = true
function finiteNumber(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : null
}
async function handleVerify() {
@@ -635,8 +625,10 @@ async function handleVerify() {
const displayName = result.data?.display_name || result.data?.username
const extra = result.data?.extra
let balanceText = `余额: ${formatQuota(quota)}`
if (extra && extra.balance !== undefined && extra.points !== undefined) {
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
const extraBalance = finiteNumber(extra?.balance)
const extraPoints = finiteNumber(extra?.points)
if (extraBalance !== null && extraPoints !== null) {
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
}
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
}
@@ -694,8 +686,10 @@ async function handleSave() {
formData.value,
props.providerWebsite,
)
request.quota_alert = normalizedQuotaAlert()
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
showSuccess(result.message || '配置已保存', '保存成功')
emit('saved')
emit('update:open', false)
@@ -730,6 +724,7 @@ async function handleClear() {
formChanged.value = false
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
loadQuotaAlert(null)
resetFormData()
emit('saved')
emit('update:open', false)
@@ -743,30 +738,27 @@ async function handleClear() {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringOrDefault(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback
}
function loadFromConfig(config: Record<string, unknown>) {
if (!config?.connector) return
const connector = isRecord(config.connector) ? config.connector : null
if (!connector) return
hasExistingConfig.value = true
const connector = config.connector as {
auth_type?: string
credentials?: Record<string, unknown>
}
// 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api'
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择
let savedAuthType = connector?.auth_type
if (
selectedArchitectureId.value === 'sub2api' &&
savedAuthType === 'api_key' &&
connector?.credentials?.refresh_token &&
!connector?.credentials?.api_key
) {
savedAuthType = 'refresh_token'
}
const savedAuthType = stringOrDefault(connector.auth_type, '')
const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = savedAuthType
@@ -776,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
const schema = currentSchema.value
if (schema) {
const parsedData = parseConfigFromSchema(schema, config)
const parsedData = parseConfigFromSchema(schema, {
...config,
connector,
})
// 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {}
@@ -789,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
formData.value = parsedData
}
loadQuotaAlert(config.quota_alert)
}
function defaultQuotaAlert(): QuotaAlertConfig {
return {
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
}
}
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
if (!value || typeof value !== 'object') return defaultQuotaAlert()
const item = value as Record<string, unknown>
const threshold = Number(item.threshold_amount)
const interval = Number(item.fetch_interval_seconds)
return {
enabled: item.enabled === true,
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
}
}
function normalizedQuotaAlert(): QuotaAlertConfig {
return normalizeQuotaAlert(quotaAlert.value)
}
function quotaAlertSignature(value: QuotaAlertConfig): string {
const normalized = normalizeQuotaAlert(value)
return JSON.stringify([
normalized.enabled,
normalized.threshold_amount,
normalized.fetch_interval_seconds,
])
}
function loadQuotaAlert(value: unknown) {
const normalized = normalizeQuotaAlert(value)
quotaAlert.value = normalized
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
}
/** 确保架构列表已加载 */
@@ -829,11 +864,13 @@ watch(
architecture_id: config.architecture_id,
base_url: config.base_url,
connector: config.connector,
quota_alert: config.quota_alert,
}
loadFromConfig(configData)
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -841,6 +878,7 @@ watch(
} catch {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -850,6 +888,7 @@ watch(
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -857,14 +896,4 @@ watch(
}
}
)
watch(
() => props.providerWebsite,
(value) => {
if (!props.open || hasExistingConfig.value || !value) return
if (!formData.value.base_url) {
formData.value.base_url = value
}
}
)
</script>

View File

@@ -1,7 +1,15 @@
<template>
<!-- 余额正在加载中 -->
<div
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 显示从上游 API 查询的余额 -->
<div
v-if="provider.ops_configured && getProviderBalance(provider.id)"
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="flex items-center gap-2 text-xs"
>
<!-- 余额文字balance + points 分开显示或普通余额 -->
@@ -87,35 +95,6 @@
</div>
</div>
</div>
<!-- 显示保存到 Key 的手动余额查询摘要 -->
<div
v-else-if="getSavedKeyBalance(provider)"
class="space-y-0.5 text-xs"
:title="getSavedKeyBalanceTitle(provider)"
>
<div class="flex items-center gap-1.5">
<WalletCards class="h-3 w-3 text-primary" />
<span class="font-semibold text-foreground/90 tabular-nums">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/70">
<span v-if="toFiniteNumber(getSavedKeyBalance(provider)?.total_used) !== null">
已用 {{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_used, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span>
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</div>
</div>
<!-- 余额正在加载中 -->
<div
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 余额查询失败时显示错误 -->
<div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -149,18 +128,11 @@
</template>
<script setup lang="ts">
import { Loader2, WalletCards } from 'lucide-vue-next'
import { Loader2 } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import type { ProviderKeyBalanceSummary, ProviderWithEndpointsSummary } from '@/api/endpoints'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
toFiniteNumber,
} from '@/features/providers/utils/keyBalanceSummary'
defineProps<{
provider: ProviderWithEndpointsSummary
@@ -175,19 +147,4 @@ defineProps<{
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

@@ -555,64 +555,6 @@
</Button>
</div>
</div>
<!-- 手动余额查询摘要 -->
<div
v-if="getKeyBalanceSummary(key)"
class="mt-2 flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px]"
>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<span class="inline-flex items-center gap-1 font-medium text-foreground">
<WalletCards class="h-3 w-3 text-primary" />
上游余额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.available, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.used !== null"
class="text-muted-foreground"
>
已用 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.used, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.granted !== null"
class="text-muted-foreground"
>
总额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.granted, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.planName"
class="text-muted-foreground"
>
套餐 {{ getKeyBalanceSummary(key)?.planName }}
</span>
<span class="text-muted-foreground/70">
{{ getKeyBalanceSummary(key)?.templateLabel }} · {{ formatUpdatedAt(getKeyBalanceSummary(key)?.updatedAt || 0) }}
</span>
<span
v-if="getKeyBalanceAutoRefreshIntervalMinutes(key) > 0"
class="text-muted-foreground/70"
>
每 {{ getKeyBalanceAutoRefreshIntervalMinutes(key) }} 分钟自动
</span>
<span
v-if="keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)"
class="text-amber-600 dark:text-amber-400"
>
需保存查询凭据
</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
:disabled="refreshingBalanceKeyId === key.id || !canRefreshKeyBalance(key)"
:title="getKeyBalanceRefreshTitle(key)"
@click.stop="handleRefreshKeyBalance(key)"
>
<RefreshCw
class="h-3 w-3"
:class="{ 'animate-spin': refreshingBalanceKeyId === key.id }"
/>
</Button>
</div>
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
<div
v-if="hasCodexQuotaDisplayData(key)"
@@ -960,6 +902,138 @@
</div>
</template>
</div>
<!-- Windsurf 上游额度信息 -->
<div
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
class="mt-2 p-2 rounded-md"
:class="isWindsurfUnavailableKey(key) ? 'bg-destructive/10 border border-destructive/30' : (isWindsurfExhaustedKey(key) ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/50' : 'bg-muted/30')"
>
<div
v-if="isWindsurfUnavailableKey(key)"
class="flex items-center gap-2 text-destructive"
>
<ShieldX class="w-4 h-4 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-[11px] font-medium">
账号不可用
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.last_error"
class="text-[10px] text-destructive/80 truncate"
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
>
{{ getWindsurfQuotaDisplay(key)?.last_error }}
</div>
</div>
</div>
<template v-else>
<div
v-if="isWindsurfExhaustedKey(key)"
class="mb-2 flex items-center gap-2 text-amber-700 dark:text-amber-300"
>
<ShieldX class="w-4 h-4 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-[11px] font-medium">
{{ getWindsurfQuotaStatusLabel(key) }}
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.last_error"
class="text-[10px] text-amber-700/80 dark:text-amber-300/80 truncate"
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
>
{{ getWindsurfQuotaDisplay(key)?.last_error }}
</div>
</div>
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">账号配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="getWindsurfQuotaDisplay(key)?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatKiroUpdatedAt(getWindsurfQuotaDisplay(key)?.updated_at || 0) }}
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-if="getWindsurfQuotaDisplay(key)?.daily_remaining_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">日额度</span>
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)">
{{ (getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0, 0)}%` }"
/>
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.daily_reset_at"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.daily_reset_at || 0) }}重置
</div>
</div>
<div v-if="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">周额度</span>
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)">
{{ (getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0, 0)}%` }"
/>
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.weekly_reset_at"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.weekly_reset_at || 0) }}重置
</div>
</div>
</div>
<div
v-if="hasWindsurfPromptQuota(key) || hasWindsurfFlexQuota(key)"
class="mt-2 flex items-center gap-3 text-[9px] text-muted-foreground/70"
>
<span v-if="hasWindsurfPromptQuota(key)">
Prompt {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_used || 0) }} /
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_limit || 0) }}
</span>
<span v-if="hasWindsurfFlexQuota(key)">
Flex {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_used || 0) }} /
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_limit || 0) }}
</span>
</div>
<div
v-if="hasWindsurfModelCount(key) || hasWindsurfModelPreview(key)"
class="mt-2 flex items-center justify-between gap-2 text-[9px] text-muted-foreground/70"
>
<span>
模型 {{ getWindsurfQuotaDisplay(key)?.allowed_models_count ?? getWindsurfQuotaDisplay(key)?.models?.length }} 个
</span>
<span
v-if="getWindsurfModelPreview(key)"
class="truncate"
:title="getWindsurfModelPreview(key) || ''"
>
{{ getWindsurfModelPreview(key) }}
</span>
</div>
</template>
</div>
<!-- ChatGPT Web 上游额度信息(生图配额) -->
<div
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
@@ -1275,7 +1349,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
import { ref, watch, computed, nextTick } from 'vue'
import {
Plus,
Key,
@@ -1294,7 +1368,6 @@ import {
ShieldX,
Globe,
GitBranch,
WalletCards,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1341,12 +1414,10 @@ import {
exportKey,
refreshProviderOAuth,
refreshProviderQuota,
queryProviderKeyBalance,
clearOAuthInvalid,
type ProviderEndpoint,
type EndpointAPIKey,
type Model,
type ProviderKeyBalanceQuery,
API_FORMAT_ORDER,
sortApiFormats,
} from '@/api/endpoints'
@@ -1357,6 +1428,7 @@ import type {
ChatGPTWebUpstreamMetadata,
GrokUpstreamMetadata,
KiroUpstreamMetadata,
WindsurfUpstreamMetadata,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
} from '@/api/endpoints/types'
@@ -1391,17 +1463,6 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
rpm_limit?: number
}
interface KeyBalanceSummary {
available: number | null
used: number | null
granted: number | null
currency: string
updatedAt: number
templateLabel: string
planName: string | null
architectureId: string
}
interface Props {
providerId: string | null
open: boolean
@@ -1437,8 +1498,6 @@ let keysLoadRequestId = 0
let mappingPreviewLoadRequestId = 0
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
const BALANCE_AUTO_REFRESH_CHECK_MS = 60_000
let balanceAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom'
@@ -1462,7 +1521,6 @@ const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false)
const keyToDelete = ref<EndpointAPIKey | null>(null)
const togglingKeyId = ref<string | null>(null)
const refreshingBalanceKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
@@ -1645,7 +1703,6 @@ watch(
// 仅在抽屉刚打开时启动倒计时
if (newOpen && !oldOpen) {
startCountdownTimer()
startKeyBalanceAutoRefreshTimer()
}
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
} else if (!newOpen && oldOpen) {
@@ -1657,7 +1714,6 @@ watch(
// 停止倒计时定时器
stopCountdownTimer()
stopKeyBalanceAutoRefreshTimer()
// 重置所有状态
loading.value = false
provider.value = null
@@ -1820,167 +1876,6 @@ function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIK
}
}
function canOpenKeyBalanceQuery(key: EndpointAPIKey): boolean {
return key.auth_type === 'api_key' || key.auth_type === 'bearer'
}
function normalizeBalanceArchitectureId(value: unknown): ProviderKeyBalanceQuery['architecture_id'] | undefined {
const normalized = String(value || '').trim().toLowerCase().replace(/-/g, '_')
if (normalized === 'newapi' || normalized === 'new_api') return 'new_api'
if (normalized === 'sub2api') return 'sub2api'
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return 'generic_api'
return undefined
}
function canRefreshKeyBalance(key: EndpointAPIKey): boolean {
return canOpenKeyBalanceQuery(key)
&& !!normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
&& (!keyBalanceRefreshRequiresSavedSecret(key) || hasSavedBalanceSecret(key))
}
function hasSavedBalanceSecret(key: EndpointAPIKey): boolean {
return key.upstream_metadata?.balance_query?.query_config?.has_saved_secret === true
}
function keyBalanceRefreshRequiresSavedSecret(key: EndpointAPIKey): boolean {
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
if (architectureId === 'new_api') return true
if (architectureId !== 'sub2api') return false
const credentialKind = String(
key.upstream_metadata?.balance_query?.query_config?.sub2api_credential_kind || ''
).trim()
return credentialKind === 'access_token' || credentialKind === 'refresh_token'
}
function getKeyBalanceAutoRefreshIntervalMinutes(key: EndpointAPIKey): number {
const parsed = toFiniteNumber(
key.upstream_metadata?.balance_query?.query_config?.auto_refresh_interval_minutes
)
if (parsed === null || parsed <= 0) return 0
return Math.min(Math.floor(parsed), 10080)
}
function isKeyBalanceAutoRefreshDue(key: EndpointAPIKey): boolean {
const intervalMinutes = getKeyBalanceAutoRefreshIntervalMinutes(key)
if (intervalMinutes <= 0 || !canRefreshKeyBalance(key)) return false
const updatedAt = toFiniteNumber(key.upstream_metadata?.balance_query?.updated_at)
if (updatedAt === null || updatedAt <= 0) return true
const now = Math.floor(Date.now() / 1000)
return now - updatedAt >= intervalMinutes * 60
}
function startKeyBalanceAutoRefreshTimer() {
if (balanceAutoRefreshTimer) return
balanceAutoRefreshTimer = setInterval(() => {
void refreshDueKeyBalances()
}, BALANCE_AUTO_REFRESH_CHECK_MS)
}
function stopKeyBalanceAutoRefreshTimer() {
if (!balanceAutoRefreshTimer) return
clearInterval(balanceAutoRefreshTimer)
balanceAutoRefreshTimer = null
}
async function refreshDueKeyBalances() {
if (!props.open || !props.providerId || refreshingBalanceKeyId.value) return
const dueKey = providerKeys.value.find(key => key.is_active && isKeyBalanceAutoRefreshDue(key))
if (!dueKey) return
await handleRefreshKeyBalance(dueKey, { silent: true })
}
function getKeyBalanceRefreshTitle(key: EndpointAPIKey): string {
if (!canOpenKeyBalanceQuery(key)) {
return '余额查询仅支持 API Key 或 Bearer Token'
}
if (!canRefreshKeyBalance(key)) {
if (keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)) {
return '需要先手动查询一次,并开启“保存余额查询凭据”'
}
return '缺少上次查询模板,请先手动查询一次余额'
}
const summary = getKeyBalanceSummary(key)
return summary?.templateLabel
? `重新查询 ${summary.templateLabel} 余额`
: '重新查询余额'
}
function assignSavedBalanceQueryConfig(query: ProviderKeyBalanceQuery, key: EndpointAPIKey) {
const config = key.upstream_metadata?.balance_query?.query_config
if (!config || typeof config !== 'object') return
query.custom_base_url = trimmedStringOrUndefined(config.custom_base_url)
query.new_api_user_id = trimmedStringOrUndefined(config.new_api_user_id)
const sub2apiKind = String(config.sub2api_credential_kind || '').trim()
if (sub2apiKind === 'api_key' || sub2apiKind === 'access_token' || sub2apiKind === 'refresh_token') {
query.sub2api_credential_kind = sub2apiKind
}
query.custom_endpoint = trimmedStringOrUndefined(config.custom_endpoint)
const customMethod = String(config.custom_method || '').trim().toUpperCase()
if (customMethod === 'GET' || customMethod === 'POST') {
query.custom_method = customMethod
}
query.custom_currency = trimmedStringOrUndefined(config.custom_currency)
const customQuotaDivisor = toFiniteNumber(config.custom_quota_divisor)
if (customQuotaDivisor !== null && customQuotaDivisor > 0) {
query.custom_quota_divisor = customQuotaDivisor
}
const intervalMinutes = toFiniteNumber(config.auto_refresh_interval_minutes)
if (intervalMinutes !== null && intervalMinutes > 0) {
query.auto_refresh_interval_minutes = Math.min(Math.floor(intervalMinutes), 10080)
}
query.custom_balance_path = trimmedStringOrUndefined(config.custom_balance_path)
query.custom_used_path = trimmedStringOrUndefined(config.custom_used_path)
query.custom_granted_path = trimmedStringOrUndefined(config.custom_granted_path)
}
function trimmedStringOrUndefined(value: unknown): string | undefined {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed || undefined
}
async function handleRefreshKeyBalance(key: EndpointAPIKey, options: { silent?: boolean } = {}) {
if (!props.providerId || refreshingBalanceKeyId.value || !canRefreshKeyBalance(key)) return
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
if (!architectureId) return
refreshingBalanceKeyId.value = key.id
try {
const query: ProviderKeyBalanceQuery = {
key_id: key.id,
auth_type: key.auth_type === 'bearer' ? 'bearer' : 'api_key',
api_formats: key.api_formats || [],
architecture_id: architectureId,
save_result: true,
}
assignSavedBalanceQueryConfig(query, key)
const result = await queryProviderKeyBalance(props.providerId, query)
if (result.status !== 'success') {
if (!options.silent) {
showError(result.message || '余额刷新失败', '错误')
}
return
}
if (!options.silent) {
showSuccess('余额已刷新')
}
await loadProviderKeysPage(currentKeyPage.value)
emit('refresh')
} catch (err: unknown) {
if (!options.silent) {
showError(parseApiError(err, '余额刷新失败'), '错误')
}
} finally {
refreshingBalanceKeyId.value = null
}
}
function handleKeyPermissions(key: EndpointAPIKey) {
editingKey.value = key
keyPermissionsDialogOpen.value = true
@@ -2185,7 +2080,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
}
}
// Codex / Antigravity / Kiro / ChatGPT Web打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
// Codex / Antigravity / Kiro / Windsurf / ChatGPT Web打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
@@ -2204,7 +2099,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2463,11 +2358,160 @@ function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
return Object.keys(display).length > 0 ? display : null
}
type WindsurfQuotaDisplay = WindsurfUpstreamMetadata & {
daily_used_percent?: number
weekly_used_percent?: number
}
function getWindsurfQuotaDisplay(key: EndpointAPIKey): WindsurfQuotaDisplay | null {
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
const upstream = key.upstream_metadata?.windsurf
if (!quota && !upstream) return null
const display: WindsurfQuotaDisplay = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota) ?? upstream?.updated_at
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota?.plan_type) display.plan_name = quota.plan_type
else if (upstream?.plan_name) display.plan_name = upstream.plan_name
if (quota?.reason) display.last_error = quota.reason
else if (upstream?.last_error) display.last_error = upstream.last_error
if (typeof quota?.allowed_models_count === 'number') display.allowed_models_count = quota.allowed_models_count
else if (typeof upstream?.allowed_models_count === 'number') display.allowed_models_count = upstream.allowed_models_count
if (quota?.rate_limit) display.rate_limit = quota.rate_limit
else if (upstream?.rate_limit) display.rate_limit = upstream.rate_limit
if (Array.isArray(upstream?.models)) display.models = upstream.models
const dailyWindow = getQuotaWindow(quota, 'daily')
const dailyRemaining = getQuotaWindowRemainingPercent(dailyWindow)
const dailyUsed = getQuotaWindowUsedPercent(dailyWindow)
if (dailyRemaining !== undefined) display.daily_remaining_percent = dailyRemaining
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_remaining_percent = upstream.daily_remaining_percent
if (dailyUsed !== undefined) display.daily_used_percent = dailyUsed
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_used_percent = Math.max(100 - upstream.daily_remaining_percent, 0)
const dailyResetAt = getQuotaWindowResetAt(dailyWindow)
if (dailyResetAt !== undefined) display.daily_reset_at = dailyResetAt
else if (typeof upstream?.daily_reset_at === 'number') display.daily_reset_at = upstream.daily_reset_at
const weeklyWindow = getQuotaWindow(quota, 'weekly')
const weeklyRemaining = getQuotaWindowRemainingPercent(weeklyWindow)
const weeklyUsed = getQuotaWindowUsedPercent(weeklyWindow)
if (weeklyRemaining !== undefined) display.weekly_remaining_percent = weeklyRemaining
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_remaining_percent = upstream.weekly_remaining_percent
if (weeklyUsed !== undefined) display.weekly_used_percent = weeklyUsed
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_used_percent = Math.max(100 - upstream.weekly_remaining_percent, 0)
const weeklyResetAt = getQuotaWindowResetAt(weeklyWindow)
if (weeklyResetAt !== undefined) display.weekly_reset_at = weeklyResetAt
else if (typeof upstream?.weekly_reset_at === 'number') display.weekly_reset_at = upstream.weekly_reset_at
const promptWindow = getQuotaWindow(quota, 'prompt')
if (typeof promptWindow?.used_value === 'number') display.prompt_used = promptWindow.used_value
else if (typeof upstream?.prompt_used === 'number') display.prompt_used = upstream.prompt_used
if (typeof promptWindow?.limit_value === 'number') display.prompt_limit = promptWindow.limit_value
else if (typeof upstream?.prompt_limit === 'number') display.prompt_limit = upstream.prompt_limit
if (typeof promptWindow?.remaining_value === 'number') display.prompt_remaining = promptWindow.remaining_value
else if (typeof upstream?.prompt_remaining === 'number') display.prompt_remaining = upstream.prompt_remaining
const flexWindow = getQuotaWindow(quota, 'flex')
if (typeof flexWindow?.used_value === 'number') display.flex_used = flexWindow.used_value
else if (typeof upstream?.flex_used === 'number') display.flex_used = upstream.flex_used
if (typeof flexWindow?.limit_value === 'number') display.flex_limit = flexWindow.limit_value
else if (typeof upstream?.flex_limit === 'number') display.flex_limit = upstream.flex_limit
if (typeof flexWindow?.remaining_value === 'number') display.flex_remaining = flexWindow.remaining_value
else if (typeof upstream?.flex_remaining === 'number') display.flex_remaining = upstream.flex_remaining
return Object.keys(display).length > 0 ? display : null
}
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
const grok = getGrokQuotaDisplay(key)
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
}
function hasWindsurfQuotaDisplayData(key: EndpointAPIKey): boolean {
const windsurf = getWindsurfQuotaDisplay(key)
return !!windsurf && (
windsurf.daily_remaining_percent !== undefined
|| windsurf.weekly_remaining_percent !== undefined
|| windsurf.prompt_limit !== undefined
|| windsurf.flex_limit !== undefined
|| windsurf.allowed_models_count !== undefined
|| windsurf.rate_limit !== undefined
|| !!windsurf.last_error
|| (Array.isArray(windsurf.models) && windsurf.models.length > 0)
)
}
function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
}
function getPositiveQuotaNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
function windsurfCooldownHasPositiveReset(key: EndpointAPIKey): boolean {
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
const rateLimit = quota?.rate_limit
if (rateLimit && typeof rateLimit === 'object') {
const retryAfterMs =
getPositiveQuotaNumber(rateLimit.retry_after_ms)
?? getPositiveQuotaNumber(rateLimit.retryAfterMs)
if (retryAfterMs !== undefined) return true
}
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
return (
getPositiveQuotaNumber(rateLimitWindow?.reset_seconds) !== undefined
|| getPositiveQuotaNumber(rateLimitWindow?.reset_at) !== undefined
)
}
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
if (code === 'cooldown') return windsurfCooldownHasPositiveReset(key)
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit'
}
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
const label = quota?.label?.trim()
if (label) return label
const code = String(quota?.code || '').trim().toLowerCase()
if (code === 'cooldown') return '冷却中'
return code === 'rate_limited' || code === 'rate_limit' ? '速率受限' : '额度耗尽'
}
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
const models = getWindsurfQuotaDisplay(key)?.models
if (!Array.isArray(models) || models.length === 0) return null
return models
.slice(0, 3)
.map(model => (model.label || model.model_uid || '').trim())
.filter(Boolean)
.join(' / ') || null
}
function hasFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function hasWindsurfPromptQuota(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.prompt_limit)
}
function hasWindsurfFlexQuota(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.flex_limit)
}
function hasWindsurfModelCount(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.allowed_models_count)
}
function hasWindsurfModelPreview(key: EndpointAPIKey): boolean {
return !!getWindsurfModelPreview(key)
}
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
image_quota_remaining_percent?: number
image_quota_used_percent?: number
@@ -2680,7 +2724,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
return false
}
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro / ChatGPT Web
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro / Windsurf / ChatGPT Web
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
@@ -2757,6 +2801,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
return false
}
function shouldAutoRefreshWindsurfQuota(): boolean {
if (provider.value?.provider_type !== 'windsurf') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasWindsurfQuotaDisplayData(key)) {
return true
}
const updatedAt = getWindsurfQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshChatGPTWebQuota(): boolean {
if (provider.value?.provider_type !== 'chatgpt_web') return false
const now = Math.floor(Date.now() / 1000)
@@ -2856,14 +2922,14 @@ function applyQuotaResults(
return applied
}
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro 和 ChatGPT Web
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro、Windsurf 和 ChatGPT Web
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
const providerId = props.providerId
if (!providerId) return
if (refreshingQuota.value) return
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
// 检查是否需要刷新
let shouldRefresh = false
@@ -2875,6 +2941,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'windsurf') {
shouldRefresh = shouldAutoRefreshWindsurfQuota()
} else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
}
@@ -2890,6 +2958,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'grok') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
} else if (providerType === 'windsurf') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
}
@@ -2936,7 +3006,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
}
async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
await Promise.all([loadEndpoints(), loadMappingPreview()])
emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
@@ -3407,58 +3477,6 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
return hasAntigravityQuotaData(key.upstream_metadata)
}
function getKeyBalanceSummary(key: EndpointAPIKey): KeyBalanceSummary | null {
const metadata = key.upstream_metadata?.balance_query
if (!metadata) return null
const updatedAt = toFiniteNumber(metadata.updated_at)
const available = toFiniteNumber(metadata.total_available)
const used = toFiniteNumber(metadata.total_used)
const granted = toFiniteNumber(metadata.total_granted)
if (updatedAt === null || (available === null && used === null && granted === null)) {
return null
}
const architectureId = String(metadata.architecture_id || '').trim()
const labels: Record<string, string> = {
new_api: 'NewAPI',
sub2api: 'Sub2API',
generic_api: '自定义'
}
return {
available,
used,
granted,
currency: String(metadata.currency || 'USD').trim() || 'USD',
updatedAt,
templateLabel: labels[architectureId] || architectureId || '余额查询',
architectureId,
planName: typeof metadata.plan_name === 'string' && metadata.plan_name.trim()
? metadata.plan_name.trim()
: null,
}
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
const numberValue = toFiniteNumber(value)
if (numberValue === null) return '未知'
const normalizedCurrency = (currency || 'USD').toUpperCase()
const prefix = normalizedCurrency === 'USD'
? '$'
: normalizedCurrency === 'CNY'
? '¥'
: `${normalizedCurrency} `
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
return `${prefix}${numberValue.toFixed(decimals)}`
}
function formatUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000)
@@ -3975,7 +3993,6 @@ async function loadProviderKeysPage(page = currentKeyPage.value) {
currentKeyPage.value = Math.min(result.page, nextTotalPages)
keyPageSize.value = result.page_size
syncCurrentSelections(endpoints.value, result.keys)
void refreshDueKeyBalances()
} catch (err: unknown) {
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
providerKeys.value = []
@@ -4073,10 +4090,6 @@ useEscapeKey(() => {
disableOnInput: true,
once: false
})
onUnmounted(() => {
stopKeyBalanceAutoRefreshTimer()
})
</script>
<style scoped>

View File

@@ -66,6 +66,9 @@
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -96,6 +99,9 @@
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -342,6 +348,7 @@ import {
createProvider,
normalizePoolAdvancedConfig,
updateProvider,
type ProviderType,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
@@ -377,7 +384,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
provider_type: 'custom' as ProviderType,
description: '',
website: '',
// 计费配置

View File

@@ -93,7 +93,7 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="配置用量查询"
title="扩展操作配置"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
@@ -125,9 +125,17 @@
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额加载中 -->
<span
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额从上游 API 查询 -->
<span
v-if="provider.ops_configured && getProviderBalance(provider.id)"
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
@@ -149,29 +157,6 @@
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</span>
<!-- 保存到 Key 的手动余额查询摘要 -->
<span
v-else-if="getSavedKeyBalance(provider)"
class="text-muted-foreground inline-flex items-center gap-1"
:title="getSavedKeyBalanceTitle(provider)"
>
<WalletCards class="h-3 w-3 text-primary" />
余额
<span class="font-semibold text-foreground/90">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span class="text-muted-foreground/70">
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</span>
<!-- 余额加载中 -->
<span
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额查询失败时显示错误 -->
<span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -248,20 +233,13 @@ import {
Check,
X,
Loader2,
WalletCards,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { type ProviderKeyBalanceSummary, type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
} from '@/features/providers/utils/keyBalanceSummary'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -329,19 +307,4 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
}
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

@@ -163,7 +163,7 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="配置用量查询"
title="扩展操作配置"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />

View File

@@ -14,7 +14,16 @@ const endpointMocks = vi.hoisted(() => ({
getAwsRegions: vi.fn(),
}))
vi.mock('@/api/endpoints', () => endpointMocks)
vi.mock('@/api/endpoints', async () => {
const actual = await vi.importActual<typeof import('@/api/endpoints/provider_oauth')>(
'@/api/endpoints/provider_oauth',
)
return {
...endpointMocks,
normalizeBatchImportCredentials: actual.normalizeBatchImportCredentials,
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
@@ -358,7 +367,7 @@ describe('OAuthAccountDialog Grok import', () => {
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
'sso-1\nsso-2',
'["sso-1","sso-2"]',
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()

View File

@@ -688,36 +688,6 @@
</TabsContent>
<TabsContent value="response-body">
<div
v-if="selectedInspectionImagePreviews.length > 0"
class="mb-3 rounded-md border border-border/60 bg-muted/20 p-3"
>
<div class="mb-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>图片预览</span>
<span>{{ selectedInspectionImagePreviews.length }} </span>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="(preview, index) in selectedInspectionImagePreviews"
:key="`${preview.src}-${index}`"
type="button"
class="group block overflow-hidden rounded-md border border-border/60 bg-background text-left transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
@click="openImagePreview(preview)"
>
<div class="aspect-square w-full overflow-hidden bg-muted/30">
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</div>
<div class="border-t border-border/60 px-2 py-1 text-[11px] text-muted-foreground">
{{ preview.label }}
</div>
</button>
</div>
</div>
<JsonContent
:data="selectedInspectionAttempt.response_body"
view-mode="formatted"
@@ -935,11 +905,17 @@ const activeImagePreview = ref<ModelTestImagePreview | null>(null)
watch(() => props.result, () => {
showAllAttempts.value = false
inspectionTab.value = 'request-body'
inspectionExpandDepth.value = 0
inspectionCopiedStates.value = {}
const defaultAttempt = inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
const defaultAttempt = resultImageAttempt.value
?? resultAttempts.value.find(attempt => attempt.status === 'success')
?? inspectableAttempts.value[0]
?? resultAttempts.value[0]
?? null
selectedInspectionKey.value = defaultAttempt ? inspectionKey(defaultAttempt) : null
inspectionTab.value = defaultAttempt && attemptImagePreviews(defaultAttempt).length > 0
? 'response-body'
: 'request-body'
})
const shouldCollapseAttempts = computed(() => resultAttempts.value.length > 20)
@@ -1245,11 +1221,11 @@ const selectedInspectionAttempt = computed(() => {
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
})
const selectedInspectionImagePreviews = computed(() => (
selectedInspectionAttempt.value
? extractModelTestImagePreviews(selectedInspectionAttempt.value.response_body)
: []
))
const resultImageAttempt = computed(() => {
return resultAttempts.value.find(attempt => attempt.status === 'success' && attemptImagePreviews(attempt).length > 0)
?? resultAttempts.value.find(attempt => attemptImagePreviews(attempt).length > 0)
?? null
})
const resultWinningTitle = computed(() => {
const summary = resultSummary.value
@@ -1432,7 +1408,7 @@ function inspectionKey(attempt: TestAttemptDetail): string {
function selectInspectionAttempt(attempt: TestAttemptDetail) {
selectedInspectionKey.value = inspectionKey(attempt)
inspectionTab.value = 'request-body'
inspectionTab.value = attemptImagePreviews(attempt).length > 0 ? 'response-body' : 'request-body'
}
function hasDebugData(attempt: TestAttemptDetail): boolean {

View File

@@ -479,6 +479,23 @@ describe('extractModelTestResponsePreview', () => {
})).toBe('图片https://example.com/generated.png')
})
it('extracts renderable URL image previews from OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
url: 'https://example.com/generated.png',
revised_prompt: 'A generated image',
},
],
})).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('summarizes base64 image responses without dumping the image payload', () => {
expect(extractModelTestResponsePreview({
data: [
@@ -506,6 +523,55 @@ describe('extractModelTestResponsePreview', () => {
])
})
it('accepts root-relative URLs in OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
url: '/v1/files/image?id=2af14311-a0cb-4bbf-ae20-d1fcf44e0479',
},
],
})).toEqual([
{
src: '/v1/files/image?id=2af14311-a0cb-4bbf-ae20-d1fcf44e0479',
label: '图片 1',
source: 'url',
},
])
})
it('parses stringified JSON image responses', () => {
expect(extractModelTestImagePreviews(JSON.stringify({
data: [
{
url: 'https://example.com/generated.png',
},
],
}))).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('parses stringified JSON base64 image responses', () => {
expect(extractModelTestImagePreviews(JSON.stringify({
data: [
{
b64_json: 'aGVsbG8=',
mime_type: 'image/jpeg',
},
],
}))).toEqual([
{
src: 'data:image/jpeg;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('extracts image previews from nested response image urls', () => {
expect(extractModelTestImagePreviews({
output: [

View File

@@ -10,16 +10,17 @@ export type ModelTestImagePreview = {
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
const normalizedBody = normalizeModelTestResponseBody(responseBody)
const text = extractResponseText(normalizedBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
const reasoning = extractResponseReasoning(normalizedBody)
if (reasoning) return `推理:${reasoning}`
const image = extractImagePreview(responseBody)
const image = extractImagePreview(normalizedBody)
if (image) return image
const summary = extractResponseSummary(responseBody)
const summary = extractResponseSummary(normalizedBody)
if (summary) return summary
return null
@@ -27,10 +28,26 @@ export function extractModelTestResponsePreview(responseBody: unknown): string |
export function extractModelTestImagePreviews(responseBody: unknown): ModelTestImagePreview[] {
const previews: ModelTestImagePreview[] = []
collectImagePreviews(responseBody, previews, new Set(), 0)
collectImagePreviews(normalizeModelTestResponseBody(responseBody), previews, new Set(), 0)
return previews
}
function normalizeModelTestResponseBody(value: unknown): unknown {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (!trimmed) return value
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed)
} catch {
return value
}
}
return value
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -146,6 +163,7 @@ function collectImagePreviews(
value.output,
value.images,
value.content,
value.item,
]
for (const nested of nestedValues) {
collectImagePreviews(nested, previews, seen, depth + 1)
@@ -203,6 +221,9 @@ function imageUrlToPreview(value: unknown, source: 'url'): ModelTestImagePreview
if (url.startsWith('data:image/')) {
return { src: url, label: 'base64', source: 'base64' }
}
if (url.startsWith('/')) {
return { src: url, label: 'URL', source }
}
try {
const parsed = new URL(url)

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { normalizeBatchImportCredentials } from '@/api/endpoints/provider_oauth'
import { isKeyManagedProviderType, isOAuthAccountProviderType } from '../providerTypeUtils'
describe('providerTypeUtils', () => {
@@ -14,4 +15,47 @@ describe('providerTypeUtils', () => {
expect(isOAuthAccountProviderType('GROK')).toBe(true)
expect(isKeyManagedProviderType('grok')).toBe(false)
})
it('treats Windsurf as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
expect(isKeyManagedProviderType('windsurf')).toBe(false)
})
})
describe('normalizeBatchImportCredentials', () => {
it('converts JSON Lines objects into a JSON array payload', () => {
const result = normalizeBatchImportCredentials([
'{"refresh_token":"rt-1","email":"one@example.com"}',
'{"token":"token-2","email":"two@example.com"}',
].join('\n'))
expect(result).toEqual({
ok: true,
isBatch: true,
credentials: JSON.stringify([
{ refresh_token: 'rt-1', email: 'one@example.com' },
{ token: 'token-2', email: 'two@example.com' },
]),
})
})
it('rejects malformed JSON Lines instead of treating them as raw tokens', () => {
const result = normalizeBatchImportCredentials('{"refresh_token":"rt-1"}\n{"refresh_token":')
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('第 2 行')
}
})
it('converts multiple raw token lines into a JSON array payload', () => {
const result = normalizeBatchImportCredentials('token-a\n# comment\ntoken-b')
expect(result).toEqual({
ok: true,
isBatch: true,
credentials: JSON.stringify(['token-a', 'token-b']),
})
})
})

View File

@@ -1,54 +0,0 @@
import type { ProviderKeyBalanceSummary } from '@/api/endpoints'
export function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function hasKeyBalanceSummary(summary: ProviderKeyBalanceSummary | null | undefined): summary is ProviderKeyBalanceSummary {
if (!summary) return false
const updatedAt = toFiniteNumber(summary.updated_at)
if (updatedAt === null) return false
return toFiniteNumber(summary.total_available) !== null
|| toFiniteNumber(summary.total_used) !== null
|| toFiniteNumber(summary.total_granted) !== null
}
export function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
const numberValue = toFiniteNumber(value)
if (numberValue === null) return '未知'
const normalizedCurrency = (currency || 'USD').toUpperCase()
const prefix = normalizedCurrency === 'USD'
? '$'
: normalizedCurrency === 'CNY'
? '¥'
: `${normalizedCurrency} `
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
return `${prefix}${numberValue.toFixed(decimals)}`
}
export function keyBalanceTemplateLabel(architectureId: unknown): string {
const normalized = String(architectureId || '').trim().toLowerCase().replace(/-/g, '_')
if (normalized === 'newapi' || normalized === 'new_api') return 'NewAPI'
if (normalized === 'sub2api') return 'Sub2API'
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return '自定义'
return normalized || '余额查询'
}
export function formatKeyBalanceUpdatedAt(updatedAt: unknown): string {
const timestamp = toFiniteNumber(updatedAt)
if (timestamp === null || timestamp <= 0) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
if (diff <= 60) return '刚刚更新'
const minutes = Math.floor(diff / 60)
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
}

View File

@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
'antigravity',
'kiro',
'grok',
'windsurf',
])
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>

View File

@@ -418,6 +418,25 @@ export function useUsageData(options: UseUsageDataOptions) {
}
}
function mergePositiveDurationMs(
existingValue: number | null | undefined,
nextValue: number | null | undefined
): number | null | undefined {
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
if (existingIsPositive && nextIsPositive) {
return Math.max(existingValue, nextValue)
}
if (existingIsPositive) {
return existingValue
}
if (nextIsPositive) {
return nextValue
}
return existingValue ?? nextValue
}
function mergeRecordStatus(
current: UsageRecord[],
next: UsageRecord[]
@@ -513,8 +532,8 @@ export function useUsageData(options: UseUsageDataOptions) {
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
cost: existing.cost || record.cost,
actual_cost: existing.actual_cost ?? record.actual_cost,
response_time_ms: existing.response_time_ms ?? record.response_time_ms,
first_byte_time_ms: existing.first_byte_time_ms ?? record.first_byte_time_ms,
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,

View File

@@ -91,6 +91,27 @@ describe('request failure notice', () => {
})
})
it('does not present HTTP 200 as the cause of stream terminal failures', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
status_code: 200,
status: 'failed',
error_message: 'This content was flagged for possible cybersecurity risk',
failure_summary: {
source: 'client_response',
status_code: 200,
type: 'stream_terminal_error',
message: 'This content was flagged for possible cybersecurity risk',
},
}))
expect(notice).toEqual({
title: '执行失败原因',
message: 'This content was flagged for possible cybersecurity risk',
isSchedulingFailure: false,
meta: ['stream_terminal_error', 'client_response'],
})
})
it('does not show a stale notice when the refreshed detail has no error fields', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
status_code: 200,

View File

@@ -51,15 +51,15 @@ describe('usage status helpers', () => {
expect(isUsageRecordSuccessful(record)).toBe(false)
})
it('treats explicit failed status with a 2xx status code as successful for display', () => {
it('treats explicit failed status as authoritative over a 2xx transport code', () => {
const record = buildUsageRecord({
status: 'failed',
status_code: 200,
error_message: 'stale failure flag'
error_message: 'stream terminal error'
})
expect(isUsageRecordFailed(record)).toBe(false)
expect(isUsageRecordSuccessful(record)).toBe(true)
expect(isUsageRecordFailed(record)).toBe(true)
expect(isUsageRecordSuccessful(record)).toBe(false)
})
it('normalizes request status strings before mapping timeline status', () => {

View File

@@ -18,7 +18,9 @@ function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): Re
}
function formatHttpStatus(statusCode: number | null | undefined): string | null {
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
return typeof statusCode === 'number' && (statusCode < 200 || statusCode >= 300)
? `HTTP ${statusCode}`
: null
}
function uniqueMeta(values: Array<string | null | undefined>): string[] {

View File

@@ -189,7 +189,7 @@ export function isUsageRecordFailed(record: UsageFailureSignal & Pick<UsageRecor
return false
}
if (status === 'failed') {
return !hasTerminalSuccessStatusCode(record)
return true
}
}
if (hasTerminalSuccessStatusCode(record)) {
@@ -208,7 +208,7 @@ export function isUsageRecordSuccessful(record: UsageFailureSignal & Pick<UsageR
return true
}
if (status === 'failed') {
return hasTerminalSuccessStatusCode(record)
return false
}
return false
}

View File

@@ -214,6 +214,9 @@
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3">
<div class="mb-3 text-xs font-semibold text-muted-foreground">
功能权限
</div>
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">敏感信息保护</Label>
<Switch v-model="form.chat_pii_redaction_enabled" />
@@ -225,6 +228,15 @@
:disabled="!form.chat_pii_redaction_enabled"
/>
</div>
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<div>
<Label class="text-sm font-medium">通知推送服务</Label>
<p class="mt-1 text-xs text-muted-foreground">
允许用户配置自己的第三方推送渠道
</p>
</div>
<Switch v-model="form.notification_push_service_enabled" />
</div>
</div>
</div>
</form>
@@ -271,6 +283,8 @@ import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form'
import {
mergeChatPiiRedactionFeatureSettings,
mergeNotificationPushServiceFeatureSettings,
readNotificationPushServiceFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import {
@@ -323,6 +337,7 @@ const form = ref({
group_ids: [] as string[],
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
notification_push_service_enabled: false,
})
const groupOptions = computed(() => (props.groups || []).map((group) => ({
@@ -348,6 +363,7 @@ function resetForm() {
group_ids: [],
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
notification_push_service_enabled: false,
}
}
@@ -355,6 +371,7 @@ function loadUserData() {
if (!props.user) return
formNonce.value = createFieldNonce()
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
const notificationPushFeature = readNotificationPushServiceFeatureSettings(props.user.feature_settings)
// 创建数组副本,避免与 props 数据共享引用
form.value = {
username: props.user.username,
@@ -368,6 +385,7 @@ function loadUserData() {
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
notification_push_service_enabled: notificationPushFeature.enabled,
}
}
@@ -442,10 +460,7 @@ async function handleSubmit() {
unlimited: form.value.unlimited,
role: form.value.role,
group_ids: [...form.value.group_ids],
feature_settings: mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
enabled: form.value.chat_pii_redaction_enabled,
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
}),
feature_settings: buildFeatureSettingsPayload(),
}
if (isEditMode.value && props.user?.id) {
@@ -472,6 +487,16 @@ async function handleSubmit() {
}
}
function buildFeatureSettingsPayload(): Record<string, unknown> | null {
const withRedaction = mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
enabled: form.value.chat_pii_redaction_enabled,
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
})
return mergeNotificationPushServiceFeatureSettings(withRedaction, {
enabled: form.value.notification_push_service_enabled,
})
}
// 设置保存状态(供父组件调用)
function setSaving(value: boolean) {
saving.value = value

View File

@@ -128,8 +128,25 @@
<div class="space-y-4 border-t border-border/60 pt-5">
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 pb-2 border-b border-border/60">
<span class="text-sm font-medium">组权限</span>
<span class="text-[11px] text-muted-foreground">
多个组与用户额外限制取交集
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
组权限叠加Key 可再收窄
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/70 bg-muted/40 text-muted-foreground outline-none transition-colors hover:border-primary/50 hover:text-primary focus-visible:border-primary/60 focus-visible:text-primary"
:title="groupPolicyHelpText"
aria-label="查看组权限合并规则"
>
<Info class="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-72 text-xs leading-5">
{{ groupPolicyHelpText }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
@@ -247,7 +264,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { BadgeCheck, ChevronRight, Plus, Trash2 } from 'lucide-vue-next'
import { BadgeCheck, ChevronRight, Info, Plus, Trash2 } from 'lucide-vue-next'
import {
Badge,
Button,
@@ -255,6 +272,10 @@ import {
Input,
Label,
Switch,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { MultiSelect } from '@/components/common'
import { useUsersStore } from '@/stores/users'
@@ -302,6 +323,8 @@ const USER_OPTIONS_CACHE_TTL_MS = 30 * 1000
let dialogUsersLoadedAt = 0
let dialogUsersLoadedVersion = -1
const groupPolicyHelpText = '模型、供应商和端点会在多个用户组之间叠加授权unrestricted 仍表示不限制deny_all 只是不授予额外权限。速率限制按付费档位取更高额度0 表示不限速;用户/API Key 自身限制仍会收窄最终权限。'
const form = ref({
name: '',
allowed_providers_mode: 'unrestricted' as ListPolicyMode,

View File

@@ -476,6 +476,7 @@ import {
Puzzle,
Zap,
FileUp,
Send,
Server,
SlidersHorizontal,
type LucideIcon,
@@ -1138,6 +1139,7 @@ const navigation = computed(() => {
Shield,
Puzzle,
Server,
Send,
SlidersHorizontal,
CreditCard,
Gift,

View File

@@ -9,6 +9,7 @@ import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
import type { ModuleStatus } from '@/api/modules'
// ========== 用户数据 ==========
@@ -902,18 +903,251 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
// ========== 系统配置 ==========
export const MOCK_SYSTEM_CONFIGS = [
export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; description?: string }> = [
{ key: 'rate_limit_enabled', value: true, description: '是否启用速率限制' },
{ key: 'default_rate_limit', value: 60, description: '默认速率限制(请求/分钟)' },
{ key: 'cache_enabled', value: true, description: '是否启用缓存' },
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL' },
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
{ key: 'module.important_notification.enabled', value: false, description: '通知服务总开关' },
{ key: 'module.important_notification.email_enabled', value: false, description: '通知服务邮件推送开关' },
{ key: 'module.important_notification.email_recipients', value: '', description: '通知服务管理员收件人' },
{ key: 'module.important_notification.default_channel', value: 'all', description: '通知服务全局推送服务' },
{
key: 'module.important_notification.items',
value: [
{
key: 'provider_quota_alert',
name: '号池额度不足',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: true,
},
{
key: 'provider_pool_abnormal',
name: '号池异常',
enabled: true,
channel: 'global',
title_template: '号池异常:{provider_name}',
markdown_template: '号池 `{provider_name}` 出现异常,请检查服务状态。',
text_template: '号池 {provider_name} 出现异常,请检查服务状态。',
user_email_enabled: false,
system: true,
},
{
key: 'user_balance_low',
name: '用户余额不足',
enabled: true,
channel: 'email',
title_template: '余额不足提醒',
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
user_email_enabled: true,
system: true,
},
],
description: '通知服务通知项和模板',
},
{ key: 'module.server_chan_push.enabled', value: false, description: 'Server 酱推送开关' },
{ key: 'module.server_chan_push.send_key', value: null, description: 'Server 酱 SendKey' },
{ key: 'module.server_chan_push.template', value: '', description: 'Server 酱推送模板' },
{ key: 'module.bark_push.enabled', value: false, description: 'Bark 推送开关' },
{ key: 'module.bark_push.device_key', value: null, description: 'Bark Device Key' },
{ key: 'module.bark_push.server_url', value: 'https://api.day.app', description: 'Bark 服务器地址' },
{ key: 'module.bark_push.template', value: '', description: 'Bark 推送模板' },
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
]
const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & { health?: ModuleStatus['health'] }> = [
{
name: 'management_tokens',
display_name: '访问令牌',
description: '管理 API 访问令牌,支持细粒度权限控制和 IP 限制',
category: 'security',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/management-tokens',
admin_menu_icon: null,
admin_menu_group: null,
admin_menu_order: 0,
},
{
name: 'ldap',
display_name: 'LDAP 认证',
description: '支持通过 LDAP/Active Directory 进行用户认证',
category: 'auth',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 LDAP 连接信息',
admin_route: '/admin/ldap',
admin_menu_icon: 'Users',
admin_menu_group: 'system',
admin_menu_order: 50,
},
{
name: 'oauth',
display_name: 'OAuth 登录',
description: '支持通过第三方 OAuth Provider 登录/绑定账号',
category: 'auth',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/oauth',
admin_menu_icon: 'Key',
admin_menu_group: null,
admin_menu_order: 55,
},
{
name: 'important_notification',
display_name: '通知服务',
description: '统一管理通知项、模板和推送服务选择,供后台任务和用户通知使用',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先完成通知服务推送渠道配置',
admin_route: '/admin/notification-service',
admin_menu_icon: 'BellRing',
admin_menu_group: null,
admin_menu_order: 58,
},
{
name: 'server_chan_push',
display_name: 'Server 酱推送',
description: '第三方推送服务,配置 Server 酱 Turbo SendKey 并测试微信推送',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 Server 酱 SendKey',
admin_route: '/admin/modules/server-chan',
admin_menu_icon: 'Send',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'bark_push',
display_name: 'Bark 推送',
description: '第三方推送服务,配置 Bark Device Key 并测试 iOS 推送',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 Bark Device Key',
admin_route: '/admin/modules/bark',
admin_menu_icon: 'Send',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'chat_pii_redaction',
display_name: '敏感信息保护',
description: '发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。',
category: 'security',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/modules/chat-pii-redaction',
admin_menu_icon: 'ShieldCheck',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'model_directives',
display_name: '模型后缀参数',
description: '允许通过模型名后缀覆盖推理参数',
category: 'integration',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/model-directives',
admin_menu_icon: 'SlidersHorizontal',
admin_menu_group: null,
admin_menu_order: 59,
},
{
name: 'gemini_files',
display_name: '文件缓存',
description: '管理 Gemini Files API 上传的文件,支持文件上传、查看和删除',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '至少启用一个具有「Gemini 文件 API」能力的 Key',
admin_route: '/admin/gemini-files',
admin_menu_icon: 'FileUp',
admin_menu_group: 'system',
admin_menu_order: 60,
health: 'degraded',
},
{
name: 'proxy_nodes',
display_name: '代理节点',
description: '添加Http/Socket代理节点, 或使用Aether-Proxy自动连接代理节点.',
category: 'integration',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/proxy-nodes',
admin_menu_icon: 'Server',
admin_menu_group: 'system',
admin_menu_order: 60,
},
{
name: 'payment_gateways',
display_name: '支付配置',
description: '配置易支付、支付宝官方、微信支付官方和 Stripe 等支付网关',
category: 'integration',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/payment-gateways',
admin_menu_icon: 'CreditCard',
admin_menu_group: null,
admin_menu_order: 70,
},
{
name: 'referral',
display_name: '邀请返利',
description: '管理用户邀请关系与返利记录,支持比例返利和人头返利',
category: 'integration',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/referrals',
admin_menu_icon: 'Gift',
admin_menu_group: 'management',
admin_menu_order: 75,
},
]
export const MOCK_MODULE_STATUSES: Record<string, ModuleStatus> = Object.fromEntries(
MOCK_MODULE_DEFINITIONS.map(module => [
module.name,
{
...module,
active: module.available && module.enabled && module.config_validated,
health: module.health ?? 'healthy',
},
])
) as Record<string, ModuleStatus>
// ========== API 格式 ==========
export const MOCK_API_FORMATS = {

View File

@@ -22,6 +22,7 @@ import {
MOCK_PROVIDERS,
MOCK_GLOBAL_MODELS,
MOCK_SYSTEM_CONFIGS,
MOCK_MODULE_STATUSES,
MOCK_API_FORMATS
} from './data'
@@ -1290,6 +1291,13 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse({ requests: [] })
},
// ========== Admin: Modules ==========
'GET /api/admin/modules/status': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_MODULE_STATUSES)
},
// ========== Admin: System ==========
'GET /api/admin/system/configs': async () => {
await delay()
@@ -1788,6 +1796,79 @@ function generateMockModelsForProvider(providerId: string) {
// ========== 注册动态路由 ==========
// 系统配置详情
registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => {
await delay()
requireAdmin()
const key = decodeURIComponent(params.configKey)
const entry = MOCK_SYSTEM_CONFIGS.find(item => item.key === key)
if (!entry) {
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
}
if (key === 'module.server_chan_push.send_key' || key === 'module.bark_push.device_key') {
return createMockResponse({
key: entry.key,
value: null,
description: entry.description,
is_set: typeof entry.value === 'string' && entry.value.trim() !== '',
})
}
return createMockResponse({ key: entry.key, value: entry.value, description: entry.description })
})
// 系统配置更新
registerDynamicRoute('PUT', '/api/admin/system/configs/:configKey', async (config, params) => {
await delay()
requireAdmin()
const key = decodeURIComponent(params.configKey)
const body = JSON.parse(config.data || '{}') as { value?: unknown; description?: string }
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === key)
const entry = {
key,
value: body.value ?? null,
description: body.description,
}
if (index === -1) {
MOCK_SYSTEM_CONFIGS.push(entry)
} else {
MOCK_SYSTEM_CONFIGS[index] = {
...MOCK_SYSTEM_CONFIGS[index],
...entry,
}
}
return createMockResponse(entry)
})
// 模块状态详情
registerDynamicRoute('GET', '/api/admin/modules/status/:moduleName', async (_config, params) => {
await delay()
requireAdmin()
const moduleStatus = MOCK_MODULE_STATUSES[params.moduleName]
if (!moduleStatus) {
throw { response: createMockResponse({ detail: '模块不存在' }, 404) }
}
return createMockResponse(moduleStatus)
})
// 模块启用状态更新
registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', async (config, params) => {
await delay()
requireAdmin()
const moduleStatus = MOCK_MODULE_STATUSES[params.moduleName]
if (!moduleStatus) {
throw { response: createMockResponse({ detail: '模块不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}') as { enabled?: boolean }
const enabled = body.enabled === true
const updated = {
...moduleStatus,
enabled,
active: moduleStatus.available && enabled && moduleStatus.config_validated,
}
MOCK_MODULE_STATUSES[params.moduleName] = updated
return createMockResponse(updated)
})
// Provider 详情
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
await delay()

View File

@@ -274,6 +274,36 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
meta: { module: 'chat_pii_redaction' }
},
{
path: 'modules/important-notification',
redirect: '/admin/notification-service'
},
{
path: 'notification-service',
name: 'ImportantNotificationModule',
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
meta: { module: 'important_notification' }
},
{
path: 'server-chan',
redirect: '/admin/modules/server-chan'
},
{
path: 'modules/server-chan',
name: 'ServerChanSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
meta: { module: 'server_chan_push' }
},
{
path: 'bark',
redirect: '/admin/modules/bark'
},
{
path: 'modules/bark',
name: 'BarkSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/BarkSettings.vue')),
meta: { module: 'bark_push' }
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -0,0 +1,43 @@
function createMemoryStorage(): Storage {
const store = new Map<string, string>()
return {
get length() {
return store.size
},
clear() {
store.clear()
},
getItem(key: string) {
return store.get(String(key)) ?? null
},
key(index: number) {
return Array.from(store.keys())[index] ?? null
},
removeItem(key: string) {
store.delete(String(key))
},
setItem(key: string, value: string) {
store.set(String(key), String(value))
},
}
}
function installStorage(name: 'localStorage' | 'sessionStorage') {
const storage = createMemoryStorage()
Object.defineProperty(globalThis, name, {
value: storage,
configurable: true,
})
if (typeof window !== 'undefined') {
Object.defineProperty(window, name, {
value: storage,
configurable: true,
})
}
}
installStorage('localStorage')
installStorage('sessionStorage')

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
canRefreshOAuthCredential,
getProviderMaskedSecretLabel,
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
@@ -17,14 +18,25 @@ describe('providerKeyAuth', () => {
expect(shouldShowOAuthRefreshControl(key, 'grok')).toBe(false)
})
it('keeps standard OAuth providers on OAuth token semantics', () => {
const key = {
it('hides oauth refresh control when backend marks a provider as non-refreshable', () => {
const input = {
auth_type: 'oauth',
oauth_managed: true,
can_refresh_oauth: false,
}
expect(getProviderMaskedSecretLabel(key, 'codex')).toBe('[OAuth Token]')
expect(shouldShowOAuthRefreshControl(key, 'codex')).toBe(true)
expect(canRefreshOAuthCredential(input)).toBe(false)
expect(shouldShowOAuthRefreshControl(input)).toBe(false)
})
it('keeps legacy oauth refresh control visible when backend capability is absent', () => {
const input = {
auth_type: 'oauth',
oauth_managed: true,
}
expect(canRefreshOAuthCredential(input)).toBe(true)
expect(shouldShowOAuthRefreshControl(input)).toBe(true)
expect(getProviderMaskedSecretLabel(input, 'codex')).toBe('[OAuth Token]')
})
})

View File

@@ -104,4 +104,122 @@ describe('providerKeyQuota', () => {
},
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
})
it('surfaces Windsurf hard account states', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'quarantined',
label: '账号隔离中',
exhausted: false,
},
},
}, 'windsurf')).toBe('账号隔离中')
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'cooldown',
label: '冷却中',
exhausted: false,
},
},
}, 'windsurf')).toBe('冷却中')
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'cooldown',
exhausted: false,
},
},
}, 'windsurf')).toBe('冷却中')
})
it('includes Windsurf quota windows and model availability in display text', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'ok',
exhausted: false,
allowed_models_count: 7,
windows: [
{
code: 'daily',
remaining_ratio: 0.75,
},
{
code: 'weekly',
remaining_ratio: 0.5,
},
{
code: 'prompt',
remaining_value: 12,
limit_value: 20,
},
{
code: 'flex',
used_value: 2,
limit_value: 5,
},
],
},
},
}, 'windsurf')).toBe('日剩余 75.0% | 周剩余 50.0% | Prompt 剩余 12/20 | Flex 剩余 3/5 | 可用模型 7 个')
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'cooldown',
label: '冷却中',
exhausted: false,
rate_limit: {
limited: true,
has_capacity: true,
messages_remaining: -1,
max_messages: -1,
},
allowed_models_count: 118,
windows: [
{
code: 'daily',
remaining_ratio: 0.99,
},
{
code: 'weekly',
remaining_ratio: 1,
},
{
code: 'prompt',
remaining_value: 100,
limit_value: 100,
},
{
code: 'rate_limit',
reset_seconds: null,
is_exhausted: false,
},
],
},
},
}, 'windsurf')).toBe('日剩余 99.0% | 周剩余 100.0% | Prompt 剩余 100/100 | 可用模型 118 个')
})
it('uses Windsurf model availability when no quota window is present', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'ok',
exhausted: false,
allowed_models_count: 3,
},
},
}, 'windsurf')).toBe('可用模型 3 个')
})
})

View File

@@ -251,4 +251,20 @@ describe('providerKeyStatus', () => {
})
expect(getOAuthStatusTitle(input, 0)).toBe('Refresh Token 未添加,无法自动刷新')
})
it('does not treat non-refreshable provider sessions as missing refresh token', () => {
const input = {
auth_type: 'oauth',
oauth_managed: true,
can_refresh_oauth: false,
}
expect(getOAuthStatusDisplayWithFallback(input, 0)).toEqual({
text: '有效期未知',
isExpired: false,
isExpiringSoon: false,
isInvalid: false,
})
expect(getOAuthStatusTitle(input, 0)).toBe('Token 有效期未知')
})
})

View File

@@ -3,6 +3,10 @@ export interface ChatPiiRedactionFeatureSettings {
inject_model_instruction: boolean
}
export interface NotificationPushServiceFeatureSettings {
enabled: boolean
}
export type FeatureSettingsMap = Record<string, unknown>
const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = {
@@ -10,6 +14,10 @@ const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettin
inject_model_instruction: true,
}
const DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS: NotificationPushServiceFeatureSettings = {
enabled: false,
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
@@ -49,3 +57,30 @@ export function mergeChatPiiRedactionFeatureSettings(
}
return Object.keys(settings).length > 0 ? settings : null
}
export function readNotificationPushServiceFeatureSettings(
featureSettings: unknown,
): NotificationPushServiceFeatureSettings {
const feature = isRecord(featureSettings)
? featureSettings.notification_push_service
: null
if (!isRecord(feature)) {
return { ...DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS }
}
return {
enabled: feature.enabled === true,
}
}
export function mergeNotificationPushServiceFeatureSettings(
featureSettings: unknown,
notificationPushService: NotificationPushServiceFeatureSettings,
): FeatureSettingsMap | null {
const settings: FeatureSettingsMap = isRecord(featureSettings)
? { ...featureSettings }
: {}
settings.notification_push_service = {
enabled: notificationPushService.enabled,
}
return Object.keys(settings).length > 0 ? settings : null
}

View File

@@ -92,7 +92,7 @@ export function shouldShowOAuthRefreshControl(
providerType?: string | null,
): boolean {
if (isGrokSessionCredential(input, providerType)) return false
return isOAuthManagedCredential(input)
return canRefreshOAuthCredential(input)
}
export function canExportOAuthCredential(input: ProviderKeyAuthCarrier): boolean {

View File

@@ -76,6 +76,24 @@ function getQuotaWindow(
return getQuotaWindows(quota).find(window => normalizeText(window.code)?.toLowerCase() === normalizedCode) ?? null
}
function positiveNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
function windsurfCooldownHasPositiveReset(quota: QuotaStatusSnapshot): boolean {
const rateLimit = quota.rate_limit
if (rateLimit && typeof rateLimit === 'object') {
const retryAfterMs = positiveNumber(rateLimit.retry_after_ms) ?? positiveNumber(rateLimit.retryAfterMs)
if (retryAfterMs != null) return true
}
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
return (
positiveNumber(rateLimitWindow?.reset_seconds) != null
|| positiveNumber(rateLimitWindow?.reset_at) != null
)
}
function getQuotaWindowsByScope(
quota: QuotaStatusSnapshot | null | undefined,
scope: string,
@@ -214,6 +232,60 @@ function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
return normalizeText(quota.label)
}
function getWindsurfQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'banned' || code === 'forbidden' || code === 'quarantined') {
return normalizeText(quota.label) || '账号不可用'
}
if (code === 'cooldown' && windsurfCooldownHasPositiveReset(quota)) {
return normalizeText(quota.label) || '冷却中'
}
if (code === 'rate_limited' || code === 'rate_limit') {
return normalizeText(quota.label) || '速率受限'
}
if (code === 'exhausted') {
return normalizeText(quota.label) || '额度已耗尽'
}
const parts: string[] = []
const dailyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'daily'))
const weeklyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'weekly'))
if (dailyRemaining != null) parts.push(`日剩余 ${formatPercent(dailyRemaining)}`)
if (weeklyRemaining != null) parts.push(`周剩余 ${formatPercent(weeklyRemaining)}`)
for (const [label, code] of [
['Prompt', 'prompt'],
['Flex', 'flex'],
] as const) {
const window = getQuotaWindow(quota, code)
if (!window) continue
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
parts.push(`${label} 剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`)
continue
}
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
parts.push(`${label} 剩余 ${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`)
continue
}
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent != null) {
parts.push(`${label} 剩余 ${formatPercent(remainingPercent)}`)
}
}
if (typeof quota.allowed_models_count === 'number') {
parts.push(`可用模型 ${quota.allowed_models_count}`)
}
if (parts.length > 0) return parts.join(' | ')
if (code === 'cooldown') {
return normalizeText(quota.label) || '冷却中'
}
return normalizeText(quota.label)
}
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'forbidden') {
@@ -312,6 +384,8 @@ export function getQuotaSnapshotFallbackText(
return getKiroQuotaText(quota)
case 'grok':
return getGrokQuotaText(quota)
case 'windsurf':
return getWindsurfQuotaText(quota)
case 'antigravity':
return getAntigravityQuotaText(quota)
case 'gemini_cli':

View File

@@ -202,7 +202,7 @@ function mergeOAuthStatusDisplay(
}
function isOAuthCredentialWithoutRefreshToken(input: ProviderKeyStatusCarrier): boolean {
return isOAuthManagedCredential(input) && !canRefreshOAuthCredential(input)
return isOAuthManagedCredential(input) && input.oauth_temporary === true
}
function getMissingRefreshTokenStatus(): OAuthStatusInfo {

View File

@@ -79,9 +79,25 @@
</div>
<!-- 扩展模块 -->
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
扩展模块
</h3>
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
扩展模块
</h3>
<Button
v-if="hasCustomModuleOrder"
variant="outline"
size="sm"
class="gap-1.5"
:disabled="loading || orderSaving"
@click="resetModuleOrder"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': orderSaving }"
/>
恢复默认
</Button>
</div>
<!-- 模块卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
@@ -89,11 +105,23 @@
v-for="module in filteredModules"
:key="module.name"
class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg"
:class="{
'bg-muted/40 border-muted': !module.available,
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
'border-border bg-card hover:border-primary/20': !module.active && module.available
}"
:class="[
{
'bg-muted/40 border-muted': !module.available,
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
'border-border bg-card hover:border-primary/20': !module.active && module.available
},
draggedModuleName === module.name ? 'opacity-70 ring-2 ring-primary/30' : '',
dragOverModuleName === module.name ? 'ring-2 ring-primary/40 border-primary/50' : '',
canReorderModules ? 'cursor-grab active:cursor-grabbing' : ''
]"
:draggable="canReorderModules"
:title="orderSaving ? '正在保存排序' : '拖拽卡片调整顺序'"
@dragstart="handleModuleDragStart(module.name, $event)"
@dragend="handleModuleDragEnd"
@dragover.prevent="handleModuleDragOver(module.name)"
@dragleave="handleModuleDragLeave(module.name)"
@drop.prevent="handleModuleDrop(module.name)"
>
<!-- 状态指示器 -->
<div class="absolute top-5 right-5">
@@ -121,7 +149,7 @@
class="w-5 h-5"
/>
</div>
<div class="flex-1 min-w-0 pt-1">
<div class="flex-1 min-w-0 pt-1 pr-8">
<h4 class="font-semibold text-base truncate">
{{ module.display_name }}
</h4>
@@ -214,7 +242,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next'
import {
RefreshCw,
Puzzle,
Users,
Shield,
Gauge,
Link,
Search,
Settings,
RotateCcw,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue'
@@ -224,6 +262,7 @@ import { useModuleStore } from '@/stores/modules'
import { BUILTIN_TOOLS } from '@/config/builtin-tools'
import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
import { modulesApi, type ModuleStatus } from '@/api/modules'
const router = useRouter()
const { success, error } = useToast()
@@ -232,6 +271,11 @@ const moduleStore = useModuleStore()
const loading = ref(false)
const toggling = ref<Record<string, boolean>>({})
const searchQuery = ref('')
const moduleOrder = ref<string[]>([])
const orderSaving = ref(false)
const draggedModuleName = ref<string | null>(null)
const dragOverModuleName = ref<string | null>(null)
const BUILTIN_BACKING_MODULES = new Set(['important_notification'])
// 过滤后的内置工具
const filteredBuiltinTools = computed(() => {
@@ -262,12 +306,65 @@ function getModuleStatusCopy(module: { name: string; enabled: boolean; active: b
return '已开启'
}
// 所有模块列表(按 admin_menu_order 排序)
const allModules = computed(() => {
function compareModuleDefaultOrder(a: ModuleStatus, b: ModuleStatus) {
return a.admin_menu_order - b.admin_menu_order ||
a.display_name.localeCompare(b.display_name, 'zh-Hans') ||
a.name.localeCompare(b.name)
}
function applySavedModuleOrder(modules: ModuleStatus[], order: string[]) {
if (order.length === 0) return modules
const modulesByName = new Map(modules.map(module => [module.name, module]))
const seen = new Set<string>()
const ordered: ModuleStatus[] = []
for (const moduleName of order) {
const module = modulesByName.get(moduleName)
if (!module || seen.has(moduleName)) continue
seen.add(moduleName)
ordered.push(module)
}
for (const module of modules) {
if (!seen.has(module.name)) {
ordered.push(module)
}
}
return ordered
}
function normalizeOrderForCurrentModules(order: string[]) {
const availableNames = new Set(defaultOrderedModules.value.map(module => module.name))
return order.filter(moduleName => availableNames.has(moduleName))
}
function moveNameToTargetIndex(names: string[], draggedName: string, targetName: string) {
const fromIndex = names.indexOf(draggedName)
const targetIndex = names.indexOf(targetName)
if (fromIndex === -1 || targetIndex === -1 || fromIndex === targetIndex) return names
const next = [...names]
const [dragged] = next.splice(fromIndex, 1)
next.splice(targetIndex, 0, dragged)
return next
}
// 后端默认顺序
const defaultOrderedModules = computed(() => {
return Object.values(moduleStore.modules)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
.filter(module => !BUILTIN_BACKING_MODULES.has(module.name))
.sort(compareModuleDefaultOrder)
})
// 所有模块列表(应用自定义展示顺序)
const allModules = computed(() => {
return applySavedModuleOrder(defaultOrderedModules.value, moduleOrder.value)
})
const hasCustomModuleOrder = computed(() => moduleOrder.value.length > 0)
const canReorderModules = computed(() => !orderSaving.value && allModules.value.length > 1)
// 过滤后的模块列表
const filteredModules = computed(() => {
if (!searchQuery.value.trim()) {
@@ -286,7 +383,11 @@ const filteredModules = computed(() => {
async function fetchModules() {
loading.value = true
try {
await moduleStore.fetchModules()
const [, savedOrder] = await Promise.all([
moduleStore.fetchModules(),
modulesApi.getModuleManagementOrder(),
])
moduleOrder.value = normalizeOrderForCurrentModules(savedOrder)
} catch (err) {
error('获取模块列表失败')
log.error('获取模块列表失败:', err)
@@ -309,6 +410,76 @@ async function toggleModule(moduleName: string, enabled: boolean) {
}
}
async function saveModuleOrder(nextOrder: string[]) {
if (orderSaving.value) return
const previousOrder = [...moduleOrder.value]
moduleOrder.value = normalizeOrderForCurrentModules(nextOrder)
orderSaving.value = true
try {
await modulesApi.updateModuleManagementOrder(moduleOrder.value)
success('模块顺序已保存')
} catch (err) {
moduleOrder.value = previousOrder
error(getErrorMessage(err, '保存模块顺序失败'))
log.error('保存模块顺序失败:', err)
} finally {
orderSaving.value = false
}
}
function resetModuleOrder() {
saveModuleOrder([])
}
function isInteractiveDragTarget(target: EventTarget | null) {
return target instanceof HTMLElement &&
target.closest('button, a, input, textarea, select, [role="switch"]') !== null
}
function handleModuleDragStart(moduleName: string, event: DragEvent) {
if (!canReorderModules.value || isInteractiveDragTarget(event.target)) {
event.preventDefault()
return
}
draggedModuleName.value = moduleName
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', moduleName)
}
}
function handleModuleDragEnd() {
draggedModuleName.value = null
dragOverModuleName.value = null
}
function handleModuleDragOver(moduleName: string) {
if (!canReorderModules.value || !draggedModuleName.value || draggedModuleName.value === moduleName) {
dragOverModuleName.value = null
return
}
dragOverModuleName.value = moduleName
}
function handleModuleDragLeave(moduleName: string) {
if (dragOverModuleName.value === moduleName) {
dragOverModuleName.value = null
}
}
function handleModuleDrop(targetModuleName: string) {
const draggedName = draggedModuleName.value
handleModuleDragEnd()
if (!canReorderModules.value || !draggedName || draggedName === targetModuleName) return
const nextOrder = moveNameToTargetIndex(
allModules.value.map(module => module.name),
draggedName,
targetModuleName,
)
saveModuleOrder(nextOrder)
}
onMounted(() => {
fetchModules()
})

View File

@@ -2076,6 +2076,7 @@ const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
@@ -2475,6 +2476,7 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
@@ -3622,11 +3624,15 @@ function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title:
if (!quota) return null
const code = String(quota.code || '').trim().toLowerCase()
if (code !== 'banned' && code !== 'forbidden') return null
if (!['banned', 'forbidden', 'quarantined', 'rate_limited', 'exhausted'].includes(code)) return null
let label = String(quota.label || '').trim()
if (!label) {
label = code === 'banned' ? '账号封禁' : '访问受限'
if (code === 'banned') label = '账号封禁'
else if (code === 'forbidden') label = '访问受限'
else if (code === 'quarantined') label = '账号隔离'
else if (code === 'rate_limited') label = '速率受限'
else label = '额度耗尽'
} else if (label === '账号已封禁' || label === '封禁') {
label = '账号封禁'
}
@@ -3683,6 +3689,7 @@ function normalizeQuotaLabel(label: string): string {
}
function getQuotaProgressLabel(label: string): string {
if (label === '日') return '日'
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === 'Spark5H') return 'Spark5H'
@@ -3693,7 +3700,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (!['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3747,14 +3754,19 @@ function getQuotaLabelOrder(label: string): number {
if (label === 'Expert') return 2
if (label === 'Heavy') return 3
if (label === 'Grok 4.3') return 4
if (label === '5H') return 0
if (label === '') return 1
if (label === 'Spark5H') return 2
if (label === 'Spark') return 3
if (label === '剩余') return 4
if (label === '最低') return 5
if (label === '生图') return 6
return 10
if (label === '') return 5
if (label === '5H') return 6
if (label === '') return 7
if (label === 'Spark5H') return 8
if (label === 'Spark周') return 9
if (label === 'Prompt') return 10
if (label === 'Flex') return 11
if (label === '剩余') return 12
if (label === '最低') return 13
if (label === '生图') return 14
if (label === '速率') return 15
if (label === '模型') return 16
return 20
}
function clampPercent(value: number): number {
@@ -3998,6 +4010,57 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
}]
}
if (providerType === 'windsurf') {
const items: QuotaProgressItem[] = []
for (const [label, code] of [
['日', 'daily'],
['周', 'weekly'],
['Prompt', 'prompt'],
['Flex', 'flex'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
: typeof window?.remaining_value === 'number' && typeof window?.limit_value === 'number'
? `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
: undefined
items.push({
label,
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
const rateLimitWindow = getQuotaSnapshotWindow(quota, 'rate_limit')
if (rateLimitWindow) {
items.push({
label: '速率',
remainingPercent: rateLimitWindow.is_exhausted ? 0 : 100,
resetAtSeconds: normalizeUnixSeconds(rateLimitWindow.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(rateLimitWindow.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
if (typeof quota.allowed_models_count === 'number' && Number.isFinite(quota.allowed_models_count)) {
items.push({
label: '模型',
remainingPercent: 100,
detail: `${quota.allowed_models_count} `,
resetAtSeconds: null,
resetSeconds: null,
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
return items
}
if (providerType === 'antigravity') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []

View File

@@ -295,7 +295,6 @@
<ProviderAuthDialog
v-model:open="opsConfigDialogOpen"
:provider-id="opsConfigProviderId"
:provider-name="opsConfigProviderName"
:provider-website="opsConfigProviderWebsite"
@saved="handleOpsConfigSaved"
/>
@@ -326,7 +325,6 @@ import { useProviderBalance } from '@/features/providers/composables/useProvider
import {
getProvidersSummary,
getProvider,
getProviderEndpoints,
deleteProvider,
getProviderDeleteTask,
updateProvider,
@@ -519,7 +517,6 @@ const {
// 扩展操作配置对话框
const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('')
const opsConfigProviderName = ref('')
const opsConfigProviderWebsite = ref('')
// 内联编辑备注
@@ -710,21 +707,10 @@ async function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
}
// 打开扩展操作配置对话框
async function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
opsConfigProviderId.value = provider.id
opsConfigProviderName.value = provider.name
opsConfigProviderWebsite.value = provider.website || ''
opsConfigDialogOpen.value = true
if (!opsConfigProviderWebsite.value) {
try {
const endpoints = await getProviderEndpoints(provider.id)
if (opsConfigProviderId.value !== provider.id || opsConfigProviderWebsite.value) return
const endpoint = endpoints.find(item => item.is_active) || endpoints[0]
opsConfigProviderWebsite.value = endpoint?.base_url || ''
} catch {
// 保持空地址,弹窗内仍可手动填写。
}
}
}
// 扩展操作配置保存回调

View File

@@ -198,6 +198,7 @@
:merge-mode="mergeMode"
:merge-mode-select-open="mergeModeSelectOpen"
:import-loading="importLoading"
:import-progress="importProgress"
@confirm="confirmImport"
@update:import-dialog-open="importDialogOpen = $event"
@update:import-result-dialog-open="importResultDialogOpen = $event"
@@ -214,6 +215,7 @@
:users-merge-mode="usersMergeMode"
:users-merge-mode-select-open="usersMergeModeSelectOpen"
:import-users-loading="importUsersLoading"
:import-users-progress="importUsersProgress"
@confirm="confirmImportUsers"
@update:import-users-dialog-open="importUsersDialogOpen = $event"
@update:import-users-result-dialog-open="importUsersResultDialogOpen = $event"
@@ -221,7 +223,7 @@
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
/>
<!-- 聚合数据导入对话框 -->
<!-- 完整备份导入对话框 -->
<AggregateImportDialog
:aggregate-import-dialog-open="aggregateImportDialogOpen"
:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen"
@@ -230,6 +232,7 @@
:aggregate-merge-mode="aggregateMergeMode"
:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen"
:import-aggregate-loading="importAggregateLoading"
:import-aggregate-progress="importAggregateProgress"
@confirm="confirmImportAggregate"
@update:aggregate-import-dialog-open="aggregateImportDialogOpen = $event"
@update:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen = $event"
@@ -364,6 +367,7 @@ const {
importResult,
mergeMode,
mergeModeSelectOpen,
importProgress,
handleExportConfig,
handleConfigFileSelect,
confirmImport,
@@ -375,6 +379,7 @@ const {
importUsersResult,
usersMergeMode,
usersMergeModeSelectOpen,
importUsersProgress,
handleExportUsers,
handleUsersFileSelect,
confirmImportUsers,
@@ -386,6 +391,7 @@ const {
aggregateImportResult,
aggregateMergeMode,
aggregateMergeModeSelectOpen,
importAggregateProgress,
handleExportAggregate,
handleAggregateFileSelect,
confirmImportAggregate,

View File

@@ -0,0 +1,271 @@
<template>
<PageContainer>
<PageHeader
title="Bark 推送"
description="第三方推送服务,用于通知服务的 Bark 渠道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="服务配置"
description="配置 Bark Device Key、服务器地址和服务启用状态"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-5">
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/70 px-4 py-3">
<div>
<Label class="text-sm font-medium">
启用 Bark 推送
</Label>
<p class="mt-1 text-xs text-muted-foreground">
通知服务选择 Bark 时会检查此开关
</p>
</div>
<Switch
v-model="enabled"
:disabled="!canEnable"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div>
<Label
for="bark-device-key"
class="block text-sm font-medium"
>
Device Key
</Label>
<Input
id="bark-device-key"
v-model="deviceKeyInput"
masked
:placeholder="deviceKeyIsSet ? '已设置(留空保持不变)' : '从 Bark App 推送地址中获取'"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
Bark App 中推送地址
<span class="font-mono">https://api.day.app/xxxx</span>
<span class="font-mono">xxxx</span> 部分
</p>
</div>
<div>
<Label
for="bark-server-url"
class="block text-sm font-medium"
>
服务器地址
</Label>
<Input
id="bark-server-url"
v-model="serverUrlInput"
placeholder="https://api.day.app"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
支持官方服务或自建 Bark Server保存时会去掉末尾斜杠
</p>
</div>
</div>
</div>
</CardSection>
<CardSection
title="通知模板"
description="模板支持 {title} 和 {body} 变量"
>
<div>
<Label
for="bark-template"
class="block text-sm font-medium"
>
模板内容
</Label>
<Textarea
id="bark-template"
v-model="templateInput"
rows="10"
class="mt-1 font-mono text-sm"
placeholder="{body}"
spellcheck="false"
/>
</div>
</CardSection>
<CardSection
title="测试服务"
description="按已保存配置发送一条 Bark 测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing || !deviceKeyIsSet"
@click="handleTest"
>
{{ testing ? '发送中...' : '发送测试' }}
</Button>
<RouterLink
to="/admin/notification-service"
class="inline-flex h-11 items-center rounded-xl px-3 text-sm text-primary hover:underline"
>
打开通知服务
</RouterLink>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Button, Input, Label, Switch, Textarea } from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { modulesApi } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
enabled: 'module.bark_push.enabled',
device_key: 'module.bark_push.device_key',
server_url: 'module.bark_push.server_url',
template: 'module.bark_push.template',
} as const
const DEFAULT_SERVER_URL = 'https://api.day.app'
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const enabled = ref(false)
const deviceKeyIsSet = ref(false)
const deviceKeyInput = ref('')
const serverUrlInput = ref(DEFAULT_SERVER_URL)
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const canEnable = computed(() => deviceKeyIsSet.value || deviceKeyInput.value.trim() !== '')
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [moduleStatus, deviceKey, serverUrl, template] = await Promise.all([
modulesApi.getStatus('bark_push'),
adminApi.getSystemConfig(CONFIG_KEYS.device_key),
adminApi.getSystemConfig(CONFIG_KEYS.server_url),
adminApi.getSystemConfig(CONFIG_KEYS.template),
])
enabled.value = moduleStatus.enabled === true
deviceKeyIsSet.value = deviceKey.is_set === true
deviceKeyInput.value = ''
serverUrlInput.value = typeof serverUrl.value === 'string' && serverUrl.value.trim()
? serverUrl.value
: DEFAULT_SERVER_URL
templateInput.value = typeof template.value === 'string' ? template.value : ''
} catch (err) {
error(parseApiError(err, '加载 Bark 推送配置失败'))
log.error('加载 Bark 推送配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(
CONFIG_KEYS.server_url,
normalizeServerUrl(serverUrlInput.value),
'Bark 服务器地址'
),
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Bark 推送模板'),
]
const trimmedKey = deviceKeyInput.value.trim()
if (trimmedKey) {
updates.push(adminApi.updateSystemConfig(
CONFIG_KEYS.device_key,
trimmedKey,
'Bark Device Key'
))
}
await Promise.all(updates)
if (trimmedKey) {
deviceKeyIsSet.value = true
deviceKeyInput.value = ''
}
if (!canEnable.value) {
enabled.value = false
}
await modulesApi.setEnabled('bark_push', enabled.value)
success('Bark 推送配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Bark 推送配置失败'))
log.error('保存 Bark 推送配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification({ channel: 'bark' })
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Bark 推送失败:', err)
} finally {
testing.value = false
}
}
function normalizeServerUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '')
return trimmed || DEFAULT_SERVER_URL
}
function formatChannel(channel: string): string {
if (channel === 'bark') return 'Bark'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -0,0 +1,749 @@
<template>
<PageContainer>
<PageHeader
title="通知服务"
description="统一管理通知项、通知模板和推送服务选择"
/>
<div class="mt-6 space-y-6">
<CardSection
title="通知服务配置"
description="选择全局推送服务,并配置邮件和第三方推送渠道"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-6">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div>
<Label class="block text-sm font-medium">
全局推送服务
</Label>
<Select v-model="config.default_channel">
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用通知服务
</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ canEnableService ? '当前策略有可用推送服务' : '当前策略没有可用推送服务' }}
</p>
</div>
<Switch
v-model="config.enabled"
:disabled="!canEnableService"
/>
</div>
</div>
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-3">
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
邮件配置
</Label>
<Badge :variant="emailReady ? 'success' : 'outline'">
{{ emailReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
SMTP 配置在
<RouterLink
to="/admin/email"
class="text-primary hover:underline"
>
邮件配置
</RouterLink>
中维护
</p>
</div>
<Switch
v-model="config.email_enabled"
:disabled="!smtpConfigured"
/>
</div>
<div>
<Label
for="notification-service-recipients"
class="block text-sm font-medium"
>
管理员收件人
</Label>
<Textarea
id="notification-service-recipients"
v-model="config.email_recipients"
rows="4"
placeholder="ops@example.com&#10;admin@example.com"
class="mt-1"
/>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
Server
</Label>
<Badge :variant="serverChanReady ? 'success' : 'outline'">
{{ serverChanReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
第三方推送服务在扩展模块中独立启用
</p>
</div>
</div>
<RouterLink
to="/admin/modules/server-chan"
class="inline-flex h-11 items-center rounded-xl border border-border/60 bg-card/60 px-4 text-sm font-semibold text-foreground hover:border-primary/60 hover:bg-primary/10 hover:text-primary"
>
配置 Server 酱推送
</RouterLink>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
Bark
</Label>
<Badge :variant="barkReady ? 'success' : 'outline'">
{{ barkReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
通过 Bark iOS 设备推送通知
</p>
</div>
</div>
<RouterLink
to="/admin/modules/bark"
class="inline-flex h-11 items-center rounded-xl border border-border/60 bg-card/60 px-4 text-sm font-semibold text-foreground hover:border-primary/60 hover:bg-primary/10 hover:text-primary"
>
配置 Bark 推送
</RouterLink>
</section>
</div>
</div>
</CardSection>
<CardSection
title="通知项"
description="每个通知项可以继承全局服务,也可以单独指定推送服务"
>
<template #actions>
<Button
size="sm"
variant="outline"
@click="addItem"
>
<Plus class="mr-1.5 h-4 w-4" />
添加通知项
</Button>
</template>
<div class="space-y-4">
<div
v-for="(item, index) in config.items"
:key="item.local_id"
class="rounded-lg border border-border/70 p-4"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<Label class="text-sm font-semibold">
{{ item.name || item.key || '未命名通知项' }}
</Label>
<Badge
v-if="item.system"
variant="outline"
>
内置
</Badge>
<Badge :variant="isItemReady(item) ? 'success' : 'outline'">
{{ isItemReady(item) ? '可投递' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">
{{ item.key }}
</p>
</div>
<div class="flex items-center gap-2">
<Switch v-model="item.enabled" />
<Button
v-if="!item.system"
size="icon"
variant="ghost"
@click="removeItem(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<Label class="block text-xs font-medium">
通知键
</Label>
<Input
v-model="item.key"
class="mt-1"
:disabled="item.system"
placeholder="custom_event"
/>
</div>
<div>
<Label class="block text-xs font-medium">
名称
</Label>
<Input
v-model="item.name"
class="mt-1"
placeholder="自定义通知"
/>
</div>
<div>
<Label class="block text-xs font-medium">
推送服务
</Label>
<Select v-model="item.channel">
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
使用全局
</SelectItem>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between rounded-lg border border-border/70 px-4 py-3">
<div>
<Label class="text-xs font-medium">
用户邮件
</Label>
<p class="mt-1 text-xs text-muted-foreground">
允许发送到用户自己的邮箱
</p>
</div>
<Switch
v-model="item.user_email_enabled"
:disabled="!smtpConfigured"
/>
</div>
</div>
<div class="mt-4 grid gap-4">
<div>
<Label class="block text-xs font-medium">
标题模板
</Label>
<Input
v-model="item.title_template"
class="mt-1"
placeholder="{title}"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div>
<Label class="block text-xs font-medium">
Markdown 模板
</Label>
<Textarea
v-model="item.markdown_template"
rows="5"
class="mt-1 font-mono text-sm"
placeholder="{body}"
/>
</div>
<div>
<Label class="block text-xs font-medium">
文本模板
</Label>
<Textarea
v-model="item.text_template"
rows="5"
class="mt-1 font-mono text-sm"
placeholder="{text_body}"
/>
</div>
</div>
</div>
</div>
</div>
</CardSection>
<CardSection
title="测试通知"
description="按已保存配置发送测试通知"
>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]">
<Select v-model="testItemKey">
<SelectTrigger>
<SelectValue placeholder="选择通知项" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in config.items"
:key="item.local_id"
:value="item.key"
>
{{ item.name || item.key }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="testChannel">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
按通知项
</SelectItem>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
:disabled="testing || !testItemKey"
@click="handleTest"
>
<Send class="mr-1.5 h-4 w-4" />
{{ testing ? '发送中...' : '发送测试' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Plus, Send, Trash2 } from 'lucide-vue-next'
import {
Badge,
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { modulesApi, type ModuleStatus } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan' | 'bark'
interface NotificationItem {
local_id: string
key: string
name: string
enabled: boolean
channel: DeliveryChannel
title_template: string
markdown_template: string
text_template: string
user_email_enabled: boolean
system: boolean
}
interface NotificationConfig {
enabled: boolean
email_enabled: boolean
email_recipients: string
default_channel: Exclude<DeliveryChannel, 'global'>
items: NotificationItem[]
}
const CONFIG_KEYS = {
enabled: 'module.important_notification.enabled',
email_enabled: 'module.important_notification.email_enabled',
email_recipients: 'module.important_notification.email_recipients',
default_channel: 'module.important_notification.default_channel',
items: 'module.important_notification.items',
server_chan_send_key: 'module.server_chan_push.send_key',
bark_device_key: 'module.bark_push.device_key',
} as const
const DEFAULT_ITEMS: NotificationItem[] = [
{
local_id: 'provider_quota_alert',
key: 'provider_quota_alert',
name: '号池额度不足',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: true,
},
{
local_id: 'provider_pool_abnormal',
key: 'provider_pool_abnormal',
name: '号池异常',
enabled: true,
channel: 'global',
title_template: '号池异常:{provider_name}',
markdown_template: '号池 `{provider_name}` 出现异常,请检查服务状态。',
text_template: '号池 {provider_name} 出现异常,请检查服务状态。',
user_email_enabled: false,
system: true,
},
{
local_id: 'user_balance_low',
key: 'user_balance_low',
name: '用户余额不足',
enabled: true,
channel: 'email',
title_template: '余额不足提醒',
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
user_email_enabled: true,
system: true,
},
]
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
const serverChanStatus = ref<ModuleStatus | null>(null)
const barkKeyIsSet = ref(false)
const barkStatus = ref<ModuleStatus | null>(null)
const testItemKey = ref('provider_quota_alert')
const testChannel = ref<DeliveryChannel>('global')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const config = ref<NotificationConfig>({
enabled: false,
email_enabled: false,
email_recipients: '',
default_channel: 'all',
items: cloneDefaultItems(),
})
const emailReady = computed(() => {
return config.value.email_enabled && smtpConfigured.value && config.value.email_recipients.trim() !== ''
})
const serverChanReady = computed(() => {
return serverChanStatus.value?.enabled === true && serverChanKeyIsSet.value
})
const barkReady = computed(() => {
return barkStatus.value?.enabled === true && barkKeyIsSet.value
})
const canEnableService = computed(() => {
if (deliveryReady(config.value.default_channel)) return true
return config.value.items.some(item => item.enabled && isItemReady(item))
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [
moduleStatus,
emailEnabled,
recipients,
defaultChannel,
items,
serverChanModuleStatus,
serverChanKey,
barkModuleStatus,
barkDeviceKey,
smtpHost,
smtpFromEmail,
] = await Promise.all([
modulesApi.getStatus('important_notification'),
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
adminApi.getSystemConfig(CONFIG_KEYS.default_channel),
adminApi.getSystemConfig(CONFIG_KEYS.items),
modulesApi.getStatus('server_chan_push'),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
modulesApi.getStatus('bark_push'),
adminApi.getSystemConfig(CONFIG_KEYS.bark_device_key),
adminApi.getSystemConfig('smtp_host'),
adminApi.getSystemConfig('smtp_from_email'),
])
config.value.enabled = moduleStatus.enabled === true
config.value.email_enabled = emailEnabled.value === true
config.value.email_recipients = normalizeRecipients(recipients.value)
config.value.default_channel = normalizeDefaultChannel(defaultChannel.value)
config.value.items = normalizeItems(items.value)
serverChanStatus.value = serverChanModuleStatus
serverChanKeyIsSet.value = serverChanKey.is_set === true
barkStatus.value = barkModuleStatus
barkKeyIsSet.value = barkDeviceKey.is_set === true
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
if (!config.value.items.some(item => item.key === testItemKey.value)) {
testItemKey.value = config.value.items[0]?.key || ''
}
} catch (err) {
error(parseApiError(err, '加载通知服务配置失败'))
log.error('加载通知服务配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
if (!canEnableService.value) {
config.value.enabled = false
}
await Promise.all([
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '通知服务邮件推送开关'),
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '通知服务管理员收件人'),
adminApi.updateSystemConfig(CONFIG_KEYS.default_channel, config.value.default_channel, '通知服务全局推送服务'),
adminApi.updateSystemConfig(CONFIG_KEYS.items, serializeItems(), '通知服务通知项和模板'),
])
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, config.value.enabled, '通知服务总开关')
success('通知服务配置已保存')
} catch (err) {
error(parseApiError(err, '保存通知服务配置失败'))
log.error('保存通知服务配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification({
item_key: testItemKey.value,
channel: testChannel.value === 'global' ? undefined : testChannel.value,
})
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试通知服务失败:', err)
} finally {
testing.value = false
}
}
function addItem() {
const suffix = Date.now().toString(36)
const key = `custom_${suffix}`
config.value.items.push({
local_id: key,
key,
name: '自定义通知',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: false,
})
testItemKey.value = key
}
function removeItem(index: number) {
const [removed] = config.value.items.splice(index, 1)
if (removed?.key === testItemKey.value) {
testItemKey.value = config.value.items[0]?.key || ''
}
}
function isItemReady(item: NotificationItem): boolean {
if (!item.enabled) return false
return deliveryReady(resolveItemChannel(item))
}
function deliveryReady(channel: Exclude<DeliveryChannel, 'global'>): boolean {
if (channel === 'all') return emailReady.value || serverChanReady.value || barkReady.value
if (channel === 'email') return emailReady.value
if (channel === 'server_chan') return serverChanReady.value
if (channel === 'bark') return barkReady.value
return false
}
function resolveItemChannel(item: NotificationItem): Exclude<DeliveryChannel, 'global'> {
return item.channel === 'global' ? config.value.default_channel : item.channel
}
function serializeItems() {
return config.value.items.map(item => ({
key: normalizeItemKey(item.key),
name: item.name.trim() || normalizeItemKey(item.key),
enabled: item.enabled,
channel: item.channel,
title_template: item.title_template.trim(),
markdown_template: item.markdown_template.trim(),
text_template: item.text_template.trim(),
user_email_enabled: item.user_email_enabled,
system: item.system,
}))
}
function normalizeItems(value: unknown): NotificationItem[] {
if (!Array.isArray(value)) return cloneDefaultItems()
const items = value
.map((item, index) => normalizeItem(item, index))
.filter((item): item is NotificationItem => item !== null)
return items.length > 0 ? items : cloneDefaultItems()
}
function normalizeItem(value: unknown, index: number): NotificationItem | null {
if (!value || typeof value !== 'object') return null
const raw = value as Record<string, unknown>
const key = normalizeItemKey(raw.key)
if (!key) return null
return {
local_id: `${key}_${index}`,
key,
name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : key,
enabled: raw.enabled !== false,
channel: normalizeItemChannel(raw.channel),
title_template: typeof raw.title_template === 'string' ? raw.title_template : '',
markdown_template: typeof raw.markdown_template === 'string' ? raw.markdown_template : '',
text_template: typeof raw.text_template === 'string' ? raw.text_template : '',
user_email_enabled: raw.user_email_enabled === true,
system: raw.system === true,
}
}
function normalizeItemKey(value: unknown): string {
if (typeof value !== 'string') return ''
return value.trim().replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64)
}
function normalizeItemChannel(value: unknown): DeliveryChannel {
if (value === 'all' || value === 'email' || value === 'server_chan' || value === 'bark') return value
return 'global'
}
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
if (value === 'email' || value === 'server_chan' || value === 'bark') return value
return 'all'
}
function cloneDefaultItems(): NotificationItem[] {
return DEFAULT_ITEMS.map(item => ({ ...item }))
}
function isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim() !== ''
}
function normalizeRecipients(value: unknown): string {
if (Array.isArray(value)) {
return value
.map(item => String(item).trim())
.filter(Boolean)
.join('\n')
}
return typeof value === 'string' ? value : ''
}
function formatChannel(channel: string): string {
if (channel === 'email') return '邮件'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'bark') return 'Bark'
if (channel === 'user_email') return '用户邮件'
if (channel === 'module') return '模块'
if (channel === 'item') return '通知项'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -0,0 +1,226 @@
<template>
<PageContainer>
<PageHeader
title="Server 酱推送"
description="第三方推送服务,用于通知服务的 Server 酱渠道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="服务配置"
description="配置 Server 酱 Turbo SendKey 和服务启用状态"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-5">
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/70 px-4 py-3">
<div>
<Label class="text-sm font-medium">
启用 Server 酱推送
</Label>
<p class="mt-1 text-xs text-muted-foreground">
通知服务选择 Server 酱时会检查此开关
</p>
</div>
<Switch
v-model="enabled"
:disabled="!canEnable"
/>
</div>
<div>
<Label
for="server-chan-send-key"
class="block text-sm font-medium"
>
SendKey
</Label>
<Input
id="server-chan-send-key"
v-model="sendKeyInput"
masked
:placeholder="sendKeyIsSet ? '已设置(留空保持不变)' : 'SCTxxxxxxxxxxxxxxxxxxxxxxxx'"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
可在 <span class="font-mono">sct.ftqq.com</span> 控制台获取
</p>
</div>
</div>
</CardSection>
<CardSection
title="通知模板"
description="Markdown 模板支持 {title} 和 {body} 变量"
>
<div>
<Label
for="server-chan-template"
class="block text-sm font-medium"
>
模板内容
</Label>
<Textarea
id="server-chan-template"
v-model="templateInput"
rows="10"
class="mt-1 font-mono text-sm"
placeholder="**{title}**&#10;&#10;{body}"
spellcheck="false"
/>
</div>
</CardSection>
<CardSection
title="测试服务"
description="按已保存配置发送一条 Server 酱测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing || !sendKeyIsSet"
@click="handleTest"
>
{{ testing ? '发送中...' : '发送测试' }}
</Button>
<RouterLink
to="/admin/notification-service"
class="inline-flex h-11 items-center rounded-xl px-3 text-sm text-primary hover:underline"
>
打开通知服务
</RouterLink>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Button, Input, Label, Switch, Textarea } from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { modulesApi } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
enabled: 'module.server_chan_push.enabled',
send_key: 'module.server_chan_push.send_key',
template: 'module.server_chan_push.template',
} as const
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const enabled = ref(false)
const sendKeyIsSet = ref(false)
const sendKeyInput = ref('')
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const canEnable = computed(() => sendKeyIsSet.value || sendKeyInput.value.trim() !== '')
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [moduleStatus, sendKey, template] = await Promise.all([
modulesApi.getStatus('server_chan_push'),
adminApi.getSystemConfig(CONFIG_KEYS.send_key),
adminApi.getSystemConfig(CONFIG_KEYS.template),
])
enabled.value = moduleStatus.enabled === true
sendKeyIsSet.value = sendKey.is_set === true
sendKeyInput.value = ''
templateInput.value = typeof template.value === 'string' ? template.value : ''
} catch (err) {
error(parseApiError(err, '加载 Server 酱推送配置失败'))
log.error('加载 Server 酱推送配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Server 酱推送模板'),
]
const trimmedKey = sendKeyInput.value.trim()
if (trimmedKey) {
updates.push(adminApi.updateSystemConfig(CONFIG_KEYS.send_key, trimmedKey, 'Server 酱 SendKey'))
}
await Promise.all(updates)
if (trimmedKey) {
sendKeyIsSet.value = true
sendKeyInput.value = ''
}
if (!canEnable.value) {
enabled.value = false
}
await modulesApi.setEnabled('server_chan_push', enabled.value)
success('Server 酱推送配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Server 酱推送配置失败'))
log.error('保存 Server 酱推送配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification({ channel: 'server_chan' })
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Server 酱推送失败:', err)
} finally {
testing.value = false
}
}
function formatChannel(channel: string): string {
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<!-- 聚合数据导入对话框 -->
<!-- 完整备份导入对话框 -->
<Dialog
:open="aggregateImportDialogOpen"
title="导入聚合数据"
title="导入完整备份"
description="选择冲突处理模式并确认导入"
@update:open="$emit('update:aggregateImportDialogOpen', $event)"
>
@@ -12,7 +12,7 @@
class="text-sm"
>
<p class="font-medium mb-2">
聚合数据预览
完整备份预览
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-muted-foreground">
<div>
@@ -84,8 +84,24 @@
</div>
<p class="text-xs text-muted-foreground">
注意聚合数据会先导入配置数据再导入用户数据用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY
注意完整备份会先导入配置数据再导入用户数据文件包含用户用户组API Keys 与钱包快照用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY
</p>
<div
v-if="importAggregateProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importAggregateProgress.message }}</span>
<span>{{ importAggregateProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importAggregateProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -104,10 +120,10 @@
</template>
</Dialog>
<!-- 聚合数据导入结果对话框 -->
<!-- 完整备份导入结果对话框 -->
<Dialog
:open="aggregateImportResultDialogOpen"
title="聚合数据导入完成"
title="完整备份导入完成"
@update:open="$emit('update:aggregateImportResultDialogOpen', $event)"
>
<div
@@ -174,6 +190,7 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Dialog } from '@/components/ui'
import type { AggregateExportData, AggregateImportResponse } from '@/api/admin'
import type { ImportProgressState } from './composables/useConfigExportImport'
const props = defineProps<{
aggregateImportDialogOpen: boolean
@@ -183,6 +200,7 @@ const props = defineProps<{
aggregateMergeMode: 'skip' | 'overwrite' | 'error'
aggregateMergeModeSelectOpen: boolean
importAggregateLoading: boolean
importAggregateProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -74,6 +74,22 @@
<p class="text-xs text-muted-foreground">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
<div
v-if="importProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importProgress.message }}</span>
<span>{{ importProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -220,6 +236,7 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Dialog } from '@/components/ui'
import type { ConfigExportData, ConfigImportResponse } from '@/api/admin'
import type { ImportProgressState } from './composables/useConfigExportImport'
defineProps<{
importDialogOpen: boolean
@@ -229,6 +246,7 @@ defineProps<{
mergeMode: 'skip' | 'overwrite' | 'error'
mergeModeSelectOpen: boolean
importLoading: boolean
importProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -209,10 +209,10 @@ const dataItems = computed<DataItem[]>(() => [
},
{
key: 'aggregate',
title: '聚合数据',
description: '配置数据和用户数据的一体化备份文件',
exportLabel: '导出聚合',
importLabel: '导入聚合',
title: '完整备份',
description: '配置用户数据的一体化备份包含用户、用户组、API Keys 与钱包快照',
exportLabel: '导出备份',
importLabel: '导入备份',
icon: markRaw(Layers3),
exportLoading: props.aggregateExportLoading,
importLoading: props.aggregateImportLoading,

View File

@@ -67,6 +67,22 @@
<p class="text-xs text-muted-foreground">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
<div
v-if="importUsersProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importUsersProgress.message }}</span>
<span>{{ importUsersProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importUsersProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -175,6 +191,7 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Dialog } from '@/components/ui'
import type { UsersExportData, UsersImportResponse } from '@/api/admin'
import type { ImportProgressState } from './composables/useConfigExportImport'
defineProps<{
importUsersDialogOpen: boolean
@@ -184,6 +201,7 @@ defineProps<{
usersMergeMode: 'skip' | 'overwrite' | 'error'
usersMergeModeSelectOpen: boolean
importUsersLoading: boolean
importUsersProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import type { AxiosProgressEvent } from 'axios'
import { useToast } from '@/composables/useToast'
import {
adminApi,
@@ -22,6 +23,11 @@ const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
type JsonObject = Record<string, unknown>
export interface ImportProgressState {
percent: number
message: string
}
function asJsonObject(value: unknown): JsonObject | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
@@ -56,6 +62,47 @@ function fileSizeLimitMessage(limitMb: number): string {
return `文件大小不能超过 ${limitMb}MB`
}
function formatBytes(bytes: number): string {
if (bytes >= BYTES_PER_MB) {
return `${(bytes / BYTES_PER_MB).toFixed(1)}MB`
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`
}
return `${bytes}B`
}
function setImportProgress(
target: { value: ImportProgressState | null },
percent: number,
message: string,
) {
target.value = {
percent: Math.max(0, Math.min(100, Math.round(percent))),
message,
}
}
function buildUploadProgressHandler(
target: { value: ImportProgressState | null },
label: string,
) {
return (event: AxiosProgressEvent) => {
if (!event.total) {
setImportProgress(target, 15, `${label}上传中:${formatBytes(event.loaded)}`)
return
}
const uploadPercent = Math.min(85, 10 + (event.loaded / event.total) * 75)
const loaded = formatBytes(event.loaded)
const total = formatBytes(event.total)
const message = event.loaded >= event.total
? `${label}已上传,服务端正在校验并写入数据`
: `${label}上传中:${loaded} / ${total}`
setImportProgress(target, uploadPercent, message)
}
}
function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -81,6 +128,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const importResult = ref<ConfigImportResponse | null>(null)
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const mergeModeSelectOpen = ref(false)
const importProgress = ref<ImportProgressState | null>(null)
// 用户数据导出/导入相关
const exportUsersLoading = ref(false)
@@ -92,8 +140,9 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false)
const importUsersProgress = ref<ImportProgressState | null>(null)
// 聚合数据导出/导入相关
// 完整备份导出/导入相关
const exportAggregateLoading = ref(false)
const importAggregateLoading = ref(false)
const aggregateImportDialogOpen = ref(false)
@@ -102,6 +151,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const aggregateImportResult = ref<AggregateImportResponse | null>(null)
const aggregateMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const aggregateMergeModeSelectOpen = ref(false)
const importAggregateProgress = ref<ImportProgressState | null>(null)
// 导出配置
async function handleExportConfig() {
@@ -182,11 +232,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
if (!importPreview.value) return
importLoading.value = true
setImportProgress(importProgress, 5, '准备提交配置数据')
await nextTick()
try {
const result = await adminApi.importConfig({
...importPreview.value,
merge_mode: mergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importProgress, '配置数据'),
})
setImportProgress(importProgress, 100, '配置数据导入完成')
importResult.value = result
importDialogOpen.value = false
mergeModeSelectOpen.value = false
@@ -197,6 +252,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
log.error('导入配置失败:', err)
} finally {
importLoading.value = false
importProgress.value = null
}
}
@@ -289,11 +345,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
if (!importUsersPreview.value) return
importUsersLoading.value = true
setImportProgress(importUsersProgress, 5, '准备提交用户数据')
await nextTick()
try {
const result = await adminApi.importUsers({
...importUsersPreview.value,
merge_mode: usersMergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importUsersProgress, '用户数据'),
})
setImportProgress(importUsersProgress, 100, '用户数据导入完成')
importUsersResult.value = result
importUsersDialogOpen.value = false
usersMergeModeSelectOpen.value = false
@@ -304,10 +365,11 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
log.error('导入用户数据失败:', err)
} finally {
importUsersLoading.value = false
importUsersProgress.value = null
}
}
// 导出聚合数据
// 导出完整备份
async function handleExportAggregate() {
exportAggregateLoading.value = true
try {
@@ -316,16 +378,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
data,
`${systemConfig.value.site_name.toLowerCase()}-data-${new Date().toISOString().slice(0, 10)}.json`,
)
success('聚合数据已导出')
success('完整备份已导出')
} catch (err) {
error('导出聚合数据失败')
log.error('导出聚合数据失败:', err)
error('导出完整备份失败')
log.error('导出完整备份失败:', err)
} finally {
exportAggregateLoading.value = false
}
}
// 处理聚合数据文件选择
// 处理完整备份文件选择
function handleAggregateFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -343,7 +405,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const content = e.target?.result as string
const root = asJsonObject(JSON.parse(content))
if (!root) {
error('无效的聚合数据文件JSON 顶层必须是对象')
error('无效的完整备份文件JSON 顶层必须是对象')
return
}
@@ -353,24 +415,24 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
} else if (looksLikeUsersExport(root)) {
error('这是用户数据导出文件,请使用“导入用户数据”')
} else {
error('无效的聚合数据文件:未找到配置数据和用户数据')
error('无效的完整备份文件:未找到配置数据和用户数据')
}
return
}
if (!root.version) {
error('无效的聚合数据文件:缺少版本信息')
error('无效的完整备份文件:缺少版本信息')
return
}
const configData = asJsonObject(root.config_data)
const userData = asJsonObject(root.user_data)
if (!configData || !looksLikeConfigExport(configData)) {
error('无效的聚合数据文件config_data 格式不正确')
error('无效的完整备份文件config_data 格式不正确')
return
}
if (!userData || !looksLikeUsersExport(userData)) {
error('无效的聚合数据文件user_data 格式不正确')
error('无效的完整备份文件user_data 格式不正确')
return
}
@@ -379,8 +441,8 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
aggregateMergeMode.value = 'skip'
aggregateImportDialogOpen.value = true
} catch (err) {
error('解析聚合数据文件失败,请确保是有效的 JSON 文件')
log.error('解析聚合数据文件失败:', err)
error('解析完整备份文件失败,请确保是有效的 JSON 文件')
log.error('解析完整备份文件失败:', err)
}
}
reader.readAsText(file)
@@ -388,26 +450,32 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
input.value = ''
}
// 确认导入聚合数据
// 确认导入完整备份
async function confirmImportAggregate() {
if (!aggregateImportPreview.value) return
importAggregateLoading.value = true
setImportProgress(importAggregateProgress, 5, '准备提交完整备份')
await nextTick()
try {
const result = await adminApi.importAggregateData({
...aggregateImportPreview.value,
merge_mode: aggregateMergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importAggregateProgress, '完整备份'),
})
setImportProgress(importAggregateProgress, 100, '完整备份导入完成')
aggregateImportResult.value = result
aggregateImportDialogOpen.value = false
aggregateMergeModeSelectOpen.value = false
aggregateImportResultDialogOpen.value = true
success('聚合数据导入成功')
success('完整备份导入成功')
} catch (err: unknown) {
error(parseApiError(err, '导入聚合数据失败'))
log.error('导入聚合数据失败:', err)
error(parseApiError(err, '导入完整备份失败'))
log.error('导入完整备份失败:', err)
} finally {
importAggregateLoading.value = false
importAggregateProgress.value = null
}
}
@@ -422,6 +490,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
importResult,
mergeMode,
mergeModeSelectOpen,
importProgress,
handleExportConfig,
triggerConfigFileSelect,
handleConfigFileSelect,
@@ -436,11 +505,12 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
importUsersResult,
usersMergeMode,
usersMergeModeSelectOpen,
importUsersProgress,
handleExportUsers,
triggerUsersFileSelect,
handleUsersFileSelect,
confirmImportUsers,
// 聚合数据导出/导入
// 完整备份导出/导入
exportAggregateLoading,
importAggregateLoading,
aggregateImportDialogOpen,
@@ -449,6 +519,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
aggregateImportResult,
aggregateMergeMode,
aggregateMergeModeSelectOpen,
importAggregateProgress,
handleExportAggregate,
handleAggregateFileSelect,
confirmImportAggregate,

View File

@@ -124,6 +124,25 @@
</div>
</Card>
<Card
v-if="featureSettingsForm.notificationPushServiceEnabled"
class="p-6"
>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-foreground">
通知推送服务
</h3>
<p class="mt-1 text-sm text-muted-foreground">
管理员已允许你配置自己的第三方推送渠道
</p>
</div>
<Badge variant="success">
已开放
</Badge>
</div>
</Card>
<!-- 密码设置LDAP 用户不显示 -->
<Card
v-if="profile?.auth_source !== 'ldap'"
@@ -499,7 +518,7 @@
邮件通知
</Label>
<p class="text-xs text-muted-foreground mt-1">
接收系统重要通知
接收系统通知邮件
</p>
</div>
<Switch
@@ -671,6 +690,7 @@ import { log } from '@/utils/logger'
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
import {
mergeChatPiiRedactionFeatureSettings,
readNotificationPushServiceFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
@@ -715,6 +735,7 @@ const preferencesForm = ref({
const featureSettingsForm = ref({
chatPiiRedactionEnabled: false,
chatPiiRedactionInjectNotice: true,
notificationPushServiceEnabled: false,
})
const savingProfile = ref(false)
@@ -819,9 +840,11 @@ async function loadProfile() {
username: profile.value.username
}
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
const notificationPushFeature = readNotificationPushServiceFeatureSettings(profile.value.feature_settings)
featureSettingsForm.value = {
chatPiiRedactionEnabled: redactionFeature.enabled,
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
notificationPushServiceEnabled: notificationPushFeature.enabled,
}
// 保存原始值
originalProfileForm.value = { ...profileForm.value }

View File

@@ -7,6 +7,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/tests/vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],