mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: add account export workflow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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。
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+42
@@ -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: {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}%)`,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user