mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge upstream/main into feat/one-click-update
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 黑白名单,控制系统访问权限',
|
||||
|
||||
@@ -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
@@ -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=... 的回调 URL;session 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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '',
|
||||
// 计费配置
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}天前`
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
|
||||
'antigravity',
|
||||
'kiro',
|
||||
'grok',
|
||||
'windsurf',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
43
frontend/src/tests/vitest.setup.ts
Normal file
43
frontend/src/tests/vitest.setup.ts
Normal 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')
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 个')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 有效期未知')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 {
|
||||
// 保持空地址,弹窗内仍可手动填写。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展操作配置保存回调
|
||||
|
||||
@@ -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,
|
||||
|
||||
271
frontend/src/views/admin/modules/BarkSettings.vue
Normal file
271
frontend/src/views/admin/modules/BarkSettings.vue
Normal 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>
|
||||
749
frontend/src/views/admin/modules/ImportantNotification.vue
Normal file
749
frontend/src/views/admin/modules/ImportantNotification.vue
Normal 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 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>
|
||||
226
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal file
226
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal 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}** {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>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user