fix: refine account management interactions

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 02:23:59 +08:00
co-authored by Codex
parent 82c8db6179
commit 3203ecdb67
28 changed files with 185 additions and 178 deletions
+2
View File
@@ -127,6 +127,7 @@ struct AdminAccountData {
user_id: Option<String>,
label: Option<String>,
plan_type: Option<String>,
has_refresh_token: bool,
status: String,
access_token_expires_at: Option<String>,
access_token_expires_at_display: Option<String>,
@@ -161,6 +162,7 @@ impl AdminAccountData {
user_id: a.user_id,
label: a.label,
plan_type: a.plan_type,
has_refresh_token: a.has_refresh_token,
status: account_status_str(a.status).to_string(),
access_token_expires_at,
access_token_expires_at_display,
@@ -20,6 +20,7 @@ pub struct AdminAccountMetadata {
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub has_refresh_token: bool,
pub status: AcctStatus,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub added_at: DateTime<Utc>,
@@ -43,6 +44,7 @@ impl From<StoredAccountMetadata> for AdminAccountMetadata {
user_id: m.user_id,
label: m.label,
plan_type: m.plan_type,
has_refresh_token: m.has_refresh_token,
status: m.status,
access_token_expires_at: m.access_token_expires_at,
added_at,
@@ -282,6 +284,7 @@ pub(super) fn stored_to_admin_metadata(
user_id: s.user_id,
label: s.label,
plan_type: s.plan_type,
has_refresh_token: s.refresh_token.is_some(),
access_token_expires_at: s.access_token_expires_at,
status: s.status,
added_at: s.added_at,
@@ -163,6 +163,8 @@ pub struct StoredAccountMetadata {
pub label: Option<String>,
/// 计划类型。
pub plan_type: Option<String>,
/// 是否保存了 refresh token。
pub has_refresh_token: bool,
/// access token 过期时间。
pub access_token_expires_at: Option<DateTime<Utc>>,
/// 账号状态。
@@ -149,6 +149,7 @@ select
chatgpt_user_id as user_id,
label,
plan_type,
case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token,
access_token_expires_at,
status,
added_at,
@@ -216,6 +217,7 @@ select
chatgpt_user_id as user_id,
label,
plan_type,
case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token,
access_token_expires_at,
status,
added_at,
@@ -234,6 +236,7 @@ select
chatgpt_user_id as user_id,
label,
plan_type,
case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token,
access_token_expires_at,
status,
added_at,
@@ -250,6 +253,7 @@ select
chatgpt_user_id as user_id,
label,
plan_type,
case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token,
access_token_expires_at,
status,
added_at,
@@ -169,6 +169,7 @@ pub(super) fn metadata_from_row(
user_id: row.get("user_id"),
label: row.get("label"),
plan_type: row.get("plan_type"),
has_refresh_token: row.get::<i64, _>("has_refresh_token") != 0,
access_token_expires_at: parse_optional_rfc3339(
row.get::<Option<String>, _>("access_token_expires_at")
.as_deref(),
+23 -6
View File
@@ -11,6 +11,10 @@ async fn admin_accounts_list_should_not_expose_account_tokens() {
.bind("acct_plain").bind("user@example.com").bind("plain-access-token")
.bind("active").bind("2026-06-18T00:00:00Z").bind("2026-06-18T00:00:00Z")
.execute(&pool).await.unwrap();
sqlx::query("insert into accounts (id, email, access_token, refresh_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)")
.bind("acct_refresh").bind("refresh@example.com").bind("refresh-access-token").bind("refresh-secret-token")
.bind("active").bind("2026-06-19T00:00:00Z").bind("2026-06-19T00:00:00Z")
.execute(&pool).await.unwrap();
let config = test_config(url);
let stores = BackgroundTaskStores {
accounts: SqliteAccountStore::new(pool.clone()),
@@ -45,12 +49,25 @@ async fn admin_accounts_list_should_not_expose_account_tokens() {
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["items"][0]["id"], "acct_plain");
assert_eq!(body["data"]["items"][0]["email"], "user@example.com");
assert_eq!(
body["data"]["items"][0]["addedAt"],
"2026-06-18T08:00:00+08:00"
);
let items = body["data"]["items"].as_array().unwrap();
let plain = items
.iter()
.find(|item| item["id"] == "acct_plain")
.unwrap();
let refresh = items
.iter()
.find(|item| item["id"] == "acct_refresh")
.unwrap();
assert_eq!(plain["email"], "user@example.com");
assert_eq!(plain["hasRefreshToken"], false);
assert_eq!(plain["addedAt"], "2026-06-18T08:00:00+08:00");
assert_eq!(refresh["email"], "refresh@example.com");
assert_eq!(refresh["hasRefreshToken"], true);
let response_body = body.to_string();
assert!(!response_body.contains("plain-access-token"));
assert!(!response_body.contains("refresh-access-token"));
assert!(!response_body.contains("refresh-secret-token"));
}
#[tokio::test]
@@ -103,6 +103,43 @@ async fn account_repository_should_list_metadata_without_exposing_tokens() {
assert_eq!(page.items[0].status, AccountStatus::Active);
}
#[tokio::test]
async fn account_repository_should_list_pool_accounts_with_tokens_and_plan() {
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 39).await;
let now = Utc::now().to_rfc3339();
sqlx::query(
"insert into accounts (
id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, access_token, refresh_token,
access_token_expires_at, status, added_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("acct_1")
.bind("user@example.com")
.bind("chatgpt-account")
.bind(Option::<String>::None)
.bind("primary")
.bind("plus")
.bind("access-token")
.bind(Some("refresh-token"))
.bind(Option::<String>::None)
.bind("active")
.bind(&now)
.bind(&now)
.execute(&pool)
.await
.expect("insert account");
let store = SqliteAccountStore::new(pool);
let accounts = AccountStore::list_pool_accounts(&store)
.await
.expect("list accounts");
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acct_1");
assert_eq!(accounts[0].access_token, "access-token");
assert_eq!(accounts[0].plan_type.as_deref(), Some("plus"));
}
#[tokio::test]
async fn account_repository_should_update_status_and_label_without_rewriting_tokens() {
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 6).await;
@@ -1,33 +0,0 @@
use codex_proxy_rs::infra::database::connect_sqlite;
use codex_proxy_rs::upstream::models::backend_entry::BackendModelEntry;
use codex_proxy_rs::upstream::models::snapshot::ModelPlanSnapshot;
use codex_proxy_rs::upstream::models::snapshot_store::ModelSnapshotStore as _;
use codex_proxy_rs::upstream::models::snapshot_store::SqliteModelSnapshotStore;
#[tokio::test]
async fn runtime_state_should_expose_backend_model_snapshot_through_model_service() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("runtime-models.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let snapshot_store = SqliteModelSnapshotStore::new(pool.clone());
snapshot_store
.replace_plan_snapshot(&ModelPlanSnapshot::from_backend_entries(
"plus",
vec![BackendModelEntry {
id: Some("gpt-6".to_string()),
name: Some("GPT-6".to_string()),
..BackendModelEntry::default()
}],
))
.await
.expect("replace snapshot");
let loaded = snapshot_store
.list_plan_snapshots()
.await
.expect("list snapshots");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].models[0].id, "gpt-6");
}
-1
View File
@@ -1,6 +1,5 @@
pub(crate) mod accounts;
pub(crate) mod fingerprint;
pub(crate) mod fingerprint_integration;
pub(crate) mod models;
pub(crate) mod protocol;
pub(crate) mod token_client;
+4 -58
View File
@@ -1,14 +1,7 @@
use chrono::Utc;
use codex_proxy_rs::upstream::{
accounts::{
model::AccountStatus,
store::{AccountStore, SqliteAccountStore},
},
models::{
backend_entry::BackendModelEntry,
snapshot::ModelPlanSnapshot,
snapshot_store::{ModelSnapshotStore, SqliteModelSnapshotStore},
},
use codex_proxy_rs::upstream::models::{
backend_entry::BackendModelEntry,
snapshot::ModelPlanSnapshot,
snapshot_store::{ModelSnapshotStore, SqliteModelSnapshotStore},
};
#[tokio::test]
@@ -41,50 +34,3 @@ async fn model_snapshot_repository_should_replace_and_load_plan_snapshots() {
assert_eq!(loaded[0].models[0].id, "gpt-team");
assert_eq!(loaded[0].models[0].source, "backend");
}
#[tokio::test]
async fn sqlite_account_store_should_list_pool_accounts() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("accounts.sqlite");
let pool =
codex_proxy_rs::infra::database::connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let now = Utc::now().to_rfc3339();
let access_token = "access-token";
let refresh_token = "refresh-token";
sqlx::query(
"insert into accounts (
id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, access_token, refresh_token,
access_token_expires_at, status, added_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("acct_1")
.bind("user@example.com")
.bind("chatgpt-account")
.bind(Option::<String>::None)
.bind("primary")
.bind("plus")
.bind(access_token)
.bind(Some(refresh_token))
.bind(Option::<String>::None)
.bind(match AccountStatus::Active {
AccountStatus::Active => "active",
_ => unreachable!(),
})
.bind(&now)
.bind(&now)
.execute(&pool)
.await
.expect("insert account");
let store = SqliteAccountStore::new(pool);
let accounts = AccountStore::list_pool_accounts(&store)
.await
.expect("list accounts");
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acct_1");
assert_eq!(accounts[0].access_token, "access-token");
assert_eq!(accounts[0].plan_type.as_deref(), Some("plus"));
}
+20 -4
View File
@@ -10,6 +10,7 @@ const props = withDefaults(
height?: string
horizontal?: boolean
vertical?: boolean
trackInset?: 'default' | 'none'
}>(),
{
viewClass: '',
@@ -17,6 +18,7 @@ const props = withDefaults(
height: undefined,
horizontal: false,
vertical: true,
trackInset: 'default',
},
)
@@ -74,6 +76,12 @@ const verticalTrackClass = computed(() =>
const horizontalTrackClass = computed(() =>
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
)
const verticalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'right-0' : 'right-1',
)
const horizontalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
)
function trackHeight(wrap: HTMLElement) {
return clamp(wrap.clientHeight - 8, 0, Number.POSITIVE_INFINITY)
@@ -389,8 +397,12 @@ defineExpose({
<div
v-show="canScrollY"
class="absolute top-1 right-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
:class="[verticalTrackClass, verticalScrollbarVisible ? 'opacity-100' : 'opacity-0']"
class="absolute top-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
:class="[
verticalTrackInsetClass,
verticalTrackClass,
verticalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
@mouseenter="handleTrackMouseEnter('vertical')"
@mouseleave="handleTrackMouseLeave('vertical')"
@pointerdown="handleTrackPointerDown"
@@ -405,8 +417,12 @@ defineExpose({
<div
v-show="canScrollX"
class="absolute bottom-1 left-1 z-40 h-1.5 rounded-full transition-opacity duration-200"
:class="[horizontalTrackClass, horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0']"
class="absolute z-40 h-1.5 rounded-full transition-opacity duration-200"
:class="[
horizontalTrackInsetClass,
horizontalTrackClass,
horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
@mouseenter="handleTrackMouseEnter('horizontal')"
@mouseleave="handleTrackMouseLeave('horizontal')"
@pointerdown="handleHorizontalTrackPointerDown"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
@@ -51,6 +51,7 @@ const emit = defineEmits<{
'page-change': [page: number]
'page-size-change': [pageSize: number]
}>()
const slots = useSlots()
const headerRowClass = 'h-10 text-[11px] font-bold text-(--cp-text-muted)'
const bodyRowClass = 'h-13'
@@ -140,6 +141,10 @@ function rowClass(row: TableRow, index: number) {
function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
}
function bodyCellTitle(column: BaseTableColumn, row: TableRow) {
return slots[column.key] ? undefined : cellTitle(column, row)
}
</script>
<template>
@@ -193,6 +198,7 @@ function isLastColumn(index: number) {
class="min-h-0 flex-1"
horizontal
:max-height="maxHeight"
track-inset="none"
@scroll="handleTableScroll"
>
<table
@@ -224,7 +230,7 @@ function isLastColumn(index: number) {
]"
role="cell"
>
<div :class="cellContentClass(column)" :title="cellTitle(column, row)">
<div :class="cellContentClass(column)" :title="bodyCellTitle(column, row)">
<slot
:name="column.key"
:row="row"
@@ -2,6 +2,8 @@
import { sumBy } from 'es-toolkit'
import { computed } from 'vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
const props = withDefaults(
defineProps<{
account: any
@@ -35,8 +37,6 @@ const secondaryText = computed(() => {
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const planTypeLabel = computed(() => props.account.planType?.trim() || 'Free')
const avatarSizeClass = computed(() =>
props.size === 'lg' ? 'size-10 text-[15px]' : 'size-9 text-[13px]',
)
@@ -58,20 +58,6 @@ const avatarClass = computed(() => {
const hash = sumBy([...key], (char) => char.charCodeAt(0))
return palettes[hash % palettes.length]
})
const planClass = computed(() => {
const normalized = planTypeLabel.value.toLowerCase()
if (normalized.includes('enterprise') || normalized.includes('team')) {
return 'bg-(--cp-bg-dark) text-(--cp-white)'
}
if (normalized.includes('pro')) {
return 'bg-(--cp-info-bg) text-(--cp-info-text)'
}
if (normalized.includes('plus') || normalized.includes('basic')) {
return 'bg-(--cp-normal-bg) text-(--cp-normal-text)'
}
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
})
</script>
<template>
@@ -87,13 +73,7 @@ const planClass = computed(() => {
<span class="truncate text-[14px] font-[760] text-(--cp-text-primary)">
{{ displayTitle }}
</span>
<span
v-if="showPlan"
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-[760]"
:class="planClass"
>
{{ planTypeLabel }}
</span>
<AccountPlanBadge v-if="showPlan" :plan-type="account.planType" size="sm" />
</div>
<div class="truncate font-[650]" :class="secondaryClass">
{{ secondaryText }}
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
planType?: string | null
size?: 'sm' | 'md'
}>(),
{
planType: null,
size: 'md',
},
)
const palettes = [
'bg-(--cp-info-bg) text-(--cp-info-text) shadow-(--cp-shadow-input)',
'bg-(--cp-success-bg) text-(--cp-success-text) shadow-(--cp-shadow-input)',
'bg-(--cp-normal-bg) text-(--cp-normal-text) shadow-(--cp-shadow-input)',
'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-(--cp-shadow-input)',
]
const label = computed(() => props.planType?.trim() || 'Free')
const sizeClass = computed(() =>
props.size === 'sm'
? 'h-5 max-w-24 rounded-full px-1.75 text-[11px] font-[720]'
: 'h-5.5 max-w-full rounded-full px-2 text-[11px] font-[760]',
)
const paletteClass = computed(() => {
const key = label.value.toLowerCase()
let hash = 0
for (const char of key) {
hash += char.charCodeAt(0)
}
return palettes[hash % palettes.length]
})
</script>
<template>
<span
class="inline-flex min-w-0 items-center justify-center leading-none capitalize"
:class="[sizeClass, paletteClass]"
>
<span class="min-w-0 truncate">{{ label }}</span>
</span>
</template>
@@ -3,6 +3,7 @@ import { RefreshCw } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import BaseButton from '@/components/base/BaseButton.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
const props = defineProps<{
account: any
@@ -48,9 +49,15 @@ function quotaWindowBarClass(window?: any) {
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">账号额度</h3>
<p class="m-0 mt-1 text-[12px] font-[620] text-(--cp-text-secondary)">
Codex 额度 · 套餐: {{ account.planType || 'Free' }} · 最近刷新:
{{ account.quota.refreshedAtDisplay }}
<p
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>Codex 额度</span>
<span>·</span>
<span>套餐:</span>
<AccountPlanBadge :plan-type="account.planType" size="sm" />
<span>·</span>
<span>最近刷新: {{ account.quota.refreshedAtDisplay }}</span>
</p>
</div>
<BaseButton
@@ -7,6 +7,12 @@ const props = defineProps<{
}>()
const quotaWindows = computed(() => props.account.quota.windows as any[])
const visibleQuotaWindows = computed(() => quotaWindows.value.slice(0, 2))
const summaryClass = computed(() =>
visibleQuotaWindows.value.length === 1
? 'flex h-13 w-full max-w-31 min-w-0 flex-col justify-center whitespace-normal py-0.5'
: 'grid h-13 w-full max-w-31 min-w-0 grid-rows-2 gap-1.5 whitespace-normal py-0.5',
)
function quotaWindowPercent(window?: any) {
return clamp(window?.usedPercent ?? 0, 0, 100)
@@ -35,18 +41,9 @@ function quotaWindowBarClass(window?: any) {
</script>
<template>
<div
v-if="quotaWindows.length > 0"
class="flex w-full max-w-31 min-w-0 flex-col gap-2 whitespace-normal py-1.5"
>
<div
v-for="window in quotaWindows"
:key="window.key"
class="min-w-0 pb-2 last:border-b-0 last:pb-0"
>
<div
class="mb-1.5 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]"
>
<div v-if="quotaWindows.length > 0" :class="summaryClass">
<div v-for="window in visibleQuotaWindows" :key="window.key" class="min-w-0">
<div class="mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]">
<span class="truncate text-(--cp-text-secondary)">
{{ window.labelDisplay }}
</span>
@@ -63,5 +60,5 @@ function quotaWindowBarClass(window?: any) {
</div>
</div>
</div>
<span v-else class="text-(--cp-text-muted)">-</span>
<span v-else class="inline-flex h-12 items-center text-(--cp-text-muted)">-</span>
</template>
@@ -23,7 +23,7 @@ const emit = defineEmits<{
</script>
<template>
<div class="relative flex items-center justify-end gap-1">
<div class="relative flex items-center justify-start gap-1">
<BaseButton
icon-only
variant="ghost"
@@ -54,6 +54,7 @@ const emit = defineEmits<{
测试连接
</button>
<button
v-if="account.hasRefreshToken"
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
:disabled="refreshing"
-1
View File
@@ -38,7 +38,6 @@ export const accountColumns = [
key: 'actions',
label: '操作',
width: '116px',
align: 'right' as const,
headerClass: '!pr-3',
cellClass: `!px-2 ${relaxedCellClass}`,
},
+2 -24
View File
@@ -17,6 +17,7 @@ import AccountConnectionTestModal from './components/AccountConnectionTestModal.
import AccountCreateModal from './components/AccountCreateModal.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel.vue'
import AccountStatusBadge from './components/AccountStatusBadge.vue'
@@ -107,24 +108,6 @@ const {
bindAccountLoader(loadAccounts)
function planTypeLabel(planType?: string) {
return planType?.trim() || 'Free'
}
function planTypeClass(planType?: string) {
const normalized = planTypeLabel(planType).toLowerCase()
if (normalized.includes('enterprise') || normalized.includes('team')) {
return 'bg-(--cp-bg-dark) text-(--cp-white)'
}
if (normalized.includes('pro')) {
return 'bg-(--cp-info-bg) text-(--cp-info-text)'
}
if (normalized.includes('plus') || normalized.includes('basic')) {
return 'bg-(--cp-normal-bg) text-(--cp-normal-text)'
}
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
}
function quotaTokenDisplay(account: any) {
const display = account?.usage?.totalTokensDisplay
return typeof display === 'string' && display.trim() ? display : '0'
@@ -247,12 +230,7 @@ function quotaTokenDisplay(account: any) {
</template>
<template #planType="{ row }">
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-[12px] font-[760] capitalize"
:class="planTypeClass(row.planType)"
>
{{ planTypeLabel(row.planType) }}
</span>
<AccountPlanBadge :plan-type="row.planType" />
</template>
<template #usage="{ row }">
-1
View File
@@ -21,7 +21,6 @@ export const apiKeyColumns = [
key: 'actions',
label: '操作',
width: '108px',
align: 'center' as const,
headerClass: relaxedActionCellClass,
cellClass: relaxedActionCellClass,
},
+1 -1
View File
@@ -146,7 +146,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
</template>
<template #actions="{ row }">
<div class="flex items-center justify-center">
<div class="flex items-center justify-start">
<BaseButton
icon-only
variant="ghost"
@@ -58,7 +58,7 @@ const emit = defineEmits<{
</div>
</template>
<div class="grid gap-4">
<div class="grid max-w-6xl gap-4">
<div
class="flex min-h-16 items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3"
>
@@ -31,7 +31,7 @@ const emit = defineEmits<{
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3">
<div class="grid max-w-6xl gap-3">
<div
class="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem] gap-2 px-0.75 text-xs leading-none font-bold text-(--cp-text-secondary) sm:grid"
>
@@ -24,7 +24,7 @@ const model = defineModel<RotationStrategy>({ required: true })
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3 lg:grid-cols-3">
<div class="grid max-w-6xl gap-3 lg:grid-cols-3">
<button
v-for="option in options"
:key="option.value"
@@ -20,7 +20,7 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<BaseForm :columns="2">
<BaseForm :columns="2" class="max-w-6xl">
<BaseFormItem label="单账号最大并发" description="限制单个账号同一时间可承载的请求数。">
<BaseInput v-model="maxConcurrentPerAccount" type="number">
<template #prefix>
+1 -1
View File
@@ -291,7 +291,7 @@ onMounted(loadSettings)
</div>
</header>
<div class="mt-5 grid max-w-6xl gap-5">
<div class="mt-5 grid w-full gap-5">
<AdminApiKeyCard
:status="adminApiKeyStatus"
:loading="adminKeyLoading"
-1
View File
@@ -89,7 +89,6 @@ export const usageRecordColumns = [
key: 'actions',
label: '操作',
width: '92px',
align: 'center' as const,
ellipsis: false,
fixed: 'right' as const,
headerClass: '!px-4',
+1 -1
View File
@@ -209,7 +209,7 @@ watch(timeRange, () => {
</template>
<template #actions="{ row }">
<div class="flex items-center justify-center">
<div class="flex items-center justify-start">
<BaseButton
icon-only
variant="ghost"