Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
538ae31a
Commit
538ae31a
authored
Apr 30, 2026
by
陈曦
Browse files
merge v0.1.121 and fixed conflict
parents
74828a7c
48912014
Pipeline
#82338
passed with stage
in 17 seconds
Changes
151
Pipelines
3
Hide whitespace changes
Inline
Side-by-side
backend/migration_release/127_add_user_group_rpm_override.sql
deleted
100644 → 0
View file @
74828a7c
-- 在已有的"用户专属分组倍率表"上扩展 rpm_override 列;同时放宽 rate_multiplier 为可空,
-- 使一行记录可以只覆盖 rate、只覆盖 rpm,或同时覆盖两者。
-- 语义:
-- - rate_multiplier NULL → 该用户在此分组使用 groups.rate_multiplier 默认值
-- - rate_multiplier 非 NULL → 覆盖分组默认计费倍率
-- - rpm_override NULL → 该用户在此分组使用 groups.rpm_limit 默认值
-- - rpm_override 非 NULL → 覆盖分组默认 RPM(0 = 不限制)
-- 用户级 users.rpm_limit 仍独立生效(跨分组总配额)。
ALTER
TABLE
user_group_rate_multipliers
ADD
COLUMN
IF
NOT
EXISTS
rpm_override
integer
NULL
;
ALTER
TABLE
user_group_rate_multipliers
ALTER
COLUMN
rate_multiplier
DROP
NOT
NULL
;
COMMENT
ON
COLUMN
user_group_rate_multipliers
.
rate_multiplier
IS
'专属计费倍率;NULL 表示沿用分组默认倍率。'
;
COMMENT
ON
COLUMN
user_group_rate_multipliers
.
rpm_override
IS
'专属 RPM 上限;NULL 表示沿用分组默认;0 表示该用户在此分组不受 RPM 限制。'
;
backend/migration_release/127_drop_channel_monitor_deleted_at.sql
deleted
100644 → 0
View file @
74828a7c
-- Migration: 127_drop_channel_monitor_deleted_at
-- 纠正 110 引入的 SoftDeleteMixin:日志/聚合表无恢复需求,软删会让行和索引只增不减,
-- 徒增磁盘和查询开销。改回分批物理删(由 OpsCleanupService 每天凌晨统一调度,
-- deleteOldRowsByID 模板,batch=5000)。
--
-- 110 尚未跑过聚合/清理(首次 maintenance 在次日 02:00),所以此处不担心业务数据。
-- 直接 DROP 列 + 索引;对应的 Go 侧 ent schema 已移除 SoftDeleteMixin、repo 的
-- raw SQL 已移除 deleted_at IS NULL 过滤。
DROP
INDEX
IF
EXISTS
idx_channel_monitor_histories_deleted_at
;
ALTER
TABLE
channel_monitor_histories
DROP
COLUMN
IF
EXISTS
deleted_at
;
DROP
INDEX
IF
EXISTS
idx_channel_monitor_daily_rollups_deleted_at
;
ALTER
TABLE
channel_monitor_daily_rollups
DROP
COLUMN
IF
EXISTS
deleted_at
;
backend/migration_release/128_add_channel_monitor_request_templates.sql
deleted
100644 → 0
View file @
74828a7c
-- Migration: 128_add_channel_monitor_request_templates
-- 加请求模板表 + 给 channel_monitors 加 4 个快照字段(template_id 关联引用 + extra_headers /
-- body_override_mode / body_override 三个真正运行时使用的快照)。
--
-- 设计要点:
-- 1) 模板与监控之间是「应用即拷贝」的快照语义,运行时 checker 不再回查模板表。
-- 模板 UPDATE 不会自动影响监控;只有用户主动「应用到关联监控」才会刷新快照。
-- 2) ON DELETE SET NULL:模板删除不级联清理监控;监控保留快照继续工作。
-- 3) extra_headers / body_override 都是 JSONB;body_override_mode 用 varchar(不是 enum)
-- 便于将来加新模式无需 ALTER TYPE。
-- 4) 同一 provider 内模板 name 唯一(允许 Anthropic + OpenAI 重名 "伪装官方客户端")。
CREATE
TABLE
IF
NOT
EXISTS
channel_monitor_request_templates
(
id
BIGSERIAL
PRIMARY
KEY
,
name
VARCHAR
(
100
)
NOT
NULL
,
provider
VARCHAR
(
20
)
NOT
NULL
,
description
VARCHAR
(
500
)
NOT
NULL
DEFAULT
''
,
extra_headers
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
,
body_override_mode
VARCHAR
(
10
)
NOT
NULL
DEFAULT
'off'
,
body_override
JSONB
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
CONSTRAINT
channel_monitor_request_templates_provider_check
CHECK
(
provider
IN
(
'openai'
,
'anthropic'
,
'gemini'
)),
CONSTRAINT
channel_monitor_request_templates_body_mode_check
CHECK
(
body_override_mode
IN
(
'off'
,
'merge'
,
'replace'
))
);
CREATE
UNIQUE
INDEX
IF
NOT
EXISTS
channel_monitor_request_templates_provider_name
ON
channel_monitor_request_templates
(
provider
,
name
);
-- channel_monitors 加 4 列(ADD COLUMN IF NOT EXISTS 需要 PG 9.6+,生产使用 PG 16)
ALTER
TABLE
channel_monitors
ADD
COLUMN
IF
NOT
EXISTS
template_id
BIGINT
NULL
;
ALTER
TABLE
channel_monitors
ADD
COLUMN
IF
NOT
EXISTS
extra_headers
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
;
ALTER
TABLE
channel_monitors
ADD
COLUMN
IF
NOT
EXISTS
body_override_mode
VARCHAR
(
10
)
NOT
NULL
DEFAULT
'off'
;
ALTER
TABLE
channel_monitors
ADD
COLUMN
IF
NOT
EXISTS
body_override
JSONB
NULL
;
-- 约束 + 外键(DO 块里 IF NOT EXISTS 判断,保证幂等)
DO
$$
BEGIN
IF
NOT
EXISTS
(
SELECT
1
FROM
information_schema
.
table_constraints
WHERE
constraint_name
=
'channel_monitors_body_mode_check'
AND
table_name
=
'channel_monitors'
)
THEN
ALTER
TABLE
channel_monitors
ADD
CONSTRAINT
channel_monitors_body_mode_check
CHECK
(
body_override_mode
IN
(
'off'
,
'merge'
,
'replace'
));
END
IF
;
IF
NOT
EXISTS
(
SELECT
1
FROM
information_schema
.
table_constraints
WHERE
constraint_name
=
'channel_monitors_template_id_fkey'
AND
table_name
=
'channel_monitors'
)
THEN
ALTER
TABLE
channel_monitors
ADD
CONSTRAINT
channel_monitors_template_id_fkey
FOREIGN
KEY
(
template_id
)
REFERENCES
channel_monitor_request_templates
(
id
)
ON
DELETE
SET
NULL
;
END
IF
;
END
$$
;
CREATE
INDEX
IF
NOT
EXISTS
idx_channel_monitors_template_id
ON
channel_monitors
(
template_id
)
WHERE
template_id
IS
NOT
NULL
;
backend/migration_release/129_seed_claude_code_template.sql
deleted
100644 → 0
View file @
74828a7c
-- Migration: 129_seed_claude_code_template
-- 内置「Claude Code 伪装」请求模板,覆盖 Anthropic 上游对官方 CLI 客户端的所有验证项:
-- 1) User-Agent / X-App / anthropic-beta / anthropic-version 等头
-- 2) system 数组首项与官方 system prompt 字面一致(Dice >= 0.5)
-- 3) metadata.user_id 满足 ParseMetadataUserID — 这里用 legacy 格式(user_<64hex>_account_<uuid>_session_<36char>)
-- 避免新版 JSON 字符串内嵌 JSON 在编辑器里出现一长串 \" 转义,便于用户阅读。
--
-- ON CONFLICT DO NOTHING:已部署环境(手动建过模板)跑此 migration 不会重复 / 覆盖。
-- 用户可自行编辑后续覆盖此 seed;CC 升大版时再起一条 migration 提供新模板,不动用户的旧模板。
INSERT
INTO
channel_monitor_request_templates
(
name
,
provider
,
description
,
extra_headers
,
body_override_mode
,
body_override
)
VALUES
(
'Claude Code 伪装'
,
'anthropic'
,
'完整模拟 Claude Code 2.1.114 客户端:UA + anthropic-beta + system + metadata.user_id 全部对齐,绕过 Anthropic 上游
''
Claude Code only
''
限制(如 Max 套餐)。'
,
'{
"User-Agent": "claude-cli/2.1.114 (external, sdk-cli)",
"X-App": "cli",
"anthropic-version": "2023-06-01",
"anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01",
"anthropic-dangerous-direct-browser-access": "true"
}'
::
jsonb
,
'merge'
,
'{
"system": [
{
"type": "text",
"text": "You are Claude Code, Anthropic
''
s official CLI for Claude."
}
],
"metadata": {
"user_id": "user_0000000000000000000000000000000000000000000000000000000000000000_account_00000000-0000-0000-0000-000000000000_session_00000000-0000-0000-0000-000000000000"
}
}'
::
jsonb
)
ON
CONFLICT
(
provider
,
name
)
DO
NOTHING
;
backend/migration_release/130_add_user_affiliates.sql
deleted
100644 → 0
View file @
74828a7c
CREATE
TABLE
IF
NOT
EXISTS
user_affiliates
(
user_id
BIGINT
PRIMARY
KEY
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
aff_code
VARCHAR
(
32
)
NOT
NULL
UNIQUE
,
inviter_id
BIGINT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
aff_count
INTEGER
NOT
NULL
DEFAULT
0
,
aff_quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
,
aff_history_quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliates_inviter_id
ON
user_affiliates
(
inviter_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliates_aff_quota
ON
user_affiliates
(
aff_quota
);
COMMENT
ON
TABLE
user_affiliates
IS
'用户邀请返利信息'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_code
IS
'用户邀请代码'
;
COMMENT
ON
COLUMN
user_affiliates
.
inviter_id
IS
'邀请人用户ID'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_count
IS
'累计邀请人数'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_quota
IS
'当前可提取返利金额'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_history_quota
IS
'累计返利历史金额'
;
backend/migration_release/131_affiliate_rebate_hardening.sql
deleted
100644 → 0
View file @
74828a7c
-- 1) Normalize historical affiliate rebate rate values.
-- Legacy compatibility treated 0<x<=1 as fractional inputs (e.g. 0.2 => 20%).
-- We now use pure percentage semantics, so convert persisted fractional values once.
UPDATE
settings
SET
value
=
to_char
((
value
::
numeric
*
100
),
'FM999999990.########'
),
updated_at
=
NOW
()
WHERE
key
=
'affiliate_rebate_rate'
AND
value
~
'^-?[0-9]+(
\\
.[0-9]+)?$'
AND
value
::
numeric
>
0
AND
value
::
numeric
<=
1
;
-- 2) Affiliate ledger for accrual/transfer traceability.
CREATE
TABLE
IF
NOT
EXISTS
user_affiliate_ledger
(
id
BIGSERIAL
PRIMARY
KEY
,
user_id
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
action
VARCHAR
(
32
)
NOT
NULL
,
amount
DECIMAL
(
20
,
8
)
NOT
NULL
,
source_user_id
BIGINT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliate_ledger_user_id
ON
user_affiliate_ledger
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliate_ledger_action
ON
user_affiliate_ledger
(
action
);
COMMENT
ON
TABLE
user_affiliate_ledger
IS
'邀请返利资金流水(累计/转入)'
;
COMMENT
ON
COLUMN
user_affiliate_ledger
.
action
IS
'accrue|transfer'
;
-- 3) Enforce idempotency at DB layer for payment audit actions.
WITH
ranked
AS
(
SELECT
id
,
ROW_NUMBER
()
OVER
(
PARTITION
BY
order_id
,
action
ORDER
BY
id
)
AS
rn
FROM
payment_audit_logs
)
DELETE
FROM
payment_audit_logs
p
USING
ranked
r
WHERE
p
.
id
=
r
.
id
AND
r
.
rn
>
1
;
CREATE
UNIQUE
INDEX
IF
NOT
EXISTS
idx_payment_audit_logs_order_action_uniq
ON
payment_audit_logs
(
order_id
,
action
);
-- 4) Prevent retroactive affiliate rebate issuance for legacy completed balance orders.
INSERT
INTO
payment_audit_logs
(
order_id
,
action
,
detail
,
operator
,
created_at
)
SELECT
po
.
id
::
text
,
'AFFILIATE_REBATE_SKIPPED'
,
'{"reason":"baseline before affiliate rebate idempotency rollout"}'
,
'system'
,
NOW
()
FROM
payment_orders
po
WHERE
po
.
order_type
=
'balance'
AND
po
.
status
=
'COMPLETED'
AND
NOT
EXISTS
(
SELECT
1
FROM
payment_audit_logs
pal
WHERE
pal
.
order_id
=
po
.
id
::
text
AND
pal
.
action
IN
(
'AFFILIATE_REBATE_APPLIED'
,
'AFFILIATE_REBATE_SKIPPED'
)
);
backend/migration_release/132_affiliate_custom_settings.sql
deleted
100644 → 0
View file @
74828a7c
-- 邀请返利:用户专属配置增强
-- 1) aff_rebate_rate_percent: 用户作为邀请人时的专属返利比例(百分比,NULL 表示沿用全局比例)
-- 2) aff_code_custom: 标记当前 aff_code 是否被管理员手动改写过(用于"专属用户"列表筛选)
ALTER
TABLE
user_affiliates
ADD
COLUMN
IF
NOT
EXISTS
aff_rebate_rate_percent
DECIMAL
(
5
,
2
);
ALTER
TABLE
user_affiliates
ADD
COLUMN
IF
NOT
EXISTS
aff_code_custom
BOOLEAN
NOT
NULL
DEFAULT
false
;
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliates_admin_settings
ON
user_affiliates
(
updated_at
)
WHERE
aff_code_custom
=
true
OR
aff_rebate_rate_percent
IS
NOT
NULL
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_rebate_rate_percent
IS
'专属返利比例(百分比 0-100,NULL 表示沿用全局)'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_code_custom
IS
'邀请码是否由管理员改写过(用于专属用户筛选)'
;
backend/migration_release/133_affiliate_rebate_freeze.sql
deleted
100644 → 0
View file @
74828a7c
-- 1) Add frozen quota column to user_affiliates for rebate freeze period.
ALTER
TABLE
user_affiliates
ADD
COLUMN
IF
NOT
EXISTS
aff_frozen_quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_frozen_quota
IS
'Rebate quota currently frozen (pending thaw after freeze period)'
;
-- 2) Add frozen_until column to user_affiliate_ledger for per-entry freeze tracking.
-- NULL = no freeze (or already thawed); non-NULL = frozen until this timestamp.
ALTER
TABLE
user_affiliate_ledger
ADD
COLUMN
IF
NOT
EXISTS
frozen_until
TIMESTAMPTZ
NULL
;
COMMENT
ON
COLUMN
user_affiliate_ledger
.
frozen_until
IS
'Rebate frozen until this time; NULL means already thawed or never frozen'
;
-- 3) Partial index for efficient thaw queries (only rows still frozen).
CREATE
INDEX
IF
NOT
EXISTS
idx_ual_frozen_thaw
ON
user_affiliate_ledger
(
user_id
,
frozen_until
)
WHERE
frozen_until
IS
NOT
NULL
;
frontend/src/api/admin/accounts.ts
View file @
538ae31a
...
...
@@ -370,8 +370,8 @@ export async function batchUpdateCredentials(request: {
* @returns Success confirmation
*/
export
async
function
bulkUpdate
(
accountIds
:
number
[],
updates
:
Record
<
string
,
unknown
>
accountIds
OrPayload
:
number
[]
|
Record
<
string
,
unknown
>
,
updates
?
:
Record
<
string
,
unknown
>
):
Promise
<
{
success
:
number
failed
:
number
...
...
@@ -379,16 +379,19 @@ export async function bulkUpdate(
failed_ids
?:
number
[]
results
:
Array
<
{
account_id
:
number
;
success
:
boolean
;
error
?:
string
}
>
}
>
{
const
payload
=
Array
.
isArray
(
accountIdsOrPayload
)
?
{
account_ids
:
accountIdsOrPayload
,
...(
updates
??
{})
}
:
accountIdsOrPayload
const
{
data
}
=
await
apiClient
.
post
<
{
success
:
number
failed
:
number
success_ids
?:
number
[]
failed_ids
?:
number
[]
results
:
Array
<
{
account_id
:
number
;
success
:
boolean
;
error
?:
string
}
>
}
>
(
'
/admin/accounts/bulk-update
'
,
{
account_ids
:
accountIds
,
...
updates
})
}
>
(
'
/admin/accounts/bulk-update
'
,
payload
)
return
data
}
...
...
frontend/src/api/admin/settings.ts
View file @
538ae31a
...
...
@@ -439,6 +439,7 @@ export interface SystemSettings {
enable_fingerprint_unification
:
boolean
;
enable_metadata_passthrough
:
boolean
;
enable_cch_signing
:
boolean
;
enable_anthropic_cache_ttl_1h_injection
:
boolean
;
web_search_emulation_enabled
?:
boolean
;
// Payment configuration
...
...
@@ -484,6 +485,9 @@ export interface SystemSettings {
// Affiliate (邀请返利) feature switch
affiliate_enabled
:
boolean
;
// OpenAI fast/flex policy
openai_fast_policy_settings
?:
OpenAIFastPolicySettings
;
}
export
interface
UpdateSettingsRequest
{
...
...
@@ -606,6 +610,7 @@ export interface UpdateSettingsRequest {
enable_fingerprint_unification
?:
boolean
;
enable_metadata_passthrough
?:
boolean
;
enable_cch_signing
?:
boolean
;
enable_anthropic_cache_ttl_1h_injection
?:
boolean
;
// Payment configuration
payment_enabled
?:
boolean
;
payment_min_amount
?:
number
;
...
...
@@ -648,6 +653,9 @@ export interface UpdateSettingsRequest {
// Affiliate (邀请返利) feature switch
affiliate_enabled
?:
boolean
;
// OpenAI fast/flex policy
openai_fast_policy_settings
?:
OpenAIFastPolicySettings
;
}
/**
...
...
@@ -875,6 +883,29 @@ export async function updateRectifierSettings(
return
data
;
}
// ==================== OpenAI Fast Policy Settings ====================
/**
* OpenAI fast/flex policy rule interface.
* Matches backend dto.OpenAIFastPolicyRule.
*/
export
interface
OpenAIFastPolicyRule
{
service_tier
:
"
all
"
|
"
priority
"
|
"
flex
"
;
action
:
"
pass
"
|
"
filter
"
|
"
block
"
;
scope
:
"
all
"
|
"
oauth
"
|
"
apikey
"
|
"
bedrock
"
;
error_message
?:
string
;
model_whitelist
?:
string
[];
fallback_action
?:
"
pass
"
|
"
filter
"
|
"
block
"
;
fallback_error_message
?:
string
;
}
/**
* OpenAI fast/flex policy settings interface.
*/
export
interface
OpenAIFastPolicySettings
{
rules
:
OpenAIFastPolicyRule
[];
}
// ==================== Beta Policy Settings ====================
/**
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
538ae31a
...
...
@@ -332,6 +332,37 @@
<!-- Usage data or unlimited flow -->
<div
class=
"space-y-1"
>
<div
v-if=
"showGeminiTodayStats && todayStats"
class=
"mb-0.5 flex items-center"
>
<div
class=
"flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatKeyRequests
}}
req
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatKeyTokens
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title=
"t('usage.accountBilled')"
>
A $
{{
formatKeyCost
}}
</span>
<span
v-if=
"todayStats.user_cost != null"
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title=
"t('usage.userBilled')"
>
U $
{{
formatKeyUserCost
}}
</span>
</div>
</div>
<div
v-else-if=
"showGeminiTodayStats && todayStatsLoading"
class=
"mb-0.5 flex items-center gap-1"
>
<div
class=
"h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
</div>
<div
v-if=
"loading"
class=
"space-y-1"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
...
...
@@ -512,6 +543,10 @@ const shouldFetchUsage = computed(() => {
return
false
})
const
showGeminiTodayStats
=
computed
(()
=>
{
return
props
.
account
.
platform
===
'
gemini
'
&&
props
.
account
.
type
===
'
service_account
'
})
const
geminiUsageAvailable
=
computed
(()
=>
{
return
(
!!
usageInfo
.
value
?.
gemini_shared_daily
||
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
538ae31a
...
...
@@ -17,7 +17,7 @@
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
admin.accounts.bulkEdit.selectionInfo
'
,
{
count
:
accountIds
.
length
}
)
}}
{{
t
(
'
admin.accounts.bulkEdit.selectionInfo
'
,
{
count
:
targetMode
===
'
filtered
'
?
targetPreviewCount
:
accountIds
.
length
}
)
}}
<
/p
>
<
/div
>
...
...
@@ -27,7 +27,7 @@
<
svg
class
=
"
mr-1.5 inline h-5 w-5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.bulkEdit.mixedPlatformWarning
'
,
{
platforms
:
s
electedPlatforms
.
join
(
'
,
'
)
}
)
}}
{{
t
(
'
admin.accounts.bulkEdit.mixedPlatformWarning
'
,
{
platforms
:
targetS
electedPlatforms
.
join
(
'
,
'
)
}
)
}}
<
/p
>
<
/div
>
...
...
@@ -227,7 +227,7 @@
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
:
platforms
=
"
s
electedPlatforms
"
:
platforms
=
"
targetS
electedPlatforms
"
/>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
...
...
@@ -698,6 +698,87 @@
<
/div
>
<
/div
>
<!--
OpenAI
OAuth
Codex
CLI
only
-->
<
div
v
-
if
=
"
allOpenAIOAuth
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
id
=
"
bulk-edit-openai-codex-cli-only-label
"
class
=
"
input-label mb-0
"
for
=
"
bulk-edit-openai-codex-cli-only-enabled
"
>
{{
t
(
'
admin.accounts.openai.codexCLIOnly
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableCodexCLIOnly
"
id
=
"
bulk-edit-openai-codex-cli-only-enabled
"
type
=
"
checkbox
"
aria
-
controls
=
"
bulk-edit-openai-codex-cli-only
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
id
=
"
bulk-edit-openai-codex-cli-only
"
:
class
=
"
!enableCodexCLIOnly && 'pointer-events-none opacity-50'
"
>
<
p
class
=
"
mb-3 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.openai.codexCLIOnlyDesc
'
)
}}
<
/p
>
<
button
id
=
"
bulk-edit-openai-codex-cli-only-toggle
"
type
=
"
button
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
@
click
=
"
codexCLIOnlyEnabled = !codexCLIOnlyEnabled
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<!--
OpenAI
API
Key
WS
mode
-->
<
div
v
-
if
=
"
allOpenAIAPIKey
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
id
=
"
bulk-edit-openai-apikey-ws-mode-label
"
class
=
"
input-label mb-0
"
for
=
"
bulk-edit-openai-apikey-ws-mode-enabled
"
>
{{
t
(
'
admin.accounts.openai.wsMode
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableOpenAIAPIKeyWSMode
"
id
=
"
bulk-edit-openai-apikey-ws-mode-enabled
"
type
=
"
checkbox
"
aria
-
controls
=
"
bulk-edit-openai-apikey-ws-mode
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
id
=
"
bulk-edit-openai-apikey-ws-mode
"
:
class
=
"
!enableOpenAIAPIKeyWSMode && 'pointer-events-none opacity-50'
"
>
<
p
class
=
"
mb-3 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.openai.wsModeDesc
'
)
}}
<
/p
>
<
p
class
=
"
mb-3 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
openAIAPIKeyWSModeConcurrencyHintKey
)
}}
<
/p
>
<
Select
v
-
model
=
"
openaiAPIKeyResponsesWebSocketV2Mode
"
data
-
testid
=
"
bulk-edit-openai-apikey-ws-mode-select
"
:
options
=
"
openAIWSModeOptions
"
aria
-
labelledby
=
"
bulk-edit-openai-apikey-ws-mode-label
"
/>
<
/div
>
<
/div
>
<!--
RPM
Limit
(
仅全部为
Anthropic
OAuth
/
SetupToken
时显示
)
-->
<
div
v
-
if
=
"
allAnthropicOAuthOrSetupToken
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
...
...
@@ -933,6 +1014,13 @@ interface Props {
accountIds
:
number
[]
selectedPlatforms
:
AccountPlatform
[]
selectedTypes
:
AccountType
[]
target
?:
{
mode
:
'
selected
'
|
'
filtered
'
filters
?:
Record
<
string
,
unknown
>
previewCount
?:
number
selectedPlatforms
?:
AccountPlatform
[]
selectedTypes
?:
AccountType
[]
}
proxies
:
ProxyConfig
[]
groups
:
AdminGroup
[]
}
...
...
@@ -947,40 +1035,53 @@ const { t } = useI18n()
const
appStore
=
useAppStore
()
// Platform awareness
const
isMixedPlatform
=
computed
(()
=>
props
.
selectedPlatforms
.
length
>
1
)
const
targetMode
=
computed
(()
=>
props
.
target
?.
mode
??
'
selected
'
)
const
targetPreviewCount
=
computed
(()
=>
props
.
target
?.
previewCount
??
props
.
accountIds
.
length
)
const
targetSelectedPlatforms
=
computed
(()
=>
props
.
target
?.
selectedPlatforms
??
props
.
selectedPlatforms
)
const
targetSelectedTypes
=
computed
(()
=>
props
.
target
?.
selectedTypes
??
props
.
selectedTypes
)
const
isMixedPlatform
=
computed
(()
=>
targetSelectedPlatforms
.
value
.
length
>
1
)
const
allOpenAIPassthroughCapable
=
computed
(()
=>
{
return
(
props
.
s
electedPlatforms
.
length
===
1
&&
props
.
s
electedPlatforms
[
0
]
===
'
openai
'
&&
props
.
s
electedTypes
.
length
>
0
&&
props
.
s
electedTypes
.
every
(
t
=>
t
===
'
oauth
'
||
t
===
'
apikey
'
)
targetS
electedPlatforms
.
value
.
length
===
1
&&
targetS
electedPlatforms
.
value
[
0
]
===
'
openai
'
&&
targetS
electedTypes
.
value
.
length
>
0
&&
targetS
electedTypes
.
value
.
every
(
t
=>
t
===
'
oauth
'
||
t
===
'
apikey
'
)
)
}
)
const
allOpenAIOAuth
=
computed
(()
=>
{
return
(
props
.
selectedPlatforms
.
length
===
1
&&
props
.
selectedPlatforms
[
0
]
===
'
openai
'
&&
props
.
selectedTypes
.
length
>
0
&&
props
.
selectedTypes
.
every
(
t
=>
t
===
'
oauth
'
)
targetSelectedPlatforms
.
value
.
length
===
1
&&
targetSelectedPlatforms
.
value
[
0
]
===
'
openai
'
&&
targetSelectedTypes
.
value
.
length
>
0
&&
targetSelectedTypes
.
value
.
every
(
t
=>
t
===
'
oauth
'
)
)
}
)
const
allOpenAIAPIKey
=
computed
(()
=>
{
return
(
targetSelectedPlatforms
.
value
.
length
===
1
&&
targetSelectedPlatforms
.
value
[
0
]
===
'
openai
'
&&
targetSelectedTypes
.
value
.
length
>
0
&&
targetSelectedTypes
.
value
.
every
(
t
=>
t
===
'
apikey
'
)
)
}
)
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const
allAnthropicOAuthOrSetupToken
=
computed
(()
=>
{
return
(
props
.
s
electedPlatforms
.
length
===
1
&&
props
.
s
electedPlatforms
[
0
]
===
'
anthropic
'
&&
props
.
s
electedTypes
.
every
(
t
=>
t
===
'
oauth
'
||
t
===
'
setup-token
'
)
targetS
electedPlatforms
.
value
.
length
===
1
&&
targetS
electedPlatforms
.
value
[
0
]
===
'
anthropic
'
&&
targetS
electedTypes
.
value
.
every
(
t
=>
t
===
'
oauth
'
||
t
===
'
setup-token
'
)
)
}
)
const
filteredPresets
=
computed
(()
=>
{
if
(
props
.
s
electedPlatforms
.
length
===
0
)
return
[]
if
(
targetS
electedPlatforms
.
value
.
length
===
0
)
return
[]
const
dedupedPresets
=
new
Map
<
string
,
ReturnType
<
typeof
getPresetMappingsByPlatform
>
[
number
]
>
()
for
(
const
platform
of
props
.
s
electedPlatforms
)
{
for
(
const
platform
of
targetS
electedPlatforms
.
value
)
{
for
(
const
preset
of
getPresetMappingsByPlatform
(
platform
))
{
const
key
=
`${preset.from
}
=>${preset.to
}
`
if
(
!
dedupedPresets
.
has
(
key
))
{
...
...
@@ -1012,6 +1113,8 @@ const enableStatus = ref(false)
const
enableGroups
=
ref
(
false
)
const
enableOpenAIPassthrough
=
ref
(
false
)
const
enableOpenAIWSMode
=
ref
(
false
)
const
enableOpenAIAPIKeyWSMode
=
ref
(
false
)
const
enableCodexCLIOnly
=
ref
(
false
)
const
enableRpmLimit
=
ref
(
false
)
// State - field values
...
...
@@ -1035,6 +1138,8 @@ const status = ref<'active' | 'inactive'>('active')
const
groupIds
=
ref
<
number
[]
>
([])
const
openaiPassthroughEnabled
=
ref
(
false
)
const
openaiOAuthResponsesWebSocketV2Mode
=
ref
<
OpenAIWSMode
>
(
OPENAI_WS_MODE_OFF
)
const
openaiAPIKeyResponsesWebSocketV2Mode
=
ref
<
OpenAIWSMode
>
(
OPENAI_WS_MODE_OFF
)
const
codexCLIOnlyEnabled
=
ref
(
false
)
const
rpmLimitEnabled
=
ref
(
false
)
const
bulkBaseRpm
=
ref
<
number
|
null
>
(
null
)
const
bulkRpmStrategy
=
ref
<
'
tiered
'
|
'
sticky_exempt
'
>
(
'
tiered
'
)
...
...
@@ -1076,6 +1181,9 @@ const openAIWSModeOptions = computed(() => [
const
openAIWSModeConcurrencyHintKey
=
computed
(()
=>
resolveOpenAIWSModeConcurrencyHintKey
(
openaiOAuthResponsesWebSocketV2Mode
.
value
)
)
const
openAIAPIKeyWSModeConcurrencyHintKey
=
computed
(()
=>
resolveOpenAIWSModeConcurrencyHintKey
(
openaiAPIKeyResponsesWebSocketV2Mode
.
value
)
)
// Model mapping helpers
const
addModelMapping
=
()
=>
{
...
...
@@ -1254,6 +1362,19 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
)
}
if
(
enableOpenAIAPIKeyWSMode
.
value
)
{
const
extra
=
ensureExtra
()
extra
.
openai_apikey_responses_websockets_v2_mode
=
openaiAPIKeyResponsesWebSocketV2Mode
.
value
extra
.
openai_apikey_responses_websockets_v2_enabled
=
isOpenAIWSModeEnabled
(
openaiAPIKeyResponsesWebSocketV2Mode
.
value
)
}
if
(
enableCodexCLIOnly
.
value
)
{
const
extra
=
ensureExtra
()
extra
.
codex_cli_only
=
codexCLIOnlyEnabled
.
value
}
// RPM limit settings (写入 extra 字段)
if
(
enableRpmLimit
.
value
)
{
const
extra
=
ensureExtra
()
...
...
@@ -1291,8 +1412,8 @@ const mixedChannelConfirmed = ref(false)
const
canPreCheck
=
()
=>
enableGroups
.
value
&&
groupIds
.
value
.
length
>
0
&&
props
.
s
electedPlatforms
.
length
===
1
&&
(
props
.
s
electedPlatforms
[
0
]
===
'
antigravity
'
||
props
.
s
electedPlatforms
[
0
]
===
'
anthropic
'
)
targetS
electedPlatforms
.
value
.
length
===
1
&&
(
targetS
electedPlatforms
.
value
[
0
]
===
'
antigravity
'
||
targetS
electedPlatforms
.
value
[
0
]
===
'
anthropic
'
)
const
handleClose
=
()
=>
{
showMixedChannelWarning
.
value
=
false
...
...
@@ -1309,7 +1430,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
try
{
const
result
=
await
adminAPI
.
accounts
.
checkMixedChannelRisk
({
platform
:
props
.
s
electedPlatforms
[
0
],
platform
:
targetS
electedPlatforms
.
value
[
0
],
group_ids
:
groupIds
.
value
}
)
if
(
!
result
.
has_risk
)
return
true
...
...
@@ -1325,7 +1446,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
}
const
handleSubmit
=
async
()
=>
{
if
(
props
.
accountIds
.
length
===
0
)
{
if
(
targetMode
.
value
===
'
selected
'
&&
props
.
accountIds
.
length
===
0
)
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noSelection
'
))
return
}
...
...
@@ -1344,6 +1465,8 @@ const handleSubmit = async () => {
enableStatus
.
value
||
enableGroups
.
value
||
enableOpenAIWSMode
.
value
||
enableOpenAIAPIKeyWSMode
.
value
||
enableCodexCLIOnly
.
value
||
enableRpmLimit
.
value
||
userMsgQueueMode
.
value
!==
null
...
...
@@ -1373,7 +1496,12 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
submitting
.
value
=
true
try
{
const
res
=
await
adminAPI
.
accounts
.
bulkUpdate
(
props
.
accountIds
,
updates
)
const
res
=
targetMode
.
value
===
'
filtered
'
&&
props
.
target
?.
filters
?
await
adminAPI
.
accounts
.
bulkUpdate
({
filters
:
props
.
target
.
filters
,
...
updates
}
)
:
await
adminAPI
.
accounts
.
bulkUpdate
(
props
.
accountIds
,
updates
)
const
success
=
res
.
success
||
0
const
failed
=
res
.
failed
||
0
...
...
@@ -1437,6 +1565,8 @@ watch(
enableGroups
.
value
=
false
enableOpenAIPassthrough
.
value
=
false
enableOpenAIWSMode
.
value
=
false
enableOpenAIAPIKeyWSMode
.
value
=
false
enableCodexCLIOnly
.
value
=
false
enableRpmLimit
.
value
=
false
// Reset all values
...
...
@@ -1456,6 +1586,8 @@ watch(
status
.
value
=
'
active
'
groupIds
.
value
=
[]
openaiOAuthResponsesWebSocketV2Mode
.
value
=
OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode
.
value
=
OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled
.
value
=
false
rpmLimitEnabled
.
value
=
false
bulkBaseRpm
.
value
=
null
bulkRpmStrategy
.
value
=
'
tiered
'
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
538ae31a
...
...
@@ -153,7 +153,7 @@
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-
3
gap-3"
data-tour=
"account-form-type"
>
<div
class=
"mt-2 grid grid-cols-
2
gap-3
sm:grid-cols-4
"
data-tour=
"account-form-type"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
...
...
@@ -244,6 +244,39 @@
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'service_account'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'service_account'
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'service_account'
? 'bg-sky-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"cloud"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Vertex
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Service Account
</span>
</div>
</button>
</div>
<div
v-if=
"accountCategory === 'service_account'"
class=
"mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200"
>
<p>
{{
t
(
'
admin.accounts.vertexAnthropicHint
'
)
}}
</p>
</div>
</div>
...
...
@@ -302,6 +335,7 @@
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.responsesApi
'
)
}}
</span>
</div>
</button>
</div>
</div>
...
...
@@ -320,7 +354,7 @@
{{
t
(
'
admin.accounts.gemini.helpButton
'
)
}}
</button>
</div>
<div
class=
"mt-2 grid grid-cols-
2
gap-3"
data-tour=
"account-form-type"
>
<div
class=
"mt-2 grid grid-cols-
3
gap-3"
data-tour=
"account-form-type"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
...
...
@@ -392,6 +426,36 @@
</span>
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'service_account'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'service_account'
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'service_account'
? 'bg-sky-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"cloud"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Vertex
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Service Account
</span>
</div>
</button>
</div>
<div
...
...
@@ -411,6 +475,13 @@
</div>
</div>
<div
v-if=
"accountCategory === 'service_account'"
class=
"mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200"
>
<p>
{{
t
(
'
admin.accounts.vertexGeminiHint
'
)
}}
</p>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div
v-if=
"accountCategory === 'oauth-based'"
class=
"mt-4"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</label>
...
...
@@ -610,7 +681,7 @@
</div>
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div
class=
"mt-4"
>
<div
v-if=
"accountCategory !== 'service_account'"
class=
"mt-4"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.gemini.tier.label
'
)
}}
</label>
<div
class=
"mt-2"
>
<select
...
...
@@ -729,6 +800,96 @@
</div>
</div>
<!-- Vertex Service Account -->
<div
v-if=
"(form.platform === 'gemini' || form.platform === 'anthropic') && accountCategory === 'service_account'"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
Service Account JSON
</label>
<input
ref=
"vertexServiceAccountFileInput"
type=
"file"
accept=
"application/json,.json"
class=
"hidden"
@
change=
"handleVertexServiceAccountFile"
/>
<div
:class=
"[
'rounded-lg border-2 border-dashed px-4 py-5 transition-colors',
vertexServiceAccountDragActive
? 'border-sky-500 bg-sky-50 dark:border-sky-500 dark:bg-sky-900/20'
: 'border-gray-300 bg-gray-50 hover:border-sky-400 hover:bg-sky-50/60 dark:border-dark-500 dark:bg-dark-700/40 dark:hover:border-sky-600 dark:hover:bg-sky-900/10'
]"
@
dragenter.prevent=
"vertexServiceAccountDragActive = true"
@
dragover.prevent=
"vertexServiceAccountDragActive = true"
@
dragleave.prevent=
"vertexServiceAccountDragActive = false"
@
drop.prevent=
"handleVertexServiceAccountDrop"
>
<div
class=
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div
class=
"min-w-0"
>
<div
class=
"flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"
>
<Icon
name=
"upload"
size=
"sm"
/>
<span>
{{
vertexClientEmail
?
t
(
'
admin.accounts.vertexSaJsonLoaded
'
)
:
t
(
'
admin.accounts.vertexSaJsonDrop
'
)
}}
</span>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
vertexClientEmail
?
t
(
'
admin.accounts.vertexSaJsonKeyHidden
'
)
:
t
(
'
admin.accounts.vertexSaJsonDropHint
'
)
}}
</p>
</div>
<button
type=
"button"
class=
"btn btn-secondary shrink-0"
@
click=
"vertexServiceAccountFileInput?.click()"
>
<Icon
name=
"upload"
size=
"sm"
/>
{{
t
(
'
admin.accounts.vertexSaJsonSelectBtn
'
)
}}
</button>
</div>
<div
v-if=
"vertexClientEmail"
class=
"mt-3 rounded-md border border-sky-200 bg-white px-3 py-2 text-xs text-sky-900 dark:border-sky-800/50 dark:bg-dark-800 dark:text-sky-200"
>
<div
class=
"truncate"
>
Project ID:
<span
class=
"font-mono"
>
{{
vertexProjectId
}}
</span></div>
<div
class=
"truncate"
>
Client Email:
<span
class=
"font-mono"
>
{{
vertexClientEmail
}}
</span></div>
</div>
</div>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.vertexSaJsonUploadHint
'
)
}}
</p>
</div>
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div>
<label
class=
"input-label"
>
Project ID
</label>
<input
v-model=
"vertexProjectId"
type=
"text"
class=
"input font-mono"
readonly
:placeholder=
"t('admin.accounts.vertexProjectIdPlaceholder')"
/>
</div>
<div>
<label
class=
"input-label"
>
Location
</label>
<select
v-model=
"vertexLocation"
required
class=
"input font-mono"
>
<optgroup
v-for=
"group in VERTEX_LOCATION_OPTIONS"
:key=
"group.label"
:label=
"group.label"
>
<option
v-for=
"option in group.options"
:key=
"option.value"
:value=
"option.value"
>
{{
option
.
label
}}
</option>
</optgroup>
</select>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.vertexLocationHint
'
)
}}
</p>
</div>
</div>
</div>
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
<div
v-if=
"form.platform === 'antigravity'"
class=
"border-t border-gray-200 pt-4 dark:border-dark-600"
>
...
...
@@ -2971,6 +3132,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import
{
applyInterceptWarmup
}
from
'
@/components/account/credentialsBuilder
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
VERTEX_LOCATION_OPTIONS
}
from
'
@/constants/account
'
import
{
OPENAI_WS_MODE_CTX_POOL
,
OPENAI_WS_MODE_OFF
,
...
...
@@ -3085,7 +3247,7 @@ interface TempUnschedRuleForm {
// State
const
step
=
ref
(
1
)
const
submitting
=
ref
(
false
)
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
|
'
bedrock
'
>
(
'
oauth-based
'
)
// UI selection for account category
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
|
'
bedrock
'
|
'
service_account
'
>
(
'
oauth-based
'
)
// UI selection for account category
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
// For oauth-based: 'oauth' or 'setup-token'
const
apiKeyBaseUrl
=
ref
(
'
https://api.anthropic.com
'
)
const
apiKeyValue
=
ref
(
''
)
...
...
@@ -3151,6 +3313,12 @@ const bedrockSessionToken = ref('')
const
bedrockRegion
=
ref
(
'
us-east-1
'
)
const
bedrockForceGlobal
=
ref
(
false
)
const
bedrockApiKeyValue
=
ref
(
''
)
const
vertexServiceAccountFileInput
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
vertexServiceAccountJson
=
ref
(
''
)
const
vertexProjectId
=
ref
(
''
)
const
vertexClientEmail
=
ref
(
''
)
const
vertexLocation
=
ref
(
'
global
'
)
const
vertexServiceAccountDragActive
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
...
...
@@ -3397,7 +3565,7 @@ watch(
// Sync form.type based on accountCategory, addMethod, and platform-specific type
watch
(
[
accountCategory
,
addMethod
,
antigravityAccountType
],
[
accountCategory
,
addMethod
,
antigravityAccountType
,
()
=>
form
.
platform
],
([
category
,
method
,
agType
])
=>
{
// Antigravity upstream 类型(实际创建为 apikey)
if
(
form
.
platform
===
'
antigravity
'
&&
agType
===
'
upstream
'
)
{
...
...
@@ -3409,7 +3577,9 @@ watch(
form
.
type
=
'
bedrock
'
as
AccountType
return
}
if
(
category
===
'
oauth-based
'
)
{
if
((
form
.
platform
===
'
gemini
'
||
form
.
platform
===
'
anthropic
'
)
&&
category
===
'
service_account
'
)
{
form
.
type
=
'
service_account
'
as
AccountType
}
else
if
(
category
===
'
oauth-based
'
)
{
form
.
type
=
method
as
AccountType
// 'oauth' or 'setup-token'
}
else
{
form
.
type
=
'
apikey
'
...
...
@@ -3447,6 +3617,12 @@ watch(
antigravityModelMappings
.
value
=
[]
antigravityModelRestrictionMode
.
value
=
'
mapping
'
}
if
(
newPlatform
!==
'
gemini
'
&&
newPlatform
!==
'
anthropic
'
&&
accountCategory
.
value
===
'
service_account
'
)
{
accountCategory
.
value
=
'
oauth-based
'
}
if
(
newPlatform
!==
'
anthropic
'
&&
accountCategory
.
value
===
'
bedrock
'
)
{
accountCategory
.
value
=
'
oauth-based
'
}
// Reset Bedrock fields when switching platforms
bedrockAccessKeyId
.
value
=
''
bedrockSecretAccessKey
.
value
=
''
...
...
@@ -3455,6 +3631,10 @@ watch(
bedrockForceGlobal
.
value
=
false
bedrockAuthMode
.
value
=
'
sigv4
'
bedrockApiKeyValue
.
value
=
''
vertexServiceAccountJson
.
value
=
''
vertexProjectId
.
value
=
''
vertexClientEmail
.
value
=
''
vertexLocation
.
value
=
'
global
'
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if
(
newPlatform
!==
'
anthropic
'
&&
newPlatform
!==
'
antigravity
'
)
{
interceptWarmupRequests
.
value
=
false
...
...
@@ -3886,6 +4066,10 @@ const resetForm = () => {
antigravityAccountType
.
value
=
'
oauth
'
upstreamBaseUrl
.
value
=
''
upstreamApiKey
.
value
=
''
vertexServiceAccountJson
.
value
=
''
vertexProjectId
.
value
=
''
vertexClientEmail
.
value
=
''
vertexLocation
.
value
=
'
global
'
tempUnschedEnabled
.
value
=
false
tempUnschedRules
.
value
=
[]
geminiOAuthType
.
value
=
'
code_assist
'
...
...
@@ -4009,6 +4193,52 @@ const normalizePoolModeRetryCount = (value: number) => {
return
normalized
}
const
applyVertexServiceAccountJson
=
(
value
:
string
)
=>
{
const
raw
=
value
.
trim
()
if
(
!
raw
)
{
vertexProjectId
.
value
=
''
vertexClientEmail
.
value
=
''
return
false
}
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
Record
<
string
,
unknown
>
const
projectId
=
typeof
parsed
.
project_id
===
'
string
'
?
parsed
.
project_id
.
trim
()
:
''
const
clientEmail
=
typeof
parsed
.
client_email
===
'
string
'
?
parsed
.
client_email
.
trim
()
:
''
const
privateKey
=
typeof
parsed
.
private_key
===
'
string
'
?
parsed
.
private_key
.
trim
()
:
''
if
(
!
projectId
||
!
clientEmail
||
!
privateKey
)
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexSaJsonMissingFields
'
))
return
false
}
vertexProjectId
.
value
=
projectId
vertexClientEmail
.
value
=
clientEmail
vertexServiceAccountJson
.
value
=
JSON
.
stringify
(
parsed
)
return
true
}
catch
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexSaJsonInvalid
'
))
return
false
}
}
const
parseVertexServiceAccountJson
=
()
=>
applyVertexServiceAccountJson
(
vertexServiceAccountJson
.
value
)
const
handleVertexServiceAccountFile
=
async
(
event
:
Event
)
=>
{
const
input
=
event
.
target
as
HTMLInputElement
const
file
=
input
.
files
?.[
0
]
if
(
!
file
)
return
try
{
applyVertexServiceAccountJson
(
await
file
.
text
())
}
finally
{
input
.
value
=
''
}
}
const
handleVertexServiceAccountDrop
=
async
(
event
:
DragEvent
)
=>
{
vertexServiceAccountDragActive
.
value
=
false
const
file
=
event
.
dataTransfer
?.
files
?.[
0
]
if
(
!
file
)
return
applyVertexServiceAccountJson
(
await
file
.
text
())
}
const
handleSubmit
=
async
()
=>
{
// For OAuth-based type, handle OAuth flow (goes to step 2)
if
(
isOAuthFlow
.
value
)
{
...
...
@@ -4122,6 +4352,29 @@ const handleSubmit = async () => {
return
}
if
((
form
.
platform
===
'
gemini
'
||
form
.
platform
===
'
anthropic
'
)
&&
accountCategory
.
value
===
'
service_account
'
)
{
if
(
!
form
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterAccountName
'
))
return
}
if
(
!
parseVertexServiceAccountJson
())
{
return
}
if
(
!
vertexLocation
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexLocationRequired
'
))
return
}
const
credentials
:
Record
<
string
,
unknown
>
=
{
service_account_json
:
vertexServiceAccountJson
.
value
.
trim
(),
project_id
:
vertexProjectId
.
value
.
trim
(),
client_email
:
vertexClientEmail
.
value
.
trim
(),
location
:
vertexLocation
.
value
.
trim
(),
tier_id
:
'
vertex
'
}
await
createAccountAndFinish
(
form
.
platform
,
'
service_account
'
as
AccountType
,
credentials
)
return
}
// For apikey type, create directly
if
(
!
apiKeyValue
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterApiKey
'
))
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
538ae31a
...
...
@@ -567,6 +567,221 @@
<
/div
>
<
/div
>
<!--
Vertex
Service
Account
-->
<
div
v
-
if
=
"
(account.platform === 'gemini' || account.platform === 'anthropic') && account.type === 'service_account'
"
class
=
"
space-y-4
"
>
<
div
class
=
"
grid grid-cols-1 gap-4 sm:grid-cols-2
"
>
<
div
>
<
label
class
=
"
input-label
"
>
Project
ID
<
/label
>
<
input
v
-
model
=
"
editVertexProjectId
"
type
=
"
text
"
class
=
"
input font-mono
"
readonly
:
placeholder
=
"
t('admin.accounts.vertexProjectIdPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.vertexSaJsonEditHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
Location
<
/label
>
<
select
v
-
model
=
"
editVertexLocation
"
required
class
=
"
input font-mono
"
>
<
optgroup
v
-
for
=
"
group in VERTEX_LOCATION_OPTIONS
"
:
key
=
"
group.label
"
:
label
=
"
group.label
"
>
<
option
v
-
for
=
"
option in group.options
"
:
key
=
"
option.value
"
:
value
=
"
option.value
"
>
{{
option
.
label
}}
<
/option
>
<
/optgroup
>
<
/select
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.vertexLocationHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Model
Restriction
Section
for
Service
Account
-->
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
<
/label
>
<!--
Mode
Toggle
-->
<
div
class
=
"
mb-4 flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
svg
class
=
"
mr-1.5 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.modelWhitelist
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
svg
class
=
"
mr-1.5 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.modelMapping
'
)
}}
<
/button
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
:
platform
=
"
account?.platform || 'anthropic'
"
/>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.selectedModels
'
,
{
count
:
allowedModels
.
length
}
)
}}
<
span
v
-
if
=
"
allowedModels.length === 0
"
>
{{
t
(
'
admin.accounts.supportsAllModels
'
)
}}
<
/span
>
<
/p
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.mapRequestModels
'
)
}}
<
/p
>
<
/div
>
<!--
Model
Mapping
List
-->
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.requestModel')
"
/>
<
svg
class
=
"
h-4 w-4 flex-shrink-0 text-gray-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14 5l7 7m0 0l-7 7m7-7H3
"
/>
<
/svg
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.actualModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
removeModelMapping(index)
"
class
=
"
rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
<
/svg
>
<
/button
>
<
/div
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
addModelMapping
"
class
=
"
mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300
"
>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Quick
Add
Buttons
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in presetMappings
"
:
key
=
"
preset.label
"
type
=
"
button
"
@
click
=
"
addPresetMapping(preset.from, preset.to)
"
:
class
=
"
['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]
"
>
+
{{
preset
.
label
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Bedrock
fields
(
for
bedrock
type
,
both
SigV4
and
API
Key
modes
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock'
"
class
=
"
space-y-4
"
>
<!--
SigV4
fields
-->
...
...
@@ -1919,6 +2134,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import
{
applyInterceptWarmup
}
from
'
@/components/account/credentialsBuilder
'
import
{
formatDateTime
,
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
VERTEX_LOCATION_OPTIONS
}
from
'
@/constants/account
'
import
{
OPENAI_WS_MODE_CTX_POOL
,
OPENAI_WS_MODE_OFF
,
...
...
@@ -1987,6 +2203,9 @@ const editBedrockSessionToken = ref('')
const
editBedrockRegion
=
ref
(
''
)
const
editBedrockForceGlobal
=
ref
(
false
)
const
editBedrockApiKeyValue
=
ref
(
''
)
const
editVertexProjectId
=
ref
(
''
)
const
editVertexClientEmail
=
ref
(
''
)
const
editVertexLocation
=
ref
(
'
us-central1
'
)
const
isBedrockAPIKeyMode
=
computed
(()
=>
props
.
account
?.
type
===
'
bedrock
'
&&
(
props
.
account
?.
credentials
as
Record
<
string
,
unknown
>
)?.
auth_mode
===
'
apikey
'
...
...
@@ -2246,6 +2465,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
|
undefined
interceptWarmupRequests
.
value
=
credentials
?.
intercept_warmup_requests
===
true
autoPauseOnExpired
.
value
=
newAccount
.
auto_pause_on_expired
===
true
editVertexProjectId
.
value
=
''
editVertexClientEmail
.
value
=
''
editVertexLocation
.
value
=
'
us-central1
'
// Load mixed scheduling setting (only for antigravity accounts)
mixedScheduling
.
value
=
false
...
...
@@ -2467,6 +2689,31 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
else
if
(
newAccount
.
type
===
'
upstream
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBaseUrl
.
value
=
(
credentials
.
base_url
as
string
)
||
''
}
else
if
((
newAccount
.
platform
===
'
gemini
'
||
newAccount
.
platform
===
'
anthropic
'
)
&&
newAccount
.
type
===
'
service_account
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editVertexProjectId
.
value
=
(
credentials
.
project_id
as
string
)
||
''
editVertexClientEmail
.
value
=
(
credentials
.
client_email
as
string
)
||
''
editVertexLocation
.
value
=
(
credentials
.
location
as
string
)
||
(
credentials
.
vertex_location
as
string
)
||
'
us-central1
'
// Load model mappings for service_account
const
existingMappings
=
credentials
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
if
(
existingMappings
&&
typeof
existingMappings
===
'
object
'
)
{
const
entries
=
Object
.
entries
(
existingMappings
)
const
isWhitelistMode
=
entries
.
length
>
0
&&
entries
.
every
(([
from
,
to
])
=>
from
===
to
)
if
(
isWhitelistMode
)
{
modelRestrictionMode
.
value
=
'
whitelist
'
allowedModels
.
value
=
entries
.
map
(([
from
])
=>
from
)
modelMappings
.
value
=
[]
}
else
{
modelRestrictionMode
.
value
=
'
mapping
'
modelMappings
.
value
=
entries
.
map
(([
from
,
to
])
=>
({
from
,
to
}
))
allowedModels
.
value
=
[]
}
}
else
{
modelRestrictionMode
.
value
=
'
whitelist
'
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
}
}
else
{
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
...
...
@@ -3057,6 +3304,46 @@ const handleSubmit = async () => {
return
}
updatePayload
.
credentials
=
newCredentials
}
else
if
((
props
.
account
.
platform
===
'
gemini
'
||
props
.
account
.
platform
===
'
anthropic
'
)
&&
props
.
account
.
type
===
'
service_account
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
if
(
!
editVertexProjectId
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexSaJsonMissingProjectId
'
))
return
}
if
(
!
editVertexClientEmail
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexSaJsonMissingClientEmail
'
))
return
}
if
(
!
editVertexLocation
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexLocationRequired
'
))
return
}
if
(
!
currentCredentials
.
service_account_json
&&
!
currentCredentials
.
service_account
)
{
appStore
.
showError
(
t
(
'
admin.accounts.vertexSaJsonRequired
'
))
return
}
newCredentials
.
project_id
=
editVertexProjectId
.
value
.
trim
()
newCredentials
.
client_email
=
editVertexClientEmail
.
value
.
trim
()
newCredentials
.
location
=
editVertexLocation
.
value
.
trim
()
newCredentials
.
tier_id
=
'
vertex
'
// Add model mapping if configured
const
modelMapping
=
buildModelMappingObject
(
modelRestrictionMode
.
value
,
allowedModels
.
value
,
modelMappings
.
value
)
if
(
modelMapping
)
{
newCredentials
.
model_mapping
=
modelMapping
}
else
{
delete
newCredentials
.
model_mapping
}
applyInterceptWarmup
(
newCredentials
,
interceptWarmupRequests
.
value
,
'
edit
'
)
if
(
!
applyTempUnschedConfig
(
newCredentials
))
{
return
}
updatePayload
.
credentials
=
newCredentials
}
else
if
(
props
.
account
.
type
===
'
bedrock
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
...
...
frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
View file @
538ae31a
...
...
@@ -57,6 +57,19 @@ function makeAccount(overrides: Partial<Account>): Account {
describe
(
'
AccountUsageCell
'
,
()
=>
{
beforeEach
(()
=>
{
getUsage
.
mockReset
()
Object
.
defineProperty
(
window
,
'
matchMedia
'
,
{
writable
:
true
,
value
:
vi
.
fn
().
mockImplementation
(()
=>
({
matches
:
true
,
media
:
'
(min-width: 768px)
'
,
onchange
:
null
,
addListener
:
vi
.
fn
(),
removeListener
:
vi
.
fn
(),
addEventListener
:
vi
.
fn
(),
removeEventListener
:
vi
.
fn
(),
dispatchEvent
:
vi
.
fn
(),
}))
})
})
it
(
'
Antigravity 图片用量会聚合新旧 image 模型
'
,
async
()
=>
{
...
...
@@ -603,4 +616,43 @@ describe('AccountUsageCell', () => {
expect
(
wrapper
.
text
().
trim
()).
toBe
(
'
-
'
)
})
it
(
'
Vertex 账号会在 Gemini 用量窗口里展示 today stats 徽章
'
,
async
()
=>
{
const
wrapper
=
mount
(
AccountUsageCell
,
{
props
:
{
account
:
makeAccount
({
id
:
4001
,
platform
:
'
gemini
'
,
type
:
'
service_account
'
,
credentials
:
{
tier_id
:
'
vertex
'
,
project_id
:
'
vertex-proj
'
,
client_email
:
'
svc@vertex-proj.iam.gserviceaccount.com
'
,
location
:
'
global
'
},
extra
:
{}
}),
todayStats
:
{
requests
:
0
,
tokens
:
0
,
cost
:
0
,
standard_cost
:
0
,
user_cost
:
0
}
},
global
:
{
stubs
:
{
UsageProgressBar
:
true
,
AccountQuotaInfo
:
true
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
0 req
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
0
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
A $0.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
U $0.00
'
)
})
})
frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
View file @
538ae31a
...
...
@@ -178,6 +178,45 @@ describe('BulkEditAccountModal', () => {
expect
(
wrapper
.
find
(
'
#bulk-edit-openai-ws-mode-enabled
'
).
exists
()).
toBe
(
false
)
})
it
(
'
OpenAI OAuth 批量编辑应提交 codex_cli_only 字段
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
selectedTypes
:
[
'
oauth
'
]
})
await
wrapper
.
get
(
'
#bulk-edit-openai-codex-cli-only-enabled
'
).
setValue
(
true
)
await
wrapper
.
get
(
'
#bulk-edit-openai-codex-cli-only-toggle
'
).
trigger
(
'
click
'
)
await
wrapper
.
get
(
'
#bulk-edit-account-form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledTimes
(
1
)
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledWith
([
1
,
2
],
{
extra
:
{
codex_cli_only
:
true
}
})
})
it
(
'
OpenAI API Key 批量编辑应提交 API Key 专属 WS mode 字段
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
selectedTypes
:
[
'
apikey
'
]
})
await
wrapper
.
get
(
'
#bulk-edit-openai-apikey-ws-mode-enabled
'
).
setValue
(
true
)
await
wrapper
.
get
(
'
[data-testid="bulk-edit-openai-apikey-ws-mode-select"]
'
).
setValue
(
'
ctx_pool
'
)
await
wrapper
.
get
(
'
#bulk-edit-account-form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledTimes
(
1
)
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledWith
([
1
,
2
],
{
extra
:
{
openai_apikey_responses_websockets_v2_mode
:
'
ctx_pool
'
,
openai_apikey_responses_websockets_v2_enabled
:
true
}
})
})
it
(
'
OpenAI 账号批量编辑可关闭自动透传
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
...
...
@@ -217,4 +256,41 @@ describe('BulkEditAccountModal', () => {
})
expect
(
wrapper
.
text
()).
toContain
(
'
admin.accounts.openai.modelRestrictionDisabledByPassthrough
'
)
})
it
(
'
filtered-results 模式下应提交 filters 而不是 account_ids
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
accountIds
:
[],
target
:
{
mode
:
'
filtered
'
,
filters
:
{
platform
:
'
openai
'
,
type
:
'
oauth
'
,
status
:
'
active
'
,
group
:
'
12
'
,
search
:
'
bulk-target
'
,
privacy_mode
:
'
training_set_cf_blocked
'
},
previewCount
:
5
,
selectedPlatforms
:
[
'
openai
'
],
selectedTypes
:
[
'
oauth
'
]
}
})
await
wrapper
.
get
(
'
#bulk-edit-status-enabled
'
).
setValue
(
true
)
await
wrapper
.
get
(
'
#bulk-edit-account-form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledTimes
(
1
)
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledWith
({
filters
:
{
platform
:
'
openai
'
,
type
:
'
oauth
'
,
status
:
'
active
'
,
group
:
'
12
'
,
search
:
'
bulk-target
'
,
privacy_mode
:
'
training_set_cf_blocked
'
},
status
:
'
active
'
})
})
})
frontend/src/components/admin/account/AccountBulkActionsBar.vue
View file @
538ae31a
<
template
>
<div
v-if=
"selectedIds.length > 0"
class=
"mb-4 flex items-center justify-between
p-3
bg-primary-50
rounded-lg
dark:bg-primary-900/20"
>
<div
class=
"mb-4 flex items-center justify-between
rounded-lg
bg-primary-50
p-3
dark:bg-primary-900/20"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<span
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
<span
v-if=
"selectedIds.length > 0"
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedIds
.
length
}
)
}}
<
/span
>
<
span
v
-
else
class
=
"
text-sm font-medium text-primary-900 dark:text-primary-100
"
>
{{
t
(
'
admin.accounts.bulkEdit.title
'
)
}}
<
/span
>
<
template
v
-
if
=
"
selectedIds.length > 0
"
>
<
button
@
click
=
"
$emit('select-page')
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
...
...
@@ -17,19 +21,25 @@
>
{{
t
(
'
admin.accounts.bulkActions.clear
'
)
}}
<
/button
>
<
/template
>
<
/div
>
<
div
class
=
"
flex gap-2
"
>
<
button
@
click
=
"
$emit('delete')
"
class
=
"
btn btn-danger btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('reset-status')
"
class
=
"
btn btn-secondary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.resetStatus
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('refresh-token')
"
class
=
"
btn btn-secondary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.refreshToken
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', true)
"
class
=
"
btn btn-success btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.enableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', false)
"
class
=
"
btn btn-warning btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.disableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('edit')
"
class
=
"
btn btn-primary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
template
v
-
if
=
"
selectedIds.length > 0
"
>
<
button
@
click
=
"
$emit('delete')
"
class
=
"
btn btn-danger btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('reset-status')
"
class
=
"
btn btn-secondary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.resetStatus
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('refresh-token')
"
class
=
"
btn btn-secondary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.refreshToken
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', true)
"
class
=
"
btn btn-success btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.enableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('toggle-schedulable', false)
"
class
=
"
btn btn-warning btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.disableScheduling
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('edit-selected')
"
class
=
"
btn btn-primary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
/template
>
<
button
@
click
=
"
$emit('edit-filtered')
"
class
=
"
btn btn-primary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkEdit.submit
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
([
'
selectedIds
'
]);
defineEmits
([
'
delete
'
,
'
edit
'
,
'
clear
'
,
'
select-page
'
,
'
toggle-schedulable
'
,
'
reset-status
'
,
'
refresh-token
'
]);
const
{
t
}
=
useI18n
()
defineProps
([
'
selectedIds
'
]);
defineEmits
([
'
delete
'
,
'
edit
-selected
'
,
'
edit-filtered
'
,
'
clear
'
,
'
select-page
'
,
'
toggle-schedulable
'
,
'
reset-status
'
,
'
refresh-token
'
]);
const
{
t
}
=
useI18n
()
<
/script
>
frontend/src/components/common/Pagination.vue
View file @
538ae31a
...
...
@@ -123,6 +123,7 @@ import { useI18n } from 'vue-i18n'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
import
{
getConfiguredTablePageSizeOptions
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
import
{
setPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
const
{
t
}
=
useI18n
()
...
...
@@ -224,6 +225,7 @@ const goToPage = (newPage: number) => {
const
handlePageSizeChange
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
const
newPageSize
=
normalizeTablePageSize
(
typeof
value
===
'
string
'
?
parseInt
(
value
,
10
)
:
value
)
setPersistedPageSize
(
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
}
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
538ae31a
...
...
@@ -25,6 +25,7 @@
<!-- Setup Token icon -->
<Icon
v-else-if=
"type === 'setup-token'"
name=
"shield"
size=
"xs"
/>
<!-- API Key icon -->
<Icon
v-else-if=
"type === 'service_account'"
name=
"cloud"
size=
"xs"
/>
<Icon
v-else
name=
"key"
size=
"xs"
/>
<span>
{{
typeLabel
}}
</span>
</span>
...
...
@@ -88,6 +89,8 @@ const typeLabel = computed(() => {
return
'
Key
'
case
'
bedrock
'
:
return
'
AWS
'
case
'
service_account
'
:
return
'
Vertex
'
default
:
return
props
.
type
}
...
...
frontend/src/composables/usePersistedPageSize.ts
View file @
538ae31a
import
{
getConfiguredTableDefaultPageSize
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
/**
* 读取当前系统配置的表格默认每页条数。
* 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/
const
STORAGE_KEY
=
'
table-page-size
'
export
function
getPersistedPageSize
(
fallback
=
getConfiguredTableDefaultPageSize
()):
number
{
if
(
typeof
window
!==
'
undefined
'
)
{
try
{
const
stored
=
window
.
localStorage
.
getItem
(
STORAGE_KEY
)
if
(
stored
!==
null
)
{
const
parsed
=
Number
(
stored
)
if
(
Number
.
isFinite
(
parsed
))
{
return
normalizeTablePageSize
(
parsed
)
}
}
}
catch
(
error
)
{
console
.
warn
(
'
Failed to read persisted page size:
'
,
error
)
}
}
return
normalizeTablePageSize
(
getConfiguredTableDefaultPageSize
()
||
fallback
)
}
export
function
setPersistedPageSize
(
size
:
number
):
void
{
if
(
typeof
window
===
'
undefined
'
)
return
try
{
window
.
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
size
))
}
catch
(
error
)
{
console
.
warn
(
'
Failed to persist page size:
'
,
error
)
}
}
Prev
1
…
3
4
5
6
7
8
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment