feat: add account export workflow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 13:27:15 +08:00
co-authored by Codex
parent 52b39af89d
commit 877e9265ca
41 changed files with 826 additions and 445 deletions
-127
View File
@@ -1,127 +0,0 @@
# 删除代码功能等价审计
审计日期:2026-06-28
对照基准:
- 当前 Rust 工程:`/home/zyy/Codes/codex-proxy-rs`
- TS 参考工程:`/home/zyy/桌面/Codes/codex-proxy`
本文档只审计近期删除或收敛的功能是否造成产品能力缺失。结论不以文件名为准,而以当前路由、服务实现和 TS 版已存在能力为准。
## 总体结论
当前删除并不全是问题。部分删除符合当前产品方向:后台设置减少人工配置、日志策略固定、接口去掉路径 id、账号导入增强为 CPR/Sub2API 双格式并支持重复账号更新,这些属于合理收敛。
但有几类能力被删后没有等价替代,会影响管理闭环:
1. 账号导出缺失,导入和备份迁移不闭环。
2. 批量账号状态更新缺失,账号池批量维护效率下降。
3. 批量健康检查缺失,目前只剩单账号模型测试。
4. 手动 Cookie 维护缺失,Cloudflare 人工旁路能力不完整。
5. 独立用量统计接口缺失,当前只被 Dashboard 和账号列表部分吸收。
6. 手动模型刷新缺失,后台只剩启动后和周期任务刷新。
7. 客户端 API Key 导出缺失,密钥备份/迁移能力被削弱。
## 当前 Rust 管理端路由事实
当前 `src/admin/router.rs` 保留的管理端接口:
- 登录会话:`/api/admin/login`、`/api/admin/auth/status`、`/api/admin/logout`
- 设置:`/api/admin/settings`
- 概览:`/api/admin/dashboard/summary`、`/api/admin/dashboard/trend`
- 账号:`/api/admin/accounts`、`/api/admin/accounts/import`、`/api/admin/accounts/oauth/authorize`、`/api/admin/accounts/oauth/exchange`、`/api/admin/accounts/test`、`/api/admin/accounts/models`、`/api/admin/accounts/delete`、`/api/admin/accounts/update`、`/api/admin/accounts/refresh`、`/api/admin/accounts/quota`
- 日志:`/api/admin/logs`、`/api/admin/logs/delete`、`/api/admin/logs/detail`
- 客户端 API Key:`/api/admin/keys`、`/api/admin/keys/delete`、`/api/admin/keys/update`
已从路由层移除的旧 Rust 管理端接口:
- `/api/admin/models/refresh`
- `/api/admin/usage`
- `/api/admin/usage/summary`
- `/api/admin/accounts/export`
- `/api/admin/accounts/quota-warnings`
- `/api/admin/accounts/health-check`
- `/api/admin/accounts/reset-usage`
- `/api/admin/accounts/cookies`
- `/api/admin/logs/state`
- `/api/admin/keys/export`
## 逐项审计
| 能力 | Rust 当前状态 | TS 参考状态 | 结论 | 建议 |
| --- | --- | --- | --- | --- |
| 账号导入 | 已保留并增强。`src/admin/accounts/service/importing.rs` 支持 CPR/Sub2API,重复账号会按 ChatGPT identity 更新,缺少字段时会补充 account id、user id、email、quota。 | `src/routes/accounts.ts` 支持 `/auth/accounts/import`。 | 不缺失。当前 Rust 实现比旧导入更贴近当前产品。 | 保留当前实现。 |
| 账号导出 | 当前路由已删除 `/api/admin/accounts/export`,旧 `export_accounts` 和 `export_with_tokens` 不再暴露。 | `src/routes/accounts.ts` 有 `/auth/accounts/export`,支持按 ids 和 format 导出。 | 功能缺失。导入存在但导出消失,备份、迁移、批量排障不闭环。 | P1 恢复,格式只保留 CPR,避免旧 native 命名和兼容分支。 |
| 批量删除账号 | 当前保留 `/api/admin/accounts/delete`。 | TS 有 `/auth/accounts/batch-delete`。 | 不缺失。 | 保留。 |
| 批量状态更新 | 当前 `/api/admin/accounts/update` 只解析单账号 `id` 更新,旧 `batch_update_status` 已删除。 | TS 有 `/auth/accounts/batch-status`。 | 功能缺失。批量禁用、恢复账号池时需要逐个操作。 | P1 恢复为 `/api/admin/accounts/update` 的批量 data 形式,保持无路径 id。 |
| 单账号刷新/额度查询 | 当前保留 `/api/admin/accounts/refresh` 和 `/api/admin/accounts/quota`,id 放在 body 或 query。 | TS 有 `/auth/accounts/:id/refresh`、`/auth/accounts/:id/quota`。 | 不缺失,且当前 Rust 路由形态更符合项目规范。 | 保留当前形态。 |
| 重置账号用量 | 当前删除 `/api/admin/accounts/reset-usage`,未发现等价管理端入口。 | TS 有 `/auth/accounts/:id/reset-usage`。 | 功能缺失但不是核心链路。主要影响调试、演示和清理异常统计。 | P3 低优先级恢复,或明确产品不提供手动重置。 |
| 批量健康检查 | 当前删除 `/api/admin/accounts/health-check`,只保留 `/api/admin/accounts/test?id=...` 单账号 SSE 模型测试。 | TS 有 `/auth/accounts/health-check`,支持 ids、stagger、concurrency。 | 功能缺失。单账号测试不能替代账号池健康巡检。 | P1 恢复批量健康检查,复用现有单账号探测和 loading 流程。 |
| 测试模型列表 | 当前保留 `/api/admin/accounts/models?id=...`,从上游返回模型。 | TS 模型列表能力来自模型 catalog 和上游刷新。 | 不缺失。 | 保留当前接口名 `models`。 |
| OAuth 添加账号 | 当前新增 `/api/admin/accounts/oauth/authorize`、`/api/admin/accounts/oauth/exchange`。 | TS 有 `/auth/accounts/login` 和 OAuth 链路。 | 不缺失。Rust 当前已按后台弹窗流程实现。 | 保留。 |
| 手动 Cookie 维护 | 当前删除 `/api/admin/accounts/cookies`。Cookie 存储仍存在,Cloudflare 逻辑会删除账号 cookie,但没有管理端查看/写入入口。 | TS 有 `/auth/accounts/:id/cookies` GET/POST/DELETE,并在 quota 失败时提示写入 cf cookie。 | 功能缺失。遇到 Cloudflare 挑战时缺少人工修复入口。 | P2 恢复,接口保持无路径 id,例如 `GET/POST /api/admin/accounts/cookies?id=...`,删除可并入 POST action 或独立 delete。 |
| 配额告警列表 | 当前删除 `/api/admin/accounts/quota-warnings`。账号列表 summary 仍有 high_usage/attention,但没有独立 warning 列表。 | TS 有 `/auth/quota/warnings`。 | 部分缺失。列表页摘要不能替代可点击、可定位、可清理的告警集合。 | P2 视 UI 是否需要恢复;如果不做告警中心,应在产品上明确删除。 |
| 独立账号用量分页 | 当前删除 `/api/admin/usage`,账号列表会内嵌账号用量,Dashboard 会读取 usage summary 和 buckets。 | TS 有 `/admin/usage-stats/summary` 与 `/admin/usage-stats/history`。 | 部分缺失。概览和账号列表替代了展示,但没有独立用量页/API。 | P2 如果后续有用量分析页,应恢复为 `/api/admin/usage` 和 `/api/admin/usage/summary`;否则可接受删除。 |
| 用量历史 | 当前 Dashboard 趋势读 `usage_time_buckets`,但没有通用 history API。 | TS 有 `/admin/usage-stats/history`,支持 granularity 和 hours。 | 部分缺失。当前只服务 Dashboard,不适合复用到独立分析页面。 | P2 如要开放分析视图,新增 `/api/admin/usage/history`,直接读聚合事实表。 |
| 手动模型刷新 | 当前删除 `/api/admin/models/refresh`,但 `ModelRefreshTask` 会启动后和每小时刷新,并有 `refresh_once()`。 | TS 有 `/admin/refresh-models` 手动触发。 | 功能缺失但已有后台替代。问题在于管理端无法立即刷新模型目录。 | P2 恢复轻量接口,直接调用现有 `refresh_once()` 或后台任务句柄,避免再引入 `admin_models` 旧模块。 |
| OpenAI 模型列表 | 当前 `/v1/models`、`/v1/models/catalog`、`/v1/models/{model}`、`/v1/models/{model}/info` 仍存在。 | TS 有同类模型路由。 | 不缺失。 | 保留。 |
| 日志列表/详情/清空 | 当前保留 `/api/admin/logs`、`/api/admin/logs/detail`、`/api/admin/logs/delete`。 | TS 有 `/admin/logs`、`/admin/logs/:id`、`/admin/logs/clear`。 | 不缺失。Rust 当前筛选字段更丰富。 | 保留当前形态。 |
| 日志开关状态 | 当前删除 `/api/admin/logs/state`。 | TS 有 `/admin/logs/state` GET/POST。 | 不算缺失。此前产品已决定日志采集和容量走默认策略,不在系统设置暴露。 | 不恢复。 |
| 设置项大而全 | 当前 `/api/admin/settings` 只保留 modelAliases、modelAccountRoutes、refreshMarginSeconds、refreshConcurrency、maxConcurrentPerAccount、requestIntervalMs、rotationStrategy。 | TS `general-settings`/`quota-settings` 暴露端口、TLS、默认模型、日志容量、更新策略等大量字段。 | 不算缺失。当前产品已收敛为只保留需要人工决策的运行配置。 | 不恢复 TS 大而全设置页。 |
| 客户端 API Key 列表/创建/更新/删除 | 当前保留 `/api/admin/keys`、`/api/admin/keys/update`、`/api/admin/keys/delete`,并保存完整 `sk_` key 供复制。 | TS 的 `/auth/api-keys` 是第三方 provider key pool,领域不同。 | 当前核心能力不缺失。 | 保留当前“客户端访问密钥”定位,不混入 provider key pool。 |
| 客户端 API Key 导出 | 当前删除 `/api/admin/keys/export`。旧 Rust 实现曾导出 `rustLocalClientApiKeys` 并标记 `rotation_required`。 | TS provider key pool 有 `/auth/api-keys/export`。 | 功能缺失但需谨慎。当前项目已允许保存完整 key,导出可以服务备份;但也会扩大密钥泄露面。 | P3 如恢复,只做管理员显式导出,并使用 CPR 格式;不恢复旧 `rustLocalClientApiKeys` 命名。 |
## 不建议恢复的删除项
以下删除符合当前项目方向,不建议回滚:
1. `/api/admin/logs/state`:日志策略已固定,系统设置不再暴露日志采集和容量。
2. TS 版 `general-settings` 中的默认模型、默认推理强度、默认 service tier:这些应由客户端请求决定,网关不兜底。
3. TS 版模型 aliases 的旧配置形态:当前 Rust 已拆成 `modelAliases` 和 `modelAccountRoutes`,边界更清楚。
4. 路径中携带 id 的管理端接口形态:当前 Rust 统一使用 query/body 传 id,更符合近期接口规范。
5. 第三方 provider API Key pool:当前 Rust 的 API Key 是客户端访问密钥,不应混入 TS 版 provider key pool 概念。
## 建议恢复优先级
P1:应该优先补齐,直接影响管理闭环。
- 账号导出:`GET /api/admin/accounts/export`
- 批量账号状态更新:复用 `/api/admin/accounts/update`
- 批量健康检查:`POST /api/admin/accounts/health-check`
P2:影响运维体验和可诊断性,建议排第二批。
- 手动 Cookie 维护:`GET/POST /api/admin/accounts/cookies`
- 配额告警列表:`GET /api/admin/accounts/quota-warnings`
- 独立用量统计和历史:`GET /api/admin/usage`、`GET /api/admin/usage/summary`、可选 `GET /api/admin/usage/history`
- 手动模型刷新:`POST /api/admin/models/refresh`
P3:低频能力,可先产品确认。
- 重置账号用量:`POST /api/admin/accounts/reset-usage`
- 客户端 API Key 导出:`GET /api/admin/keys/export`
## 恢复原则
如果恢复上述能力,不应该把旧代码原样搬回来。需要按当前项目规范重写:
1. 不在路径中放 id,id 统一放 query 或 body。
2. 不恢复兼容命名,例如 `native`、`rustLocalClientApiKeys`。
3. 不恢复大而全的 settings/config 写回逻辑。
4. 不为了兼容旧数据增加迁移分支;已有开发库需要重建。
5. 账号导出格式只使用 CPR,和当前导入格式闭环。
6. 批量操作必须返回 `updated/deleted/notFound` 这类可被 UI 准确呈现的数据。
7. 涉及真实网络请求的操作必须有后端测试覆盖,包括成功、部分失败、空 ids、未登录。
## 需要补的测试
当前被删除的测试里包含 `tests/admin/models/*`、`tests/admin/keys/import_export.rs`、旧账号 import/export 测试等。若恢复能力,至少补回以下测试面:
- 账号导出:全部导出、按 ids 导出、无效格式、未登录。
- 批量状态:部分 id 不存在、空 ids、非法 status、状态写入后账号列表可见。
- 批量健康检查:成功、失败、跳过、并发参数校验。
- Cookie 维护:写入、读取、清空、账号不存在。
- 用量接口:分页、汇总、空数据库。
- 模型刷新:无账号、部分计划失败、成功刷新并写入模型快照。
- API Key 导出:未登录、空列表、导出字段包含完整 key。
+37
View File
@@ -77,6 +77,12 @@ pub(crate) struct AccountIdQuery {
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountExportQuery {
ids: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountTestRequest {
@@ -491,6 +497,27 @@ pub(crate) async fn create_account(
}
}
/// `GET /api/admin/accounts/export`
pub(crate) async fn export_accounts(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
.export(account_export_ids(query.ids.as_deref()))
.await
{
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result),
)),
Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/refresh`
pub(crate) async fn refresh_account(
State(state): State<AppState>,
@@ -1401,6 +1428,16 @@ fn account_not_found() -> AdminError {
AdminError::new(StatusCode::NOT_FOUND, 40401, "Account not found")
}
fn account_export_ids(value: Option<&str>) -> Vec<String> {
value
.into_iter()
.flat_map(|ids| ids.split(','))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect()
}
fn account_status_str(status: crate::upstream::accounts::model::AccountStatus) -> &'static str {
match status {
crate::upstream::accounts::model::AccountStatus::Active => "active",
+97
View File
@@ -0,0 +1,97 @@
use secrecy::ExposeSecret;
use super::{
types::{ExportedAccount, ExportedAccounts},
AdminAccountError, AdminAccountService,
};
use crate::{
infra::time::china_rfc3339_str,
upstream::accounts::{model::AccountStatus, store::StoredAccount},
};
const EXPORT_PAGE_LIMIT: u32 = 200;
impl AdminAccountService {
pub async fn export(&self, ids: Vec<String>) -> Result<ExportedAccounts, AdminAccountError> {
let accounts = if ids.is_empty() {
self.export_all_accounts().await?
} else {
self.export_accounts_by_id(ids).await?
};
Ok(ExportedAccounts {
source_format: "cpr",
accounts: accounts.into_iter().map(ExportedAccount::from).collect(),
})
}
async fn export_all_accounts(&self) -> Result<Vec<StoredAccount>, AdminAccountError> {
let mut accounts = Vec::new();
let mut cursor = None;
loop {
let page = self
.store
.list(cursor, EXPORT_PAGE_LIMIT)
.await
.map_err(|_| AdminAccountError::Export)?;
accounts.extend(page.items);
let Some(next_cursor) = page.next_cursor else {
return Ok(accounts);
};
cursor = Some(next_cursor);
}
}
async fn export_accounts_by_id(
&self,
ids: Vec<String>,
) -> Result<Vec<StoredAccount>, AdminAccountError> {
let mut accounts = Vec::with_capacity(ids.len());
for id in ids {
let Some(account) = self
.store
.get(&id)
.await
.map_err(|_| AdminAccountError::Export)?
else {
return Err(AdminAccountError::NotFound);
};
accounts.push(account);
}
Ok(accounts)
}
}
impl From<StoredAccount> for ExportedAccount {
fn from(account: StoredAccount) -> Self {
Self {
id: account.id,
email: account.email,
account_id: account.account_id,
user_id: account.user_id,
label: account.label,
plan_type: account.plan_type,
token: account.access_token.expose_secret().to_string(),
refresh_token: account
.refresh_token
.map(|token| token.expose_secret().to_string()),
access_token_expires_at: account
.access_token_expires_at
.map(|value| value.to_rfc3339()),
status: export_status_str(account.status).to_string(),
added_at: china_rfc3339_str(&account.added_at),
updated_at: china_rfc3339_str(&account.updated_at),
}
}
}
fn export_status_str(status: AccountStatus) -> &'static str {
match status {
AccountStatus::Active => "active",
AccountStatus::Expired => "expired",
AccountStatus::QuotaExhausted => "quota_exhausted",
AccountStatus::Refreshing => "refreshing",
AccountStatus::Disabled => "disabled",
AccountStatus::Banned => "banned",
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
//! 管理端账号业务服务。
mod cookies;
mod exporting;
mod importing;
mod lifecycle;
pub(crate) mod oauth;
@@ -22,7 +23,8 @@ use crate::{
pub use types::{
AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, BatchDeleteAccounts,
ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput, UpdatedAccountStatus,
ExportedAccounts, ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput,
UpdatedAccountStatus,
};
#[derive(Clone)]
+27
View File
@@ -1,6 +1,7 @@
//! 管理端账号 DTO、错误与内部转换辅助。
use chrono::{DateTime, Utc};
use serde::Serialize;
use thiserror::Error;
use crate::{
@@ -56,6 +57,8 @@ pub enum AdminAccountError {
List,
#[error("failed to import accounts")]
Import,
#[error("failed to export accounts")]
Export,
#[error("failed to inspect account")]
Inspect,
#[error("account not found")]
@@ -150,6 +153,30 @@ pub struct ImportedAccounts {
pub source_format: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportedAccount {
pub id: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub token: String,
pub refresh_token: Option<String>,
pub access_token_expires_at: Option<String>,
pub status: String,
pub added_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportedAccounts {
pub source_format: &'static str,
pub accounts: Vec<ExportedAccount>,
}
#[derive(Debug, Clone)]
pub struct OAuthAuthorizeResult {
pub session_id: String,
+3 -2
View File
@@ -10,8 +10,8 @@ use crate::runtime::state::AppState;
use super::{
accounts::routes::{
account_models, account_quota, accounts, batch_delete_accounts, create_account,
import_accounts, oauth_authorize_account, oauth_exchange_account, refresh_account,
test_account_connection, update_account,
export_accounts, import_accounts, oauth_authorize_account, oauth_exchange_account,
refresh_account, test_account_connection, update_account,
},
auth::session::{login, logout, session_status},
keys::routes::{api_keys, batch_delete_api_keys, create_api_key, update_api_key},
@@ -32,6 +32,7 @@ pub fn router() -> Router<AppState> {
.route("/api/admin/dashboard/summary", get(dashboard_summary))
.route("/api/admin/dashboard/trend", get(dashboard_trend))
.route("/api/admin/accounts", get(accounts).post(create_account))
.route("/api/admin/accounts/export", get(export_accounts))
.route("/api/admin/accounts/import", post(import_accounts))
.route(
"/api/admin/accounts/oauth/authorize",
+251
View File
@@ -0,0 +1,251 @@
use super::*;
#[tokio::test]
async fn admin_accounts_export_should_return_cpr_payload_for_all_accounts() {
let (app, _state, pool, _dir) =
admin_accounts_test_app("admin-accounts-export-all.sqlite", 131).await;
seed_account(
&pool,
NewAccount {
id: "acct_export_a".into(),
email: Some("export-a@example.com".into()),
account_id: Some("chatgpt_export_a".into()),
user_id: Some("user_export_a".into()),
label: Some("primary".into()),
plan_type: Some("team".into()),
access_token: SecretString::new(
test_jwt(
"chatgpt_export_a",
Some("user_export_a"),
Some("export-a@example.com"),
Some("team"),
)
.into(),
),
refresh_token: Some(SecretString::new("refresh-export-a".into())),
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
seed_account(
&pool,
NewAccount {
id: "acct_export_b".into(),
email: Some("export-b@example.com".into()),
account_id: Some("chatgpt_export_b".into()),
user_id: Some("user_export_b".into()),
label: Some("backup".into()),
plan_type: Some("plus".into()),
access_token: SecretString::new(
test_jwt(
"chatgpt_export_b",
Some("user_export_b"),
Some("export-b@example.com"),
Some("plus"),
)
.into(),
),
refresh_token: Some(SecretString::new("refresh-export-b".into())),
access_token_expires_at: None,
status: AccountStatus::Disabled,
added_at: None,
},
)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let accounts = body["data"]["accounts"].as_array().unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["sourceFormat"], "cpr");
assert_eq!(accounts.len(), 2);
assert!(accounts.iter().any(|account| {
account["id"] == "acct_export_a"
&& account["token"]
.as_str()
.is_some_and(|token| !token.is_empty())
&& account["refreshToken"] == "refresh-export-a"
}));
}
#[tokio::test]
async fn admin_accounts_export_should_filter_by_ids() {
let (app, _state, pool, _dir) =
admin_accounts_test_app("admin-accounts-export-filter.sqlite", 132).await;
seed_account(
&pool,
NewAccount {
id: "acct_export_selected".into(),
email: Some("selected@example.com".into()),
account_id: Some("chatgpt_export_selected".into()),
user_id: Some("user_export_selected".into()),
label: None,
plan_type: Some("pro".into()),
access_token: SecretString::new(
test_jwt(
"chatgpt_export_selected",
Some("user_export_selected"),
Some("selected@example.com"),
Some("pro"),
)
.into(),
),
refresh_token: Some(SecretString::new("refresh-selected".into())),
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
seed_account(
&pool,
NewAccount {
id: "acct_export_ignored".into(),
email: Some("ignored@example.com".into()),
account_id: Some("chatgpt_export_ignored".into()),
user_id: Some("user_export_ignored".into()),
label: None,
plan_type: Some("free".into()),
access_token: SecretString::new(
test_jwt(
"chatgpt_export_ignored",
Some("user_export_ignored"),
Some("ignored@example.com"),
Some("free"),
)
.into(),
),
refresh_token: Some(SecretString::new("refresh-ignored".into())),
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?ids=acct_export_selected")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
let accounts = body["data"]["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0]["id"], "acct_export_selected");
}
#[tokio::test]
async fn admin_accounts_export_should_fail_when_selected_id_is_missing() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-accounts-export-missing.sqlite", 133).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?ids=missing_account")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_accounts_export_payload_should_import_directly() {
let (source_app, _source_state, source_pool, _source_dir) =
admin_accounts_test_app("admin-accounts-export-source.sqlite", 134).await;
seed_account(
&source_pool,
NewAccount {
id: "acct_export_roundtrip".into(),
email: Some("roundtrip@example.com".into()),
account_id: Some("chatgpt_export_roundtrip".into()),
user_id: Some("user_export_roundtrip".into()),
label: Some("roundtrip".into()),
plan_type: Some("team".into()),
access_token: SecretString::new(
test_jwt(
"chatgpt_export_roundtrip",
Some("user_export_roundtrip"),
Some("roundtrip@example.com"),
Some("team"),
)
.into(),
),
refresh_token: Some(SecretString::new("refresh-roundtrip".into())),
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
let exported = response_json(
source_app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?ids=acct_export_roundtrip")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
)
.await;
let payload = exported["data"].clone();
let (target_app, _target_state, target_pool, _target_dir) =
admin_accounts_test_app("admin-accounts-export-target.sqlite", 135).await;
let response = target_app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let stored = SqliteAccountStore::new(target_pool)
.get("acct_export_roundtrip")
.await
.unwrap()
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["imported"], 1);
assert_eq!(
stored.refresh_token.unwrap().expose_secret(),
"refresh-roundtrip"
);
}
+1
View File
@@ -28,6 +28,7 @@ use tower::util::ServiceExt;
use crate::support::jwt::unsigned_jwt;
use crate::support::{admin::seed_admin_session, config::test_config, http::response_json};
mod exporting;
mod importing;
mod lifecycle;
mod list;
+19 -6
View File
@@ -7,12 +7,25 @@
<title>Codex Proxy RS</title>
<script>
;(() => {
const state = JSON.parse(localStorage.getItem('codex-proxy-rs-ui') || '{}')
const mode = state.themeMode || 'system'
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const theme = mode === 'dark' || (mode === 'system' && prefersDark) ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
const root = document.documentElement
let mode = 'system'
try {
const storedMode = JSON.parse(localStorage.getItem('codex-proxy-rs-ui') || '{}').themeMode
if (storedMode === 'light' || storedMode === 'dark') {
mode = storedMode
}
} catch {}
const theme =
mode === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: mode
root.dataset.theme = theme
root.style.colorScheme = theme
})()
</script>
</head>
+2
View File
@@ -15,9 +15,11 @@
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@lucide/vue": "^1.21.0",
"@vueuse/core": "^14.3.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"es-toolkit": "^1.49.0",
"gsap": "^3.15.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
+42
View File
@@ -17,6 +17,9 @@ importers:
'@lucide/vue':
specifier: ^1.21.0
version: 1.21.0(vue@3.5.39(typescript@6.0.3))
'@vueuse/core':
specifier: ^14.3.0
version: 14.3.0(vue@3.5.39(typescript@6.0.3))
axios:
specifier: ^1.18.1
version: 1.18.1
@@ -26,6 +29,9 @@ importers:
echarts:
specifier: ^6.1.0
version: 6.1.0
es-toolkit:
specifier: ^1.49.0
version: 1.49.0
gsap:
specifier: ^3.15.0
version: 3.15.0
@@ -356,6 +362,9 @@ packages:
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.7':
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -442,6 +451,19 @@ packages:
vue:
optional: true
'@vueuse/core@14.3.0':
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.3.0':
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
'@vueuse/shared@14.3.0':
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
peerDependencies:
vue: ^3.5.0
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
@@ -550,6 +572,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.49.0:
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -1219,6 +1244,8 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.7(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -1347,6 +1374,19 @@ snapshots:
typescript: 6.0.3
vue: 3.5.39(typescript@6.0.3)
'@vueuse/core@14.3.0(vue@3.5.39(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.39(typescript@6.0.3))
vue: 3.5.39(typescript@6.0.3)
'@vueuse/metadata@14.3.0': {}
'@vueuse/shared@14.3.0(vue@3.5.39(typescript@6.0.3))':
dependencies:
vue: 3.5.39(typescript@6.0.3)
acorn@8.17.0: {}
agent-base@6.0.2:
@@ -1450,6 +1490,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.4
es-toolkit@1.49.0: {}
estree-walker@2.0.2: {}
exsolve@1.1.0: {}
+8
View File
@@ -16,6 +16,14 @@ export function importAccounts(data: any) {
})
}
export function exportAccounts(params?: any) {
return request({
url: '/api/admin/accounts/export',
method: 'GET',
params,
})
}
export function authorizeAccountOAuth(data?: any) {
return request({
url: '/api/admin/accounts/oauth/authorize',
+19 -33
View File
@@ -1,5 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
import {
onClickOutside,
onKeyStroke,
useEventListener,
useThrottleFn,
whenever,
} from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, ref, useAttrs } from 'vue'
import type { CSSProperties } from 'vue'
type PopoverPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
@@ -51,10 +59,6 @@ function toCssLength(value?: number | string) {
return trimmed || undefined
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function updatePopoverPosition() {
if (!open.value || !triggerRef.value) return
@@ -91,6 +95,8 @@ function updatePopoverPosition() {
}
}
const updatePopoverPositionThrottled = useThrottleFn(updatePopoverPosition, 32, true)
async function openPopover() {
if (props.disabled || open.value) return
@@ -118,39 +124,19 @@ function handleTriggerKeydown(event: KeyboardEvent) {
}
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (!open.value || event.key !== 'Escape') return
closePopover()
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null
if (!target) return
if (rootRef.value?.contains(target) || popoverRef.value?.contains(target)) return
closePopover()
}
watch(open, async (isOpen) => {
if (!isOpen) return
whenever(open, async () => {
await nextTick()
updatePopoverPosition()
})
onMounted(() => {
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleDocumentKeydown)
window.addEventListener('resize', updatePopoverPosition)
window.addEventListener('scroll', updatePopoverPosition, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleDocumentKeydown)
window.removeEventListener('resize', updatePopoverPosition)
window.removeEventListener('scroll', updatePopoverPosition, true)
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
onKeyStroke('Escape', () => {
if (open.value) {
closePopover()
}
})
useEventListener(window, 'resize', updatePopoverPositionThrottled)
useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: true })
</script>
<template>
+25 -31
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
import { useEventListener, useResizeObserver, useScroll, useTimeoutFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef } from 'vue'
const props = withDefaults(
defineProps<{
@@ -25,10 +27,11 @@ const thumbTop = shallowRef(0)
const visible = shallowRef(false)
const dragging = shallowRef(false)
let resizeObserver: ResizeObserver | undefined
let hideTimer: number | undefined
let dragStartY = 0
let dragStartScrollTop = 0
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 900, {
immediate: false,
})
const canScrollY = computed(() => thumbHeight.value > 0)
const scrollbarVisible = computed(() => props.forceVisible || dragging.value || visible.value)
@@ -42,15 +45,15 @@ const rootClasses = computed(() => [
])
function trackHeight(wrap: HTMLElement) {
return Math.max(wrap.clientHeight - 8, 0)
return clamp(wrap.clientHeight - 8, 0, Number.POSITIVE_INFINITY)
}
function maxScrollTop(wrap: HTMLElement) {
return Math.max(wrap.scrollHeight - wrap.clientHeight, 0)
return clamp(wrap.scrollHeight - wrap.clientHeight, 0, Number.POSITIVE_INFINITY)
}
function maxThumbTop(wrap: HTMLElement) {
return Math.max(trackHeight(wrap) - thumbHeight.value, 0)
return clamp(trackHeight(wrap) - thumbHeight.value, 0, Number.POSITIVE_INFINITY)
}
function showScrollbar() {
@@ -58,8 +61,7 @@ function showScrollbar() {
}
function clearHideTimer() {
window.clearTimeout(hideTimer)
hideTimer = undefined
stopHideTimer()
}
function scheduleHideScrollbar() {
@@ -68,9 +70,7 @@ function scheduleHideScrollbar() {
return
}
hideTimer = window.setTimeout(() => {
hideScrollbar()
}, 900)
startHideTimer()
}
function hideScrollbar() {
@@ -102,7 +102,7 @@ function update() {
}
const ratio = wrap.clientHeight / wrap.scrollHeight
thumbHeight.value = Math.min(availableTrackHeight, Math.max(availableTrackHeight * ratio, 32))
thumbHeight.value = clamp(availableTrackHeight * ratio, 32, availableTrackHeight)
thumbTop.value = (wrap.scrollTop / scrollRange) * maxThumbTop(wrap)
}
@@ -149,9 +149,7 @@ function handleTrackPointerDown(event: PointerEvent) {
const scrollRange = maxScrollTop(wrap)
const thumbRange = maxThumbTop(wrap)
wrap.scrollTop =
thumbRange > 0
? (Math.max(0, Math.min(nextThumbTop, thumbRange)) / thumbRange) * scrollRange
: 0
thumbRange > 0 ? (clamp(nextThumbTop, 0, thumbRange) / thumbRange) * scrollRange : 0
}
function handleThumbPointerDown(event: PointerEvent) {
@@ -166,11 +164,13 @@ function handleThumbPointerDown(event: PointerEvent) {
clearHideTimer()
dragStartY = event.clientY
dragStartScrollTop = wrap.scrollTop
document.addEventListener('pointermove', handleThumbPointerMove)
document.addEventListener('pointerup', handleThumbPointerUp, { once: true })
}
function handleThumbPointerMove(event: PointerEvent) {
if (!dragging.value) {
return
}
const wrap = wrapRef.value
if (!wrap) {
return
@@ -186,28 +186,23 @@ function handleThumbPointerMove(event: PointerEvent) {
}
function handleThumbPointerUp() {
if (!dragging.value) {
return
}
dragging.value = false
document.removeEventListener('pointermove', handleThumbPointerMove)
activateScrollbar()
}
onMounted(async () => {
await nextTick()
update()
resizeObserver = new ResizeObserver(update)
if (wrapRef.value) {
resizeObserver.observe(wrapRef.value)
}
if (viewRef.value) {
resizeObserver.observe(viewRef.value)
}
})
onBeforeUnmount(() => {
clearHideTimer()
resizeObserver?.disconnect()
document.removeEventListener('pointermove', handleThumbPointerMove)
})
useResizeObserver([wrapRef, viewRef], update)
useScroll(wrapRef, { onScroll: handleScroll })
useEventListener(document, 'pointermove', handleThumbPointerMove)
useEventListener(document, 'pointerup', handleThumbPointerUp)
defineExpose({
update,
@@ -223,7 +218,6 @@ defineExpose({
class="h-full min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent"
@mouseenter="activateScrollbar"
@mouseleave="hideScrollbar"
@scroll="handleScroll"
>
<div ref="view" :class="viewClass">
<slot />
+5 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
interface SegmentedOption {
@@ -23,12 +24,14 @@ const activeIndex = computed(() => {
return index >= 0 ? index : 0
})
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(props.options.length, 1)}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
}))
const indicatorStyle = computed(() => ({
width: `calc((100% - 8px) / ${Math.max(props.options.length, 1)})`,
width: `calc((100% - 8px) / ${optionCount.value})`,
transform: `translateX(${activeIndex.value * 100}%)`,
}))
+12 -26
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { Check, ChevronDown } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
import { clamp } from 'es-toolkit'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import type { CSSProperties } from 'vue'
interface SelectOption {
@@ -111,16 +113,16 @@ function updatePopoverPosition() {
const rect = triggerRef.value.getBoundingClientRect()
const gap = 6
const estimatedMenuHeight = Math.min(Math.max(props.options.length, 1) * 34 + 8, 244)
const estimatedMenuHeight = clamp(props.options.length * 34 + 8, 42, 244)
const belowSpace = window.innerHeight - rect.bottom - gap
const aboveSpace = rect.top - gap
const placeAbove = belowSpace < estimatedMenuHeight && aboveSpace > belowSpace
const availableHeight = Math.max(placeAbove ? aboveSpace : belowSpace, 120)
const maxHeight = Math.min(estimatedMenuHeight, availableHeight)
const maxHeight = clamp(estimatedMenuHeight, 0, availableHeight)
const top = placeAbove
? Math.max(8, rect.top - maxHeight - gap)
: Math.min(rect.bottom + gap, window.innerHeight - maxHeight - 8)
const left = Math.max(8, Math.min(rect.left, window.innerWidth - rect.width - 8))
const left = clamp(rect.left, 8, window.innerWidth - rect.width - 8)
popoverStyle.value = {
left: `${left}px`,
@@ -130,6 +132,8 @@ function updatePopoverPosition() {
}
}
const updatePopoverPositionThrottled = useThrottleFn(updatePopoverPosition, 32, true)
async function openMenu() {
if (props.disabled || open.value) return
@@ -214,14 +218,6 @@ function handleTriggerKeydown(event: KeyboardEvent) {
}
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null
if (!target) return
if (rootRef.value?.contains(target) || popoverRef.value?.contains(target)) return
closeMenu()
}
function optionClasses(option: SelectOption, index: number) {
return [
'flex w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 px-3 text-left font-[650] leading-none outline-none transition-colors',
@@ -236,9 +232,7 @@ function optionClasses(option: SelectOption, index: number) {
]
}
watch(open, async (isOpen) => {
if (!isOpen) return
whenever(open, async () => {
await nextTick()
updatePopoverPosition()
})
@@ -251,17 +245,9 @@ watch(
},
)
onMounted(() => {
document.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('resize', updatePopoverPosition)
window.addEventListener('scroll', updatePopoverPosition, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('resize', updatePopoverPosition)
window.removeEventListener('scroll', updatePopoverPosition, true)
})
onClickOutside(rootRef, closeMenu, { ignore: [popoverRef] })
useEventListener(window, 'resize', updatePopoverPositionThrottled)
useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: true })
</script>
<template>
+14 -11
View File
@@ -1,3 +1,5 @@
import { clamp, sumBy } from 'es-toolkit'
export interface BaseTableColumn {
key: string
label?: string
@@ -23,27 +25,28 @@ export type ResolvedTableColumn = BaseTableColumn & {
}
export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | string) {
const fixedPercentTotal = columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPercentWidth(column.width)
}, 0)
const fixedPixelTotal = columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPixelWidth(column.width)
}, 0)
const fixedPercentTotal = sumBy(columns, (column) =>
column.width === undefined ? 0 : numericPercentWidth(column.width),
)
const fixedPixelTotal = sumBy(columns, (column) =>
column.width === undefined ? 0 : numericPixelWidth(column.width),
)
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
const flexibleColumns = columns.filter((column) => column.width === undefined)
const flexTotal = flexibleColumns.reduce((sum, column) => sum + (column.flex ?? 1), 0)
const available = Math.max(100 - fixedPercentTotal, 0)
const availablePixels = Math.max(minWidthPixels - fixedPixelTotal, 0)
const flexTotal = sumBy(flexibleColumns, (column) => column.flex ?? 1)
const flexibleColumnCount = clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
return columns.map((column) => {
const flex = column.flex ?? 1
const width =
column.width === undefined
? minWidthPixels > 0 && fixedPercentTotal === 0
? `${availablePixels / Math.max(flexibleColumns.length, 1)}px`
? `${availablePixels / flexibleColumnCount}px`
: flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / Math.max(flexibleColumns.length, 1)}%`
: `${available / flexibleColumnCount}%`
: normalizeWidth(column.width)
return {
+27 -25
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { useEventListener, useResizeObserver } from '@vueuse/core'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
@@ -94,7 +96,6 @@ const horizontalHovering = shallowRef(false)
const horizontalDragging = shallowRef(false)
const horizontalScrolled = shallowRef(false)
let resizeObserver: ResizeObserver | undefined
let horizontalDragStartX = 0
let horizontalDragStartScrollLeft = 0
@@ -160,15 +161,15 @@ function rowClass(row: TableRow, index: number) {
}
function horizontalTrackWidth(wrap: HTMLElement) {
return Math.max(wrap.clientWidth - 8, 0)
return clamp(wrap.clientWidth - 8, 0, Number.POSITIVE_INFINITY)
}
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
return clamp(wrap.scrollWidth - wrap.clientWidth, 0, Number.POSITIVE_INFINITY)
}
function maxHorizontalThumbLeft(wrap: HTMLElement) {
return Math.max(horizontalTrackWidth(wrap) - horizontalThumbWidth.value, 0)
return clamp(horizontalTrackWidth(wrap) - horizontalThumbWidth.value, 0, Number.POSITIVE_INFINITY)
}
function scrollWrap() {
@@ -190,9 +191,10 @@ function updateHorizontalScrollbar() {
return
}
horizontalThumbWidth.value = Math.min(
horizontalThumbWidth.value = clamp(
trackWidth * (wrap.clientWidth / wrap.scrollWidth),
32,
trackWidth,
Math.max(trackWidth * (wrap.clientWidth / wrap.scrollWidth), 32),
)
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
horizontalScrolled.value = wrap.scrollLeft > 0
@@ -221,9 +223,7 @@ function handleHorizontalTrackPointerDown(event: PointerEvent) {
const scrollRange = maxScrollLeft(wrap)
const thumbRange = maxHorizontalThumbLeft(wrap)
wrap.scrollLeft =
thumbRange > 0
? (Math.max(0, Math.min(nextThumbLeft, thumbRange)) / thumbRange) * scrollRange
: 0
thumbRange > 0 ? (clamp(nextThumbLeft, 0, thumbRange) / thumbRange) * scrollRange : 0
}
function handleHorizontalThumbPointerDown(event: PointerEvent) {
@@ -236,11 +236,13 @@ function handleHorizontalThumbPointerDown(event: PointerEvent) {
horizontalDragging.value = true
horizontalDragStartX = event.clientX
horizontalDragStartScrollLeft = wrap.scrollLeft
document.addEventListener('pointermove', handleHorizontalThumbPointerMove)
document.addEventListener('pointerup', handleHorizontalThumbPointerUp, { once: true })
}
function handleHorizontalThumbPointerMove(event: PointerEvent) {
if (!horizontalDragging.value) {
return
}
const wrap = scrollWrap()
if (!wrap) {
return
@@ -258,27 +260,27 @@ function handleHorizontalThumbPointerMove(event: PointerEvent) {
}
function handleHorizontalThumbPointerUp() {
if (!horizontalDragging.value) {
return
}
horizontalDragging.value = false
document.removeEventListener('pointermove', handleHorizontalThumbPointerMove)
}
onMounted(async () => {
await nextTick()
updateHorizontalScrollbar()
resizeObserver = new ResizeObserver(updateHorizontalScrollbar)
const wrap = scrollWrap()
if (wrap) {
resizeObserver.observe(wrap)
}
if (tableViewRef.value) {
resizeObserver.observe(tableViewRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
document.removeEventListener('pointermove', handleHorizontalThumbPointerMove)
})
useResizeObserver(
() =>
[scrollWrap(), tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
updateHorizontalScrollbar,
)
useEventListener(document, 'pointermove', handleHorizontalThumbPointerMove)
useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
@@ -1,3 +1,5 @@
import { clamp, range } from 'es-toolkit'
export interface BaseTablePagination {
page: number
pageSize: number
@@ -14,7 +16,7 @@ export function getTotalPages(pagination?: BaseTablePagination) {
return 0
}
return Math.max(1, Math.ceil(pagination.total / pagination.pageSize))
return clamp(Math.ceil(pagination.total / pagination.pageSize), 1, Number.POSITIVE_INFINITY)
}
export function getCurrentPage(pagination: BaseTablePagination | undefined, totalPages: number) {
@@ -22,7 +24,7 @@ export function getCurrentPage(pagination: BaseTablePagination | undefined, tota
return 0
}
return Math.min(Math.max(pagination.page, 1), totalPages)
return clamp(pagination.page, 1, totalPages)
}
export function getPageSizeOptions(pagination?: BaseTablePagination) {
@@ -36,7 +38,7 @@ export function getPageSizeOptions(pagination?: BaseTablePagination) {
export function getPagerItems(total: number, current: number): PagerItem[] {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
return range(1, total + 1)
}
const pages = new Set<number>([1, total, current, current - 1, current + 1])
+28 -21
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useRafFn, useResizeObserver } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
import { init, type EChartsType } from 'echarts/core'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
@@ -18,8 +19,7 @@ const props = withDefaults(
const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const observer = shallowRef<ResizeObserver>()
const animationFrame = shallowRef<number>()
const pendingInitElement = shallowRef<HTMLElement>()
const style = computed(() => ({
height: `${props.height}px`,
@@ -30,10 +30,8 @@ function elementHasSize(element: HTMLElement) {
}
function cancelPendingInit() {
if (animationFrame.value !== undefined) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = undefined
}
pendingInitElement.value = undefined
pausePendingInit()
}
function ensureChart(element: HTMLElement) {
@@ -43,22 +41,28 @@ function ensureChart(element: HTMLElement) {
}
function scheduleInit(element: HTMLElement) {
cancelPendingInit()
animationFrame.value = requestAnimationFrame(() => {
animationFrame.value = undefined
ensureChart(element)
})
pendingInitElement.value = element
resumePendingInit()
}
const { pause: pausePendingInit, resume: resumePendingInit } = useRafFn(
() => {
const element = pendingInitElement.value
pendingInitElement.value = undefined
if (element) {
ensureChart(element)
}
},
{ immediate: false, once: true },
)
function resize() {
chart.value?.resize()
}
function dispose() {
cancelPendingInit()
observer.value?.disconnect()
chart.value?.dispose()
observer.value = undefined
chart.value = undefined
}
@@ -81,19 +85,22 @@ watch(
(element) => {
dispose()
if (!element) return
observer.value = new ResizeObserver(() => {
if (chart.value) {
resize()
return
}
ensureChart(element)
})
observer.value.observe(element)
scheduleInit(element)
},
{ immediate: true },
)
useResizeObserver(chartElement, () => {
const element = chartElement.value
if (!element) return
if (chart.value) {
resize()
return
}
ensureChart(element)
})
onBeforeUnmount(dispose)
</script>
+28
View File
@@ -0,0 +1,28 @@
// @env browser
import { useObjectUrl } from '@vueuse/core'
import { nextTick, shallowRef } from 'vue'
export function useJsonDownload() {
const object = shallowRef<Blob | null>(null)
const objectUrl = useObjectUrl(object)
async function downloadJson(payload: unknown, fileName: string) {
object.value = new Blob([`${JSON.stringify(payload, null, 2)}\n`], {
type: 'application/json;charset=utf-8',
})
await nextTick()
if (!objectUrl.value) return
const link = document.createElement('a')
link.href = objectUrl.value
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
}
return {
downloadJson,
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -27,6 +28,7 @@ const authStore = useAuthStore()
const uiStore = useUiStore()
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const preferredMotion = usePreferredReducedMotion()
const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
@@ -71,7 +73,7 @@ const themeToggleLabel = computed(() =>
)
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
return preferredMotion.value === 'reduce'
}
function animateSidebarLabels(collapsed: boolean) {
+15 -26
View File
@@ -1,3 +1,4 @@
import { usePreferredDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, shallowRef, watch } from 'vue'
@@ -18,33 +19,31 @@ export const useUiStore = defineStore(
() => {
const sidebarCollapsed = shallowRef(false)
const themeMode = shallowRef<ThemeMode>('system')
const systemTheme = shallowRef<ThemeName>(preferredTheme())
const prefersDark = usePreferredDark()
const preferredMotion = usePreferredReducedMotion()
const systemTheme = computed<ThemeName>(() => (prefersDark.value ? 'dark' : 'light'))
const effectiveTheme = computed<ThemeName>(() =>
themeMode.value === 'system' ? systemTheme.value : themeMode.value,
)
let mediaQuery: MediaQueryList | undefined
let listeningTheme = false
let themeApplied = false
let themeTransitionTimer: number | undefined
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
},
180,
{ immediate: false },
)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function preferredTheme(): ThemeName {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTheme(theme: ThemeName) {
if (
!themeApplied ||
!themeTransitionRequested ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
if (!themeApplied || !themeTransitionRequested || preferredMotion.value === 'reduce') {
commitTheme(theme)
themeApplied = true
themeTransitionRequested = false
@@ -116,24 +115,14 @@ export const useUiStore = defineStore(
}
function runFallbackThemeTransition(theme: ThemeName) {
window.clearTimeout(themeTransitionTimer)
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
themeTransitionTimer = window.setTimeout(() => {
document.documentElement.classList.remove('theme-fallback-transition')
}, 180)
startFallbackTransitionTimer()
}
function initializeTheme() {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemTheme.value = mediaQuery.matches ? 'dark' : 'light'
if (listeningTheme) return
mediaQuery.addEventListener('change', (event) => {
systemTheme.value = event.matches ? 'dark' : 'light'
})
listeningTheme = true
applyTheme(effectiveTheme.value)
}
function setThemeMode(mode: ThemeMode) {
+3 -1
View File
@@ -1,3 +1,5 @@
import { delay } from 'es-toolkit'
export async function withMinimumDuration<T>(
task: Promise<T> | (() => Promise<T>),
minimumMs = 1000,
@@ -8,7 +10,7 @@ export async function withMinimumDuration<T>(
} finally {
const remaining = minimumMs - (Date.now() - startedAt)
if (remaining > 0) {
await new Promise((resolve) => window.setTimeout(resolve, remaining))
await delay(remaining)
}
}
}
-39
View File
@@ -1,39 +0,0 @@
// @env browser
export async function copyText(text: string) {
if (!text) {
return false
}
if (window.isSecureContext && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the textarea fallback below.
}
}
return copyTextWithTextarea(text)
}
function copyTextWithTextarea(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
try {
return document.execCommand('copy')
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { useClipboard, useFileDialog } from '@vueuse/core'
import { computed, ref } from 'vue'
import { Copy, KeyRound, Upload } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -7,7 +8,6 @@ import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { toast } from '@/components/base/BaseToast'
import { copyText } from '@/utils/clipboard'
const props = withDefaults(
defineProps<{
@@ -22,14 +22,19 @@ const props = withDefaults(
const open = defineModel<boolean>({ default: false })
const form = defineModel<any>('form', { required: true })
const { copy } = useClipboard()
const emit = defineEmits<{
create: []
generateOauth: []
}>()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const fileError = ref('')
const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
accept: 'application/json,.json',
multiple: false,
reset: true,
})
const modeOptions = [
{ label: 'OAuth 授权', value: 'oauth' },
@@ -94,30 +99,30 @@ const description = computed(() => {
return '导入 Codex Proxy RS 账号 JSON,已存在账号会更新。'
})
async function handleFileChange(event: Event) {
async function loadImportFile(files: FileList | null) {
fileError.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const file = files?.[0]
if (!file) return
try {
importText.value = await file.text()
} catch {
fileError.value = '文件读取失败'
} finally {
input.value = ''
}
}
onImportFileChange((files) => {
void loadImportFile(files)
})
async function copyOAuthAuthUrl() {
if (!oauthAuthUrl.value) return
const copied = await copyText(oauthAuthUrl.value)
if (copied) {
try {
await copy(oauthAuthUrl.value)
toast.success('授权链接已复制')
return
} catch {
toast.error('复制失败')
}
toast.error('复制失败')
}
</script>
@@ -221,17 +226,10 @@ async function copyOAuthAuthUrl() {
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
JSON 内容 <span class="text-(--cp-danger)">*</span>
</label>
<BaseButton variant="default" size="sm" :disabled="saving" @click="fileInput?.click()">
<BaseButton variant="default" size="sm" :disabled="saving" @click="openImportFile()">
<Upload class="size-3.5" />
上传文件
</BaseButton>
<input
ref="fileInput"
class="hidden"
type="file"
accept="application/json,.json"
@change="handleFileChange"
/>
</div>
<textarea
v-model="importText"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { sumBy } from 'es-toolkit'
import { computed } from 'vue'
const props = withDefaults(
@@ -50,7 +51,7 @@ const avatarClass = computed(() => {
'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
]
const key = String(props.account.id || props.account.email || displayTitle.value)
const hash = [...key].reduce((sum, char) => sum + char.charCodeAt(0), 0)
const hash = sumBy([...key], (char) => char.charCodeAt(0))
return palettes[hash % palettes.length]
})
</script>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { RefreshCw } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -17,7 +18,7 @@ function quotaWindows(group: string) {
}
function quotaWindowPercent(window?: any) {
return Math.max(0, Math.min(window?.usedPercent ?? 0, 100))
return clamp(window?.usedPercent ?? 0, 0, 100)
}
function quotaWindowBarWidth(window?: any) {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
const props = defineProps<{
@@ -8,7 +9,7 @@ const props = defineProps<{
const quotaWindows = computed(() => props.account.quota.windows as any[])
function quotaWindowPercent(window?: any) {
return Math.max(0, Math.min(window?.usedPercent ?? 0, 100))
return clamp(window?.usedPercent ?? 0, 0, 100)
}
function quotaWindowBarStyle(window?: any) {
@@ -1,5 +1,6 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { CheckCircle2, Clock3, Wifi, XCircle } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { getAccountModels, testAccountConnectionStream } from '@/api'
import { withMinimumDuration } from '@/utils/async'
@@ -129,7 +130,11 @@ export function useAccountConnectionTest() {
function finishConnectionTest(status: 'success' | 'error') {
connectionTestStatus.value = status
connectionTestFinishedAt.value = formatDateTime()
connectionTestDurationMs.value = Math.max(0, Date.now() - connectionTestStartedAtMs)
connectionTestDurationMs.value = clamp(
Date.now() - connectionTestStartedAtMs,
0,
Number.POSITIVE_INFINITY,
)
}
function handleConnectionTestEvent(event: any) {
@@ -1,11 +1,11 @@
// @env browser
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { computed, ref, type Ref } from 'vue'
export function useAccountFilters(totalAccounts: Ref<number>) {
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
let searchTimer: number | undefined
let loadAccounts: (() => Promise<void> | void) | undefined
const accountPagination = computed(() => ({
@@ -36,19 +36,14 @@ export function useAccountFilters(totalAccounts: Ref<number>) {
requestLoad()
}
watch(searchQuery, () => {
page.value = 1
if (searchTimer) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(requestLoad, 250)
})
onBeforeUnmount(() => {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
})
watchDebounced(
searchQuery,
() => {
page.value = 1
requestLoad()
},
{ debounce: 250 },
)
return {
page,
@@ -1,9 +1,12 @@
import { clamp } from 'es-toolkit'
import { computed, onMounted, ref, type Ref } from 'vue'
import dayjs from 'dayjs'
import {
authorizeAccountOAuth,
deleteAccounts,
exchangeAccountOAuth,
exportAccounts,
getAccountQuota,
getAccounts,
importAccounts,
@@ -11,6 +14,7 @@ import {
updateAccount,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useJsonDownload } from '@/composables/useJsonDownload'
import { withMinimumDuration } from '@/utils/async'
import { editableStatusOptions } from '../constants'
@@ -21,6 +25,7 @@ export function useAccountMutations(options: {
selectedIds: Ref<Set<string>>
totalAccounts: Ref<number>
}) {
const { downloadJson } = useJsonDownload()
const loading = ref(true)
const accounts = ref<any[]>([])
const accountSummary = ref({
@@ -42,6 +47,7 @@ export function useAccountMutations(options: {
const authorizingOAuth = ref(false)
const savingAccount = ref(false)
const batchDeleting = ref(false)
const exportingAccounts = ref(false)
const createForm = ref({
mode: 'oauth',
@@ -98,7 +104,11 @@ export function useAccountMutations(options: {
options.totalAccounts.value > 0 &&
options.page.value > 1
) {
options.page.value = Math.max(1, result.page.totalPages ?? options.page.value - 1)
options.page.value = clamp(
result.page.totalPages ?? options.page.value - 1,
1,
Number.POSITIVE_INFINITY,
)
await loadAccounts()
}
} finally {
@@ -316,6 +326,24 @@ export function useAccountMutations(options: {
}
}
async function handleExportAccounts() {
if (exportingAccounts.value) return
try {
exportingAccounts.value = true
const selected = [...options.selectedIds.value]
const payload = await exportAccounts(
selected.length ? { ids: selected.join(',') } : undefined,
)
await downloadJson(payload, exportFileName(selected.length))
toast.success(selected.length ? `已导出 ${selected.length} 个账号` : '账号已导出')
} catch (error: any) {
toast.error(error.message || '导出失败')
} finally {
exportingAccounts.value = false
}
}
async function handleRefresh(accountId: string) {
if (refreshingAccountIds.value.has(accountId)) return
refreshingAccountIds.value = new Set(refreshingAccountIds.value).add(accountId)
@@ -372,6 +400,11 @@ export function useAccountMutations(options: {
return account.status === 'disabled' ? '启用调度' : '禁用调度'
}
function exportFileName(selectedCount: number) {
const suffix = selectedCount > 0 ? `selected-${selectedCount}` : 'all'
return `cpr-accounts-${suffix}-${dayjs().format('YYYY-MM-DD')}.json`
}
onMounted(() => {
loadAccounts()
})
@@ -394,6 +427,7 @@ export function useAccountMutations(options: {
authorizingOAuth,
savingAccount,
batchDeleting,
exportingAccounts,
createForm,
editForm,
editStatusModel,
@@ -406,6 +440,7 @@ export function useAccountMutations(options: {
requestDeleteAccount,
handleDelete,
handleBatchDelete,
handleExportAccounts,
handleRefresh,
handleRefreshQuota,
handleToggleSchedule,
+12 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDown, Plus, Search, Trash2 } from '@lucide/vue'
import { ChevronDown, Download, Plus, Search, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -74,6 +74,7 @@ const {
authorizingOAuth,
savingAccount,
batchDeleting,
exportingAccounts,
createForm,
editForm,
editStatusModel,
@@ -85,6 +86,7 @@ const {
requestDeleteAccount,
handleDelete,
handleBatchDelete,
handleExportAccounts,
handleRefresh,
handleRefreshQuota,
handleToggleSchedule,
@@ -175,6 +177,15 @@ function planTypeClass(planType?: string) {
</div>
<div class="flex shrink-0 items-center gap-2">
<BaseButton
variant="default"
:loading="exportingAccounts"
:disabled="accounts.length === 0"
@click="handleExportAccounts"
>
<Download class="size-4" />
{{ selectedIds.size > 0 ? '导出选中' : '导出' }}
</BaseButton>
<BaseButton variant="primary" @click="showCreateModal = true">
<Plus class="size-4" />
添加账号
@@ -1,3 +1,4 @@
import { clamp } from 'es-toolkit'
import { computed, ref, watch, type Ref } from 'vue'
export function useApiKeyFilters(apiKeys: Ref<any[]>) {
@@ -42,7 +43,11 @@ export function useApiKeyFilters(apiKeys: Ref<any[]>) {
})
watch(filteredKeys, () => {
const totalPages = Math.max(1, Math.ceil(filteredKeys.value.length / pageSize.value))
const totalPages = clamp(
Math.ceil(filteredKeys.value.length / pageSize.value),
1,
Number.POSITIVE_INFINITY,
)
if (page.value > totalPages) {
page.value = totalPages
}
@@ -1,11 +1,12 @@
// @env browser
import { useClipboard } from '@vueuse/core'
import { onMounted, ref, type Ref } from 'vue'
import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { copyText } from '@/utils/clipboard'
export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
const { copy } = useClipboard()
const loading = ref(true)
const apiKeys = ref<any[]>([])
const showCreateModal = ref(false)
@@ -165,13 +166,17 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
}
async function copyToClipboard(text: string) {
const copied = await copyText(text)
if (copied) {
toast.success('已复制到剪贴板')
if (!text) {
toast.error('复制失败')
return
}
toast.error('复制失败')
try {
await copy(text)
toast.success('已复制到剪贴板')
} catch {
toast.error('复制失败')
}
}
function maskKey(prefix: string): string {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
@@ -24,7 +25,7 @@ const scheduleStats = computed(() => {
const usedPercent = computed(() => {
const cap = props.capacity
if (!cap || cap.totalSlots === 0) return 0
return Math.min(100, Math.round((cap.usedSlots / cap.totalSlots) * 100))
return clamp(Math.round((cap.usedSlots / cap.totalSlots) * 100), 0, 100)
})
const usedRatio = computed(() => {
@@ -1,4 +1,6 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { onMounted, ref } from 'vue'
import {
Activity,
@@ -30,7 +32,13 @@ export function useDashboard(): any {
const refreshing = ref(false)
const trendLoading = ref(false)
const lastRefreshedAt = ref('')
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const { resume: startAutoRefresh } = useIntervalFn(
() => {
void loadDashboardData()
},
30000,
{ immediate: false },
)
async function loadDashboardData() {
try {
@@ -288,7 +296,7 @@ export function useDashboard(): any {
function quotaUsedPercent(value: number | null | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
return clamp(Math.round(value), 0, 100)
}
function quotaTone(percent: number) {
@@ -390,29 +398,11 @@ export function useDashboard(): any {
return `${ms}ms`
}
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer.value = setInterval(() => {
loadDashboardData()
}, 30000)
}
function stopAutoRefresh() {
if (autoRefreshTimer.value) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
}
onMounted(() => {
loadDashboardData()
void loadDashboardData()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
return {
loading,
refreshing,
+10 -15
View File
@@ -1,12 +1,12 @@
// @env browser
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { computed, ref, type Ref } from 'vue'
export function useLogFilters(totalLogs: Ref<number>) {
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filterLevel = ref('')
let searchTimer: number | undefined
let loadLogs: (() => Promise<void> | void) | undefined
const logPagination = computed(() => ({
@@ -37,19 +37,14 @@ export function useLogFilters(totalLogs: Ref<number>) {
requestLoad()
}
watch([searchQuery, filterLevel], () => {
page.value = 1
if (searchTimer) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(requestLoad, 250)
})
onBeforeUnmount(() => {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
})
watchDebounced(
[searchQuery, filterLevel],
() => {
page.value = 1
requestLoad()
},
{ debounce: 250 },
)
return {
page,
@@ -1,3 +1,4 @@
import { clamp } from 'es-toolkit'
import { onMounted, ref, type Ref } from 'vue'
import { clearLogs, getLogs } from '@/api'
@@ -31,7 +32,11 @@ export function useLogsTable(options: {
options.page.value = result.page.page ?? options.page.value
if (logs.value.length === 0 && options.totalLogs.value > 0 && options.page.value > 1) {
options.page.value = Math.max(1, result.page.totalPages ?? options.page.value - 1)
options.page.value = clamp(
result.page.totalPages ?? options.page.value - 1,
1,
Number.POSITIVE_INFINITY,
)
await loadLogs()
}
} catch (error: any) {
+5 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { sortBy } from 'es-toolkit'
import { Gauge, GitBranch, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { getSettings, updateSettings } from '@/api'
@@ -82,9 +83,10 @@ const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
const requestIntervalMsValue = numericModel('requestIntervalMs')
function modelAliasesToRows(modelAliases: Record<string, string> = {}) {
return Object.entries(modelAliases)
.sort(([left], [right]) => left.localeCompare(right))
.map(([alias, target]) => ({ alias, target }))
return sortBy(Object.entries(modelAliases), [([alias]) => alias]).map(([alias, target]) => ({
alias,
target,
}))
}
function rowsToAliases(rows: AliasRow[]) {
+12
View File
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
function shouldIgnoreRolldownLog(log: { code?: string; id?: string; loc?: { file?: string } }) {
const source = String(log.id ?? log.loc?.file ?? '')
return log.code === 'INVALID_ANNOTATION' && source.includes('@vueuse/core')
}
export default defineConfig({
base: '/',
plugins: [vue(), tailwindcss()],
@@ -25,6 +30,13 @@ export default defineConfig({
outDir: 'dist',
assetsDir: 'assets',
rolldownOptions: {
onLog(level, log, handler) {
if (level === 'warn' && shouldIgnoreRolldownLog(log)) {
return
}
handler(level, log)
},
output: {
codeSplitting: {
groups: [