mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine account management interactions
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,7 +38,6 @@ export const accountColumns = [
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '116px',
|
||||
align: 'right' as const,
|
||||
headerClass: '!pr-3',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -21,7 +21,6 @@ export const apiKeyColumns = [
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '108px',
|
||||
align: 'center' as const,
|
||||
headerClass: relaxedActionCellClass,
|
||||
cellClass: relaxedActionCellClass,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user