Compare commits

..
27 Commits
Author SHA1 Message Date
ZyyandCodex d425428ebe chore: bump version to 3.2.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:02:02 +08:00
ZyyandCodex b26d3b386a fix(admin): align quota diagnostics and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:00:29 +08:00
ZyyandCodex cf0b14c5fd chore: bump version to 3.2.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:40:35 +08:00
ZyyandCodex 056561de41 refactor(frontend): clarify component boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:33:36 +08:00
dependabot[bot] f2215df72e chore(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue), [pinia](https://github.com/vuejs/pinia), [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) and [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc).


Updates `@lucide/vue` from 1.30.0 to 1.31.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.31.0/packages/vue)

Updates `pinia` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v4.0.2...v4.0.3)

Updates `eslint-plugin-vuejs-accessibility` from 2.5.0 to 2.6.0
- [Release notes](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/releases)
- [Changelog](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/compare/v2.5.0...v2.6.0)

Updates `vue-tsc` from 3.3.9 to 3.3.10
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.10/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 72f8957163 chore(deps): bump the minor-and-patch group in /backend with 5 updates
Bumps the minor-and-patch group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.19` | `2.0.20` |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` | `0.1.92` |
| [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` | `0.3.34` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.1` | `1.6.2` |
| [cookie](https://github.com/SergioBenitez/cookie-rs) | `0.18.1` | `0.18.2` |


Updates `thiserror` from 2.0.19 to 2.0.20
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20)

Updates `async-trait` from 0.1.91 to 0.1.92
- [Release notes](https://github.com/dtolnay/async-trait/releases)
- [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92)

Updates `futures` from 0.3.33 to 0.3.34
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

Updates `aws-smithy-types` from 1.6.1 to 1.6.2
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `cookie` from 0.18.1 to 0.18.2
- [Changelog](https://github.com/rwf2/cookie-rs/blob/0.18.2/CHANGELOG.md)
- [Commits](https://github.com/SergioBenitez/cookie-rs/compare/0.18.1...0.18.2)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-trait
  dependency-version: 0.1.92
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: futures
  dependency-version: 0.3.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: cookie
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 73f0c15790 chore(deps): bump github/codeql-action/upload-sarif
Bumps the minor-and-patch group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
ZyyandCodex 776637fccb chore: bump version to 3.2.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:52:54 +08:00
ZyyandCodex 82acb08d26 fix(frontend): restore diagnostic table scrolling
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:51:56 +08:00
ZyyandCodex 7744e1b0b4 fix(accounts): preserve rolling window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:28:44 +08:00
ZyyandCodex 0b8a1add18 feat(frontend): refresh account setup defaults and copy
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:09:31 +08:00
201 changed files with 7213 additions and 5624 deletions
+4 -1
View File
@@ -55,6 +55,9 @@ jobs:
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
@@ -76,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: runtime-container
+3
View File
@@ -468,6 +468,9 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
no-cache-filters: |
runtime-base
runtime-prebuilt
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
+29 -29
View File
@@ -120,9 +120,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -451,9 +451,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
dependencies = [
"base64-simd",
"bytes",
@@ -801,9 +801,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.1"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
@@ -1384,9 +1384,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@@ -1399,9 +1399,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@@ -1409,15 +1409,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@@ -1437,32 +1437,32 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
@@ -1472,9 +1472,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@@ -3633,18 +3633,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -608,8 +608,9 @@ pub struct ProviderQuota {
impl ProviderQuota {
/// 返回账号展开面板使用的当前额度窗口用量。
///
/// 只选择具备完整时间边界且可按账号归属的窗口;优先级与 Dashboard
/// 代表性额度窗口一致,同一优先级保持 Provider 投影顺序。
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
#[must_use]
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
self.windows
@@ -618,7 +619,6 @@ impl ProviderQuota {
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.reset_at.is_some()
&& window.local_usage.is_some()
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
@@ -31,23 +31,51 @@ pub enum ProviderAdminErrorKind {
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 不携带 OAuth 请求材料的管理错误。
///
/// `message` 用于当前认证管理请求展示;上游没有标准 `error.message` 时,
/// Provider 可以按其官方契约回退为完整的非成功响应正文。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
}
impl std::fmt::Debug for ProviderAdminError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
Self {
kind,
message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
@@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService {
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -59,7 +59,11 @@ fn map_provider_error(
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
let message = error
.message()
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{resource} operation failed"));
AdminError::new(kind, message)
}
async fn publish_committed(
@@ -6,6 +6,7 @@ use gateway_core::engine::credential::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
@@ -135,6 +136,51 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
success_count: 5,
input_tokens: Some(100),
output_tokens: Some(20),
cached_tokens: Some(80),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: Some(0),
image_output_tokens: Some(0),
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: Some(120),
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: Some(Utc::now()),
request_buckets: Vec::new(),
models: Vec::new(),
};
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
@@ -60,7 +60,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminErrorKind>>,
failure: Mutex<Option<ProviderAdminError>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -89,7 +89,12 @@ impl FakeProviderAdmin {
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(kind);
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
@@ -145,7 +150,7 @@ impl FakeProviderAdmin {
fn require_available(&self) -> Result<(), ProviderAdminError> {
match self.failure.lock().expect("provider failure").take() {
Some(kind) => Err(ProviderAdminError::new(kind)),
Some(error) => Err(error),
None => Ok(()),
}
}
@@ -1650,6 +1655,32 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
);
}
#[tokio::test]
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let upstream_message = "Your refresh token has already been used.";
provider.fail_next_with_message(ProviderAdminErrorKind::Conflict, upstream_message);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.refresh(
&context("refresh-provider-message"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect_err("Provider preparation must fail");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(error.to_string(), upstream_message);
assert_eq!(
recorded(&events),
["store.load_account", "provider.prepare_refresh"]
);
}
#[tokio::test]
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
let events = events();
@@ -946,7 +946,28 @@ impl fmt::Debug for LoadedCredential {
}
}
/// 刷新成功后的完整 CAS 写回。
/// 与 credential revision CAS 同事务提交的账号错误事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialStateWrite {
pub credential_state: CredentialState,
pub observed_at: SystemTime,
pub error_reason: Option<AccountErrorReason>,
pub message: Option<String>,
}
/// [`CredentialCasUpdate`] 跨 store 边界的命名字段。
pub struct CredentialCasUpdateParts {
pub account_id: ProviderAccountId,
pub expected_revision: CredentialRevision,
pub profile: ProviderAccountUpdate,
pub credential: PlaintextCredential,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<SystemTime>,
pub next_refresh_at: Option<SystemTime>,
pub account_state: Option<Box<CredentialStateWrite>>,
}
/// 刷新后的完整 CAS 写回。
#[derive(Clone, PartialEq)]
pub struct CredentialCasUpdate {
account_id: ProviderAccountId,
@@ -956,6 +977,7 @@ pub struct CredentialCasUpdate {
has_refresh_token: bool,
access_token_expires_at: Option<SystemTime>,
next_refresh_at: Option<SystemTime>,
account_state: Option<Box<CredentialStateWrite>>,
}
impl fmt::Debug for CredentialCasUpdate {
@@ -969,6 +991,7 @@ impl fmt::Debug for CredentialCasUpdate {
.field("has_refresh_token", &self.has_refresh_token)
.field("access_token_expires_at", &self.access_token_expires_at)
.field("next_refresh_at", &self.next_refresh_at)
.field("account_state", &self.account_state)
.finish()
}
}
@@ -1002,9 +1025,34 @@ impl CredentialCasUpdate {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
account_state: None,
})
}
/// 将刷新调度与账号错误事实放入同一个 revision CAS。
#[must_use]
pub fn with_account_state(
mut self,
credential_state: CredentialState,
observed_at: SystemTime,
error_reason: Option<AccountErrorReason>,
message: Option<String>,
) -> Self {
let message = message.filter(|value| !value.trim().is_empty());
let error_reason = if credential_state == CredentialState::Ready {
message.as_ref().and(error_reason)
} else {
error_reason.or_else(|| credential_state.error_reason())
};
self.account_state = Some(Box::new(CredentialStateWrite {
credential_state,
observed_at,
error_reason,
message,
}));
self
}
#[must_use]
pub const fn account_id(&self) -> &ProviderAccountId {
&self.account_id
@@ -1041,26 +1089,17 @@ impl CredentialCasUpdate {
}
#[must_use]
pub fn into_parts(
self,
) -> (
ProviderAccountId,
CredentialRevision,
ProviderAccountUpdate,
PlaintextCredential,
bool,
Option<SystemTime>,
Option<SystemTime>,
) {
(
self.account_id,
self.expected_revision,
self.profile,
self.credential,
self.has_refresh_token,
self.access_token_expires_at,
self.next_refresh_at,
)
pub fn into_parts(self) -> CredentialCasUpdateParts {
CredentialCasUpdateParts {
account_id: self.account_id,
expected_revision: self.expected_revision,
profile: self.profile,
credential: self.credential,
has_refresh_token: self.has_refresh_token,
access_token_expires_at: self.access_token_expires_at,
next_refresh_at: self.next_refresh_at,
account_state: self.account_state,
}
}
}
@@ -1102,6 +1141,14 @@ pub enum QuotaWriteOutcome {
Conflict,
}
/// 只推进 Provider quota 文档的最后成功查询时间。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuotaObservationTouch {
pub account_id: ProviderAccountId,
pub expected_revision: CredentialRevision,
pub observed_at: SystemTime,
}
/// 不改 Provider 原始 quota JSON 的额度访问事实写入。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuotaAccessChange {
@@ -1117,9 +1164,13 @@ pub struct AccountStateChange {
pub expected_revision: CredentialRevision,
pub credential_state: CredentialState,
pub observed_at: SystemTime,
/// 受控错误原因;恢复 Ready 时必须清空。
/// 受控错误原因;无失败事实的 Ready 写入必须清空。
///
/// Ready 凭据可以在 AT 过期后继续 RT 恢复,此时允许保留最近一次
/// 刷新失败原因,供错误状态投影展示。
pub error_reason: Option<AccountErrorReason>,
/// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
pub message: Option<String>,
}
@@ -1170,6 +1221,11 @@ pub trait ProviderAccountStore: Send + Sync {
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
@@ -103,7 +103,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, CoreStoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -111,7 +111,8 @@ impl ProviderAccountStore for PgProviderAccountRepository {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
if profile.account_id != account_id {
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
}
@@ -122,13 +123,38 @@ impl ProviderAccountStore for PgProviderAccountRepository {
CREDENTIALS_MAX_BYTES,
)
.map_err(core_store_error)?;
let credential_state = account_state
.as_ref()
.map(|state| state.credential_state.as_str());
let credential_observed_at = account_state
.as_ref()
.map(|state| DateTime::<Utc>::from(state.observed_at));
let error_reason = account_state
.as_ref()
.and_then(|state| state.error_reason.map(AccountErrorReason::as_str));
let message = account_state
.as_ref()
.and_then(|state| state.message.as_deref());
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $3, email = $4, plan_type = $5,
provider_credentials_json = $6,
credential_revision = credential_revision + 1,
has_refresh_token = $7, access_token_expires_at = $8,
next_refresh_at = $9, updated_at = now()
next_refresh_at = $9,
credential_state = case
when $10::text is not null and enabled and upstream_user_id is not null
then $10 else credential_state end,
credential_observed_at = case
when $10::text is not null and enabled and upstream_user_id is not null
then $11 else credential_observed_at end,
last_error_reason = case
when $10::text is not null and enabled and upstream_user_id is not null
then $12 else last_error_reason end,
last_error_message = case
when $10::text is not null and enabled and upstream_user_id is not null
then $13 else last_error_message end,
updated_at = greatest(now(), coalesce($11, now()))
where id = $1 and credential_revision = $2
returning credential_revision",
)
@@ -141,6 +167,10 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.bind(has_refresh_token)
.bind(access_token_expires_at.map(DateTime::<Utc>::from))
.bind(next_refresh_at.map(DateTime::<Utc>::from))
.bind(credential_state)
.bind(credential_observed_at)
.bind(error_reason)
.bind(message)
.fetch_optional(&self.pool)
.await
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
@@ -280,6 +310,25 @@ impl ProviderAccountStore for PgProviderAccountRepository {
})
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, CoreStoreError> {
let updated = self
.touch_provider_quota_observation(
touch.account_id.as_str(),
Revision::new(touch.expected_revision.get()).map_err(core_store_error)?,
DateTime::<Utc>::from(touch.observed_at),
)
.await
.map_err(core_store_error)?;
Ok(if updated {
QuotaWriteOutcome::Updated
} else {
QuotaWriteOutcome::Conflict
})
}
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), CoreStoreError> {
let updated = self
.apply_provider_account_state(ProviderAccountStateUpdate {
@@ -38,12 +38,13 @@ use sqlx::{PgPool, Postgres, Row, Transaction};
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision as CoreCredentialRevision,
CredentialState, LoadedCredential, NewProviderAccount as CoreNewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount as CoreProviderAccount,
ProviderAccountId as CoreProviderAccountId, ProviderAccountIdentity, ProviderAccountStore,
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
CredentialRevision as CoreCredentialRevision, CredentialState, LoadedCredential,
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
ProviderAccountIdentity, ProviderAccountStore,
ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaAccessChange, QuotaAccessState,
QuotaEvidence, QuotaObservation, QuotaState, QuotaWriteOutcome,
QuotaEvidence, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
use gateway_core::routing::{AccountGroupId, ProviderKind};
@@ -29,6 +29,12 @@ pub trait ProviderAccountRepository: Send + Sync {
observed_at: DateTime<Utc>,
state: QuotaState,
) -> StoreResult<bool>;
async fn touch_provider_quota_observation(
&self,
account_id: &str,
expected_revision: Revision,
observed_at: DateTime<Utc>,
) -> StoreResult<bool>;
async fn apply_provider_quota_access(
&self,
account_id: &str,
@@ -237,13 +243,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
else credential_observed_at
end,
last_error_reason = case
when enabled and upstream_user_id is not null then
case when $3 = 'ready' then null else $5 end
when enabled and upstream_user_id is not null then $5
else last_error_reason
end,
last_error_message = case
when enabled and upstream_user_id is not null then
case when $3 = 'ready' then null else $6 end
when enabled and upstream_user_id is not null then $6
else last_error_message
end,
updated_at = case when enabled then greatest(now(), $4) else updated_at end
@@ -354,6 +358,29 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
Ok(result.rows_affected() == 1)
}
async fn touch_provider_quota_observation(
&self,
account_id: &str,
expected_revision: Revision,
observed_at: DateTime<Utc>,
) -> StoreResult<bool> {
require_nonempty(ENTITY, "account_id", account_id)?;
let result = sqlx::query(
"update provider_accounts
set quota_observed_at = $3, updated_at = greatest(now(), $3)
where id = $1 and credential_revision = $2
and provider_quota_json is not null
and (quota_observed_at is null or quota_observed_at <= $3)",
)
.bind(account_id)
.bind(to_i64(expected_revision.get())?)
.bind(observed_at)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("touch provider quota observation"))?;
Ok(result.rows_affected() == 1)
}
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query("delete from provider_accounts where id = $1 and not enabled")
@@ -18,8 +18,8 @@ use gateway_core::engine::credential::{
AccountErrorReason, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision, CredentialState, OpaqueProviderData, PlaintextCredential,
ProviderAccountId, ProviderAccountIdentity, ProviderAccountStore, ProviderAccountUpdate,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
QuotaWriteOutcome,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
QuotaState, QuotaWriteOutcome,
};
use gateway_core::routing::{AccountGroupId, ProviderKind};
use gateway_store::{
@@ -51,6 +51,22 @@ struct RecoveredAccountRow {
access_token_expires_at: Option<chrono::DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct CoreRefreshRow {
name: String,
email: Option<String>,
plan_type: Option<String>,
provider_credentials_json: serde_json::Value,
credential_revision: i64,
credential_state: String,
last_error_reason: Option<String>,
last_error_message: Option<String>,
quota_access_state: String,
quota_evidence: Option<String>,
quota_access_observed_at: Option<chrono::DateTime<Utc>>,
quota_reset_at: Option<chrono::DateTime<Utc>>,
}
#[test]
fn postgres_provider_account_adapter_implements_core_port() {
fn assert_port<T: ProviderAccountStore>() {}
@@ -156,6 +172,115 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
database.close().await;
}
#[tokio::test]
async fn quota_observation_touch_preserves_quota_state_and_advances_account_update_time() {
let Some(database) = TestDatabase::create("provider_account_quota_touch").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
let account_id = ProviderAccountId::new("acct_quota_touch").expect("account id");
repository
.insert_provider_account(account(account_id.as_str(), "user-quota-touch"))
.await
.expect("insert quota fixture");
let revision = CredentialRevision::new(1).expect("revision");
let observed_at = SystemTime::now()
.checked_sub(Duration::from_secs(60))
.expect("old observation time");
let touched_at = observed_at
.checked_add(Duration::from_secs(90))
.expect("new observation time");
let reset_at = observed_at
.checked_add(Duration::from_secs(3_600))
.expect("quota reset time");
let quota_json = json!({
"rate_limit": {
"allowed": false,
"limit_reached": true,
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
}
});
assert_eq!(
repository
.compare_and_swap_quota(QuotaObservation {
account_id: account_id.clone(),
expected_revision: revision,
quota: OpaqueProviderData::new(
quota_json.as_object().expect("quota object").clone(),
),
observed_at,
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
observed_at,
Some(reset_at),
),
})
.await
.expect("persist exhausted quota"),
QuotaWriteOutcome::Updated
);
let account_updated_at: chrono::DateTime<Utc> = sqlx::query_scalar(
"select updated_at from provider_accounts where id = 'acct_quota_touch'",
)
.fetch_one(&database.pool)
.await
.expect("load account update time before quota touch");
assert_eq!(
repository
.touch_quota_observation(QuotaObservationTouch {
account_id,
expected_revision: revision,
observed_at: touched_at,
})
.await
.expect("touch quota refresh time"),
QuotaWriteOutcome::Updated
);
let stored = sqlx::query_as::<
_,
(
serde_json::Value,
chrono::DateTime<Utc>,
String,
Option<String>,
chrono::DateTime<Utc>,
Option<chrono::DateTime<Utc>>,
chrono::DateTime<Utc>,
),
>(
"select provider_quota_json, quota_observed_at, quota_access_state,
quota_evidence, quota_access_observed_at, quota_reset_at, updated_at
from provider_accounts where id = 'acct_quota_touch'",
)
.fetch_one(&database.pool)
.await
.expect("load touched quota row");
assert_eq!(stored.0, quota_json);
assert_eq!(
stored.1.timestamp_micros(),
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
);
assert_eq!(stored.2, "exhausted");
assert_eq!(stored.3.as_deref(), Some("usage_limit_reached"));
assert_eq!(
stored.4.timestamp_micros(),
chrono::DateTime::<Utc>::from(observed_at).timestamp_micros()
);
assert_eq!(
stored.5.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(reset_at).timestamp_micros())
);
assert_eq!(
stored.6.timestamp_micros(),
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
);
assert!(stored.6 > account_updated_at);
database.close().await;
}
#[tokio::test]
async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
let Some(database) = TestDatabase::create("provider_account_observation_fence").await else {
@@ -280,6 +405,27 @@ async fn credential_error_message_is_persisted_and_cleared_on_recovery() {
Some("upstream returned 402 payment required")
);
repository
.apply_state_change(AccountStateChange {
message: Some("Invalid refresh token.".to_owned()),
account_id: account_id.clone(),
expected_revision: revision,
credential_state: CredentialState::Ready,
observed_at: SystemTime::now(),
error_reason: Some(AccountErrorReason::AccessTokenExpired),
})
.await
.expect("persist recoverable refresh failure");
let recoverable: (Option<String>, Option<String>) = sqlx::query_as(
"select last_error_reason, last_error_message from provider_accounts where id = $1",
)
.bind(account_id.as_str())
.fetch_one(&database.pool)
.await
.expect("read recoverable refresh failure");
assert_eq!(recoverable.0.as_deref(), Some("access_token_expired"));
assert_eq!(recoverable.1.as_deref(), Some("Invalid refresh token."));
repository
.apply_state_change(AccountStateChange {
message: None,
@@ -1208,8 +1354,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
})
.await
.expect("seed provider account");
sqlx::query(
"update provider_accounts
set last_error_reason = 'access_token_expired',
last_error_message = 'stale refresh failure'
where id = 'acct_core_refresh'",
)
.execute(&database.pool)
.await
.expect("seed refresh failure details");
let account_id = ProviderAccountId::new("acct_core_refresh").expect("account ID");
let quota_observed_at = SystemTime::now();
let quota_reset_at = quota_observed_at
.checked_add(Duration::from_secs(3_600))
.expect("quota reset time");
assert_eq!(
repository
.apply_quota_access(QuotaAccessChange {
account_id: account_id.clone(),
expected_revision: CredentialRevision::new(1).expect("credential revision"),
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
quota_observed_at,
Some(quota_reset_at),
),
})
.await
.expect("seed quota exhaustion"),
QuotaWriteOutcome::Updated
);
let state_observed_at = SystemTime::now();
let refreshed = CredentialCasUpdate::new(
account_id.clone(),
CredentialRevision::new(1).expect("credential revision"),
@@ -1224,7 +1399,8 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
Some(SystemTime::now() + Duration::from_secs(3_600)),
Some(SystemTime::now() + Duration::from_secs(1_800)),
)
.expect("valid refresh update");
.expect("valid refresh update")
.with_account_state(CredentialState::Ready, state_observed_at, None, None);
assert_eq!(
repository
.compare_and_swap_credential(refreshed)
@@ -1235,24 +1411,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
)
);
let row: (
String,
Option<String>,
Option<String>,
serde_json::Value,
i64,
) = sqlx::query_as(
"select name, email, plan_type, provider_credentials_json, credential_revision
let row = sqlx::query_as::<_, CoreRefreshRow>(
"select name, email, plan_type, provider_credentials_json, credential_revision,
credential_state, last_error_reason, last_error_message,
quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at
from provider_accounts where id = 'acct_core_refresh'",
)
.fetch_one(&database.pool)
.await
.expect("load refreshed account");
assert_eq!(row.0, "after refresh");
assert_eq!(row.1.as_deref(), Some("after@example.invalid"));
assert_eq!(row.2.as_deref(), Some("premium"));
assert_eq!(row.3["access_token"], "after-secret");
assert_eq!(row.4, 2);
assert_eq!(row.name, "after refresh");
assert_eq!(row.email.as_deref(), Some("after@example.invalid"));
assert_eq!(row.plan_type.as_deref(), Some("premium"));
assert_eq!(
row.provider_credentials_json["access_token"],
"after-secret"
);
assert_eq!(row.credential_revision, 2);
assert_eq!(row.credential_state, "ready");
assert_eq!(row.last_error_reason, None);
assert_eq!(row.last_error_message, None);
assert_eq!(row.quota_access_state, "exhausted");
assert_eq!(row.quota_evidence.as_deref(), Some("usage_limit_reached"));
assert_eq!(
row.quota_access_observed_at
.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(quota_observed_at).timestamp_micros())
);
assert_eq!(
row.quota_reset_at.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(quota_reset_at).timestamp_micros())
);
assert!(
repository
+30 -20
View File
@@ -25,8 +25,9 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -49,7 +50,9 @@ use crate::credential::{
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
};
use crate::transport::CodexWebSocketPool;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
@@ -258,7 +261,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
))
.await
.inspect_err(|error| {
log_import_failure("prepare_document", credential_admin_error_code(*error));
log_import_failure("prepare_document", credential_admin_error_code(error));
})
.map_err(map_credential_admin_error)?;
let observed_at = Utc::now();
@@ -380,8 +383,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
.oauth()
.and_then(|oauth| oauth.id_token.clone());
}
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, None, None)
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -546,15 +551,16 @@ fn prepared_rotation(
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, replacement_identity, guard) = prepared.into_parts();
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
credential_profile,
profile: credential_profile,
credential,
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = credential.into_parts();
account_state: _account_state,
} = credential.into_parts();
if profile != credential_profile || profile.account_id != account_id {
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
}
@@ -1275,31 +1281,37 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError
fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError {
use CodexCredentialAdminError as Error;
provider_admin_error(match error {
let upstream_message = error.upstream_message().map(ToOwned::to_owned);
let kind = match error {
Error::InvalidInput
| Error::InvalidCredential
| Error::MissingRefreshToken
| Error::RefreshRejected
| Error::AccountBanned => ProviderAdminErrorKind::Invalid,
| Error::RefreshRejected { .. }
| Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid,
Error::NotFound => ProviderAdminErrorKind::NotFound,
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => {
ProviderAdminErrorKind::Conflict
}
Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
})
};
let error = provider_admin_error(kind);
match upstream_message {
Some(message) => error.with_message(message),
None => error,
}
}
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str {
match error {
CodexCredentialAdminError::InvalidInput => "invalid_input",
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
CodexCredentialAdminError::NotFound => "not_found",
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
CodexCredentialAdminError::AccountBanned => "account_banned",
CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected",
CodexCredentialAdminError::AccountBanned { .. } => "account_banned",
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous",
}
}
@@ -1368,9 +1380,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
ProviderAdminErrorKind::Invalid
}
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
@@ -376,7 +376,7 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CodexCredentialAdminError {
#[error("Codex account input is invalid")]
InvalidInput,
@@ -389,13 +389,30 @@ pub enum CodexCredentialAdminError {
#[error("Codex refresh lease is unavailable")]
RefreshLeaseUnavailable,
#[error("Codex refresh token was rejected")]
RefreshRejected,
RefreshRejected { message: Option<String> },
#[error("Codex account is banned")]
AccountBanned,
AccountBanned { message: Option<String> },
#[error("Codex refresh service is unavailable")]
RefreshUnavailable,
#[error("Codex refresh send state is ambiguous")]
RefreshAmbiguous,
RefreshAmbiguous { message: Option<String> },
}
impl CodexCredentialAdminError {
#[must_use]
pub fn upstream_message(&self) -> Option<&str> {
match self {
Self::RefreshRejected { message }
| Self::AccountBanned { message }
| Self::RefreshAmbiguous { message } => message.as_deref(),
Self::InvalidInput
| Self::InvalidCredential
| Self::NotFound
| Self::MissingRefreshToken
| Self::RefreshLeaseUnavailable
| Self::RefreshUnavailable => None,
}
}
}
/// 无状态的 Codex Admin command preparer。
@@ -800,8 +817,15 @@ impl CodexCredentialAdminService {
.refresher
.refresh(refresh_token.expose_secret())
.await
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
.map_err(map_refresh_failure)?;
let access_token_rotated = tokens.access_token.is_some();
let access_token_expires_at = match tokens.access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token),
None => current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from),
};
let secret = CodexOAuthSecret {
access_token: tokens
.access_token
@@ -821,17 +845,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = if access_token_rotated {
tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from)
} else {
current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
};
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before。
let next_refresh_at = None;
@@ -998,6 +1011,7 @@ impl CodexCredentialAdminService {
let access_token = tokens
.access_token
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let access_token_expires_at = parse_access_token_expiration(&access_token);
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
@@ -1012,10 +1026,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
Ok((secret, access_token_expires_at))
}
@@ -1133,15 +1143,35 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
.to_rfc3339()
}
const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
match error {
RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected,
RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned,
RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous,
RefreshFailure::InvalidGrant { message, .. } => {
CodexCredentialAdminError::RefreshRejected { message }
}
RefreshFailure::Banned { message, .. } => {
CodexCredentialAdminError::AccountBanned { message }
}
RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport { message, .. } => {
CodexCredentialAdminError::RefreshAmbiguous { message }
}
}
}
fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFailure) {
let upstream = error.upstream();
tracing::warn!(
account_id = %account_id,
failure_class = error.classification(),
upstream_message = ?error.message(),
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
"OpenAI OAuth manual refresh failed"
);
}
fn parse_import_document(
payload: &Value,
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
@@ -138,8 +138,6 @@ pub enum CodexCredentialCatalogError {
InvalidCredentialData,
#[error("Codex model catalog upstream query failed: {detail}")]
Upstream { detail: String },
#[error("Codex model catalog contains conflicting account facts")]
ConflictingModelFacts,
#[error("Codex model catalog cache is unavailable")]
Cache,
#[error("Codex model catalog ETag is invalid")]
@@ -359,11 +357,6 @@ impl CodexCredentialCatalogService {
union_order.push(entry.key().clone());
entry.insert(model.clone());
}
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
// 事实冲突才让整轮刷新失败。
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
}
Entry::Occupied(_) => {}
}
}
@@ -661,13 +654,6 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
.collect()
}
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
/// 跨套餐漂移不构成冲突。
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
}
fn catalog_candidates_by_scope(
accounts: Vec<ProviderAccount>,
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
@@ -1,10 +1,6 @@
//! Codex Authorization Code + PKCE/OIDC 管理流。
use std::{
fmt,
sync::Arc,
time::{Duration, SystemTime},
};
use std::{fmt, sync::Arc, time::Duration};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -35,7 +31,7 @@ use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
};
use super::types::parse_chatgpt_jwt_claims;
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
use crate::transport::profile::CodexWireProfileState;
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
@@ -527,13 +523,12 @@ impl CodexOAuthAdminService {
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token, expires_in) = self
let (mut secret, id_token) = self
.exchange_pending(&pending, callback_url, fallback_refresh_token)
.await?;
let mutation = pending.mutation.clone();
let access_token_expires_at = expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
secret.id_token = Some(id_token);
@@ -671,14 +666,7 @@ impl CodexOAuthAdminService {
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
) -> Result<
(
super::types::CodexOAuthSecret,
SecretString,
Option<Duration>,
),
CodexOAuthAdminError,
> {
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
if !constant_time_equal(
pending.state.expose_secret().as_bytes(),
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
Ok((secret, tokens.id_token, tokens.expires_in))
Ok((secret, tokens.id_token))
}
}
@@ -19,8 +19,8 @@ use chrono::Utc;
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
QuotaWriteOutcome,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
QuotaState, QuotaWriteOutcome,
};
use gateway_core::provider_ports::{ProviderCooldown, ProviderCooldownPort};
use gateway_protocol::openai::events::{
@@ -646,7 +646,25 @@ impl CodexCredentialQuotaService {
} else {
None
};
if !quota_refresh_can_update(account, &snapshot, previous.as_ref()) {
if account.quota().is_exhausted()
&& !quota_refresh_allows_recovery(account, &snapshot, previous.as_ref())
{
let outcome = self
.store
.touch_quota_observation(QuotaObservationTouch {
account_id: account.id().clone(),
expected_revision: account.revision(),
observed_at,
})
.await?;
if outcome == QuotaWriteOutcome::Conflict {
summary.stale += 1;
return Ok(());
}
if let Some(previous) = previous {
self.scheduling
.observe(&previous.with_observed_at(observed_at));
}
summary.exhausted += 1;
return Ok(());
}
@@ -930,8 +948,29 @@ impl CodexCredentialQuotaService {
} else {
None
};
if !quota_refresh_can_update(&account, &snapshot, previous.as_ref()) {
return Ok(previous.unwrap_or_else(|| snapshot.with_quota_state(account.quota())));
if !quota_refresh_allows_recovery(&account, &snapshot, previous.as_ref()) {
if self
.store
.touch_quota_observation(QuotaObservationTouch {
account_id: account.id().clone(),
expected_revision: account.revision(),
observed_at,
})
.await?
== QuotaWriteOutcome::Conflict
{
return Err(CodexCredentialQuotaError::RevisionConflict);
}
return Ok(match previous {
Some(previous) => {
let previous = previous.with_observed_at(observed_at);
self.scheduling.observe(&previous);
previous
}
None => snapshot
.with_quota_state(account.quota())
.with_observed_at(observed_at),
});
}
let state = if account.quota().is_exhausted() {
QuotaState::allowed(observed_at)
@@ -1027,7 +1066,7 @@ impl CodexCredentialQuotaService {
}
}
fn quota_refresh_can_update(
fn quota_refresh_allows_recovery(
account: &ProviderAccount,
refreshed: &CodexAccountQuotaSnapshot,
previous: Option<&CodexAccountQuotaSnapshot>,
@@ -151,6 +151,13 @@ impl CodexAccountQuotaSnapshot {
self.quota = quota;
self
}
/// 只更新展示与调度使用的最后成功查询时间,保留既有额度事实。
#[must_use]
pub(crate) const fn with_observed_at(mut self, observed_at: SystemTime) -> Self {
self.observed_at = observed_at;
self
}
}
impl CodexQuotaFact {
@@ -20,8 +20,8 @@ use thiserror::Error;
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use super::types::CodexOAuthSecret;
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
const PROVIDER_NAME: &str = "openai";
const MAX_REFRESH_BATCH: u32 = 1_000;
@@ -32,7 +32,7 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
/// 耗尽指数退避后的 OAuth 恢复周期。
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
/// 过期 AT 仍允许 RT 恢复的最长窗口。
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(2 * 60 * 60);
pub(crate) fn refresh_recovery_deadline(
access_token_expires_at: Option<SystemTime>,
@@ -91,12 +91,19 @@ fn log_refresh_deferred(
access_token_expires_at: Option<SystemTime>,
attempt: u32,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
retry_at: SystemTime,
) {
tracing::warn!(
account_id = %account_id,
attempt,
reason,
upstream_message = ?upstream_message,
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
retry_at = %DateTime::<Utc>::from(retry_at),
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(access_token_expires_at)
@@ -105,6 +112,26 @@ fn log_refresh_deferred(
);
}
struct RefreshFailureContext<'a> {
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
}
impl<'a> RefreshFailureContext<'a> {
const fn new(
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
) -> Self {
Self {
reason,
message,
upstream,
}
}
}
fn refresh_due_at(
account: &ProviderAccount,
policy: ProviderRefreshPolicy,
@@ -248,20 +275,10 @@ impl CodexCredentialRefreshService {
policy: ProviderRefreshPolicy,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
let account_id = due.account.id().to_string();
if refresh_recovery_window_exhausted(
let recovery_window_exhausted = refresh_recovery_window_exhausted(
due.account.access_token_expires_at(),
SystemTime::now(),
) {
return self
.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_recovery_window_exhausted",
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await;
}
);
let capacity = self
.leases
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
@@ -291,31 +308,72 @@ impl CodexCredentialRefreshService {
.refresh_token
.as_ref()
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
match self.refresher.refresh(refresh_token.expose_secret()).await {
let refresh_result = self.refresher.refresh(refresh_token.expose_secret()).await;
if recovery_window_exhausted && let Err(failure) = &refresh_result {
let message = failure.message().map(str::to_owned);
let upstream = failure.upstream();
return match failure {
RefreshFailure::Banned { .. } => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
RefreshFailureContext::new(
"refresh_recovery_window_exhausted",
message,
upstream,
),
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
}
RefreshFailure::InvalidGrant { .. }
| RefreshFailure::RetryableTransport { .. }
| RefreshFailure::Transport { .. } => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
RefreshFailureContext::new(
"refresh_recovery_window_exhausted",
message,
upstream,
),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
};
}
match refresh_result {
Ok(tokens) => self.persist_success(due, tokens).await,
Err(RefreshFailure::InvalidGrant) => {
Err(RefreshFailure::InvalidGrant { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_invalid_grant",
RefreshFailureContext::new(
"refresh_invalid_grant",
message,
upstream.as_deref(),
),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
Err(RefreshFailure::Banned) => {
Err(RefreshFailure::Banned { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
"account_banned",
RefreshFailureContext::new("account_banned", message, upstream.as_deref()),
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
}
Err(RefreshFailure::RetryableTransport) => {
Err(RefreshFailure::RetryableTransport { message }) => {
if self
.defer_refresh(&due.account, "transport-not-sent")
.defer_refresh(&due.account, "transport-not-sent", Some(&message), None)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -323,11 +381,16 @@ impl CodexCredentialRefreshService {
Ok(CodexCredentialRefreshOutcome::Stale { account_id })
}
}
Err(RefreshFailure::Transport) => {
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
Err(RefreshFailure::Transport { message, upstream }) => {
// 上游瞬态(401/429/5xx/超时/畸形响应等)保留现有凭据、
// 记录最近一次失败并推进有界退避。
if self
.defer_refresh(&due.account, "transport-ambiguous")
.defer_refresh(
&due.account,
"transport-ambiguous",
message.as_deref(),
upstream.as_deref(),
)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -403,9 +466,11 @@ impl CodexCredentialRefreshService {
access_token,
refresh_token: rotated_refresh_token,
id_token: rotated_id_token,
expires_in,
} = tokens;
let access_token_rotated = access_token.is_some();
let access_token_expires_at = match access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
None => due.account.access_token_expires_at(),
};
let CodexOAuthSecret {
access_token: current_access_token,
refresh_token: current_refresh_token,
@@ -432,12 +497,6 @@ impl CodexCredentialRefreshService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
let observed_at = SystemTime::now();
let access_token_expires_at = if access_token_rotated {
expires_in.and_then(|value| observed_at.checked_add(value))
} else {
due.account.access_token_expires_at()
};
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
let next_refresh_at = None;
@@ -483,9 +542,14 @@ impl CodexCredentialRefreshService {
account: &ProviderAccount,
credential_state: CredentialState,
error_reason: AccountErrorReason,
reason: &'static str,
failure: RefreshFailureContext<'_>,
outcome: CodexCredentialRefreshOutcome,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
let RefreshFailureContext {
reason,
message,
upstream,
} = failure;
match self.repository.load_runtime_credential(account).await {
Ok(_) => {}
Err(CredentialRepositoryError::RevisionConflict) => {
@@ -495,6 +559,12 @@ impl CodexCredentialRefreshService {
}
Err(error) => return Err(error.into()),
}
let persisted_message = message
.as_ref()
.filter(|message| !message.trim().is_empty())
.map(String::as_str)
.or_else(|| account.last_error_message())
.map(str::to_owned);
match self
.repository
.apply_state_with_reason(
@@ -502,7 +572,7 @@ impl CodexCredentialRefreshService {
credential_state,
SystemTime::now(),
Some(error_reason),
Some(reason.to_owned()),
persisted_message,
)
.await
{
@@ -511,6 +581,11 @@ impl CodexCredentialRefreshService {
account_id = %account.id(),
?credential_state,
reason,
upstream_message = ?message.as_deref(),
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
access_token_expires_at = ?account.access_token_expires_at()
.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
@@ -532,6 +607,8 @@ impl CodexCredentialRefreshService {
&self,
account: &ProviderAccount,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
) -> Result<bool, CodexCredentialRefreshError> {
let attempt = self
.credential_state
@@ -545,13 +622,28 @@ impl CodexCredentialRefreshService {
attempt,
reason,
)?;
match self.repository.defer_refresh(account, retry_at).await {
let message = upstream_message
.filter(|message| !message.trim().is_empty())
.map(str::to_owned);
let error_reason = upstream.map(|_| {
account
.credential_state()
.error_reason()
.unwrap_or(AccountErrorReason::AccessTokenExpired)
});
match self
.repository
.defer_refresh(account, retry_at, error_reason, message)
.await
{
Ok(_) => {
log_refresh_deferred(
account.id(),
account.access_token_expires_at(),
attempt,
reason,
upstream_message,
upstream,
retry_at,
);
Ok(true)
@@ -120,15 +120,18 @@ impl CodexCredentialRepository {
access_token_expires_at,
next_refresh_at,
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
/// token 签名边界不可用时,以相同 credential 推进持久刷新退避。
/// 以相同 credential 原子推进刷新退避及本次上游错误事实。
pub async fn defer_refresh(
&self,
account: &ProviderAccount,
next_refresh_at: SystemTime,
error_reason: Option<AccountErrorReason>,
message: Option<String>,
) -> Result<CredentialRevision, CredentialRepositoryError> {
let current = self
.store
@@ -140,7 +143,7 @@ impl CodexCredentialRepository {
let data = CodexCredentialCodec::decode_complete(&current.credential)?;
let has_refresh_token = data.has_refresh_token();
let credential = CodexCredentialCodec::encode_complete(data)?;
let update = CredentialCasUpdate::new(
let mut update = CredentialCasUpdate::new(
account.id().clone(),
account.revision(),
unchanged_profile(account),
@@ -150,6 +153,14 @@ impl CodexCredentialRepository {
Some(next_refresh_at),
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
if let Some(error_reason) = error_reason {
update = update.with_account_state(
account.credential_state(),
SystemTime::now(),
Some(error_reason),
message,
);
}
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
@@ -245,13 +256,11 @@ impl CodexCredentialRepository {
if !account.enabled() {
return Ok(());
}
let (error_reason, message) = if credential_state == CredentialState::Ready {
(None, None)
let message = message.filter(|value| !value.trim().is_empty());
let error_reason = if credential_state == CredentialState::Ready {
message.as_ref().and(error_reason)
} else {
(
error_reason.or_else(|| credential_state.error_reason()),
message.filter(|value| !value.trim().is_empty()),
)
error_reason.or_else(|| credential_state.error_reason())
};
self.store
.apply_state_change(AccountStateChange {
@@ -720,7 +720,15 @@ impl CodexCredentialSelector {
.map(chrono::DateTime::<chrono::Utc>::from),
"OpenAI access token expired; retaining account for bounded OAuth refresh recovery"
);
return Ok(());
return self
.apply_credential_state(
account,
CredentialState::Ready,
AccountErrorReason::AccessTokenExpired,
now,
message,
)
.await;
}
self.apply_credential_state(
account,
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use reqwest::{Client, StatusCode, redirect::Policy};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
@@ -27,9 +27,6 @@ pub struct TokenPair {
pub refresh_token: Option<String>,
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
pub id_token: Option<String>,
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
/// exchange,只意味着调用方不能据此安排下一次刷新。
pub expires_in: Option<Duration>,
}
impl fmt::Debug for TokenPair {
@@ -45,22 +42,131 @@ impl fmt::Debug for TokenPair {
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
.field("expires_in", &self.expires_in)
.finish()
}
}
/// Codex token 刷新的稳定失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
pub enum RefreshFailure {
#[error("refresh token is invalid or expired")]
InvalidGrant,
InvalidGrant {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("account is banned")]
Banned,
Banned {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("refresh transport failed before server processing")]
RetryableTransport,
RetryableTransport { message: String },
#[error("refresh transport failed after possible server processing")]
Transport,
Transport {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
}
impl fmt::Debug for RefreshFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshFailure")
.field("classification", &self.classification())
.field("message", &self.message().map(|_| "<redacted>"))
.field("upstream", &self.upstream())
.finish()
}
}
impl RefreshFailure {
#[must_use]
pub fn message(&self) -> Option<&str> {
match self {
Self::InvalidGrant { message, .. }
| Self::Banned { message, .. }
| Self::Transport { message, .. } => message.as_deref(),
Self::RetryableTransport { message } => Some(message),
}
}
#[must_use]
pub fn upstream(&self) -> Option<&RefreshUpstreamFailure> {
match self {
Self::InvalidGrant { upstream, .. }
| Self::Banned { upstream, .. }
| Self::Transport { upstream, .. } => upstream.as_deref(),
Self::RetryableTransport { .. } => None,
}
}
#[must_use]
pub const fn classification(&self) -> &'static str {
match self {
Self::InvalidGrant { .. } => "invalid-grant",
Self::Banned { .. } => "account-banned",
Self::RetryableTransport { .. } => "transport-not-sent",
Self::Transport { .. } => "transport-ambiguous",
}
}
}
/// 当前 OAuth 刷新请求收到的完整非成功响应。
///
/// 该值不持久化;`Debug` 不输出正文。调用方仅在受控刷新失败日志中显式记录正文。
#[derive(Clone, PartialEq, Eq)]
pub struct RefreshUpstreamFailure {
status: u16,
code: Option<String>,
error_type: Option<String>,
body: String,
}
impl RefreshUpstreamFailure {
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
Self {
status: status.as_u16(),
code: error
.and_then(RefreshErrorResponse::code)
.map(str::to_owned),
error_type: error
.and_then(RefreshErrorResponse::error_type)
.map(str::to_owned),
body: String::from_utf8_lossy(body).into_owned(),
}
}
#[must_use]
pub const fn status(&self) -> u16 {
self.status
}
#[must_use]
pub fn code(&self) -> Option<&str> {
self.code.as_deref()
}
#[must_use]
pub fn error_type(&self) -> Option<&str> {
self.error_type.as_deref()
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
}
impl fmt::Debug for RefreshUpstreamFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshUpstreamFailure")
.field("status", &self.status)
.field("code", &self.code)
.field("error_type", &self.error_type)
.field("body", &"<redacted>")
.finish()
}
}
/// Codex token 刷新端口。
@@ -92,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
pub struct AuthorizationTokenSet {
pub secret: crate::credential::CodexOAuthSecret,
pub id_token: SecretString,
pub expires_in: Option<Duration>,
}
impl fmt::Debug for AuthorizationTokenSet {
@@ -101,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
.debug_struct("AuthorizationTokenSet")
.field("secret", &"[REDACTED]")
.field("id_token", &"[REDACTED]")
.field("expires_in", &self.expires_in)
.finish()
}
}
@@ -183,7 +287,60 @@ struct RefreshTokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
expires_in: Option<u64>,
}
#[derive(Serialize)]
struct RefreshTokenRequest<'a> {
client_id: &'a str,
grant_type: &'static str,
refresh_token: &'a str,
}
#[derive(Deserialize)]
struct RefreshErrorResponse {
error: Option<RefreshError>,
code: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RefreshError {
Details(RefreshErrorDetails),
Code(String),
}
#[derive(Deserialize)]
struct RefreshErrorDetails {
code: Option<String>,
message: Option<String>,
#[serde(rename = "type")]
error_type: Option<String>,
}
impl RefreshErrorResponse {
fn code(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.code.as_deref(),
Some(RefreshError::Code(code)) => Some(code.as_str()),
None => None,
}
.or(self.code.as_deref())
}
fn message(&self) -> Option<String> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.message.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
.map(ToOwned::to_owned)
}
fn error_type(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
}
}
#[derive(Deserialize)]
@@ -191,7 +348,6 @@ struct AuthorizationCodeResponse {
access_token: String,
refresh_token: String,
id_token: String,
expires_in: Option<u64>,
}
#[async_trait]
@@ -200,11 +356,11 @@ impl TokenRefresher for OpenAiTokenClient {
let response = self
.client
.post(&self.config.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
])
.json(&RefreshTokenRequest {
client_id: self.config.client_id.as_str(),
grant_type: "refresh_token",
refresh_token,
})
.send()
.await
.map_err(|error| refresh_transport_failure(&error))?;
@@ -212,7 +368,10 @@ impl TokenRefresher for OpenAiTokenClient {
if !status.is_success() {
return Err(classify_refresh_failure(status, &body));
}
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport)
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport {
message: Some("OpenAI OAuth refresh returned an invalid success response".to_owned()),
upstream: None,
})
}
}
@@ -260,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
id_token: None,
},
id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
}
@@ -273,13 +431,21 @@ async fn read_bounded_response(
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| RefreshFailure::Transport)?
.map_err(|error| RefreshFailure::Transport {
message: Some(error.to_string()),
upstream: None,
})?
{
let next_len = body
.len()
.checked_add(chunk.len())
.filter(|length| *length <= MAX_OAUTH_RESPONSE_BYTES)
.ok_or(RefreshFailure::Transport)?;
.ok_or_else(|| RefreshFailure::Transport {
message: Some(format!(
"OpenAI OAuth response exceeded {MAX_OAUTH_RESPONSE_BYTES} bytes"
)),
upstream: None,
})?;
body.reserve(next_len.saturating_sub(body.len()));
body.extend_from_slice(&chunk);
}
@@ -301,43 +467,68 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
refresh_token: tokens.refresh_token,
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
id_token: tokens.id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
value
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
// 不据此终态失效账号。
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
return RefreshFailure::Transport;
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
let message = error.as_ref().and_then(RefreshErrorResponse::message);
let upstream = || {
Some(Box::new(RefreshUpstreamFailure::new(
status,
body,
error.as_ref(),
)))
};
let normalized_code = error
.as_ref()
.and_then(RefreshErrorResponse::code)
.map(str::to_ascii_lowercase);
// 生产策略故意与官方 Codex 的“任意 401 立即终态”不同:
// 显式 401 先进入有界恢复退避,避免瞬时授权故障直接失效账号。
if status == StatusCode::UNAUTHORIZED {
return RefreshFailure::Transport {
message,
upstream: upstream(),
};
}
// 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。
let lower = String::from_utf8_lossy(body).to_ascii_lowercase();
if lower.contains("account has been deactivated") {
return RefreshFailure::Banned;
// 非 401 响应仍与官方一致:三个明确的 RT 原因是永久失败。
if matches!(
normalized_code.as_deref(),
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
) {
return RefreshFailure::InvalidGrant {
message,
upstream: upstream(),
};
}
if lower.contains("invalid_grant")
|| lower.contains("invalid_token")
|| lower.contains("access_denied")
|| lower.contains("refresh_token_expired")
|| lower.contains("refresh_token_reused")
{
return RefreshFailure::InvalidGrant;
if message.as_deref().is_some_and(|message| {
message
.to_ascii_lowercase()
.contains("account has been deactivated")
}) {
return RefreshFailure::Banned {
message,
upstream: upstream(),
};
}
RefreshFailure::Transport {
message,
upstream: upstream(),
}
RefreshFailure::Transport
}
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
let message = error.to_string();
if is_safe_to_retry_refresh_transport(error) {
RefreshFailure::RetryableTransport
RefreshFailure::RetryableTransport { message }
} else {
RefreshFailure::Transport
RefreshFailure::Transport {
message: Some(message),
upstream: None,
}
}
}
@@ -1492,12 +1492,10 @@ fn nonnegative_millis(value: Option<i64>) -> Option<u64> {
}
fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabilities {
let evidence = model.capabilities();
let mut operations = BTreeSet::new();
if evidence.responses_api() == CodexCatalogCapabilityEvidence::DeclaredNative {
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
// Catalog membership is enough to publish Generate. `supported_in_api` is advisory;
// the upstream response is authoritative for normal and diagnostic requests.
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
.with_upstream_feature_validation();
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
.with_presentation(codex_model_presentation(model))
}
@@ -3,6 +3,7 @@ use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_admin::model::accounts::AccountRecord;
@@ -762,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
}
#[tokio::test]
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_rotation_expiration".to_owned(),
name: "admin rotation expiration".to_owned(),
secret: secret("admin-rotation-access"),
verified_account: profile("chatgpt-admin-rotation-expiration"),
next_refresh_at: None,
enabled: true,
})
.await;
let account = store
.account("acct_admin_rotation_expiration")
.expect("stored account");
let record = account_record(&account);
let config = valid_config();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("OpenAI bundle");
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let mut material = Map::new();
material.insert(
"access_token".to_owned(),
Value::String(format!("unverified-header.{payload}.unverified-signature")),
);
material.insert(
"refresh_token".to_owned(),
Value::String("admin-rotation-refresh".to_owned()),
);
let prepared = bundle
.admin_provider()
.prepare_rotation(PrepareCredentialRotation {
account: record,
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
.await
.expect("JWT rotation should be prepared");
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -253,7 +253,7 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
}
#[tokio::test]
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() {
let store = Arc::new(MemoryAccountStore::default());
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
@@ -272,15 +272,12 @@ async fn cross_plan_responses_api_conflict_still_fails_the_union() {
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let error = service
let snapshot = service
.synchronize()
.await
.expect_err("routing facts must still agree across plans");
.expect("supported_in_api drift must not block catalog publication");
assert!(matches!(
error,
CodexCredentialCatalogError::ConflictingModelFacts
));
assert_eq!(snapshot.models().len(), 1);
server.verify().await;
}
@@ -812,13 +812,15 @@ fn credential_expired_failure_keeps_expired_oauth_for_bounded_refresh_recovery()
))
.expect("record credential expiry");
let retained = store
.account("acct_primary")
.expect("account retained for refresh");
assert_eq!(retained.credential_state(), CredentialState::Ready);
assert_eq!(
store
.account("acct_primary")
.expect("account retained for refresh")
.credential_state(),
CredentialState::Ready
retained.last_error_reason(),
Some(AccountErrorReason::AccessTokenExpired)
);
assert_eq!(retained.last_error_message(), Some("token_expired"));
}
#[test]
@@ -112,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
id_token: None,
},
id_token: SecretString::from(self.id_token.clone()),
expires_in: Some(Duration::from_secs(60 * 60)),
})
}
}
@@ -75,7 +75,7 @@ async fn quota_refresh_updates_access_fact_without_recovering_credential_error()
}
#[tokio::test]
async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high() {
async fn exhausted_quota_worker_only_touches_refresh_time_when_recovery_is_rejected() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_worker_quota_recovery_gate";
create_account(&store, account_id).await;
@@ -101,7 +101,7 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
server.uri(),
);
service
let old_snapshot = service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
@@ -125,6 +125,14 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
assert_eq!(summary.exhausted, 1);
assert_eq!(summary.updated, 0);
assert_eq!(store.quota_json(account_id), Some(old_quota));
let refreshed = service
.read_account(account.id())
.await
.expect("read refreshed quota")
.expect("refreshed quota snapshot");
assert!(refreshed.observed_at() > old_snapshot.observed_at());
assert_eq!(refreshed.fact(), old_snapshot.fact());
assert_eq!(refreshed.quota().access(), QuotaAccessState::Exhausted);
assert_eq!(
store
.account(account_id)
@@ -137,10 +145,18 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
#[tokio::test]
async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_percent() {
for (suffix, reset_at, used_percent, recovered) in [
("same_reset", 1_900_000_000_i64, 0_u64, false),
("exactly_ten", 1_900_003_600_i64, 10_u64, false),
("below_ten", 1_900_003_600_i64, 9_u64, true),
for (suffix, allowed, limit_reached, reset_at, used_percent, recovered) in [
(
"still_exhausted",
false,
true,
1_900_003_600_i64,
0_u8,
false,
),
("same_reset", true, false, 1_900_000_000_i64, 0_u8, false),
("exactly_ten", true, false, 1_900_003_600_i64, 10_u8, false),
("below_ten", true, false, 1_900_003_600_i64, 9_u8, true),
] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = format!("acct_manual_quota_recovery_{suffix}");
@@ -166,7 +182,7 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
.expect("client"),
server.uri(),
);
service
let old_snapshot = service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
@@ -177,8 +193,8 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"limit_reached": false,
"allowed": allowed,
"limit_reached": limit_reached,
"primary_window": {"used_percent": used_percent, "reset_at": reset_at}
}
})))
@@ -197,11 +213,12 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
assert_ne!(store.quota_json(&account_id), Some(old_quota));
assert_eq!(current.quota().access(), QuotaAccessState::Allowed);
} else {
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
assert_eq!(snapshot.fact(), old_snapshot.fact());
assert_eq!(snapshot.quota().access(), QuotaAccessState::Exhausted);
assert_eq!(store.quota_json(&account_id), Some(old_quota));
assert_eq!(current.quota().access(), QuotaAccessState::Exhausted);
}
assert!(snapshot.observed_at() > old_snapshot.observed_at());
}
}
@@ -4,9 +4,13 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
use gateway_core::engine::credential::{
AccountErrorReason, AccountStateChange, AccountStatus, CredentialState, ProviderAccountId,
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaState, QuotaWriteOutcome,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
@@ -14,12 +18,16 @@ use gateway_core::provider_ports::{
ProviderSchedulingState, ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use provider_openai::credential::token_client::{
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenPair, TokenRefresher,
};
use provider_openai::credential::{
CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
ImportCodexOAuthCredential,
};
use secrecy::{ExposeSecret as _, SecretString};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, profile, secret};
@@ -36,7 +44,6 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -52,7 +59,17 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: Some(id_token.to_owned()),
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
fn with_access_token(access_token: String) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: Some(access_token),
refresh_token: None,
id_token: None,
})),
})
}
@@ -64,7 +81,6 @@ impl SingleUseRefresher {
access_token: None,
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -78,7 +94,21 @@ impl TokenRefresher for SingleUseRefresher {
.lock()
.expect("refresh response lock")
.take()
.ok_or(RefreshFailure::Transport)
.ok_or_else(|| RefreshFailure::Transport {
message: Some("test refresh response is exhausted".to_owned()),
upstream: None,
})
}
}
struct FailingRefresher {
failure: RefreshFailure,
}
#[async_trait]
impl TokenRefresher for FailingRefresher {
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
Err(self.failure.clone())
}
}
@@ -267,6 +297,263 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
);
}
#[tokio::test]
async fn scheduled_refresh_rotates_tokens_while_quota_is_exhausted() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
let account_id = "acct_exhausted_quota_refresh";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let account = store.account(account_id).expect("seeded account");
assert_eq!(
store
.apply_quota_access(QuotaAccessChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
SystemTime::now(),
None,
),
})
.await
.expect("persist exhausted quota"),
QuotaWriteOutcome::Updated
);
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Refreshed {
account_id: refreshed_account_id,
..
}] if refreshed_account_id == account_id
));
let account = store.account(account_id).expect("refreshed account");
assert!(account.quota().is_exhausted());
let loaded = store
.load_credential(account.id(), account.revision())
.await
.expect("refreshed credential");
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(oauth.access_token.expose_secret(), "refreshed-access-token");
let expected_refresh_token = format!("rt-access-{account_id}");
assert_eq!(
oauth
.refresh_token
.as_ref()
.map(SecretString::expose_secret),
Some(expected_refresh_token.as_str())
);
}
#[tokio::test]
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Refresh token has already been used.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::InvalidGrant {
message: Some(upstream_message.to_owned()),
upstream: None,
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_invalid_refresh_token";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
assert_eq!(
store
.account(account_id)
.expect("invalidated account")
.last_error_message(),
Some(upstream_message)
);
}
#[tokio::test]
async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Invalid refresh token.";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": upstream_message,
"type": "invalid_request_error",
"code": "invalid_refresh_token"
}
})))
.expect(1)
.mount(&server)
.await;
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(OpenAiTokenClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("test HTTP client"),
TokenClientConfig {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
)),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_retryable_unauthorized";
let expires_at = SystemTime::now()
.checked_sub(Duration::from_secs(30 * 60))
.expect("expired access token");
seed_refreshable_account(&store, account_id, expires_at, None).await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Transient {
account_id: deferred_account_id,
}] if deferred_account_id == account_id
));
let account = store.account(account_id).expect("deferred account");
assert_eq!(account.credential_state(), CredentialState::Ready);
assert_eq!(
account.last_error_reason(),
Some(AccountErrorReason::AccessTokenExpired)
);
assert_eq!(account.last_error_message(), Some(upstream_message));
let retry_at = account.next_refresh_at().expect("next refresh attempt");
assert!(retry_at > SystemTime::now());
assert!(
retry_at
<= expires_at
.checked_add(Duration::from_secs(2 * 60 * 60))
.expect("recovery deadline")
);
let projection = account.status_projection(SystemTime::now(), None);
assert_eq!(projection.status, AccountStatus::Error);
assert_eq!(projection.error_message.as_deref(), Some(upstream_message));
}
#[tokio::test]
async fn scheduled_refresh_uses_the_final_message_after_the_two_hour_window() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Invalid refresh token.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::Transport {
message: Some(upstream_message.to_owned()),
upstream: None,
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_exhausted_unauthorized";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_sub(Duration::from_secs(2 * 60 * 60 + 60))
.expect("expired recovery window"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
let account = store.account(account_id).expect("invalidated account");
assert_eq!(account.credential_state(), CredentialState::Expired);
assert_eq!(account.last_error_message(), Some(upstream_message));
}
#[tokio::test]
async fn scheduled_refresh_success_clears_a_deferred_failure_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let account_id = "acct_recovered_after_backoff";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_sub(Duration::from_secs(60))
.expect("expired access token"),
None,
)
.await;
let account = store.account(account_id).expect("seeded account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
credential_state: CredentialState::Ready,
observed_at: SystemTime::now(),
error_reason: Some(AccountErrorReason::AccessTokenExpired),
message: Some("Invalid refresh token.".to_owned()),
})
.await
.expect("seed deferred failure");
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Refreshed {
account_id: refreshed_account_id,
..
}] if refreshed_account_id == account_id
));
let account = store.account(account_id).expect("refreshed account");
assert_eq!(account.credential_state(), CredentialState::Ready);
assert_eq!(account.last_error_reason(), None);
assert_eq!(account.last_error_message(), None);
}
#[tokio::test]
async fn scheduled_refresh_persists_a_returned_id_token() {
let store = Arc::new(MemoryAccountStore::default());
@@ -302,6 +589,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
);
}
#[tokio::test]
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let refresher = SingleUseRefresher::with_access_token(format!(
"unverified-header.{payload}.unverified-signature"
));
let store = Arc::new(MemoryAccountStore::default());
let service = refresh_service(
&store,
refresher,
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
);
let account_id = "acct_rotated_access_expiration";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
service.refresh_due().await.expect("refresh cycle");
assert_eq!(
store
.account(account_id)
.expect("refreshed account")
.access_token_expires_at()
.map(DateTime::<Utc>::from),
Some(expires_at)
);
}
#[tokio::test]
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1,5 +1,3 @@
use std::time::Duration;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
@@ -46,13 +44,16 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
.expect_err("oversized response must fail closed before body classification");
let diagnostic = format!("{failure:?} {failure}");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
assert!(!diagnostic.contains(marker));
assert!(!diagnostic.contains("refresh-secret-request-marker"));
}
#[tokio::test]
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -74,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
}
#[tokio::test]
@@ -95,7 +95,10 @@ async fn refresh_response_should_reject_a_malformed_rotated_id_token() {
.await
.expect_err("malformed ID token must not replace the stored token set");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
}
#[tokio::test]
@@ -116,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
assert!(tokens.access_token.is_none());
assert!(tokens.refresh_token.is_none());
assert!(tokens.id_token.is_none());
assert!(tokens.expires_in.is_none());
}
#[tokio::test]
@@ -142,7 +144,7 @@ async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
}
#[tokio::test]
async fn refresh_should_exchange_the_official_form_fields() {
async fn refresh_should_exchange_the_official_json_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -160,11 +162,24 @@ async fn refresh_should_exchange_the_official_form_fields() {
.await
.expect("refresh succeeds");
let requests = server.received_requests().await.expect("received request");
let body = String::from_utf8(requests[0].body.clone()).expect("form body is UTF-8");
let body: serde_json::Value =
serde_json::from_slice(&requests[0].body).expect("JSON request body");
assert!(body.contains("grant_type=refresh_token"));
assert!(body.contains("client_id=test-public-client"));
assert!(body.contains("refresh_token=refresh+secret"));
assert_eq!(
body,
serde_json::json!({
"client_id": "test-public-client",
"grant_type": "refresh_token",
"refresh_token": "refresh secret"
})
);
assert_eq!(
requests[0]
.headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
}
#[tokio::test]
@@ -273,72 +288,165 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
#[tokio::test]
async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again.";
let failure = refresh_failure(
400,
r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#,
r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::InvalidGrant);
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("refresh-token reuse must be terminal");
};
assert_eq!(message.as_deref(), Some(upstream_message));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 400);
assert_eq!(upstream.code(), Some("refresh_token_reused"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
}
#[tokio::test]
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
let failure = refresh_failure(400, body).await;
let diagnostic = format!("{failure:?} {failure}");
let RefreshFailure::Transport { message, upstream } = failure else {
panic!("unknown official refresh code must remain transient");
};
assert_eq!(message, None);
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.code(), Some("invalid_grant"));
assert_eq!(upstream.body(), body);
assert!(!diagnostic.contains("error_description"));
}
#[tokio::test]
async fn unauthorized_should_preserve_upstream_details_and_remain_retryable() {
let body = r#"{
"error": {
"message": "Invalid refresh token.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_refresh_token"
}
}"#;
let failure = refresh_failure(401, body).await;
let RefreshFailure::Transport { message, upstream } = failure else {
panic!("production policy gives every 401 a bounded recovery window");
};
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 401);
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert_eq!(upstream.body(), body);
}
#[tokio::test]
async fn unauthorized_should_back_off_even_with_a_recognized_refresh_code() {
let failure = refresh_failure(
401,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_transport_failure(
&failure,
401,
Some("Refresh token expired."),
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
);
}
#[tokio::test]
async fn deactivated_account_should_be_classified_as_banned() {
let failure = refresh_failure(
403,
r#"{"error":"access_denied","error_description":"account has been deactivated"}"#,
r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::Banned);
let RefreshFailure::Banned { message, upstream } = failure else {
panic!("deactivated account must be banned");
};
assert_eq!(message.as_deref(), Some("account has been deactivated"));
assert_eq!(upstream.expect("complete upstream failure").status(), 403);
}
#[tokio::test]
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
let failure = refresh_failure(403, "account is banned").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 403, None, "account is banned");
}
#[tokio::test]
async fn unregistered_disabled_account_text_should_remain_a_transport_failure() {
let failure = refresh_failure(400, "account disabled").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "account disabled");
}
#[tokio::test]
async fn quota_text_should_not_disable_the_oauth_credential() {
let failure = refresh_failure(400, "quota exceeded").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "quota exceeded");
}
#[tokio::test]
async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
let failure = refresh_failure(400, "token_revoked").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "token_revoked");
}
#[tokio::test]
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
async fn recognized_refresh_code_should_be_permanent_on_non_unauthorized_status() {
for status in [500, 502, 503, 429] {
let failure = refresh_failure(
status,
r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_eq!(
failure,
RefreshFailure::Transport,
"status {status} must classify as transient"
assert!(
matches!(failure, RefreshFailure::InvalidGrant { .. }),
"official refresh code must take precedence for status {status}"
);
}
}
#[tokio::test]
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
for status in [500, 502, 503, 429] {
let failure = refresh_failure(status, body).await;
assert_transport_failure(&failure, status, Some("Try again."), body);
}
}
fn assert_transport_failure(
failure: &RefreshFailure,
status: u16,
message: Option<&str>,
body: &str,
) {
let RefreshFailure::Transport {
message: actual_message,
upstream,
} = failure
else {
panic!("status {status} must classify as transient");
};
assert_eq!(actual_message.as_deref(), message);
let upstream = upstream.as_deref().expect("complete upstream failure");
assert_eq!(upstream.status(), status);
assert_eq!(upstream.body(), body);
}
async fn refresh_failure(status: u16, body: &str) -> RefreshFailure {
let server = MockServer::start().await;
Mock::given(method("POST"))
@@ -18,7 +18,10 @@ use gateway_core::engine::{
};
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::GatewayEvent;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_core::operation::{
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
ProviderSessionState,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
@@ -2424,3 +2427,36 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.verbosity());
assert!(!presentation.hidden());
}
#[tokio::test]
async fn provider_routes_catalog_model_when_supported_in_api_is_false() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_non_api_model").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_raw(
br#"{"models":[{"slug":"gpt-5.6-sol-wm","display_name":"GPT-5.6-Sol-WM","supported_in_api":false,"visibility":"hide"}]}"#,
"application/json",
),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let capabilities = provider
.query_model_capabilities()
.await
.expect("catalog model capabilities");
let model = capabilities.first().expect("catalog model");
assert!(
model
.capabilities()
.match_requirements(&CapabilityRequirements::new(OperationKind::Generate))
.is_some()
);
}
@@ -10,10 +10,10 @@ use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountRuntimeSignals, AccountStateChange,
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
LoadedCredential, NewProviderAccount, OpaqueProviderData, ProviderAccount, ProviderAccountId,
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
QuotaWriteOutcome,
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
CredentialRevision, CredentialState, LoadedCredential, NewProviderAccount, OpaqueProviderData,
ProviderAccount, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
QuotaAccessChange, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::policy::ClientApiKeyId;
@@ -207,7 +207,7 @@ impl ProviderAccountStore for MemoryAccountStore {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -215,7 +215,8 @@ impl ProviderAccountStore for MemoryAccountStore {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
let mut accounts = self.accounts.lock().expect("account store lock");
let stored = accounts
.get_mut(&account_id)
@@ -226,15 +227,29 @@ impl ProviderAccountStore for MemoryAccountStore {
let next = expected_revision
.next()
.map_err(|_| store_error(StoreErrorKind::Conflict))?;
let credential_state = account_state
.as_ref()
.map_or(stored.account.credential_state(), |state| {
state.credential_state
});
let last_error_reason = account_state.as_ref().map_or_else(
|| stored.account.last_error_reason(),
|state| state.error_reason,
);
let last_error_message = account_state.as_ref().map_or_else(
|| stored.account.last_error_message().map(str::to_owned),
|state| state.message.clone(),
);
let state_observed_at = account_state.as_ref().map(|state| state.observed_at);
stored.account = rebuild_account(
&stored.account,
AccountRebuild {
revision: next,
enabled: stored.account.enabled(),
credential_state: stored.account.credential_state(),
credential_state,
quota: stored.account.quota(),
last_error_reason: stored.account.last_error_reason(),
last_error_message: stored.account.last_error_message().map(str::to_owned),
last_error_reason,
last_error_message,
access_token_expires_at,
has_refresh_token,
next_refresh_at,
@@ -243,6 +258,9 @@ impl ProviderAccountStore for MemoryAccountStore {
);
stored.credential = credential;
stored.quota = None;
if let Some(observed_at) = state_observed_at {
stored.state_observed_at = Some(observed_at);
}
Ok(CredentialCasOutcome::Updated(next))
}
@@ -285,6 +303,27 @@ impl ProviderAccountStore for MemoryAccountStore {
Ok(QuotaWriteOutcome::Updated)
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError> {
let mut accounts = self.accounts.lock().expect("account store lock");
let stored = accounts
.get_mut(&touch.account_id)
.ok_or_else(|| store_error(StoreErrorKind::InvalidData))?;
if stored.account.revision() != touch.expected_revision {
return Ok(QuotaWriteOutcome::Conflict);
}
let Some(quota) = &mut stored.quota else {
return Ok(QuotaWriteOutcome::Conflict);
};
if quota.observed_at > touch.observed_at {
return Ok(QuotaWriteOutcome::Conflict);
}
quota.observed_at = touch.observed_at;
Ok(QuotaWriteOutcome::Updated)
}
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
+7 -5
View File
@@ -27,8 +27,9 @@ use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision}
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -906,15 +907,16 @@ fn prepared_rotation(
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, guard) = prepared.into_parts();
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
credential_profile,
profile: credential_profile,
credential,
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = credential.into_parts();
account_state: _account_state,
} = credential.into_parts();
if profile != credential_profile || profile.account_id != account_id {
return Err(provider_error(ProviderAdminErrorKind::Internal));
}
+42 -9
View File
@@ -9,10 +9,10 @@ use async_trait::async_trait;
use chrono::Utc;
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
QuotaWriteOutcome,
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts, CredentialRevision,
CredentialState, LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount,
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange,
QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::provider_ports::{
@@ -327,7 +327,7 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -335,22 +335,36 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
let mut accounts = lock(&self.accounts);
let stored = accounts.get_mut(&account_id).ok_or_else(invalid)?;
if stored.account.revision() != expected_revision {
return Ok(CredentialCasOutcome::Conflict);
}
let next = expected_revision.next().map_err(|_| invalid())?;
let credential_state = account_state
.as_ref()
.map_or(stored.account.credential_state(), |state| {
state.credential_state
});
let last_error_reason = account_state.as_ref().map_or_else(
|| stored.account.last_error_reason(),
|state| state.error_reason,
);
let last_error_message = account_state.as_ref().map_or_else(
|| stored.account.last_error_message().map(str::to_owned),
|state| state.message.clone(),
);
stored.account = rebuild_account(
&stored.account,
AccountReplacement {
revision: next,
access_token_expires_at,
credential_state: stored.account.credential_state(),
credential_state,
quota: stored.account.quota(),
last_error_reason: stored.account.last_error_reason(),
last_error_message: stored.account.last_error_message().map(str::to_owned),
last_error_reason,
last_error_message,
enabled: stored.account.enabled(),
has_refresh_token,
next_refresh_at,
@@ -396,6 +410,25 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
Ok(QuotaWriteOutcome::Updated)
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError> {
let mut accounts = lock(&self.accounts);
let stored = accounts.get_mut(&touch.account_id).ok_or_else(invalid)?;
if stored.account.revision() != touch.expected_revision {
return Ok(QuotaWriteOutcome::Conflict);
}
let Some(quota) = &mut stored.quota else {
return Ok(QuotaWriteOutcome::Conflict);
};
if quota.observed_at > touch.observed_at {
return Ok(QuotaWriteOutcome::Conflict);
}
quota.observed_at = touch.observed_at;
Ok(QuotaWriteOutcome::Updated)
}
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
+62
View File
@@ -1,5 +1,48 @@
import antfu from '@antfu/eslint-config'
const templateStylePlugin = {
rules: {
'single-line-simple-interpolation': {
meta: {
type: 'layout',
fixable: 'whitespace',
schema: [],
messages: {
expectedSingleLine: 'Expected a simple mustache interpolation to stay on one line.',
},
},
create(context) {
const { parserServices } = context.sourceCode
if (!parserServices.defineTemplateBodyVisitor)
return {}
return parserServices.defineTemplateBodyVisitor({
'VExpressionContainer[expression!=null]': function (node) {
if (node.parent.type !== 'VElement')
return
const expression = node.expression
if (
expression.loc.start.line !== expression.loc.end.line
|| node.loc.start.line === node.loc.end.line
) {
return
}
context.report({
node,
messageId: 'expectedSingleLine',
fix(fixer) {
return fixer.replaceText(node, `{{ ${context.sourceCode.getText(expression)} }}`)
},
})
},
})
},
},
},
}
export default antfu(
{
type: 'app',
@@ -18,4 +61,23 @@ export default antfu(
a11y: true,
},
},
{
files: ['**/*.vue'],
plugins: {
'template-style': templateStylePlugin,
},
rules: {
'template-style/single-line-simple-interpolation': 'error',
'vue/html-closing-bracket-newline': ['error', {
singleline: 'never',
multiline: 'always',
}],
'vue/multiline-html-element-content-newline': ['error', {
ignores: ['pre', 'textarea'],
ignoreWhenEmpty: true,
allowEmptyLines: false,
}],
'vue/mustache-interpolation-spacing': ['error', 'always'],
},
},
)
+4 -4
View File
@@ -17,7 +17,7 @@
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.30.0",
"@lucide/vue": "^1.31.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
@@ -26,7 +26,7 @@
"es-toolkit": "^1.50.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"pinia": "^4.0.2",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
@@ -39,10 +39,10 @@
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.9"
"vue-tsc": "^3.3.10"
}
}
+33 -33
View File
@@ -18,8 +18,8 @@ importers:
specifier: ^5.3.0
version: 5.3.0
'@lucide/vue':
specifier: ^1.30.0
version: 1.30.0(vue@3.5.41(typescript@6.0.3))
specifier: ^1.31.0
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
@@ -45,21 +45,21 @@ importers:
specifier: ^18.0.9
version: 18.0.9
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
vue:
specifier: ^3.5.41
version: 3.5.41(typescript@6.0.3)
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
devDependencies:
'@antfu/eslint-config':
specifier: ^9.3.0
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))
@@ -79,8 +79,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1(eslint@10.8.1(jiti@2.7.0))
eslint-plugin-vuejs-accessibility:
specifier: ^2.5.0
version: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
specifier: ^2.6.0
version: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
@@ -91,8 +91,8 @@ importers:
specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.9
version: 3.3.9(typescript@6.0.3)
specifier: ^3.3.10
version: 3.3.10(typescript@6.0.3)
packages:
@@ -354,8 +354,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lucide/vue@1.30.0':
resolution: {integrity: sha512-EF+N6eH/zaaZN0EY0y0URvKjMAj4QUeVtdDeFxjWHjhXihve/pBge+ahaeXLtqa1np0Ax5vsZEIsOTcsGZwPNQ==}
'@lucide/vue@1.31.0':
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
peerDependencies:
vue: '>=3.0.1'
@@ -849,8 +849,8 @@ packages:
'@vue/devtools-shared@8.2.1':
resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
'@vue/language-core@3.3.9':
resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
'@vue/language-core@3.3.10':
resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==}
'@vue/reactivity@3.5.41':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
@@ -1297,8 +1297,8 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-vuejs-accessibility@2.5.0:
resolution: {integrity: sha512-oZ2fL4tS91Cm/ezH3BueNP+FtpbbeS627OSqqgp9/lsN//glmoPcLBT6D53xwGocLtyBybaT99tX4ThBh8+ytA==}
eslint-plugin-vuejs-accessibility@2.6.0:
resolution: {integrity: sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==}
engines: {node: '>=16.0.0'}
peerDependencies:
eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
@@ -2045,8 +2045,8 @@ packages:
pinia:
optional: true
pinia@4.0.2:
resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==}
pinia@4.0.3:
resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==}
peerDependencies:
'@vue/devtools-api': ^8.1.5
typescript: '>=5.6.0'
@@ -2377,8 +2377,8 @@ packages:
vite:
optional: true
vue-tsc@3.3.9:
resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
vue-tsc@3.3.10:
resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
@@ -2431,7 +2431,7 @@ packages:
snapshots:
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@antfu/install-pkg': 2.0.1
'@clack/prompts': 1.7.0
@@ -2472,7 +2472,7 @@ snapshots:
yaml-eslint-parser: 2.1.0
optionalDependencies:
eslint-plugin-format: 2.0.1(eslint@10.8.1(jiti@2.7.0))
eslint-plugin-vuejs-accessibility: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
eslint-plugin-vuejs-accessibility: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
transitivePeerDependencies:
- '@eslint/json'
- '@typescript-eslint/typescript-estree'
@@ -2687,7 +2687,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
@@ -3086,7 +3086,7 @@ snapshots:
'@vue/devtools-shared@8.2.1': {}
'@vue/language-core@3.3.9':
'@vue/language-core@3.3.10':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.41
@@ -3551,7 +3551,7 @@ snapshots:
'@stylistic/eslint-plugin': 5.10.0(eslint@10.8.1(jiti@2.7.0))
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
dependencies:
aria-query: 5.3.2
eslint: 10.8.1(jiti@2.7.0)
@@ -4423,13 +4423,13 @@ snapshots:
picomatch@4.0.5: {}
pinia-plugin-persistedstate@4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
dependencies:
defu: 6.1.7
optionalDependencies:
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 8.1.5
nostics: 1.2.0
@@ -4685,7 +4685,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@babel/generator': 8.0.0
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@6.0.3))
@@ -4708,7 +4708,7 @@ snapshots:
yaml: 2.9.0
optionalDependencies:
'@vue/compiler-sfc': 3.5.41
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
@@ -4720,10 +4720,10 @@ snapshots:
- unloader
- webpack
vue-tsc@3.3.9(typescript@6.0.3):
vue-tsc@3.3.10(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.3.9
'@vue/language-core': 3.3.10
typescript: 6.0.3
vue@3.5.41(typescript@6.0.3):
-176
View File
@@ -392,179 +392,3 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery) {
params: data,
})
}
// Usage 记录的规范化 view model:组件只消费这个形状,兼容性回退只发生在本文件。
export interface UsageViewModel {
id: string
requestId: string
clientApiKeyId: string | null
kind: string
provider: string | null
authenticationKind: string | null
accountId: string | null
accountEmail: string | null
accountName: string | null
route: string
model: string
requestedModel: string | null
upstreamModel: string | null
serviceTier: string | null
statusCode: number | null
transport: string | null
stream: boolean | null
apiKind: string | null
attemptIndex: number | null
attemptCount: number
responseId: string | null
upstreamRequestId: string | null
protocol: string
httpVersion: string | null
clientStatusCode: number | null
upstreamStatusCode: number | null
websocketPool: { kind: string } | null
imageGenerationRequested: boolean
imageGenerationSucceeded: boolean | null
latencyMs: number | null
firstTokenLatencyMs: number | null
latencyDetails: UsageLatencyDetails | null
inputTokens: number | null
outputTokens: number | null
cachedTokens: number | null
cacheWriteTokens: number | null
reasoningTokens: number | null
imageInputTokens: number | null
imageOutputTokens: number | null
message: string
createdAt: string
createdAtDisplay: string
clientIp: string | null
userAgent: string | null
reasoningEffort: string | null
reasoningPreset: string | null
compact: boolean
requestKind: string | null
subagentKind: string | null
tokenDetails: UsageTokenDetails
billing: UsageBilling | null
costs: UsageCost[]
costCoverage: UsageCostCoverage
firstTokenLatencyMsDisplay: string
latencyMsDisplay: string
logicalOutcome: string
providerMetadata: Record<string, unknown>
requestBody?: unknown
responseBody?: unknown
attempts?: UsageAttempt[]
attemptsComplete?: boolean
}
const CORE_METADATA_KEYS = new Set([
'protocol',
'logicalOutcome',
'attemptCount',
'requestedModel',
'upstreamModel',
'clientIp',
'userAgent',
'reasoningEffort',
'reasoningPreset',
'compact',
'requestKind',
'subagentKind',
'transport',
'httpVersion',
'clientStatusCode',
'upstreamStatusCode',
'responseId',
'upstreamRequestId',
'websocketPool',
'imageGenerationRequested',
'imageGenerationSucceeded',
'latencyDetails',
])
function metadataRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
/** 一次性兼容 normalize:顶层 Core 字段优先,旧响应中的 metadata 回退只发生在这里。 */
export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): UsageViewModel {
const metadata = metadataRecord(record.metadata)
const legacy = <T>(top: T | null | undefined, key: string): T | null | undefined =>
top ?? (metadata[key] as T | undefined)
const latencyDetails = legacy(record.latencyDetails, 'latencyDetails') ?? null
const providerMetadata: Record<string, unknown> = {}
for (const [key, value] of Object.entries(metadata)) {
if (!CORE_METADATA_KEYS.has(key))
providerMetadata[key] = value
}
return {
id: record.id,
requestId: record.requestId,
clientApiKeyId: record.clientApiKeyId,
kind: record.kind,
provider: record.provider,
authenticationKind: record.authenticationKind,
accountId: record.accountId,
accountEmail: record.accountEmail,
accountName: record.accountName,
route: record.route,
model: record.model,
requestedModel: legacy(record.requestedModel, 'requestedModel') ?? null,
upstreamModel: legacy(record.upstreamModel, 'upstreamModel') ?? null,
serviceTier: record.serviceTier,
statusCode: record.statusCode,
transport: legacy(record.transport, 'transport') ?? null,
stream: typeof metadata.stream === 'boolean' ? metadata.stream : null,
apiKind: typeof metadata.apiKind === 'string' ? metadata.apiKind : null,
attemptIndex: record.attemptIndex,
attemptCount: legacy(record.attemptCount, 'attemptCount') ?? 0,
responseId: legacy(record.responseId, 'responseId') ?? null,
upstreamRequestId: legacy(record.upstreamRequestId, 'upstreamRequestId') ?? null,
protocol: legacy(record.protocol, 'protocol') ?? '',
httpVersion: legacy(record.httpVersion, 'httpVersion') ?? null,
clientStatusCode: legacy(record.clientStatusCode, 'clientStatusCode') ?? null,
upstreamStatusCode: legacy(record.upstreamStatusCode, 'upstreamStatusCode') ?? null,
websocketPool: legacy(record.websocketPool, 'websocketPool') ?? null,
imageGenerationRequested:
legacy(record.imageGenerationRequested, 'imageGenerationRequested') ?? false,
imageGenerationSucceeded:
legacy(record.imageGenerationSucceeded, 'imageGenerationSucceeded') ?? null,
latencyMs: record.latencyMs,
firstTokenLatencyMs: record.firstTokenLatencyMs,
latencyDetails,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
cacheWriteTokens: record.cacheWriteTokens,
reasoningTokens: record.reasoningTokens,
imageInputTokens: record.imageInputTokens,
imageOutputTokens: record.imageOutputTokens,
message: record.message,
createdAt: record.createdAt,
createdAtDisplay: record.createdAtDisplay,
clientIp: legacy(record.clientIp, 'clientIp') ?? null,
userAgent: legacy(record.userAgent, 'userAgent') ?? null,
reasoningEffort: legacy(record.reasoningEffort, 'reasoningEffort') ?? null,
reasoningPreset: legacy(record.reasoningPreset, 'reasoningPreset') ?? null,
compact: legacy(record.compact, 'compact') ?? false,
requestKind: legacy(record.requestKind, 'requestKind') ?? null,
subagentKind: legacy(record.subagentKind, 'subagentKind') ?? null,
tokenDetails: record.tokenDetails,
billing: record.billing,
costs: record.costs,
costCoverage: record.costCoverage,
firstTokenLatencyMsDisplay: record.firstTokenLatencyMsDisplay,
latencyMsDisplay: record.latencyMsDisplay,
logicalOutcome: legacy(record.logicalOutcome, 'logicalOutcome') ?? '',
providerMetadata,
requestBody: metadata.requestBody,
responseBody: metadata.responseBody,
attempts: 'attempts' in record ? record.attempts : undefined,
attemptsComplete: 'attemptsComplete' in record ? record.attemptsComplete : undefined,
}
}
@@ -32,11 +32,11 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
class="size-3.5 shrink-0 rounded-[4px]"
class="size-3.5 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
aria-hidden="true"
/>
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-(--cp-text-muted)"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -21,11 +21,11 @@ const fullNames = computed(() => props.groups
<span
v-for="group in groups"
:key="group.id"
class="size-4 shrink-0 rounded-[4px]"
class="size-4 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
:title="group.name"
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-(--cp-text-muted)">—</span>
<span v-else class="text-xs text-cp-muted-text">—</span>
</template>
@@ -7,7 +7,7 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
const props = withDefaults(
defineProps<{
@@ -76,7 +76,7 @@ function toggle(groupId: string) {
function providerSummary(group: AccountGroup) {
return Object.entries(group.providerCounts)
.filter(([, count]) => count > 0)
.map(([provider, count]) => `${providerDisplayName(provider) ?? provider} ${count}`)
.map(([provider, count]) => `${formatProviderLabel(provider)} ${count}`)
.join(' · ')
}
</script>
@@ -90,72 +90,73 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-(--cp-text-tertiary)" />
<Search class="size-4 text-cp-tertiary" />
</template>
</BaseInput>
<BaseScrollbar
v-if="visibleGroups.length > 0"
max-height="260px"
view-class="grid gap-2 pr-2"
>
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-(--cp-input-radius-base) border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="selectedIds.includes(group.id)
? 'bg-(--cp-info-bg)'
: 'bg-(--cp-bg-subtle) hover:bg-(--cp-default-bg-hover)'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-(--cp-text-primary)">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-(--cp-warning-bg) px-1.5 py-1 text-[10px] leading-none font-bold text-(--cp-warning-text)"
>
已禁用
<div class="grid gap-2">
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-cp-warning-bg px-1.5 py-1 text-[10px] leading-none font-bold text-cp-warning-text"
>
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-(--cp-text-secondary)">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
</div>
</div>
</BaseScrollbar>
<BaseEmpty
v-else-if="groups.length === 0 && !loading"
compact
size="sm"
title="暂无可选分组"
description="请先在分组管理中创建分组。"
/>
<BaseEmpty
v-else-if="!loading"
compact
size="sm"
title="没有匹配分组"
description="请调整搜索关键词。"
/>
<div
v-if="selectedIds.length === 0"
class="rounded-(--cp-input-radius-base) px-3.5 py-3"
class="rounded-cp-control px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'"
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
>
<p class="m-0 text-[12px] font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-[1.5] font-emphasis">
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
+3 -1
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import { formatDateTime, formatRelativeTime } from '@/utils/date'
const props = defineProps<{
value: string | null
}>()
const displayValue = computed(() => props.value ? formatRelativeTime(props.value) : '')
const now = useUiClock()
const displayValue = computed(() => props.value ? formatRelativeTime(props.value, now.value) : '')
const fullValue = computed(() => props.value ? formatDateTime(props.value) : undefined)
</script>
@@ -2,6 +2,7 @@
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
import BaseSegmented from './base/BaseSegmented.vue'
withDefaults(
@@ -17,18 +18,17 @@ const provider = defineModel<string>({ required: true })
const providerOptions = [
{ label: '全部平台', value: '', icon: LayoutGrid },
{ label: 'OpenAI', value: 'openai', icon: Openai },
{ label: 'xAI', value: 'xai', icon: Xai },
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
]
</script>
<template>
<BaseSegmented
v-model="provider"
label="按平台筛选"
:options="providerOptions"
:disabled="disabled"
icon-only
size="input"
aria-label="按平台筛选"
display="icon"
/>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
const props = withDefaults(
defineProps<{
@@ -25,9 +25,7 @@ const iconContainerClass = computed(() =>
)
const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
const providerLabel = computed(
() => providerDisplayName(normalizedProvider.value) ?? (props.provider?.trim() || '未知平台'),
)
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'agent_identity')
@@ -41,18 +39,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -66,7 +64,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span class="sr-only">
+38 -102
View File
@@ -1,119 +1,62 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed, useSlots } from 'vue'
import { computed } from 'vue'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
type ButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
spinIconOnLoading?: boolean
disabled?: boolean
active?: boolean
iconOnly?: boolean
label?: string
title?: string
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
variant: 'secondary',
size: 'md',
loading: false,
spinIconOnLoading: false,
disabled: false,
active: false,
iconOnly: false,
type: 'button',
},
)
const slots = useSlots()
defineSlots<{
icon?: () => unknown
loading?: () => unknown
default: () => unknown
}>()
const variantClasses = {
default:
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
warning:
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
}
const iconOnlyDefaultClasses
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-7 gap-1.5 px-3 text-xs',
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
md: 'h-11 gap-2 px-4 text-[13px]',
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
sm: 'size-8 rounded-(--cp-icon-button-radius)',
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
md: 'size-11 rounded-(--cp-icon-button-radius)',
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
}
const loadingIconSize: Record<ButtonSize, number> = {
const spinnerSizes: Record<ButtonSize, number> = {
sm: 14,
default: 15,
md: 16,
md: 15,
lg: 17,
}
const loadingIndicatorStyle = computed(() => {
const iconSize = loadingIconSize[props.size]
return {
width: `${iconSize}px`,
height: `${iconSize}px`,
}
})
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? 'shrink-0' : undefined,
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
])
const ariaLabel = computed(() => props.label || props.title)
const labelText = computed(() => {
const defaultSlot = slots.default?.()
const text = defaultSlot
?.map(node => (typeof node.children === 'string' ? node.children : ''))
.join('')
.trim()
return text || ''
})
const labelClasses = computed(() => [
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
])
const loadingIconClasses = computed(() => [
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
props.loading
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
: undefined,
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
</script>
@@ -122,31 +65,24 @@ const loadingIconClasses = computed(() => [
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="iconOnly ? ariaLabel : undefined"
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
:aria-busy="loading || undefined"
>
<span
v-if="loading && !spinIconOnLoading"
:class="loadingIconClasses"
:style="loadingIndicatorStyle"
v-if="loading"
class="inline-grid shrink-0 place-items-center leading-none"
aria-hidden="true"
>
<LoaderCircle
:size="loadingIconSize[size]"
/>
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
:size="spinnerSizes[size]"
/>
</slot>
</span>
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
<slot name="icon" />
</span>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
:class="loadingIconClasses"
aria-hidden="true"
>
<span class="inline-flex min-w-0 items-center justify-center gap-2">
<slot />
</span>
</button>
+17 -51
View File
@@ -1,30 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
type CardPadding = 'default' | 'compact'
type CardLayout = 'content' | 'flush'
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
type CardPadding = 'none' | 'compact' | 'default'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
padding?: CardPadding
layout?: CardLayout
title?: string
description?: string
headerCollapseAt?: HeaderCollapseAt
headerClass?: string
bodyClass?: string
}>(),
{
as: 'section',
padding: 'default',
layout: 'content',
title: undefined,
description: undefined,
headerCollapseAt: 'sm',
headerClass: undefined,
bodyClass: '',
},
)
@@ -37,55 +26,36 @@ const slots = defineSlots<{
default?: () => unknown
}>()
const paddingClasses: Record<CardPadding, string> = {
default: 'p-5.5',
const paddingClasses: Record<CardPadding, string | undefined> = {
none: undefined,
compact: 'p-4',
default: 'p-5.5',
}
const hasManagedHeader = computed(
() =>
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
() => Boolean(props.title || props.description || slots.actions || slots.title || slots.description),
)
const outerPaddingClass = computed(() => {
if (props.layout === 'flush')
return undefined
return paddingClasses[props.padding]
})
const managedHeaderLayoutClasses = computed(() => {
if (props.headerCollapseAt === 'none') {
return 'flex items-start justify-between gap-3'
}
if (props.headerCollapseAt === 'lg') {
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
}
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
})
const hasHeader = computed(() => Boolean(slots.header || hasManagedHeader.value))
const contentClasses = computed(() => [
hasHeader.value ? 'mt-4' : undefined,
'flex min-h-0 min-w-0 flex-1 flex-col',
])
</script>
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
:class="[
outerPaddingClass,
]"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
<header
v-if="$slots.header || hasManagedHeader"
class="shrink-0"
:class="props.headerClass"
>
<header v-if="$slots.header || hasManagedHeader" class="shrink-0">
<slot name="header">
<div :class="managedHeaderLayoutClasses">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
>
<slot name="title">
{{ props.title }}
@@ -93,14 +63,13 @@ const managedHeaderLayoutClasses = computed(() => {
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
</slot>
</p>
</div>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions" />
</div>
@@ -108,10 +77,7 @@ const managedHeaderLayoutClasses = computed(() => {
</slot>
</header>
<div
v-if="$slots.body || $slots.default"
:class="props.bodyClass"
>
<div v-if="$slots.body || $slots.default" :class="contentClasses">
<slot name="body">
<slot />
</slot>
+35 -36
View File
@@ -1,70 +1,69 @@
<script setup lang="ts">
import { Check, Minus } from '@lucide/vue'
import { computed } from 'vue'
import { computed, useTemplateRef, watchEffect } from 'vue'
const props = withDefaults(
defineProps<{
label: string
indeterminate?: boolean
disabled?: boolean
label?: string
showLabel?: boolean
}>(),
{
indeterminate: false,
disabled: false,
label: undefined,
showLabel: false,
},
)
const model = defineModel<boolean>({ default: false })
const inputRef = useTemplateRef<HTMLInputElement>('input')
const checked = computed(() => model.value || props.indeterminate)
watchEffect(() => {
if (inputRef.value)
inputRef.value.indeterminate = props.indeterminate
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
? 'bg-cp-disabled text-cp-disabled-icon'
: checked.value
? 'bg-(--cp-info-bg) text-(--cp-info)'
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
])
function toggle() {
if (props.disabled) {
return
}
model.value = !model.value
}
</script>
<template>
<button
type="button"
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-emphasis transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
role="checkbox"
:aria-checked="indeterminate ? 'mixed' : model"
:aria-label="label"
:disabled="disabled"
@click="toggle"
>
<span :class="boxClasses">
<input
ref="input"
v-model="model"
type="checkbox"
class="peer sr-only"
:aria-label="label"
:disabled="disabled"
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
aria-hidden="true"
>
<Minus
class="absolute transition-opacity duration-150 size-3"
:class="[indeterminate ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="indeterminate ? 'opacity-100' : 'opacity-0'"
/>
<Check
class="absolute transition-opacity duration-150 size-3"
:class="[!indeterminate && model ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span
v-if="showLabel && label"
class="text-[13px] leading-none font-emphasis"
>
{{ label }}
</span>
</button>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -0,0 +1,113 @@
import { normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
h: number
s: number
v: number
a: number
}
interface RgbaColor {
r: number
g: number
b: number
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
const maximum = Math.max(red, green, blue)
const minimum = Math.min(red, green, blue)
const delta = maximum - minimum
let hue = 0
if (delta > 0) {
if (maximum === red)
hue = 60 * (((green - blue) / delta) % 6)
else if (maximum === green)
hue = 60 * ((blue - red) / delta + 2)
else
hue = 60 * ((red - green) / delta + 4)
}
return {
h: hue < 0 ? hue + 360 : hue,
s: maximum === 0 ? 0 : (delta / maximum) * 100,
v: maximum * 100,
a: alpha,
}
}
export function hsvToHex(color: HsvColor) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
const chroma = brightness * saturation
const section = hue / 60
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
const offset = brightness - chroma
const [red, green, blue] = section < 1
? [chroma, intermediate, 0]
: section < 2
? [intermediate, chroma, 0]
: section < 3
? [0, chroma, intermediate]
: section < 4
? [0, intermediate, chroma]
: section < 5
? [intermediate, 0, chroma]
: [chroma, 0, intermediate]
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
return `#${rgb}${alpha}`.toUpperCase()
}
export function parseRgbaColor(value: string): RgbaColor | null {
const match = RGBA_COLOR_PATTERN.exec(value.trim())
if (!match)
return null
const red = Number(match[1])
const green = Number(match[2])
const blue = Number(match[3])
const alpha = Number(match[4])
if ([red, green, blue].some(channel => channel > 255))
return null
return { r: red, g: green, b: blue, a: alpha }
}
export function rgbaToHexColor(color: RgbaColor) {
return `#${[color.r, color.g, color.b, color.a * 255]
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
.join('')}`.toUpperCase()
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16)
const green = Number.parseInt(normalized.slice(3, 5), 16)
const blue = Number.parseInt(normalized.slice(5, 7), 16)
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(3))
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
}
@@ -1,18 +1,19 @@
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeRgbaHexColor } from '@/utils/color'
import BaseButton from '../BaseButton.vue'
import BaseInput from '../BaseInput.vue'
import BasePopover from '../BasePopover.vue'
import {
formatRgbaColor,
hexToHsv,
hsvToHex,
normalizeHexColor,
parseRgbaColor,
rgbaToHexColor,
} from '@/utils/color'
import BaseButton from './BaseButton.vue'
import BaseInput from './BaseInput.vue'
import BasePopover from './BasePopover.vue'
} from './color'
const props = withDefaults(
defineProps<{
@@ -38,7 +39,7 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
const normalizedModel = computed(() => normalizeHexColor(model.value) ?? '#60A5FA80')
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
const draftColor = computed(() => hsvToHex({
h: hue.value,
s: saturation.value,
@@ -201,18 +202,18 @@ function updateFromInput() {
}
function choosePreset(value: string) {
const normalized = normalizeHexColor(value)
const normalized = normalizeRgbaHexColor(value)
if (normalized)
resetDraft(normalized)
}
function isPresetSelected(value: string) {
const normalized = normalizeHexColor(value)
const normalized = normalizeRgbaHexColor(value)
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
}
function presetCheckClass(value: string) {
const normalized = normalizeHexColor(value)
const normalized = normalizeRgbaHexColor(value)
if (!normalized)
return 'text-white'
@@ -233,10 +234,6 @@ function confirm() {
model.value = rgbaToHexColor(rgba)
open.value = false
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
}
</script>
<template>
@@ -244,31 +241,29 @@ function clamp(value: number, minimum: number, maximum: number) {
v-model="open"
class="p-0.75"
placement="bottom-start"
:width="304"
:disabled="props.disabled"
panel-class="p-3!"
>
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-[4px] border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-[4px]" aria-hidden="true">
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
</template>
<div class="grid gap-3" role="dialog" :aria-label="props.label">
<div class="grid w-76 gap-3 p-3" role="dialog" :aria-label="props.label">
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -290,7 +285,7 @@ function clamp(value: number, minimum: number, maximum: number) {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
role="slider"
tabindex="0"
aria-label="色相"
@@ -303,7 +298,7 @@ function clamp(value: number, minimum: number, maximum: number) {
@keydown="handleHueKeydown"
>
<span
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-[2px] bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-xs bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
:style="hueThumbStyle"
aria-hidden="true"
/>
@@ -313,7 +308,7 @@ function clamp(value: number, minimum: number, maximum: number) {
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -333,7 +328,7 @@ function clamp(value: number, minimum: number, maximum: number) {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-(--cp-text-secondary)">
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -348,7 +343,7 @@ function clamp(value: number, minimum: number, maximum: number) {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-[4px] border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -366,18 +361,28 @@ function clamp(value: number, minimum: number, maximum: number) {
</div>
<div class="flex items-start gap-2">
<BaseInput
v-model="customInput"
class="min-w-0 flex-1"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
autocomplete="off"
:disabled="props.disabled"
:error="inputError"
@change="updateFromInput"
@keydown.enter="confirm"
/>
<BaseButton size="default" variant="default" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
aria-describedby="color-input-error"
@change="updateFromInput"
@keydown.enter="confirm"
/>
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
确定
</BaseButton>
</div>
@@ -1,28 +1,23 @@
<script setup lang="ts">
import BaseButton from './BaseButton.vue'
import BaseModal from './BaseModal.vue'
type ConfirmVariant = 'info' | 'warning' | 'danger' | 'success'
type ButtonVariant = 'primary' | 'success' | 'warning' | 'danger'
import BaseModal from './BaseModal/index.vue'
const props = withDefaults(
defineProps<{
title: string
description?: string
variant?: ConfirmVariant
destructive?: boolean
confirmText?: string
cancelText?: string
loading?: boolean
confirmDisabled?: boolean
width?: string | number
}>(),
{
variant: 'warning',
destructive: false,
confirmText: '确认',
cancelText: '取消',
loading: false,
confirmDisabled: false,
width: '480px',
},
)
@@ -31,13 +26,6 @@ const emit = defineEmits<{
cancel: []
}>()
const open = defineModel<boolean>({ default: false })
const confirmVariantMap: Record<ConfirmVariant, ButtonVariant> = {
info: 'primary',
warning: 'warning',
danger: 'danger',
success: 'success',
}
function handleCancel() {
if (props.loading)
return
@@ -57,23 +45,24 @@ function handleConfirm() {
v-model="open"
:title="title"
:description="description"
:variant="variant"
:width="width"
:close-disabled="loading"
size="sm"
:tone="destructive ? 'danger' : 'warning'"
role="alertdialog"
:dismissible="!loading"
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-(--cp-text-secondary)"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
>
<slot />
</div>
<template #footer>
<BaseButton variant="default" :disabled="loading" @click="handleCancel">
<BaseButton variant="secondary" :disabled="loading" @click="handleCancel">
{{ cancelText }}
</BaseButton>
<BaseButton
:variant="confirmVariantMap[variant]"
:variant="destructive ? 'destructive' : 'primary'"
:loading="loading"
:disabled="confirmDisabled"
@click="handleConfirm"
+23 -14
View File
@@ -3,13 +3,22 @@ import type { Component } from 'vue'
import { Inbox } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{
title?: string
description?: string
icon?: Component
compact?: boolean
plain?: boolean
}>()
const props = withDefaults(
defineProps<{
title?: string
description?: string
icon?: Component
size?: 'sm' | 'md'
surface?: 'subtle' | 'none'
}>(),
{
title: undefined,
description: undefined,
icon: undefined,
size: 'md',
surface: 'subtle',
},
)
const resolvedTitle = computed(() => props.title ?? '暂无数据')
const resolvedIcon = computed(() => props.icon ?? Inbox)
@@ -19,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
plain ? 'bg-transparent' : 'rounded-(--cp-panel-radius) bg-(--cp-bg-subtle)',
compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-muted)"
:class="compact ? 'size-8' : 'size-10'"
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="compact ? 16 : 18" />
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-(--cp-text-primary)">
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-(--cp-text-secondary)"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
>
{{ description }}
</p>
@@ -1,18 +1,36 @@
<script setup lang="ts">
withDefaults(
import { computed, provide, useId } from 'vue'
import { formFieldKey } from './context'
const props = withDefaults(
defineProps<{
label?: string
description?: string
error?: string
required?: boolean
controlId?: string
}>(),
{
label: undefined,
description: undefined,
error: undefined,
required: false,
controlId: undefined,
},
)
const generatedId = useId()
const resolvedControlId = computed(() => props.controlId ?? `field-${generatedId}`)
const descriptionId = computed(() => props.description ? `${resolvedControlId.value}-description` : undefined)
const errorId = computed(() => props.error ? `${resolvedControlId.value}-error` : undefined)
const describedBy = computed(() => [descriptionId.value, errorId.value].filter(Boolean).join(' ') || undefined)
provide(formFieldKey, {
controlId: resolvedControlId,
describedBy,
invalid: computed(() => Boolean(props.error)),
required: computed(() => props.required),
})
</script>
<template>
@@ -21,33 +39,36 @@ withDefaults(
v-if="label || $slots.label || $slots.extra"
class="mb-2 flex min-w-0 items-center justify-between gap-3"
>
<div class="flex min-w-0 items-center gap-1.5">
<slot name="label">
<span
v-if="label"
class="min-w-0 text-[13px] leading-none font-medium text-(--cp-text-secondary)"
>
{{ label }}
</span>
</slot>
<span v-if="required" class="text-[13px] leading-none font-bold text-(--cp-danger)">
*
</span>
</div>
<!-- The runtime id comes from the injected field context. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
<slot name="extra" />
</div>
</div>
<p
v-if="description"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-(--cp-text-muted)"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
>
{{ description }}
</p>
<div class="min-w-0">
<slot />
</div>
<p v-if="error" class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-(--cp-danger-text)">
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
aria-live="polite"
>
{{ error }}
</p>
</div>
@@ -0,0 +1,10 @@
import type { ComputedRef, InjectionKey } from 'vue'
interface FormFieldContext {
controlId: ComputedRef<string>
describedBy: ComputedRef<string | undefined>
invalid: ComputedRef<boolean>
required: ComputedRef<boolean>
}
export const formFieldKey: InjectionKey<FormFieldContext> = Symbol('form-field')
@@ -1,52 +1,5 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
type FormGap = 'compact' | 'default' | 'loose'
const props = withDefaults(
defineProps<{
columns?: number | string
gap?: FormGap
}>(),
{
columns: 1,
gap: 'default',
},
)
const gapClasses: Record<FormGap, string> = {
compact: 'gap-3',
default: 'gap-4',
loose: 'gap-5',
}
const formClasses = computed(() => ['base-form grid min-w-0 grid-cols-1', gapClasses[props.gap]])
const formStyle = computed<CSSProperties>(() => {
if (typeof props.columns === 'number') {
return {
'--cp-form-columns':
props.columns > 1 ? `repeat(${props.columns}, minmax(0, 1fr))` : 'minmax(0, 1fr)',
} as CSSProperties
}
return {
'--cp-form-columns': props.columns,
} as CSSProperties
})
</script>
<template>
<form :class="formClasses" :style="formStyle" @submit.prevent>
<form class="grid min-w-0 gap-4" @submit.prevent>
<slot />
</form>
</template>
<style scoped>
@media (min-width: 640px) {
.base-form {
grid-template-columns: var(--cp-form-columns);
}
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
type IconButtonVariant = 'primary' | 'secondary' | 'success' | 'ghost' | 'destructive'
type IconButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
label: string
variant?: IconButtonVariant
size?: IconButtonSize
loading?: boolean
disabled?: boolean
pressed?: boolean
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'ghost',
size: 'md',
loading: false,
disabled: false,
pressed: false,
type: 'button',
},
)
defineSlots<{
loading?: () => unknown
default: () => unknown
}>()
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
const spinnerSizes: Record<IconButtonSize, number> = {
sm: 14,
md: 16,
lg: 18,
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
])
</script>
<template>
<button
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="label"
:aria-pressed="pressed || undefined"
:aria-busy="loading || undefined"
:title="label"
>
<span
v-if="loading"
class="inline-grid place-items-center [&>svg]:block [&>svg]:origin-center [&>svg]:transform-view [&>svg]:will-change-transform"
aria-hidden="true"
>
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
:size="spinnerSizes[size]"
/>
</slot>
</span>
<span v-else class="inline-grid place-items-center" aria-hidden="true">
<slot />
</span>
</button>
</template>
+58 -48
View File
@@ -1,99 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject, useAttrs } from 'vue'
import { formFieldKey } from './BaseForm/context'
type InputSize = 'sm' | 'md' | 'lg'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
placeholder?: string
type?: string
disabled?: boolean
error?: string
autocomplete?: string
name?: string
ariaLabel?: string
min?: number | string
max?: number | string
size?: InputSize
}>(),
{
placeholder: '',
type: 'text',
disabled: false,
error: undefined,
autocomplete: undefined,
name: undefined,
ariaLabel: undefined,
min: undefined,
max: undefined,
size: 'md',
},
)
const model = defineModel<string>({ default: '' })
const attrs = useAttrs()
const field = inject(formFieldKey, null)
// number 输入也保持字符串契约,避免原生 v-model 隐式转换为 number。
function updateModel(event: Event) {
model.value = (event.target as HTMLInputElement).value
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
const invalid = computed(() => Boolean(
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
))
const describedBy = computed(() => [
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
field?.describedBy.value,
].filter(Boolean).join(' ') || undefined)
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const controlAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => ![
'class',
'style',
'id',
'aria-describedby',
'aria-invalid',
'aria-required',
].includes(key)),
))
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
}
const containerClasses = computed(() => [
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
: props.error
? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)'
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-(--cp-shadow-input-focus)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-(--cp-disabled-icon)'
: props.error
? 'text-(--cp-danger)'
: 'text-(--cp-text-muted)',
? 'text-cp-disabled-icon'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
])
const inputClasses = computed(() => [
'h-full min-w-0 flex-1 border-0 bg-transparent text-[13px] font-emphasis leading-[1.15] outline-0',
'placeholder:text-(--cp-text-muted) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)',
props.error
? 'text-(--cp-danger-text)'
: props.disabled
? 'text-(--cp-disabled-text)'
: 'text-(--cp-text-primary)',
])
function updateModel(event: Event) {
model.value = (event.target as HTMLInputElement).value
}
</script>
<template>
<div class="grid min-w-0 gap-2">
<div v-bind="rootAttrs" class="min-w-0">
<span class="base-input__control" :class="containerClasses">
<span v-if="$slots.prefix" :class="iconClasses">
<span v-if="$slots.prefix" :class="iconClasses" aria-hidden="true">
<slot name="prefix" />
</span>
<input
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field"
:class="inputClasses"
:name="name"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
:autocomplete="autocomplete"
:min="min"
:max="max"
:aria-label="ariaLabel"
:aria-invalid="error ? 'true' : undefined"
:required="field?.required.value || undefined"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
@input="updateModel"
>
<span v-if="$slots.suffix" :class="iconClasses">
<slot name="suffix" />
</span>
</span>
<span v-if="error" class="text-xs font-emphasis leading-[1.15] text-(--cp-danger-text)">{{
error
}}</span>
</div>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
tone?: 'neutral' | 'destructive'
disabled?: boolean
loading?: boolean
}>(),
{
tone: 'neutral',
disabled: false,
loading: false,
},
)
defineSlots<{
icon?: () => unknown
loading?: () => unknown
default: () => unknown
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
])
</script>
<template>
<button
type="button"
:class="classes"
:disabled="disabled || loading"
:aria-busy="loading || undefined"
>
<span v-if="loading" class="inline-grid size-3.5 shrink-0 place-items-center" aria-hidden="true">
<slot name="loading">
<LoaderCircle class="size-3.5 animate-spin motion-reduce:animate-none" />
</slot>
</span>
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
<slot name="icon" />
</span>
<span class="min-w-0 flex-1 truncate"><slot /></span>
</button>
</template>
@@ -0,0 +1,31 @@
import { useScrollLock } from '@vueuse/core'
let bodyScrollLocked: ReturnType<typeof useScrollLock> | undefined
let lockCount = 0
function getBodyScrollLock() {
if (typeof document === 'undefined')
return undefined
bodyScrollLocked ??= useScrollLock(document.body)
return bodyScrollLocked
}
export function lockBodyScroll() {
const scrollLock = getBodyScrollLock()
if (!scrollLock)
return
lockCount += 1
if (lockCount === 1)
scrollLock.value = true
}
export function unlockBodyScroll() {
if (!bodyScrollLocked)
return
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0)
bodyScrollLocked.value = false
}
@@ -1,26 +1,36 @@
<script setup lang="ts">
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
import BaseButton from './BaseButton.vue'
import BaseScrollbar from './BaseScrollbar.vue'
import BaseIconButton from '../BaseIconButton.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
const props = defineProps<{
title: string
description?: string
width?: string | number
variant?: ModalVariant
closeDisabled?: boolean
bodyMaxHeight?: string
bodyViewClass?: string
hideFooter?: boolean
}>()
const props = withDefaults(
defineProps<{
title: string
description?: string
size?: ModalSize
tone?: ModalTone
dismissible?: boolean
role?: 'dialog' | 'alertdialog'
}>(),
{
description: undefined,
size: 'md',
tone: 'neutral',
dismissible: true,
role: 'dialog',
},
)
const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLElement>('panel')
const titleId = useId()
const descriptionId = useId()
let previouslyFocused: HTMLElement | null = null
let ownsScrollLock = false
@@ -33,57 +43,46 @@ const focusableSelector = [
'[tabindex]:not([tabindex="-1"])',
].join(',')
const variant = computed(() => props.variant ?? 'default')
const modalStyle = computed(() => {
const preferredWidth
= typeof props.width === 'number' ? `${props.width}px` : (props.width ?? '560px')
return {
width: `min(${preferredWidth}, calc(100dvw - 1.5rem))`,
}
})
const bodyClass = computed(() => [
'min-h-0 overflow-hidden py-4 pr-4 pl-3.25 sm:py-7 sm:pr-7 sm:pl-6.25',
props.description || variant.value !== 'default' ? 'pt-4 sm:pt-5' : 'pt-4 sm:pt-6',
])
const scrollViewClass = computed(() =>
['pb-0.75 pl-0.75 pr-3 sm:pr-4', props.bodyViewClass].filter(Boolean).join(' '),
)
const sizeClasses: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
}
const iconMap = {
default: Info,
neutral: Info,
info: Info,
warning: AlertTriangle,
danger: AlertCircle,
success: CheckCircle2,
}
const variantClasses: Record<ModalVariant, { iconBg: string, icon: string }> = {
default: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
neutral: {
iconBg: 'bg-cp-info-bg',
icon: 'text-cp-info',
},
info: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
iconBg: 'bg-cp-info-bg',
icon: 'text-cp-info',
},
warning: {
iconBg: 'bg-(--cp-warning-bg)',
icon: 'text-(--cp-warning)',
iconBg: 'bg-cp-warning-bg',
icon: 'text-cp-warning',
},
danger: {
iconBg: 'bg-(--cp-danger-bg)',
icon: 'text-(--cp-danger)',
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
},
success: {
iconBg: 'bg-(--cp-success-bg)',
icon: 'text-(--cp-success)',
iconBg: 'bg-cp-success-bg',
icon: 'text-cp-success',
},
}
function closeModal() {
if (props.closeDisabled)
if (!props.dismissible)
return
open.value = false
}
@@ -187,74 +186,68 @@ onBeforeUnmount(() => {
type="button"
tabindex="-1"
aria-label="关闭弹窗"
class="absolute inset-0 cursor-default border-0 bg-(--cp-overlay-scrim) p-0"
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
@click="closeModal"
/>
<section
ref="panel"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover) sm:max-h-[calc(100dvh-3rem)]"
:style="modalStyle"
role="dialog"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
:class="sizeClasses[size]"
:role="role"
aria-modal="true"
:aria-label="title"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined"
tabindex="-1"
>
<header
class="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_28px] gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
:class="tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
>
<span
v-if="variant !== 'default'"
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
:class="$slots.icon ? 'bg-(--cp-bg-subtle)' : variantClasses[variant].iconBg"
v-if="tone !== 'neutral'"
class="inline-flex size-11 items-center justify-center rounded-cp-control"
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
>
<slot name="icon">
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
</slot>
</span>
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
<h2 class="m-0 text-lg leading-[1.15] font-heavy text-(--cp-text-primary)">
<div class="min-w-0">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
{{ title }}
</h2>
<p
v-if="description"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-(--cp-text-secondary)"
:id="descriptionId"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
>
{{ description }}
</p>
</div>
<BaseButton
icon-only
class="col-start-3"
<BaseIconButton
label="关闭"
size="sm"
variant="ghost"
:disabled="closeDisabled"
:disabled="!dismissible"
@click="closeModal"
>
<X :size="16" />
</BaseButton>
</BaseIconButton>
</header>
<div :class="bodyClass">
<BaseScrollbar
class="h-full -mr-3 sm:-mr-4"
:max-height="bodyMaxHeight"
:view-class="scrollViewClass"
>
<slot />
<div class="min-h-0 overflow-hidden px-4 py-4 sm:px-7 sm:py-6">
<BaseScrollbar class="h-full">
<div class="p-0.75">
<slot />
</div>
</BaseScrollbar>
</div>
<footer
v-if="!hideFooter"
v-if="$slots.footer"
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-7 sm:pb-7"
>
<slot name="footer">
<BaseButton variant="default" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" @click="open = false">
确认
</BaseButton>
</slot>
<slot name="footer" />
</footer>
</section>
</div>
@@ -1,27 +1,21 @@
<script setup lang="ts">
const props = defineProps<{
defineProps<{
title: string
description?: string
actionsInline?: boolean
}>()
</script>
<template>
<header
class="grid min-h-17 shrink-0 items-start"
:class="
props.actionsInline
? 'grid-cols-[minmax(0,1fr)_auto] gap-x-4'
: 'grid-cols-1 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-x-4'
"
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
>
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
<div class="min-w-0 flex-1 basis-64">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
{{ title }}
</h1>
<p
v-if="description || $slots.description"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
>
<slot name="description">
{{ description }}
@@ -31,7 +25,6 @@ const props = defineProps<{
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
:class="props.actionsInline ? undefined : 'max-sm:w-full'"
>
<slot name="actions" />
</div>
+9 -40
View File
@@ -27,12 +27,9 @@ const props = withDefaults(
placement?: PopoverPlacement
trigger?: PopoverTrigger
offset?: number
width?: number | string
disabled?: boolean
/** 包含 hover 的触发方式打开弹窗的延迟(毫秒);click 触发不生效。 */
hoverDelay?: number
panelClass?: string
triggerClass?: string
anchorElement?: HTMLElement | null
animatePosition?: boolean
}>(),
@@ -42,8 +39,6 @@ const props = withDefaults(
offset: 6,
disabled: false,
hoverDelay: 400,
panelClass: '',
triggerClass: '',
anchorElement: null,
animatePosition: false,
},
@@ -53,7 +48,6 @@ const open = defineModel<boolean>({ default: false })
const attrs = useAttrs()
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref<CSSProperties>({})
const popoverArrowStyle = ref<CSSProperties>({})
@@ -62,39 +56,21 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
props.panelClass,
])
const sizeStyle = computed<CSSProperties>(() => {
const width = toCssLength(props.width)
return width ? { width } : {}
})
function toCssLength(value?: number | string) {
if (typeof value === 'number') {
return `${value}px`
}
const trimmed = value?.trim()
return trimmed || undefined
}
function updatePopoverPosition() {
const anchorElement = props.anchorElement ?? triggerRef.value
const anchorElement = props.anchorElement ?? rootRef.value
if (!open.value || !anchorElement)
return
const viewportPadding = 8
const triggerRect = anchorElement.getBoundingClientRect()
const panelRect = popoverRef.value?.getBoundingClientRect()
const panelWidth = Math.max(
panelRect?.width ?? 0,
toCssLength(props.width) ? 0 : triggerRect.width,
)
const panelWidth = panelRect?.width ?? 0
const panelHeight = panelRect?.height ?? 0
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
@@ -113,7 +89,6 @@ function updatePopoverPosition() {
left: `${left}px`,
top: `${top}px`,
maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
...(toCssLength(props.width) ? {} : { minWidth: `${triggerRect.width}px` }),
}
popoverArrowStyle.value = popoverArrowPosition({
placement: position.placement,
@@ -355,12 +330,12 @@ watch(
)
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
useEventListener(triggerRef, 'click', (event) => {
useEventListener(rootRef, 'click', (event) => {
event.stopPropagation()
togglePopover()
})
useEventListener(triggerRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(triggerRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(rootRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(rootRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(popoverRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(popoverRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(viewportTarget, 'keydown', (event) => {
@@ -377,14 +352,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootRef" class="relative inline-block overflow-visible" v-bind="attrs">
<div
ref="triggerRef"
class="inline-flex"
:class="props.triggerClass"
>
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
</div>
<div ref="rootRef" class="relative inline-flex overflow-visible" v-bind="attrs">
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
<Teleport to="body">
<Transition
@@ -399,7 +368,7 @@ onBeforeUnmount(() => {
v-if="open"
ref="popoverRef"
:class="popoverClasses"
:style="[sizeStyle, popoverStyle]"
:style="popoverStyle"
>
<span
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
+51 -26
View File
@@ -5,20 +5,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplate
const props = withDefaults(
defineProps<{
viewClass?: string
maxHeight?: string
height?: string
horizontal?: boolean
vertical?: boolean
trackInset?: 'default' | 'none'
}>(),
{
viewClass: '',
maxHeight: undefined,
height: undefined,
horizontal: false,
vertical: true,
trackInset: 'default',
},
)
@@ -61,7 +57,6 @@ let horizontalDragStartScrollLeft = 0
let scrollTopPosition = 0
let scrollLeftPosition = 0
let scrollFrameId: number | undefined
let rootHovering = false
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
immediate: false,
})
@@ -91,16 +86,10 @@ const wrapClasses = computed(() => [
props.maxHeight ? undefined : 'h-full',
])
const verticalTrackClass = computed(() =>
props.horizontal && canScrollX.value ? 'bottom-3' : 'bottom-1',
props.horizontal && canScrollX.value ? 'bottom-1.5' : 'bottom-0',
)
const horizontalTrackClass = computed(() =>
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
)
const verticalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'right-0' : 'right-1',
)
const horizontalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
props.vertical && canScrollY.value ? 'right-1.5' : 'right-0',
)
function showScrollbar() {
@@ -113,7 +102,10 @@ function clearHideTimer() {
function scheduleHideScrollbar() {
clearHideTimer()
if (!rootHovering) {
if (!dragging.value
&& !horizontalDragging.value
&& !verticalTrackHovering.value
&& !horizontalTrackHovering.value) {
startHideTimer()
}
}
@@ -129,12 +121,10 @@ function activateScrollbar() {
}
function handleRootMouseEnter() {
rootHovering = true
activateScrollbar()
}
function handleRootMouseLeave() {
rootHovering = false
hideScrollbar()
}
@@ -152,7 +142,7 @@ function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
function handleTrackMouseEnter(axis: ScrollbarAxis) {
setTrackHovering(axis, true)
showScrollbar()
scheduleHideScrollbar()
clearHideTimer()
}
function handleTrackMouseLeave(axis: ScrollbarAxis) {
@@ -182,7 +172,12 @@ function measureVerticalScrollbar(wrap: HTMLElement) {
const clientHeight = wrap.clientHeight
const scrollHeight = wrap.scrollHeight
const scrollRange = clamp(scrollHeight - clientHeight, 0, Number.POSITIVE_INFINITY)
const availableTrackHeight = clamp(clientHeight - 8, 0, Number.POSITIVE_INFINITY)
const availableTrackHeight = trackLength(
verticalTrackRef.value,
clientHeight,
'top',
'bottom',
)
if (scrollRange <= overflowTolerance || availableTrackHeight <= 0) {
wrap.scrollTop = 0
verticalMetrics.scrollRange = 0
@@ -209,7 +204,12 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
const clientWidth = wrap.clientWidth
const scrollWidth = wrap.scrollWidth
const scrollRange = clamp(scrollWidth - clientWidth, 0, Number.POSITIVE_INFINITY)
const availableTrackWidth = clamp(clientWidth - 8, 0, Number.POSITIVE_INFINITY)
const availableTrackWidth = trackLength(
horizontalTrackRef.value,
clientWidth,
'left',
'right',
)
if (scrollRange <= overflowTolerance || availableTrackWidth <= 0) {
wrap.scrollLeft = 0
horizontalMetrics.scrollRange = 0
@@ -225,6 +225,28 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
horizontalThumbWidth.value = nextThumbWidth
}
function trackLength(
track: HTMLElement | null,
viewportLength: number,
startProperty: 'top' | 'left',
endProperty: 'bottom' | 'right',
) {
if (!track)
return clamp(viewportLength - 8, 0, Number.POSITIVE_INFINITY)
const style = getComputedStyle(track)
const start = Number.parseFloat(style[startProperty])
const end = Number.parseFloat(style[endProperty])
const inset = (Number.isFinite(start) ? start : 4) + (Number.isFinite(end) ? end : 4)
const root = rootRef.value
const containerLength = root
? startProperty === 'top'
? root.clientHeight
: root.clientWidth
: viewportLength
return clamp(containerLength - inset, 0, Number.POSITIVE_INFINITY)
}
function updateVerticalThumbPosition(scrollTop: number) {
const { scrollRange, thumbRange } = verticalMetrics
thumbTop.value
@@ -448,6 +470,7 @@ useEventListener(wrapRef, 'scroll', handleScroll, { passive: true })
useEventListener(dragDocument, 'pointermove', handleDocumentPointerMove)
useEventListener(dragDocument, ['pointerup', 'pointercancel'], handleDocumentPointerUp)
useEventListener(rootRef, 'mouseenter', handleRootMouseEnter)
useEventListener(rootRef, 'mousemove', activateScrollbar, { passive: true })
useEventListener(rootRef, 'mouseleave', handleRootMouseLeave)
useEventListener(verticalTrackRef, 'mouseenter', () => handleTrackMouseEnter('vertical'))
useEventListener(verticalTrackRef, 'mouseleave', () => handleTrackMouseLeave('vertical'))
@@ -471,7 +494,7 @@ defineExpose({
:style="{ maxHeight, height }"
>
<div ref="wrap" :class="wrapClasses" tabindex="0">
<div ref="view" :class="viewClass">
<div ref="view">
<slot />
</div>
</div>
@@ -480,15 +503,14 @@ defineExpose({
v-show="canScrollY"
ref="verticalTrack"
aria-hidden="true"
class="absolute top-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
class="base-scrollbar-track-y absolute right-0 bottom-0 z-40 flex w-3 justify-end transition-opacity duration-200"
:class="[
verticalTrackInsetClass,
verticalTrackClass,
verticalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
>
<div
class="w-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="thumbStyle"
@pointerdown="handleThumbPointerDown"
@@ -499,15 +521,14 @@ defineExpose({
v-show="canScrollX"
ref="horizontalTrack"
aria-hidden="true"
class="absolute z-40 h-1.5 rounded-full transition-opacity duration-200"
class="base-scrollbar-track-x absolute bottom-0 left-0 z-40 flex h-3 items-end transition-opacity duration-200"
:class="[
horizontalTrackInsetClass,
horizontalTrackClass,
horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
>
<div
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
@@ -526,4 +547,8 @@ defineExpose({
width: 0;
height: 0;
}
.base-scrollbar-track-y {
top: var(--cp-scrollbar-track-top, 0.25rem);
}
</style>
+83 -47
View File
@@ -1,27 +1,30 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
interface SegmentedOption {
import { clamp } from 'es-toolkit'
import { computed, nextTick } from 'vue'
type SegmentedSize = 'sm' | 'md' | 'lg'
export interface SegmentedOption {
label: string
value: string
icon?: Component
disabled?: boolean
}
type SegmentedSize = 'default' | 'input'
const props = withDefaults(
defineProps<{
label: string
options: SegmentedOption[]
disabled?: boolean
iconOnly?: boolean
display?: 'label' | 'icon'
size?: SegmentedSize
}>(),
{
disabled: false,
iconOnly: false,
size: 'default',
display: 'label',
size: 'md',
},
)
@@ -31,73 +34,106 @@ const activeIndex = computed(() => {
const index = props.options.findIndex(option => option.value === model.value)
return index >= 0 ? index : 0
})
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
}))
const isInputSize = computed(() => props.size === 'input')
const indicatorStyle = computed(() => ({
width: isInputSize.value
? `calc((100% - 6px) / ${optionCount.value})`
: `calc((100% - 8px) / ${optionCount.value})`,
width: `calc((100% - 6px) / ${optionCount.value})`,
transform: `translateX(${activeIndex.value * 100}%)`,
}))
const rootClasses = computed(() => isInputSize.value
? 'h-(--cp-input-height-default) rounded-(--cp-input-radius-base) p-0.75'
: 'h-9 rounded-(--cp-icon-button-radius) p-1')
const rootClasses: Record<SegmentedSize, string> = {
sm: 'h-cp-control-sm',
md: 'h-cp-control-md',
lg: 'h-cp-control-lg',
}
const optionClasses: Record<SegmentedSize, string> = {
sm: 'h-6.5 px-2 text-[11px]',
md: 'h-8 px-3 text-xs',
lg: 'h-9.5 px-4 text-[13px]',
}
const indicatorClasses = computed(() => isInputSize.value
? 'inset-y-0.75 left-0.75 rounded-(--cp-input-radius-base)'
: 'top-1 bottom-1 left-1 rounded-(--cp-input-radius-base)')
function enabledIndexes() {
return props.options.flatMap((option, index) => option.disabled ? [] : [index])
}
const optionHeightClass = computed(() => isInputSize.value ? 'h-8' : 'h-7')
function selectOption(value: string) {
if (props.disabled || value === model.value)
function selectOption(index: number) {
const option = props.options[index]
if (props.disabled || !option || option.disabled || option.value === model.value)
return
model.value = value
model.value = option.value
}
async function handleKeydown(event: KeyboardEvent, index: number) {
const indexes = enabledIndexes()
if (indexes.length === 0)
return
const current = indexes.indexOf(index)
let nextIndex: number | undefined
if (event.key === 'ArrowRight' || event.key === 'ArrowDown')
nextIndex = indexes[(current + 1 + indexes.length) % indexes.length]
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
nextIndex = indexes[(current - 1 + indexes.length) % indexes.length]
else if (event.key === 'Home')
nextIndex = indexes[0]
else if (event.key === 'End')
nextIndex = indexes[indexes.length - 1]
else
return
if (nextIndex === undefined)
return
event.preventDefault()
selectOption(nextIndex)
await nextTick()
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')
buttons?.[nextIndex]?.focus()
}
</script>
<template>
<div
class="relative inline-grid items-center bg-(--cp-bg-muted)"
:class="rootClasses"
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
:class="rootClasses[size]"
:style="gridStyle"
role="tablist"
role="radiogroup"
:aria-label="label"
:aria-disabled="disabled || undefined"
>
<span
class="pointer-events-none absolute bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200 ease-out"
:class="indicatorClasses"
v-if="options.length > 0"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
:style="indicatorStyle"
aria-hidden="true"
/>
<button
v-for="option in options"
v-for="(option, index) in options"
:key="option.value"
class="relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-emphasis transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
:class="[
optionHeightClass,
optionClasses[size],
model === option.value
? 'text-(--cp-text-primary)'
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)',
disabled ? 'cursor-not-allowed opacity-60 hover:text-(--cp-text-secondary)' : undefined,
iconOnly ? 'px-0' : undefined,
? 'text-cp-primary'
: 'text-cp-secondary hover:text-cp-primary',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
display === 'icon' ? 'px-0' : undefined,
]"
type="button"
role="tab"
:aria-selected="model === option.value"
:aria-label="iconOnly ? option.label : undefined"
:title="iconOnly ? option.label : undefined"
:disabled="disabled"
@click="selectOption(option.value)"
role="radio"
:aria-checked="model === option.value"
:aria-label="display === 'icon' ? option.label : undefined"
:title="display === 'icon' ? option.label : undefined"
:tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1"
:disabled="disabled || option.disabled"
@click="selectOption(index)"
@keydown="handleKeydown($event, index)"
>
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
<span v-if="!iconOnly">{{ option.label }}</span>
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" aria-hidden="true" />
<span v-if="display === 'label'" class="truncate">{{ option.label }}</span>
</button>
</div>
</template>
+71 -39
View File
@@ -1,18 +1,20 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { Check, ChevronDown } from '@lucide/vue'
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { computed, inject, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { formFieldKey } from './BaseForm/context'
interface SelectOption {
type SelectSize = 'sm' | 'md' | 'lg'
export interface SelectOption {
label: string
value: string
disabled?: boolean
}
type SelectSize = 'default' | 'compact'
defineOptions({
inheritAttrs: false,
})
@@ -24,19 +26,18 @@ const props = withDefaults(
disabled?: boolean
placeholder?: string
emptyText?: string
ariaLabel?: string
}>(),
{
size: 'default',
size: 'md',
disabled: false,
placeholder: '请选择',
emptyText: '暂无选项',
ariaLabel: undefined,
},
)
const model = defineModel<string>({ required: true })
const attrs = useAttrs()
const field = inject(formFieldKey, null)
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
@@ -44,7 +45,26 @@ const popoverRef = ref<HTMLElement | null>(null)
const open = ref(false)
const activeIndex = ref(-1)
const popoverStyle = ref<CSSProperties>({})
const selectId = `base-select-${Math.random().toString(36).slice(2)}`
const selectId = `base-select-${useId()}`
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : (field?.controlId.value ?? selectId))
const invalid = computed(() => Boolean(
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
))
const describedBy = computed(() => [
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
field?.describedBy.value,
].filter(Boolean).join(' ') || undefined)
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const triggerAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => ![
'class',
'style',
'id',
'aria-describedby',
'aria-invalid',
'aria-required',
].includes(key)),
))
const sizeConfig: Record<
SelectSize,
@@ -54,37 +74,44 @@ const sizeConfig: Record<
icon: number
}
> = {
default: {
md: {
trigger:
'h-(--cp-input-height-default) px-3.5 pr-9 text-[13px] rounded-(--cp-input-radius-base)',
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
option: 'h-8.5 px-3 text-[13px]',
icon: 16,
},
compact: {
trigger: 'h-8 px-2.5 pr-7 text-xs rounded-(--cp-input-radius-base)',
sm: {
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
option: 'h-8 px-2.5 text-xs',
icon: 14,
},
lg: {
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
option: 'h-10 px-3.5 text-[14px]',
icon: 17,
},
}
const selectedOption = computed(() => props.options.find(option => option.value === model.value))
const triggerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
sizeConfig[props.size].trigger,
props.disabled
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-(--cp-text-primary) shadow-(--cp-shadow-input-focus)'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-(--cp-text-primary)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-(--cp-shadow-input-focus)',
],
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
: invalid.value
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
],
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1 shadow-(--cp-shadow-popover)',
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -228,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
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-emphasis leading-none outline-none transition-colors',
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
sizeConfig[props.size].option,
option.disabled
? 'cursor-not-allowed bg-transparent text-(--cp-disabled-text)'
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
: option.value === model.value
? 'cursor-pointer bg-(--cp-info-bg) text-(--cp-info-text)'
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
: activeIndex.value === index
? 'cursor-pointer bg-(--cp-default-bg-hover) text-(--cp-text-primary)'
: 'cursor-pointer bg-transparent text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover)',
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
]
}
@@ -263,19 +290,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<div
ref="rootRef"
class="relative inline-block text-left"
v-bind="attrs"
v-bind="rootAttrs"
>
<button
:id="selectId"
v-bind="triggerAttrs"
:id="controlId"
ref="triggerRef"
type="button"
:class="triggerClasses"
:disabled="disabled"
role="combobox"
:aria-label="ariaLabel"
:aria-expanded="open"
:aria-controls="`${selectId}-listbox`"
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
@@ -286,21 +316,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
:class="
disabled
? 'text-(--cp-disabled-icon)'
? 'text-cp-disabled-icon'
: open
? 'rotate-180 text-(--cp-info)'
: 'text-(--cp-text-muted)'
? 'rotate-180 text-cp-info'
: 'text-cp-muted-text'
"
:size="sizeConfig[size].icon"
aria-hidden="true"
/>
</button>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
enter-from-class="-translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
@@ -311,11 +342,11 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
:class="popoverClasses"
:style="popoverStyle"
role="listbox"
:aria-labelledby="selectId"
:aria-labelledby="controlId"
>
<div
v-if="options.length === 0"
class="flex h-8.5 items-center rounded-(--cp-input-radius-small) px-3 text-[13px] leading-none font-emphasis text-(--cp-text-muted)"
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
>
{{ emptyText }}
</div>
@@ -338,8 +369,9 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<Check
v-if="option.value === model"
class="shrink-0 text-(--cp-info)"
:size="size === 'compact' ? 13 : 15"
class="shrink-0 text-cp-info"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
aria-hidden="true"
/>
</button>
</template>
+31 -35
View File
@@ -3,57 +3,53 @@ import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label: string
disabled?: boolean
label?: string
showLabel?: boolean
}>(),
{
disabled: false,
label: undefined,
showLabel: false,
},
)
const model = defineModel<boolean>({ default: false })
const buttonClasses = computed(() => [
'group inline-flex rounded-full border-0 bg-transparent p-0 outline-none transition-[background-color,box-shadow,opacity] duration-180',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
props.disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
])
const trackClasses = computed(() => [
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180',
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
props.disabled
? 'bg-(--cp-disabled-bg) shadow-none'
? 'bg-cp-disabled shadow-none'
: model.value
? 'bg-(--cp-info) shadow-(--cp-shadow-control) group-hover:bg-(--cp-info-hover)'
: 'bg-(--cp-bg-muted) shadow-(--cp-shadow-control) group-hover:bg-(--cp-default-bg-active)',
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
])
const thumbClasses = computed(() => [
'size-5 rounded-full bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-180 ease-out',
model.value ? 'translate-x-5' : 'translate-x-0',
])
function toggle() {
if (props.disabled)
return
model.value = !model.value
}
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="model"
:aria-label="label"
:disabled="disabled"
:class="buttonClasses"
@click="toggle"
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex items-center gap-2.5"
:class="disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'"
>
<span :class="trackClasses">
<span :class="thumbClasses" />
<input
v-model="model"
type="checkbox"
role="switch"
class="peer sr-only"
:aria-label="label"
:disabled="disabled"
>
<span
:class="trackClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
aria-hidden="true"
>
<span
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
:class="model ? 'translate-x-5' : 'translate-x-0'"
/>
</span>
</button>
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
</label>
</template>
@@ -3,9 +3,9 @@ import type { BaseTablePagination } from './pagination'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed } from 'vue'
import BaseIconButton from '../BaseIconButton.vue'
import BaseSelect from '../BaseSelect.vue'
import {
getCurrentPage,
getPagerItems,
getPageSizeOptions,
@@ -49,21 +49,12 @@ function goToPage(page: number) {
emit('pageChange', page)
}
function paginationButtonClass(disabled: boolean) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 bg-(--cp-bg-subtle) text-(--cp-text-secondary) transition-colors duration-150 outline-none',
disabled
? 'cursor-not-allowed opacity-45 shadow-none'
: 'cursor-pointer hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
]
}
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-(--cp-info) text-(--cp-info-on)'
: 'cursor-pointer bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
? 'cursor-default bg-cp-accent text-cp-accent-on'
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
]
}
</script>
@@ -73,7 +64,7 @@ function paginationPageClass(page: number) {
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-(--cp-text-secondary)"
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
@@ -84,28 +75,27 @@ function paginationPageClass(page: number) {
aria-label="每页条数"
:options="pageSizeOptions"
:disabled="loading"
size="compact"
size="sm"
class="w-28"
/>
<div class="flex items-center gap-2">
<button
type="button"
:class="paginationButtonClass(loading || currentPage <= 1)"
<BaseIconButton
variant="secondary"
size="sm"
:disabled="loading || currentPage <= 1"
title="上一页"
aria-label="上一页"
label="上一页"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="size-4" />
</button>
</BaseIconButton>
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-(--cp-text-muted)"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
>
...
…
</span>
<button
v-else
@@ -119,16 +109,15 @@ function paginationPageClass(page: number) {
</button>
</template>
<button
type="button"
:class="paginationButtonClass(loading || currentPage >= totalPages)"
<BaseIconButton
variant="secondary"
size="sm"
:disabled="loading || currentPage >= totalPages"
title="下一页"
aria-label="下一页"
label="下一页"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="size-4" />
</button>
</BaseIconButton>
</div>
</div>
</footer>
+205 -113
View File
@@ -1,113 +1,236 @@
import { clamp, sumBy } from 'es-toolkit'
// 行类型保持结构化约束;动态列 key 的取值统一走 cellValue。
export type TableRow = object
export type TableColumnKind
= | 'text'
| 'identity'
| 'meta'
| 'status'
| 'numeric'
| 'datetime'
| 'mono'
| 'index'
| 'selection'
| 'expander'
| 'actions'
| 'custom'
export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
export type TableColumnAlign = 'left' | 'right' | 'center'
type TableColumnSticky = 'left' | 'right'
export interface BaseTableColumn<Row extends TableRow = TableRow> {
key: string
label?: string
sortable?: boolean
sortKey?: string
kind?: TableColumnKind
size?: TableColumnSize
align?: TableColumnAlign
sortable?: boolean | string
format?: (value: unknown, row: Row) => unknown
width?: number | string
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left' | 'right' | false
align?: 'left' | 'right' | 'center'
ellipsis?: boolean
emptyText?: string
headerClass?: string
cellClass?: string
}
type BaseTableSortDirection = 'asc' | 'desc'
export interface BaseTableSort {
key: string
direction: BaseTableSortDirection
direction: 'asc' | 'desc'
}
export type ResolvedTableColumn<Row extends TableRow = TableRow> = Omit<
BaseTableColumn<Row>,
'fixed'
> & {
fixed?: 'left' | 'right'
resolvedWidth: string
resolvedMinWidth?: string
resolvedMaxWidth?: string
export interface BaseTableProps<Row extends TableRow> {
columns: BaseTableColumn<Row>[]
rows: Row[]
rowKey?: string | ((row: Row, index: number) => string | number)
selectedRowKeys?: Array<string | number>
expandedRowKeys?: Array<string | number>
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
sort?: BaseTableSort
}
interface ColumnRecipe {
size: TableColumnSize
basisWidth?: number
align: TableColumnAlign
truncate: boolean
contentClass?: string
paddingClass?: string
sticky?: TableColumnSticky
}
const columnWidths: Record<TableColumnSize, number> = {
'xs': 64,
'sm': 88,
'md': 112,
'lg': 144,
'xl': 184,
'2xl': 240,
'3xl': 288,
'4xl': 352,
}
const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
text: {
size: '2xl',
align: 'left',
truncate: true,
},
identity: {
size: '2xl',
align: 'left',
truncate: true,
},
meta: {
size: 'lg',
align: 'left',
truncate: true,
contentClass: 'text-cp-secondary',
},
status: {
size: 'md',
align: 'center',
truncate: false,
},
numeric: {
size: 'md',
align: 'right',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
},
datetime: {
size: 'xl',
align: 'left',
truncate: false,
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
},
mono: {
size: 'xl',
align: 'left',
truncate: true,
contentClass: 'font-mono text-[12px] font-emphasis',
},
index: {
size: 'xs',
align: 'center',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
},
selection: {
size: 'xs',
basisWidth: 48,
align: 'center',
truncate: false,
paddingClass: 'px-2',
sticky: 'left',
},
expander: {
size: 'xs',
basisWidth: 40,
align: 'center',
truncate: false,
paddingClass: 'px-2',
sticky: 'left',
},
actions: {
size: 'md',
align: 'left',
truncate: false,
paddingClass: 'px-3',
sticky: 'right',
},
custom: {
size: 'lg',
align: 'left',
truncate: false,
},
}
export interface ResolvedTableColumn<Row extends TableRow = TableRow>
extends BaseTableColumn<Row> {
kind: TableColumnKind
basisWidth: number
align: TableColumnAlign
truncate: boolean
contentClass?: string
paddingClass?: string
sticky?: TableColumnSticky
stickyOffset?: number
}
export function defineTableColumns<Row extends TableRow>(columns: BaseTableColumn<Row>[]) {
return columns
}
export function resolveColumns<Row extends TableRow>(
columns: BaseTableColumn<Row>[],
minWidth?: number | string,
): ResolvedTableColumn<Row>[] {
const actionColumnIndex = columns.findIndex(column => column.key === 'actions')
const fixedColumnIndex = actionColumnIndex >= 0 ? actionColumnIndex : 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 = sumBy(flexibleColumns, column => column.flex ?? 1)
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
return columns.map((column, index) => {
const flex = column.flex ?? 1
const automaticallyFixed
= index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined
const width
= column.width === undefined
? minWidthPixels > 0 && fixedPercentTotal === 0
? flexTotal > 0
? `${(availablePixels * flex) / flexTotal}px`
: `${availablePixels / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}px`
: flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}%`
: normalizeWidth(column.width)
const resolved = columns.map((column): ResolvedTableColumn<Row> => {
const kind = column.kind ?? 'text'
const recipe = columnRecipes[kind]
const basisWidth = recipe.basisWidth ?? columnWidths[column.size ?? recipe.size]
return {
...column,
fixed: column.fixed === false ? undefined : (column.fixed ?? automaticallyFixed),
resolvedWidth: width,
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
kind,
basisWidth,
align: column.align ?? recipe.align,
truncate: recipe.truncate,
contentClass: recipe.contentClass,
paddingClass: recipe.paddingClass,
sticky: recipe.sticky,
}
})
let leftOffset = 0
for (const column of resolved) {
if (column.sticky !== 'left')
continue
column.stickyOffset = leftOffset
leftOffset += column.basisWidth
}
let rightOffset = 0
for (const column of [...resolved].reverse()) {
if (column.sticky !== 'right')
continue
column.stickyOffset = rightOffset
rightOffset += column.basisWidth
}
return resolved
}
function normalizeWidth(width: number | string) {
return typeof width === 'number' ? `${width}px` : width
export function minimumTableWidth<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
return columns.reduce((total, column) => total + column.basisWidth, 0)
}
export function columnStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
export function tableStyle<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
return { width: `max(100%, ${minimumTableWidth(columns)}px)` }
}
export function columnStyle<Row extends TableRow>(
column: ResolvedTableColumn<Row>,
columns: ResolvedTableColumn<Row>[],
) {
const tableWidth = minimumTableWidth(columns)
const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
return {
width: column.resolvedWidth,
minWidth: column.resolvedMinWidth,
maxWidth: column.resolvedMaxWidth,
width: `${widthPercent}%`,
minWidth: `${column.basisWidth}px`,
}
}
export function tableStyle(minWidth?: number | string) {
if (minWidth === undefined) {
export function stickyStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
if (!column.sticky)
return undefined
}
return { width: `max(100%, ${normalizeWidth(minWidth)})` }
return { [column.sticky]: `${column.stickyOffset ?? 0}px` }
}
export function alignClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
if (column.align === 'center') {
export function alignClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
if (column.align === 'center')
return 'text-center'
}
if (column.align === 'right') {
if (column.align === 'right')
return 'text-right'
}
return 'text-left'
}
@@ -125,21 +248,15 @@ export function cellDisplayValue<Row extends TableRow>(column: BaseTableColumn<R
return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value
}
export function cellTitle<Row extends TableRow>(column: BaseTableColumn<Row>, row: Row) {
if (column.ellipsis === false || column.key === 'selection' || column.key === 'actions') {
export function cellTitle<Row extends TableRow>(column: ResolvedTableColumn<Row>, row: Row) {
if (!column.truncate)
return undefined
}
const value = cellDisplayValue(column, row)
if (isEmptyCellValue(value)) {
return undefined
}
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
}
export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
if (column.key === 'selection') {
export function cellContentClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
if (column.kind === 'selection' || column.kind === 'expander') {
return [
'flex min-w-0 items-center overflow-visible leading-none',
column.align === 'right'
@@ -149,36 +266,11 @@ export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<R
: 'justify-start',
]
}
if (column.key === 'actions') {
if (column.kind === 'actions')
return 'min-w-0 overflow-visible'
}
return ['min-w-0', column.ellipsis === false ? undefined : 'truncate']
return ['min-w-0', column.truncate ? 'truncate' : undefined]
}
function numericPercentWidth(width: number | string) {
if (typeof width === 'number') {
return 0
}
if (width.endsWith('%')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function numericPixelWidth(width: number | string) {
if (typeof width === 'number') {
return width
}
if (width.endsWith('px')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
export function columnSortKey<Row extends TableRow>(column: BaseTableColumn<Row>) {
return typeof column.sortable === 'string' ? column.sortable : column.key
}
+210 -276
View File
@@ -1,154 +1,111 @@
<script setup lang="ts" generic="Row extends object = Record<string, unknown>">
import type { BaseTableColumn, BaseTableSort, ResolvedTableColumn } from './columns'
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
import type { BaseTableProps, BaseTableSort, ResolvedTableColumn } from './columns'
import { Triangle } from '@lucide/vue'
import { computed, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onMounted, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import BaseTablePagination from './BaseTablePagination.vue'
import {
alignClass,
cellContentClass,
cellDisplayValue,
cellTitle,
cellValue,
columnSortKey,
columnStyle,
resolveColumns,
tableStyle as resolveTableStyle,
stickyStyle,
tableStyle,
} from './columns'
import { useHorizontalStickyShadow } from './useHorizontalStickyShadow'
type RowKey = string | ((row: Row, index: number) => string | number)
const props = withDefaults(
defineProps<{
columns: BaseTableColumn<Row>[]
rows: Row[]
rowKey?: RowKey
selectedRowKeys?: Array<string | number>
stripe?: boolean
compact?: boolean
loading?: boolean
emptyText?: string
maxHeight?: string
minWidth?: number | string
pagination?: BaseTablePaginationConfig
expandedRowKeys?: Array<string | number>
sort?: BaseTableSort
}>(),
{
rowKey: 'id',
selectedRowKeys: () => [],
expandedRowKeys: () => [],
stripe: true,
compact: false,
loading: false,
emptyText: '暂无数据',
maxHeight: undefined,
minWidth: undefined,
},
)
const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
rowKey: 'id',
selectedRowKeys: () => [],
expandedRowKeys: () => [],
density: 'default',
loading: false,
emptyText: '暂无数据',
sort: undefined,
})
const emit = defineEmits<{
pageChange: [page: number]
pageSizeChange: [pageSize: number]
sortChange: [sort: BaseTableSort | undefined]
}>()
const slots = useSlots()
const computedColumns = computed(() => resolveColumns(props.columns))
const resolvedTableStyle = computed(() => tableStyle(computedColumns.value))
const headerRowClass = computed(() => [
props.compact ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
'font-bold text-(--cp-text-muted)',
])
const bodyRowClass = computed(() => (props.compact ? 'h-10' : 'h-13'))
const headerCellClass = computed(() => [
'min-w-0 bg-(--cp-bg-subtle) first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]',
props.compact ? 'px-3' : 'px-4',
])
const bodyCellClass = computed(() => [
'min-w-0 first:pl-3 text-(--cp-text-primary) first:rounded-l-lg',
props.compact ? 'px-3 text-[12px]' : 'px-4 text-[13px]',
])
const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth))
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
const retainedRows = shallowRef<Row[]>([])
watch(
[() => props.rows, () => props.loading],
([rows, loading]) => {
if (rows.length > 0 || !loading) {
if (rows.length > 0 || !loading)
retainedRows.value = rows
}
},
{ immediate: true },
)
const displayRows = computed(() =>
props.loading && props.rows.length === 0 ? retainedRows.value : props.rows,
)
const displayRows = computed(() => props.loading && props.rows.length === 0 ? retainedRows.value : props.rows)
const hasRows = computed(() => displayRows.value.length > 0)
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const { horizontalScrolled, horizontalCanScrollRight, handleTableScroll }
= useHorizontalStickyShadow({
hasRows,
headerWrapRef,
bodyScrollbarRef,
tableViewRef,
watchSources: () => [displayRows.value.length, props.minWidth],
})
function fixedHeaderClass(column: ResolvedTableColumn<Row>) {
if (!column.fixed) {
return undefined
const scrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('scrollbar')
const tableRef = useTemplateRef<HTMLTableElement>('table')
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
function measureHorizontalScroll() {
const wrap = scrollbarRef.value?.wrapRef
if (!wrap) {
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
return
}
const showShadow
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
return [
'sticky z-30 bg-(--cp-bg-subtle)',
column.fixed === 'left' ? 'left-0' : 'right-0',
showShadow
? column.fixed === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: undefined,
]
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
horizontalScrolled.value = wrap.scrollLeft > 1
horizontalCanScrollRight.value = wrap.scrollLeft < range - 1
}
function fixedBodyClass(column: ResolvedTableColumn<Row>, row: Row, index: number) {
if (!column.fixed) {
return undefined
}
const showShadow
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
return [
'sticky z-20',
column.fixed === 'left' ? 'left-0' : 'right-0',
showShadow
? column.fixed === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: undefined,
isRowSelected(row, index)
? 'bg-(--cp-bg-tertiary)'
: props.stripe && index % 2 === 1
? 'bg-(--cp-bg-subtle)'
: 'bg-(--cp-bg-surface)',
]
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
const wrap = scrollbarRef.value?.wrapRef
if (!wrap)
return
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
horizontalScrolled.value = payload.scrollLeft > 1
horizontalCanScrollRight.value = payload.scrollLeft < range - 1
}
onMounted(async () => {
await nextTick()
measureHorizontalScroll()
})
useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), measureHorizontalScroll)
watch([() => displayRows.value.length, () => props.columns], async () => {
await nextTick()
measureHorizontalScroll()
})
const headerRowClass = computed(() => [
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
'font-bold text-cp-secondary',
])
const bodyRowClass = computed(() =>
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
)
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
const bodyCellFrameClass = computed(() => props.density === 'compact'
? 'border-y-2 border-transparent bg-clip-padding'
: 'border-y-[3px] border-transparent bg-clip-padding')
const firstRowTopGapClass = computed(() =>
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
)
const bodyCellContentClass = computed(() => props.density === 'compact'
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
function getRowKey(row: Row, index: number) {
if (typeof props.rowKey === 'function') {
if (typeof props.rowKey === 'function')
return props.rowKey(row, index)
}
const value = cellValue(row, props.rowKey)
return typeof value === 'string' || typeof value === 'number' ? value : index
}
@@ -161,46 +118,56 @@ function isRowExpanded(row: Row, index: number) {
return props.expandedRowKeys.includes(getRowKey(row, index))
}
function rowClass(row: Row, index: number) {
function rowBackgroundClass(row: Row, index: number) {
if (isRowSelected(row, index))
return 'bg-cp-tertiary-bg'
if (index % 2 === 1)
return 'bg-cp-subtle'
return 'bg-cp-surface'
}
function rowClass() {
return [
bodyRowClass.value,
'hover:[&>td]:bg-(--cp-default-bg-hover)',
props.stripe && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : undefined,
isRowSelected(row, index) ? 'bg-(--cp-bg-tertiary)' : undefined,
'hover:[&>td]:bg-cp-default-hover',
]
}
function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
if (!column.sticky)
return undefined
const showShadow = column.sticky === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
return [
'sticky',
header ? 'z-40' : 'z-20',
showShadow
? column.sticky === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: undefined,
]
}
function bodyCellTitle(column: BaseTableColumn<Row>, row: Row) {
function bodyCellTitle(column: ResolvedTableColumn<Row>, row: Row) {
return slots[column.key] ? undefined : cellTitle(column, row)
}
function columnSortKey(column: BaseTableColumn<Row>) {
return column.sortKey || column.key
}
function columnSortDirection(column: BaseTableColumn<Row>) {
function columnSortDirection(column: ResolvedTableColumn<Row>) {
return props.sort?.key === columnSortKey(column) ? props.sort.direction : undefined
}
function toggleColumnSort(column: BaseTableColumn<Row>) {
function toggleColumnSort(column: ResolvedTableColumn<Row>) {
const key = columnSortKey(column)
const direction = columnSortDirection(column)
if (!direction) {
if (!direction)
emit('sortChange', { key, direction: 'asc' })
}
else if (direction === 'asc') {
else if (direction === 'asc')
emit('sortChange', { key, direction: 'desc' })
}
else {
else
emit('sortChange', undefined)
}
}
function columnAriaSort(column: BaseTableColumn<Row>) {
function columnAriaSort(column: ResolvedTableColumn<Row>) {
const direction = columnSortDirection(column)
if (direction === 'asc')
return 'ascending'
@@ -209,7 +176,7 @@ function columnAriaSort(column: BaseTableColumn<Row>) {
return column.sortable ? 'none' : undefined
}
function sortButtonLabel(column: BaseTableColumn<Row>) {
function sortButtonLabel(column: ResolvedTableColumn<Row>) {
const direction = columnSortDirection(column)
if (!direction)
return `${column.label || column.key}:升序排列`
@@ -221,126 +188,101 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
<template>
<div class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
<div
v-loading="loading"
class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3"
>
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
class="w-full shrink-0 table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-0.5' : 'border-spacing-y-1'"
:style="tableViewStyle"
>
<colgroup>
<col
v-for="column in computedColumns"
:key="column.key"
:style="columnStyle(column)"
>
</colgroup>
<thead>
<tr :class="headerRowClass">
<th
v-for="(column, index) in computedColumns"
:key="column.key"
class="bg-(--cp-bg-subtle)"
:class="[
headerCellClass,
alignClass(column),
isLastColumn(index) ? 'rounded-r-lg pr-3' : undefined,
fixedHeaderClass(column),
column.headerClass,
]"
scope="col"
:aria-sort="columnAriaSort(column)"
>
<div :class="cellContentClass(column)">
<button
v-if="column.sortable"
type="button"
class="inline-flex max-w-full cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-(--cp-text-primary) focus-visible:text-(--cp-info)"
:aria-label="sortButtonLabel(column)"
:title="sortButtonLabel(column)"
@click="toggleColumnSort(column)"
>
<span class="truncate">
<slot :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</span>
<span
class="inline-flex shrink-0 -translate-y-px flex-col gap-px"
aria-hidden="true"
>
<Triangle
class="size-1.25 fill-current"
:class="
columnSortDirection(column) === 'asc'
? 'text-(--cp-info)'
: 'text-(--cp-text-tertiary)'
"
:stroke-width="0"
/>
<Triangle
class="size-1.25 rotate-180 fill-current"
:class="
columnSortDirection(column) === 'desc'
? 'text-(--cp-info)'
: 'text-(--cp-text-tertiary)'
"
:stroke-width="0"
/>
</span>
</button>
<slot v-else :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</div>
</th>
</tr>
</thead>
</table>
</div>
<BaseScrollbar
v-if="hasRows"
ref="bodyScrollbar"
class="min-h-0 flex-1"
horizontal
:max-height="maxHeight"
track-inset="none"
@scroll="handleTableScroll"
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden">
<BaseScrollbar
v-if="hasRows"
ref="scrollbar"
class="min-h-0 flex-1"
:class="density === 'compact'
? '[--cp-scrollbar-track-top:2.25rem]'
: '[--cp-scrollbar-track-top:2.75rem]'"
horizontal
@scroll="handleTableScroll"
>
<table
ref="table"
class="table-fixed border-separate border-spacing-0 text-left"
:style="resolvedTableStyle"
>
<table
ref="tableView"
class="w-full table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-1' : 'border-spacing-y-2'"
:style="tableViewStyle"
>
<colgroup>
<col
v-for="column in computedColumns"
<colgroup>
<col
v-for="column in computedColumns"
:key="column.key"
:style="columnStyle(column, computedColumns)"
>
</colgroup>
<thead>
<tr :class="headerRowClass">
<th
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
:style="columnStyle(column)"
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
:class="[
column.paddingClass ?? cellPaddingClass,
alignClass(column),
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
stickyClass(column, true),
]"
:style="stickyStyle(column)"
scope="col"
:aria-sort="columnAriaSort(column)"
>
</colgroup>
<tbody>
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
<tr :class="rowClass(row, index)">
<td
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
:class="[
bodyCellClass,
alignClass(column),
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
fixedBodyClass(column, row, index),
column.cellClass,
]"
<div :class="cellContentClass(column)">
<button
v-if="column.sortable"
type="button"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
:aria-label="sortButtonLabel(column)"
:title="sortButtonLabel(column)"
@click="toggleColumnSort(column)"
>
<span class="truncate">
<slot :name="`header-${column.key}`" :column="column">{{ column.label }}</slot>
</span>
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
<Triangle
class="size-1.25 fill-current"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:stroke-width="0"
/>
<Triangle
class="size-1.25 rotate-180 fill-current"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:stroke-width="0"
/>
</span>
</button>
<slot v-else :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</div>
</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
<tr :class="rowClass()" :aria-selected="isRowSelected(row, index) || undefined">
<td
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
class="min-w-0"
:class="[
column.paddingClass ?? cellPaddingClass,
bodyTextClass,
bodyCellFrameClass,
column.contentClass,
alignClass(column),
index === 0 ? firstRowTopGapClass : undefined,
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
stickyClass(column),
rowBackgroundClass(row, index),
]"
:style="stickyStyle(column)"
>
<div class="grid content-center" :class="bodyCellContentClass">
<div :class="cellContentClass(column)" :title="bodyCellTitle(column, row)">
<slot
:name="column.key"
@@ -352,33 +294,25 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
{{ cellDisplayValue(column, row) }}
</slot>
</div>
</td>
</tr>
<tr v-if="isRowExpanded(row, index)">
<td
:colspan="computedColumns.length"
class="rounded-(--cp-input-radius-base) bg-(--cp-info-bg) p-0"
>
<slot name="expanded" :row="row" :index="index" />
</td>
</tr>
</template>
</tbody>
</table>
</BaseScrollbar>
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
<BaseEmpty v-if="!loading" :title="emptyText" plain class="w-full max-w-80" />
</div>
</div>
</td>
</tr>
<tr v-if="isRowExpanded(row, index)">
<td
:colspan="computedColumns.length"
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
:class="bodyCellFrameClass"
>
<slot name="expanded" :row="row" :index="index" />
</td>
</tr>
</template>
</tbody>
</table>
</BaseScrollbar>
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
<BaseEmpty v-if="!loading" :title="emptyText" surface="none" class="w-full max-w-80" />
</div>
</div>
<BaseTablePagination
v-if="pagination"
:pagination="pagination"
:loading="loading"
@page-change="emit('pageChange', $event)"
@page-size-change="emit('pageSizeChange', $event)"
/>
</div>
</template>
@@ -1,101 +0,0 @@
import type { Ref } from 'vue'
import type BaseScrollbar from '../BaseScrollbar.vue'
import { useResizeObserver } from '@vueuse/core'
import { nextTick, onMounted, shallowRef, watch } from 'vue'
interface UseHorizontalStickyShadowOptions {
hasRows: Ref<boolean>
headerWrapRef: Readonly<Ref<HTMLDivElement | null>>
bodyScrollbarRef: Readonly<Ref<InstanceType<typeof BaseScrollbar> | null>>
tableViewRef: Readonly<Ref<HTMLTableElement | null>>
watchSources: () => unknown[]
}
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
let horizontalScrollRange = 0
let scrollLeftPosition = 0
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
}
function scrollWrap() {
return options.bodyScrollbarRef.value?.wrapRef ?? null
}
function resetHorizontalScrollbar() {
horizontalScrollRange = 0
scrollLeftPosition = 0
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
syncHeaderScroll(0)
}
function syncHeaderScroll(scrollLeft: number) {
const headerWrap = options.headerWrapRef.value
if (headerWrap && headerWrap.scrollLeft !== scrollLeft) {
headerWrap.scrollLeft = scrollLeft
}
}
function updateHorizontalScrollPosition(scrollLeft: number) {
horizontalScrolled.value = scrollLeft > 0
horizontalCanScrollRight.value = scrollLeft < horizontalScrollRange - 1
}
function measureHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap || !options.hasRows.value) {
resetHorizontalScrollbar()
return
}
const scrollRange = maxScrollLeft(wrap)
if (scrollRange <= 0) {
resetHorizontalScrollbar()
return
}
horizontalScrollRange = scrollRange
scrollLeftPosition = wrap.scrollLeft
syncHeaderScroll(scrollLeftPosition)
updateHorizontalScrollPosition(scrollLeftPosition)
}
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
if (payload.scrollLeft === scrollLeftPosition) {
return
}
scrollLeftPosition = payload.scrollLeft
syncHeaderScroll(scrollLeftPosition)
updateHorizontalScrollPosition(scrollLeftPosition)
}
onMounted(async () => {
await nextTick()
measureHorizontalScrollbar()
})
useResizeObserver(
() =>
[scrollWrap(), options.tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
measureHorizontalScrollbar,
)
watch(options.watchSources, async () => {
await nextTick()
measureHorizontalScrollbar()
})
return {
horizontalScrolled,
horizontalCanScrollRight,
handleTableScroll,
}
}
+44 -22
View File
@@ -1,58 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject, useAttrs } from 'vue'
import { formFieldKey } from './BaseForm/context'
type TextareaSize = 'sm' | 'md' | 'lg'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
placeholder?: string
disabled?: boolean
error?: string
size?: TextareaSize
name?: string
ariaLabel?: string
rows?: number
}>(),
{
placeholder: '',
disabled: false,
error: undefined,
size: 'md',
name: undefined,
ariaLabel: undefined,
rows: 5,
},
)
const model = defineModel<string>({ default: '' })
const attrs = useAttrs()
const field = inject(formFieldKey, null)
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
const invalid = computed(() => Boolean(
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
))
const describedBy = computed(() => [
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
field?.describedBy.value,
].filter(Boolean).join(' ') || undefined)
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const controlAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => ![
'class',
'style',
'id',
'aria-describedby',
'aria-invalid',
'aria-required',
].includes(key)),
))
const sizeClasses: Record<TextareaSize, string> = {
sm: 'h-28',
md: 'h-40',
lg: 'h-56',
sm: 'px-3 py-2.5 text-xs',
md: 'px-3.5 py-3 text-[13px]',
lg: 'px-4 py-3.5 text-[14px]',
}
const textareaClasses = computed(() => [
'cp-scrollbar w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-(--cp-text-muted)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
'cp-scrollbar w-full resize-y rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'leading-[1.55] font-emphasis',
sizeClasses[props.size],
'text-[13px] leading-[1.55] font-emphasis',
props.error ? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)' : undefined,
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
])
</script>
<template>
<div class="grid min-w-0 gap-2">
<div v-bind="rootAttrs" class="min-w-0">
<textarea
v-bind="controlAttrs"
:id="controlId"
v-model="model"
:class="textareaClasses"
:name="name"
:rows="rows"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
:aria-invalid="error ? 'true' : undefined"
:required="field?.required.value || undefined"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
/>
<span v-if="error" class="text-xs leading-[1.15] font-emphasis text-(--cp-danger-text)">
{{ error }}
</span>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import BaseIconButton from '../BaseIconButton.vue'
import { toast } from './toast'
const iconMap = {
@@ -19,35 +20,37 @@ const titleMap = {
const colorClasses = {
success: {
iconBg: 'bg-(--cp-success-bg)',
icon: 'text-(--cp-success)',
iconBg: 'bg-cp-success-bg',
icon: 'text-cp-success',
},
error: {
iconBg: 'bg-(--cp-danger-bg)',
icon: 'text-(--cp-danger)',
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
},
warning: {
iconBg: 'bg-(--cp-warning-bg)',
icon: 'text-(--cp-warning)',
iconBg: 'bg-cp-warning-bg',
icon: 'text-cp-warning',
},
info: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
iconBg: 'bg-cp-info-bg',
icon: 'text-cp-info',
},
}
</script>
<template>
<Teleport to="body">
<div class="fixed top-6 right-6 z-9999 flex flex-col gap-3 pointer-events-none">
<div class="pointer-events-none fixed inset-x-3 top-3 z-9999 flex flex-col items-end gap-3 sm:inset-x-auto sm:top-6 sm:right-6">
<TransitionGroup name="toast" tag="div" class="flex flex-col gap-3">
<div
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 items-center gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-popover) transition-all"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<div
class="flex items-center justify-center shrink-0 w-8.5 h-8.5 rounded-(--cp-icon-button-radius)"
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
:class="colorClasses[message.type].iconBg"
>
<component
@@ -58,25 +61,25 @@ const colorClasses = {
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
{{ message.title ?? titleMap[message.type] }}
</p>
<p
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-(--cp-text-secondary)"
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
>
{{ message.message }}
</p>
</div>
<button
type="button"
class="flex items-center justify-center shrink-0 p-0 border-0 bg-transparent w-7 h-7 rounded-(--cp-button-radius-base) cursor-pointer opacity-60 transition-opacity hover:bg-(--cp-bg-subtle) hover:opacity-100"
:aria-label="`关闭${message.title ?? titleMap[message.type]}通知`"
<BaseIconButton
size="sm"
variant="ghost"
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
@click="toast.remove(message.id)"
>
<X :size="16" class="text-(--cp-text-muted)" />
</button>
</div>
<X :size="16" class="text-cp-muted-text" />
</BaseIconButton>
</article>
</TransitionGroup>
</div>
</Teleport>
@@ -91,6 +94,13 @@ const colorClasses = {
animation: toast-out 0.2s ease-in;
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active {
animation: none;
}
}
@keyframes toast-in {
from {
opacity: 0;
+49 -6
View File
@@ -1,14 +1,57 @@
// ECharts tooltip 回调参数的通用安全取值:参数形状不受控(数组/对象混合),
// 统一在此收敛类型判断。
import { isRecord } from '@/utils/object'
interface ChartTooltipTheme {
surface: string
textPrimary: string
pointer: string
}
interface ChartTooltipStyleOptions {
axisPointer?: boolean
confine?: boolean
padding?: [number, number]
fontFamily?: string
fontWeight?: number
}
export function chartTooltipStyle(
theme: ChartTooltipTheme,
options: ChartTooltipStyleOptions = {},
) {
const base = {
confine: options.confine,
backgroundColor: theme.surface,
borderColor: 'transparent',
borderWidth: 0,
padding: options.padding ?? [10, 14],
textStyle: {
color: theme.textPrimary,
fontSize: 12,
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: options.fontWeight ?? 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
}
if (!options.axisPointer)
return base
return {
...base,
axisPointer: {
type: 'line' as const,
lineStyle: { color: theme.pointer, type: 'dashed' as const, width: 1 },
},
}
}
export function tooltipRows(params: unknown): Record<string, unknown>[] {
const values = Array.isArray(params) ? params : [params]
return values.filter(
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
)
return values.filter(isRecord)
}
export function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
if (!isRecord(source))
return -1
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
}
+2 -15
View File
@@ -1,7 +1,7 @@
import { shallowRef } from 'vue'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
import { errorMessage, withMinimumDuration } from '@/utils/async'
type MaybePromise<T> = T | Promise<T>
type AsyncActionErrorText = string | false | ((error: unknown) => string | false)
@@ -13,26 +13,13 @@ interface AsyncActionRunOptions {
rethrow?: boolean
}
function errorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message?: unknown }).message
return typeof message === 'string' ? message : ''
}
return ''
}
function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) {
const fallback = typeof errorText === 'function' ? errorText(error) : errorText
if (fallback === false) {
return ''
}
return errorMessage(error) || fallback || ''
return errorMessage(error, '') || fallback || ''
}
export function useAsyncAction() {
@@ -0,0 +1,26 @@
import { computed } from 'vue'
import { useThemeColor } from '@/composables/useThemeColor'
export function useChartPalette() {
const color = useThemeColor()
const palette = computed(() => ({
textPrimary: color('--cp-text-primary', '#0E1726'),
textSecondary: color('--cp-text-secondary', '#64748B'),
textMuted: color('--cp-text-muted', '#94A3B8'),
surface: color('--cp-bg-surface', '#FFFFFF'),
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
grid: color('--cp-bg-muted', '#F1F5F9'),
divider: color('--cp-divider-subtle', '#E2E8F0'),
border: color('--cp-default-border', '#E2E8F0'),
pointer: color('--cp-default-border-hover', '#CBD5E1'),
info: color('--cp-info', '#2563EB'),
success: color('--cp-success', '#10B981'),
warning: color('--cp-warning', '#F59E0B'),
danger: color('--cp-danger', '#EF4444'),
normal: color('--cp-normal', '#0F9F9A'),
reasoning: color('--cp-reasoning', '#8B5CF6'),
}))
return { color, palette }
}
+6 -1
View File
@@ -1,7 +1,6 @@
import { storeToRefs } from 'pinia'
import { useUiStore } from '@/stores/modules/ui'
import { readCssVariable } from '@/utils/css'
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
// 引用该函数的 computed 会重算并读到新变量值。
@@ -12,3 +11,9 @@ export function useThemeColor() {
return readCssVariable(name, fallback)
}
}
function readCssVariable(name: string, fallback: string) {
if (typeof document === 'undefined')
return fallback
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
+5
View File
@@ -0,0 +1,5 @@
import { createSharedComposable, useNow } from '@vueuse/core'
export const useUiClock = createSharedComposable(() =>
useNow({ interval: 30_000 }),
)
+2 -2
View File
@@ -28,7 +28,7 @@ const OVERLAY_BASE_CLASS = [
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-(--cp-bg-surface)/82 backdrop-blur-[4px]'
const OVERLAY_MASK_CLASS = 'bg-cp-surface/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
@@ -41,7 +41,7 @@ const PANEL_CLASS = [
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-(--cp-text-secondary)',
'text-cp-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
@@ -4,7 +4,7 @@ import { ExternalLink } from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
const open = defineModel<boolean>({ default: false })
@@ -49,19 +49,19 @@ const linkItems = [
</script>
<template>
<BaseModal v-model="open" title="关于" width="420px" hide-footer>
<BaseModal v-model="open" title="关于" size="sm">
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-subtle) font-mono text-[15px] leading-none font-extrabold text-(--cp-text-primary)"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-subtle font-mono text-[15px] leading-none font-extrabold text-cp-primary"
>
Z
</span>
<div class="min-w-0">
<p class="m-0 truncate text-[15px] leading-none font-heavy text-(--cp-text-primary)">
<p class="m-0 truncate text-[15px] leading-none font-heavy text-cp-primary">
{{ author }}
</p>
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-(--cp-text-secondary)">
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-cp-secondary">
Built by Zyy · Codex Proxy RS
</p>
</div>
@@ -71,20 +71,20 @@ const linkItems = [
<div
v-for="item in linkItems"
:key="item.label"
class="group flex min-w-0 items-center justify-between gap-3 rounded-(--cp-input-radius-base) px-1 py-2.5 transition-colors hover:bg-(--cp-bg-subtle)"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp-control px-1 py-2.5 transition-colors hover:bg-cp-subtle"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-muted text-cp-primary"
>
<component :is="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-muted)">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
{{ item.label }}
</p>
<p
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-(--cp-text-primary)"
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-cp-primary"
:title="item.value"
>
{{ item.value }}
@@ -96,7 +96,7 @@ const linkItems = [
:href="item.href"
target="_blank"
rel="noreferrer"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) text-(--cp-text-muted) transition-colors hover:bg-(--cp-bg-muted) hover:text-(--cp-info)"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp-control text-cp-muted-text transition-colors hover:bg-cp-muted hover:text-cp-info"
:aria-label="`打开 ${item.label}`"
>
<ExternalLink class="size-3.5" />
@@ -104,7 +104,7 @@ const linkItems = [
</div>
</section>
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-(--cp-text-muted)">
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-cp-muted-text">
{{ versionLine }}
</p>
</div>
+43 -48
View File
@@ -29,7 +29,7 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
@@ -328,10 +328,10 @@ onBeforeUnmount(() => {
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar)"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-cp-surface px-4 shadow-cp-sidebar"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-[251px] basis-[251px]',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
]"
>
<div
@@ -341,7 +341,7 @@ onBeforeUnmount(() => {
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius)"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
>
<img
src="/favicon.svg"
@@ -355,27 +355,27 @@ onBeforeUnmount(() => {
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-base leading-[1.1] font-heavy text-(--cp-text-primary)">
<strong class="text-base leading-[1.1] font-heavy text-cp-primary">
Codex Proxy
</strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-(--cp-text-secondary)">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-secondary">
Rust build
</span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-control-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
:class="[
hasUpdate
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)',
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
]"
:title="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-cp-success" />
</button>
</span>
</span>
@@ -388,7 +388,7 @@ onBeforeUnmount(() => {
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-(--cp-icon-button-radius) bg-(--cp-bg-nav-active) transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
@@ -397,16 +397,16 @@ onBeforeUnmount(() => {
v-for="item in navItems"
:key="item.label"
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-(--cp-text-primary) transition-none'
: 'bg-transparent font-bold text-(--cp-text-primary) transition-colors duration-200'
? 'bg-transparent font-bold text-cp-primary transition-none'
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-(--cp-text-secondary) transition-none'
: 'bg-transparent font-semibold text-(--cp-text-secondary) transition-colors duration-200 hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
? 'bg-transparent font-semibold text-cp-secondary transition-none'
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
]"
@click="navigate(item.path)"
>
@@ -414,94 +414,89 @@ onBeforeUnmount(() => {
<span
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
>{{ item.label }}</span>
>
{{ item.label }}
</span>
</button>
</nav>
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
<div
class="bg-(--cp-bg-subtle)"
class="bg-cp-subtle"
:class="
isCollapsed
? 'grid gap-1 rounded-(--cp-icon-button-radius) p-1'
: 'flex h-11 items-center justify-between rounded-(--cp-panel-radius) px-2'
? 'grid gap-1 rounded-cp-control p-1'
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
"
>
<span
v-if="!isCollapsed"
class="inline-flex whitespace-nowrap h-7 items-center gap-1.5 rounded-lg bg-(--cp-success-bg) px-2.5 text-xs leading-none font-emphasis text-(--cp-success-text)"
class="inline-flex whitespace-nowrap h-7 items-center gap-1.5 rounded-lg bg-cp-success-bg px-2.5 text-xs leading-none font-emphasis text-cp-success-text"
>
<i class="size-1.5 rounded-full bg-(--cp-success)" />
<i class="size-1.5 rounded-full bg-cp-success" />
在线
</span>
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<BaseButton
<BaseIconButton
v-if="isCollapsed && hasUpdate"
icon-only
:variant="hasUpdate ? 'success' : 'ghost'"
size="default"
variant="success"
size="md"
:label="updateButtonLabel"
@click="openSystemUpdate"
>
<ArrowUpCircle :size="19" />
</BaseButton>
</BaseIconButton>
<BaseButton
icon-only
variant="ghost"
:size="isCollapsed ? 'default' : 'sm'"
<BaseIconButton
:size="isCollapsed ? 'md' : 'sm'"
label="退出登录"
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
variant="destructive"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
</BaseButton>
</BaseIconButton>
<BaseButton
icon-only
<BaseIconButton
variant="ghost"
:size="isCollapsed ? 'default' : 'sm'"
:size="isCollapsed ? 'md' : 'sm'"
:label="themeToggleLabel"
@click="toggleTheme($event)"
>
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
<Moon v-else :size="isCollapsed ? 19 : 18" />
</BaseButton>
</BaseIconButton>
<BaseButton
icon-only
<BaseIconButton
variant="ghost"
:size="isCollapsed ? 'default' : 'sm'"
:size="isCollapsed ? 'md' : 'sm'"
label="关于"
@click="emit('openAbout')"
>
<Info :size="isCollapsed ? 19 : 18" />
</BaseButton>
</BaseIconButton>
<BaseButton
<BaseIconButton
v-if="mobile"
icon-only
variant="ghost"
size="sm"
label="关闭侧边栏"
@click="emit('close')"
>
<PanelLeftClose :size="18" />
</BaseButton>
</BaseIconButton>
<BaseButton
<BaseIconButton
v-else
icon-only
variant="ghost"
:size="isCollapsed ? 'default' : 'sm'"
:size="isCollapsed ? 'md' : 'sm'"
data-sidebar-toggle
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="emit('toggle')"
>
<PanelLeftOpen v-if="isCollapsed" :size="19" />
<PanelLeftClose v-else :size="18" />
</BaseButton>
</BaseIconButton>
</div>
</div>
</div>
@@ -8,6 +8,7 @@ import {
useStorage,
useWindowSize,
} from '@vueuse/core'
import { clamp } from 'es-toolkit'
import {
computed,
nextTick,
@@ -179,10 +180,6 @@ function applyPosition(position: StoredPosition) {
storedPosition.value = next
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function positiveNumber(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback
}
@@ -223,7 +220,7 @@ onScopeDispose(() => {
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
class="inline-flex size-11.5 items-center justify-center rounded-cp-control border-0 bg-cp-surface text-cp-primary shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-default-hover hover:text-cp-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-page"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { SystemUpdateDetail } from '@/api'
import {
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
Power,
RefreshCw,
Terminal,
XCircle,
} from '@lucide/vue'
import { storeToRefs } from 'pinia'
@@ -15,12 +14,17 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
import { errorMessage } from '@/utils/async'
import { renderMarkdown } from '@/utils/markdown'
import { formatTime } from '@/utils/date'
import { renderReleaseNotes } from './markdown'
import {
resolveSystemUpdateLogClasses,
resolveSystemUpdatePresentation,
} from './presenter'
const open = defineModel<boolean>({ default: false })
@@ -46,177 +50,39 @@ const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<any>(null)
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
const preparingUpdate = shallowRef(false)
const statusView = computed(() => {
if (restarting.value) {
return {
label: '重启中',
icon: RefreshCw,
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
iconClass: 'text-(--cp-info)',
}
}
if (updating.value) {
return {
label: '更新中',
icon: RefreshCw,
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
iconClass: 'text-(--cp-info)',
}
}
if (updateError.value || updateInfo.value?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
iconClass: 'text-(--cp-danger)',
}
}
if (updateSuccess.value) {
return {
label: '已更新',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
if (hasUpdate.value) {
return {
label: '有新版本',
icon: ArrowUpCircle,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
if (updateInfo.value) {
return {
label: '已是最新',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
iconClass: 'text-(--cp-text-muted)',
}
})
const summaryItems = computed(() => [
{
key: 'current',
label: '当前版本',
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
title: version.value?.version,
},
{
key: 'latest',
label: '最新版本',
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
title: updateInfo.value?.latestVersion,
releaseUrl: updateInfo.value?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(updateInfo.value?.buildTypeLabel),
title: updateInfo.value?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(version.value?.deploymentModeLabel),
title: version.value?.deploymentMode,
},
])
const presentation = computed(() => resolveSystemUpdatePresentation({
version: version.value,
updateInfo: updateInfo.value,
loading: loading.value,
restarting: restarting.value,
updating: updating.value,
updateError: updateError.value,
updateSuccess: updateSuccess.value,
hasUpdate: hasUpdate.value,
updateStreaming: updateStreaming.value,
updateStreamError: updateStreamError.value,
previousTargetVersion: updateConfirmPreviousTarget.value,
confirmedTargetVersion: updateConfirmInfo.value?.latestVersion ?? null,
}))
const updateLogRows = computed(() =>
updateLogs.value.map(item => ({
...item,
time: formatLogTime(item.at),
time: formatTime(item.at, '--:--:--'),
classes: resolveSystemUpdateLogClasses(item.level),
})),
)
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
)
const updateConfirmRows = computed(() => [
{
key: 'current',
label: '当前版本',
value: version.value?.version ? `v${version.value.version}` : '-',
},
{
key: 'previous',
label: '已显示目标',
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
},
{
key: 'target',
label: '远端最新目标',
value: updateConfirmInfo.value?.latestVersion
? `v${updateConfirmInfo.value.latestVersion}`
: '-',
},
])
const streamStatusLabel = computed(() => {
if (updateStreaming.value)
return '实时'
if (updateStreamError.value)
return '断开'
return '待连接'
})
const restartButtonLabel = computed(() => {
return restarting.value ? '重启中' : '立即重启'
})
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
function formatLogTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return '--:--:--'
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
function logMarkerClass(level: string) {
if (level === 'success')
return 'text-(--cp-success)'
if (level === 'warning')
return 'text-(--cp-warning)'
if (level === 'error')
return 'text-(--cp-danger)'
return 'text-(--cp-info)'
}
function logTextClass(level: string) {
if (level === 'success')
return 'text-(--cp-success)'
if (level === 'warning')
return 'text-(--cp-warning)'
if (level === 'error')
return 'text-(--cp-danger)'
return 'text-(--cp-text-primary)'
}
function normalizeUpdateVersion(value: unknown) {
return String(value ?? '')
.trim()
.replace(/^v/i, '')
}
async function scrollUpdateLogsToBottom() {
await nextTick()
await updateLogScrollbar.value?.scrollToBottom()
@@ -242,7 +108,7 @@ async function handleUpdateRequest() {
if (preparingUpdate.value || updating.value)
return
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
const previousTargetVersion = normalizeSystemVersion(updateInfo.value?.latestVersion)
preparingUpdate.value = true
try {
const data = await checkUpdates(true)
@@ -250,7 +116,7 @@ async function handleUpdateRequest() {
toast.success('当前已是最新版本')
return
}
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
if (!remoteTargetVersion) {
toast.error('远端目标版本为空')
return
@@ -284,7 +150,7 @@ async function runConfirmedUpdate(targetVersion: string) {
}
async function handleConfirmUpdate() {
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
if (!targetVersion)
return
@@ -327,40 +193,42 @@ watch(
v-model="open"
title="系统更新"
description="检查版本、查看发布说明并执行在线更新"
variant="success"
width="820px"
body-max-height="72dvh"
body-view-class="pr-3"
:close-disabled="updating || restarting"
tone="success"
size="lg"
:dismissible="!updating && !restarting"
>
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<section class="grid gap-4 rounded-cp-surface bg-cp-subtle px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-muted)">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-(--cp-text-primary)">
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-primary">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
:class="statusView.badge"
:class="presentation.status.badge"
>
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
{{ statusView.label }}
<component
:is="presentation.status.icon"
class="size-3.5"
:class="presentation.status.iconClass"
/>
{{ presentation.status.label }}
</span>
</div>
<div class="grid gap-2.5 sm:grid-cols-4">
<div
v-for="item in summaryItems"
v-for="item in presentation.summaryItems"
:key="item.key"
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-heavy text-(--cp-text-muted)">
<p class="m-0 truncate text-[11px] leading-none font-heavy text-cp-muted-text">
{{ item.label }}
</p>
<a
@@ -368,14 +236,14 @@ watch(
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-(--cp-info-text) transition-colors hover:text-(--cp-info)"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-cp-info-text transition-colors hover:text-cp-info"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-(--cp-text-primary)"
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-cp-primary"
:title="item.title || item.value"
>
{{ item.value }}
@@ -385,7 +253,7 @@ watch(
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2 text-[12px] leading-normal font-bold text-(--cp-danger-text)"
class="m-0 rounded-cp-control bg-cp-danger-bg px-3 py-2 text-[12px] leading-normal font-bold text-cp-danger-text"
>
{{ updateError || updateInfo?.warning }}
</p>
@@ -393,41 +261,43 @@ watch(
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
class="grid gap-2 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-heavy text-(--cp-text-primary)">
<p class="m-0 text-[13px] font-heavy text-cp-primary">
发布说明
</p>
<span class="font-mono text-[11px] font-emphasis text-(--cp-text-muted)">
{{ displayValue(updateInfo?.latestVersion) }}
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
{{ presentation.releaseVersion }}
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
<div class="release-notes" v-html="renderedReleaseNotes" />
<BaseScrollbar class="-mx-4" max-height="160px">
<div class="px-4">
<div class="release-notes" v-html="renderedReleaseNotes" />
</div>
</BaseScrollbar>
</section>
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
class="overflow-hidden rounded-cp-surface bg-cp-subtle"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
<p class="m-0 text-[13px] leading-none font-heavy text-(--cp-text-primary)">
<Terminal class="size-4 shrink-0 text-cp-success" />
<p class="m-0 text-[13px] leading-none font-heavy text-cp-primary">
更新进度
</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-(--cp-bg-subtle) px-2 text-[11px] leading-none font-bold text-(--cp-text-secondary)"
:title="updateStreamError || streamStatusLabel"
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-(--cp-success)' : 'bg-(--cp-text-muted)'"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
/>
{{ streamStatusLabel }}
{{ presentation.streamStatusLabel }}
</span>
</header>
@@ -435,40 +305,42 @@ watch(
v-if="updateLogRows.length"
ref="updateLogScrollbar"
height="260px"
view-class="min-h-full px-4 pb-4"
>
<div class="grid gap-2">
<div class="grid min-h-full gap-2 px-4 pb-4">
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2 font-mono text-[11px] leading-[1.55]"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp-control bg-cp-surface px-3 py-2 font-mono text-[11px] leading-[1.55]"
>
<span class="tabular-nums text-(--cp-text-muted)">{{ log.time }}</span>
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="logMarkerClass(log.level)"
:class="log.classes.marker"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
<span v-if="log.step" class="mr-1 text-(--cp-text-muted)">[{{ log.step }}]</span>
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
</div>
</BaseScrollbar>
<div v-else class="grid h-30 place-items-center px-4 pb-4">
<BaseEmpty title="暂无进度" :icon="Terminal" compact plain />
<BaseEmpty title="暂无进度" :icon="Terminal" size="sm" surface="none" />
</div>
</section>
</div>
<template #footer>
<BaseButton
variant="default"
variant="secondary"
:loading="checking"
:disabled="loading || updating || restarting"
@click="handleCheckUpdates(true)"
>
<template #loading>
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<template #icon>
<RefreshCw class="size-3.5" />
</template>
@@ -476,7 +348,7 @@ watch(
</BaseButton>
<BaseButton
v-if="updateSuccess && needRestart"
variant="success"
variant="primary"
:loading="restarting"
:disabled="updating"
@click="handleRestart"
@@ -484,11 +356,11 @@ watch(
<template #icon>
<Power class="size-4" />
</template>
{{ restartButtonLabel }}
{{ presentation.restartButtonLabel }}
</BaseButton>
<BaseButton
v-else
variant="success"
variant="primary"
:loading="preparingUpdate || updating"
:disabled="!canUpdate || preparingUpdate"
@click="handleUpdateRequest"
@@ -505,30 +377,29 @@ watch(
v-model="updateConfirmOpen"
title="发现新的更新版本"
description="检测到远端 latest 与当前显示的目标版本不一致"
variant="warning"
confirm-text="确认更新"
:loading="updating"
:confirm-disabled="!updateConfirmInfo?.latestVersion"
@confirm="handleConfirmUpdate"
>
<div class="grid gap-3">
<div class="grid gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) p-3">
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
<div
v-for="item in updateConfirmRows"
v-for="item in presentation.confirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
<span class="text-[12px] leading-none font-bold text-(--cp-text-muted)">
<span class="text-[12px] leading-none font-bold text-cp-muted-text">
{{ item.label }}
</span>
<span
class="truncate font-mono text-[13px] leading-none font-heavy text-(--cp-text-primary)"
class="truncate font-mono text-[13px] leading-none font-heavy text-cp-primary"
>
{{ item.value }}
</span>
</div>
</div>
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-(--cp-text-muted)">
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-cp-muted-text">
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
@@ -603,7 +474,7 @@ watch(
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-input-radius-base);
border-radius: var(--cp-radius-control);
background: var(--cp-bg-surface);
padding: 8px 10px;
}
@@ -7,7 +7,7 @@ const marked = new Marked({
gfm: true,
})
export function renderMarkdown(source?: string | null) {
export function renderReleaseNotes(source?: string | null) {
const value = source?.trim()
if (!value)
return ''
@@ -0,0 +1,122 @@
import type { SystemUpdateDetail, SystemVersion } from '@/api'
import { ArrowUpCircle, CheckCircle2, RefreshCw, XCircle } from '@lucide/vue'
interface SystemUpdatePresentationInput {
version: SystemVersion | null
updateInfo: SystemUpdateDetail | null
loading: boolean
restarting: boolean
updating: boolean
updateError: string
updateSuccess: boolean
hasUpdate: boolean
updateStreaming: boolean
updateStreamError: string
previousTargetVersion: string
confirmedTargetVersion: string | null
}
export function resolveSystemUpdatePresentation(input: SystemUpdatePresentationInput) {
return {
status: resolveStatus(input),
summaryItems: resolveSummaryItems(input),
confirmRows: resolveConfirmRows(input),
releaseVersion: displayValue(input.updateInfo?.latestVersion),
streamStatusLabel: input.updateStreaming
? '实时'
: input.updateStreamError ? '断开' : '待连接',
restartButtonLabel: input.restarting ? '重启中' : '立即重启',
}
}
export function resolveSystemUpdateLogClasses(level: string) {
switch (level) {
case 'success':
return { marker: 'text-cp-success', text: 'text-cp-success' }
case 'warning':
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
case 'error':
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
default:
return { marker: 'text-cp-info', text: 'text-cp-primary' }
}
}
function resolveStatus(input: SystemUpdatePresentationInput) {
if (input.restarting || input.updating) {
return {
label: input.restarting ? '重启中' : '更新中',
icon: RefreshCw,
badge: 'bg-cp-info-bg text-cp-info-text',
iconClass: 'text-cp-info',
}
}
if (input.updateError || input.updateInfo?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
return {
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有新版本' : '已是最新',
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
badge: 'bg-cp-success-bg text-cp-success-text',
iconClass: 'text-cp-success',
}
}
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
}
}
function resolveSummaryItems(input: SystemUpdatePresentationInput) {
return [
{
key: 'current',
label: '当前版本',
value: input.loading ? '...' : versionLabel(input.version?.version),
title: input.version?.version,
},
{
key: 'latest',
label: '最新版本',
value: versionLabel(input.updateInfo?.latestVersion),
title: input.updateInfo?.latestVersion,
releaseUrl: input.updateInfo?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(input.updateInfo?.buildTypeLabel),
title: input.updateInfo?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(input.version?.deploymentModeLabel),
title: input.version?.deploymentMode,
},
]
}
function resolveConfirmRows(input: SystemUpdatePresentationInput) {
return [
{ key: 'current', label: '当前版本', value: versionLabel(input.version?.version) },
{ key: 'previous', label: '已显示目标', value: versionLabel(input.previousTargetVersion) },
{ key: 'target', label: '远端最新目标', value: versionLabel(input.confirmedTargetVersion) },
]
}
function versionLabel(value?: string | null) {
return value ? `v${value}` : '-'
}
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
+9 -10
View File
@@ -10,7 +10,7 @@ import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal.vue'
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
@@ -70,7 +70,7 @@ watch(
</script>
<template>
<div class="relative flex h-dvh overflow-hidden bg-(--cp-bg-page)">
<div class="relative flex h-dvh overflow-hidden bg-cp-page">
<AppSidebar
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
@@ -78,14 +78,13 @@ watch(
@open-system-update="openSystemUpdate"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar
ref="pageScrollbarRef"
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
>
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar ref="pageScrollbarRef">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
</div>
</BaseScrollbar>
</main>
+3 -3
View File
@@ -242,7 +242,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion })
updateSuccess.value = true
needRestart.value = result.needRestart
restartTargetVersion.value = result.needRestart ? normalizeVersion(result.targetVersion) : ''
restartTargetVersion.value = result.needRestart ? normalizeSystemVersion(result.targetVersion) : ''
updateInfo.value = {
...currentInfo,
latestVersion: result.targetVersion,
@@ -271,7 +271,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
while (Date.now() < deadline) {
try {
const readyVersion = await getSystemVersion(restartProbeTimeoutMs)
if (normalizeVersion(readyVersion.version) === expectedVersion) {
if (normalizeSystemVersion(readyVersion.version) === expectedVersion) {
window.location.reload()
return
}
@@ -348,7 +348,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
})
function normalizeVersion(value: unknown) {
export function normalizeSystemVersion(value: unknown) {
return String(value ?? '')
.trim()
.replace(/^v/i, '')
-1
View File
@@ -1,5 +1,4 @@
html {
min-width: 360px;
background: var(--cp-bg-page);
color: var(--cp-text-primary);
font-family: var(--font-sans);
+70 -2
View File
@@ -1,8 +1,8 @@
@import 'tailwindcss';
@import './tokens.css';
@import './base.css';
@import 'tailwindcss';
@theme {
@theme inline {
--font-sans:
'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
@@ -14,11 +14,79 @@
--color-cp-surface: var(--cp-bg-surface);
--color-cp-subtle: var(--cp-bg-subtle);
--color-cp-muted: var(--cp-bg-muted);
--color-cp-tertiary-bg: var(--cp-bg-tertiary);
--color-cp-nav-active: var(--cp-bg-nav-active);
--color-cp-primary: var(--cp-text-primary);
--color-cp-strong: var(--cp-text-strong);
--color-cp-secondary: var(--cp-text-secondary);
--color-cp-tertiary: var(--cp-text-tertiary);
--color-cp-muted-text: var(--cp-text-muted);
--color-cp-accent: var(--cp-accent);
--color-cp-accent-hover: var(--cp-accent-hover);
--color-cp-accent-pressed: var(--cp-accent-pressed);
--color-cp-accent-border: var(--cp-accent-border);
--color-cp-accent-on: var(--cp-accent-on);
--color-cp-default-hover: var(--cp-default-bg-hover);
--color-cp-default-active: var(--cp-default-bg-active);
--color-cp-default-border: var(--cp-default-border);
--color-cp-default-border-hover: var(--cp-default-border-hover);
--color-cp-border: var(--cp-border-primary);
--color-cp-divider: var(--cp-divider-subtle);
--color-cp-progress: var(--cp-progress-track);
--color-cp-overlay: var(--cp-overlay-scrim);
--color-cp-disabled: var(--cp-disabled-bg);
--color-cp-disabled-text: var(--cp-disabled-text);
--color-cp-disabled-icon: var(--cp-disabled-icon);
--color-cp-info: var(--cp-info);
--color-cp-info-hover: var(--cp-info-hover);
--color-cp-info-pressed: var(--cp-info-pressed);
--color-cp-info-bg: var(--cp-info-bg);
--color-cp-info-border: var(--cp-info-border);
--color-cp-info-text: var(--cp-info-text);
--color-cp-info-on: var(--cp-info-on);
--color-cp-normal: var(--cp-normal);
--color-cp-normal-hover: var(--cp-normal-hover);
--color-cp-normal-pressed: var(--cp-normal-pressed);
--color-cp-normal-bg: var(--cp-normal-bg);
--color-cp-normal-border: var(--cp-normal-border);
--color-cp-normal-text: var(--cp-normal-text);
--color-cp-success: var(--cp-success);
--color-cp-success-bg: var(--cp-success-bg);
--color-cp-success-bg-hover: var(--cp-success-bg-hover);
--color-cp-success-bg-active: var(--cp-success-bg-active);
--color-cp-success-border: var(--cp-success-border);
--color-cp-success-text: var(--cp-success-text);
--color-cp-warning: var(--cp-warning);
--color-cp-warning-bg: var(--cp-warning-bg);
--color-cp-warning-bg-hover: var(--cp-warning-bg-hover);
--color-cp-warning-bg-active: var(--cp-warning-bg-active);
--color-cp-warning-border: var(--cp-warning-border);
--color-cp-warning-text: var(--cp-warning-text);
--color-cp-danger: var(--cp-danger);
--color-cp-danger-bg: var(--cp-danger-bg);
--color-cp-danger-bg-hover: var(--cp-danger-bg-hover);
--color-cp-danger-bg-active: var(--cp-danger-bg-active);
--color-cp-danger-border: var(--cp-danger-border);
--color-cp-danger-text: var(--cp-danger-text);
--color-cp-plan-pro-bg: var(--cp-plan-pro-bg);
--color-cp-plan-pro-text: var(--cp-plan-pro-text);
--color-cp-plan-prolite-bg: var(--cp-plan-prolite-bg);
--color-cp-plan-prolite-text: var(--cp-plan-prolite-text);
--radius-cp-control-sm: var(--cp-radius-control-sm);
--radius-cp-control: var(--cp-radius-control);
--radius-cp-overlay: var(--cp-radius-overlay);
--radius-cp-surface: var(--cp-radius-surface);
--shadow-cp-card: var(--cp-shadow-card);
--shadow-cp-control: var(--cp-shadow-control);
--shadow-cp-popover: var(--cp-shadow-popover);
--shadow-cp-input: var(--cp-shadow-input);
--shadow-cp-input-hover: var(--cp-shadow-input-hover);
--shadow-cp-input-focus: var(--cp-shadow-input-focus);
--shadow-cp-input-error: var(--cp-shadow-input-error);
--shadow-cp-sidebar: var(--cp-shadow-sidebar);
--spacing-cp-control-sm: var(--cp-control-height-sm);
--spacing-cp-control-md: var(--cp-control-height-md);
--spacing-cp-control-lg: var(--cp-control-height-lg);
--spacing-cp-table-row-min: var(--cp-table-row-min-height);
--spacing-cp-table-row-min-compact: var(--cp-table-row-min-height-compact);
}
+21 -11
View File
@@ -18,7 +18,12 @@
--cp-text-secondary: #64748b;
--cp-text-tertiary: #94a3b8;
--cp-text-muted: #94a3b8;
--cp-accent-primary: #2563eb;
--cp-brand-accent: #2563eb;
--cp-accent: #2563eb;
--cp-accent-hover: #1d4ed8;
--cp-accent-pressed: #1e40af;
--cp-accent-border: #93c5fd;
--cp-accent-on: #ffffff;
--cp-reasoning: #8b5cf6;
--cp-plan-pro-bg: #ede9fe;
--cp-plan-pro-text: #5b21b6;
@@ -80,15 +85,15 @@
--cp-danger-bg-active: #fecaca;
--cp-danger-border: #fca5a5;
--cp-danger-text: #b91c1c;
--cp-input-height-default: 38px;
--cp-input-radius-small: 6px;
--cp-input-radius-base: 8px;
--cp-popover-radius: 4px;
--cp-checkbox-radius: 5px;
--cp-button-radius-base: 6px;
--cp-icon-button-radius: 10px;
--cp-card-radius: 18px;
--cp-panel-radius: 16px;
--cp-control-height-sm: 32px;
--cp-control-height-md: 38px;
--cp-control-height-lg: 44px;
--cp-table-row-min-height: 60px;
--cp-table-row-min-height-compact: 40px;
--cp-radius-control-sm: 6px;
--cp-radius-control: 8px;
--cp-radius-overlay: 12px;
--cp-radius-surface: 18px;
--cp-shadow-card: 0 10px 22px -18px #0e172614;
--cp-shadow-control: 0 9px 18px -14px #0e172616;
@@ -122,7 +127,12 @@ html[data-theme='dark'] {
--cp-text-secondary: #8fa0b7;
--cp-text-tertiary: #71839c;
--cp-text-muted: #64748b;
--cp-accent-primary: #6ea8ff;
--cp-brand-accent: #6ea8ff;
--cp-accent: #3b82f6;
--cp-accent-hover: #4f8ee8;
--cp-accent-pressed: #2563eb;
--cp-accent-border: #2e5e92;
--cp-accent-on: var(--cp-text-primary);
--cp-reasoning: #a78bfa;
--cp-plan-pro-bg: #35136f;
--cp-plan-pro-text: #ddd6fe;
+6 -1
View File
@@ -1,7 +1,12 @@
import { delay } from 'es-toolkit'
export function errorMessage(error: unknown, fallback = '请求失败') {
return error instanceof Error && error.message ? error.message : fallback
const message = error instanceof Error
? error.message
: error && typeof error === 'object' && 'message' in error
? (error as { message?: unknown }).message
: undefined
return typeof message === 'string' && message ? message : fallback
}
export async function withMinimumDuration<T>(
-16
View File
@@ -1,16 +0,0 @@
import { useScrollLock } from '@vueuse/core'
const bodyScrollLocked = useScrollLock(document.body)
let lockCount = 0
export function lockBodyScroll() {
lockCount += 1
if (lockCount === 1)
bodyScrollLocked.value = true
}
export function unlockBodyScroll() {
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0)
bodyScrollLocked.value = false
}
+3 -114
View File
@@ -1,117 +1,6 @@
const HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
const RGBA_HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
export interface HsvColor {
h: number
s: number
v: number
a: number
}
export interface RgbaColor {
r: number
g: number
b: number
a: number
}
export function normalizeHexColor(value: string) {
export function normalizeRgbaHexColor(value: string) {
const normalized = value.trim().toUpperCase()
return HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
const maximum = Math.max(red, green, blue)
const minimum = Math.min(red, green, blue)
const delta = maximum - minimum
let hue = 0
if (delta > 0) {
if (maximum === red)
hue = 60 * (((green - blue) / delta) % 6)
else if (maximum === green)
hue = 60 * ((blue - red) / delta + 2)
else
hue = 60 * ((red - green) / delta + 4)
}
return {
h: hue < 0 ? hue + 360 : hue,
s: maximum === 0 ? 0 : (delta / maximum) * 100,
v: maximum * 100,
a: alpha,
}
}
export function hsvToHex(color: HsvColor) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
const chroma = brightness * saturation
const section = hue / 60
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
const offset = brightness - chroma
const [red, green, blue] = section < 1
? [chroma, intermediate, 0]
: section < 2
? [intermediate, chroma, 0]
: section < 3
? [0, chroma, intermediate]
: section < 4
? [0, intermediate, chroma]
: section < 5
? [intermediate, 0, chroma]
: [chroma, 0, intermediate]
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
return `#${rgb}${alpha}`.toUpperCase()
}
export function parseRgbaColor(value: string): RgbaColor | null {
const match = RGBA_COLOR_PATTERN.exec(value.trim())
if (!match)
return null
const red = Number(match[1])
const green = Number(match[2])
const blue = Number(match[3])
const alpha = Number(match[4])
if ([red, green, blue].some(channel => channel > 255))
return null
return { r: red, g: green, b: blue, a: alpha }
}
export function rgbaToHexColor(color: RgbaColor) {
return `#${[color.r, color.g, color.b, color.a * 255]
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
.join('')}`.toUpperCase()
}
export function formatRgbaColor(value: string) {
const normalized = normalizeHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16)
const green = Number.parseInt(normalized.slice(3, 5), 16)
const blue = Number.parseInt(normalized.slice(5, 7), 16)
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(2))
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
return RGBA_HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
-7
View File
@@ -1,7 +0,0 @@
// @env browser
export function readCssVariable(name: string, fallback: string) {
if (typeof document === 'undefined')
return fallback
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
+16 -5
View File
@@ -4,19 +4,26 @@ import dayjs from 'dayjs'
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const TIME_FORMAT = 'HH:mm:ss'
export function formatDateTime(value: ConfigType = new Date()): string {
return dayjs(value).format(DATE_TIME_FORMAT)
export function formatDateTime(value: ConfigType = new Date(), fallback = '—'): string {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.format(DATE_TIME_FORMAT) : fallback
}
export function formatTime(value: ConfigType = new Date()): string {
return dayjs(value).format(TIME_FORMAT)
export function formatTime(value: ConfigType = new Date(), fallback = '—'): string {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.format(TIME_FORMAT) : fallback
}
export function parseTimestamp(value: ConfigType): number | null {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.valueOf() : null
}
export function formatRelativeTime(
value: ConfigType,
now: ConfigType = new Date(),
): string {
const timestamp = dayjs(value)
const timestamp = normalizedDate(value)
if (!timestamp.isValid())
return '—'
@@ -34,3 +41,7 @@ export function formatRelativeTime(
return `${Math.floor(elapsedHours / 24)} 天前`
}
function normalizedDate(value: ConfigType) {
return dayjs(typeof value === 'string' ? value.replace(' ', 'T') : value)
}
+39
View File
@@ -0,0 +1,39 @@
const integerFormatter = new Intl.NumberFormat('zh-CN')
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const metricUnits = [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const
export function formatInteger(value: number) {
return integerFormatter.format(value)
}
export function formatLocalizedCompactNumber(value: number) {
return localizedCompactFormatter.format(Number.isFinite(value) ? value : 0)
}
export function formatCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return formatInteger(normalized)
for (const [unit, threshold] of metricUnits) {
if (normalized < threshold)
continue
const scaled = normalized / threshold
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
return `${rounded.replace(/\.?0+$/, '')}${unit}`
}
return formatInteger(normalized)
}
+3
View File
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+18 -7
View File
@@ -1,8 +1,19 @@
// 平台标识到展示名的唯一映射;未知平台返回 undefined,由调用方决定兜底文案。
export function providerDisplayName(value?: string | null): 'OpenAI' | 'xAI' | undefined {
if (value === 'openai')
return 'OpenAI'
if (value === 'xai')
return 'xAI'
return undefined
export const PROVIDER_DISPLAY_NAMES = {
openai: 'OpenAI',
xai: 'xAI',
} as const
export type SupportedProvider = keyof typeof PROVIDER_DISPLAY_NAMES
export function isSupportedProvider(value: unknown): value is SupportedProvider {
return typeof value === 'string' && value in PROVIDER_DISPLAY_NAMES
}
export function providerDisplayName(value?: string | null) {
return isSupportedProvider(value) ? PROVIDER_DISPLAY_NAMES[value] : undefined
}
export function formatProviderLabel(value?: string | null, fallback = '—') {
const normalized = value?.trim()
return providerDisplayName(normalized?.toLowerCase()) ?? (normalized || fallback)
}
@@ -1,12 +0,0 @@
export const DEFAULT_ACCOUNT_GROUP_COLOR = '#60A5FA80'
export const ACCOUNT_GROUP_COLOR_PRESETS = [
'#60A5FA80',
'#22D3EE80',
'#2DD4BF80',
'#4ADE8080',
'#FBBF2480',
'#FB718580',
'#A78BFA80',
'#94A3B880',
] as const

Some files were not shown because too many files have changed in this diff Show More