mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e96b9d870 | ||
|
|
d3c0b1aa7f | ||
|
|
66bfd3e592 | ||
|
|
392c557831 | ||
|
|
c34565b02b | ||
|
|
f57fe6e13e | ||
|
|
7c678b715f | ||
|
|
bd4e5f3a5d | ||
|
|
165d9eab8f | ||
|
|
dfb95f09e1 | ||
|
|
4d5c591654 | ||
|
|
46737d32f8 | ||
|
|
25d38ae632 | ||
|
|
aa83b4a7a7 | ||
|
|
913ce2dbcb | ||
|
|
cae5e520ac | ||
|
|
772f2ea601 | ||
|
|
28fa03451c | ||
|
|
6984984c22 | ||
|
|
1209c835c7 | ||
|
|
e4ebd5cca1 | ||
|
|
f573110725 | ||
|
|
a8620e133a | ||
|
|
ddd6adbcf7 |
@@ -204,6 +204,7 @@ logs/
|
||||
|
||||
# Git backup
|
||||
.git.backup/
|
||||
.worktrees/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
+7
-1
@@ -90,6 +90,11 @@ RUN printf '%s\n' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_upgrade $connection_upgrade {' \
|
||||
' default upgrade;' \
|
||||
' "" "";' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -160,7 +165,8 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Upgrade $http_upgrade;' \
|
||||
' proxy_set_header Connection $connection_upgrade;' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
|
||||
@@ -112,6 +112,11 @@ RUN printf '%s\n' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_upgrade $connection_upgrade {' \
|
||||
' default upgrade;' \
|
||||
' "" "";' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -182,7 +187,8 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Upgrade $http_upgrade;' \
|
||||
' proxy_set_header Connection $connection_upgrade;' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""add status_snapshot column to provider_api_keys
|
||||
|
||||
Revision ID: c9d8e7f6a5b4
|
||||
Revises: f6e7d8c9b0a1
|
||||
Create Date: 2026-03-20 12:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c9d8e7f6a5b4"
|
||||
down_revision: str | None = "f6e7d8c9b0a1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "status_snapshot"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("status_snapshot", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "status_snapshot"):
|
||||
op.drop_column("provider_api_keys", "status_snapshot")
|
||||
@@ -0,0 +1,317 @@
|
||||
"""usage token semantics v2
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: c9d8e7f6a5b4
|
||||
Create Date: 2026-03-24 14:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c3d4e5f6a7b8"
|
||||
down_revision: str | None = "c9d8e7f6a5b4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
BACKFILL_BATCH_SIZE = 2000
|
||||
|
||||
# 最大批次数,防止因数据异常导致死循环(2000 * 500000 = 10亿行上限)
|
||||
_MAX_BATCHES = 500000
|
||||
|
||||
# 使用子查询中间层展开 input_output_total_tokens 的计算,
|
||||
# 确保 total_tokens 引用的是本次 SET 后的新值而非旧值。
|
||||
_UPGRADE_BACKFILL_SQL = sa.text(
|
||||
"""
|
||||
UPDATE usage
|
||||
SET
|
||||
input_output_total_tokens = src.new_iot,
|
||||
input_context_tokens = src.new_ict,
|
||||
total_tokens = src.new_total,
|
||||
cache_creation_cost_usd_5m = src.new_cc5m,
|
||||
cache_creation_cost_usd_1h = src.new_cc1h,
|
||||
actual_cache_creation_cost_usd_5m = src.new_acc5m,
|
||||
actual_cache_creation_cost_usd_1h = src.new_acc1h,
|
||||
actual_cache_cost_usd = src.new_accu,
|
||||
cache_creation_price_per_1m_5m = src.new_cp5m,
|
||||
cache_creation_price_per_1m_1h = src.new_cp1h,
|
||||
cache_cost_usd = src.new_ccu
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
AS new_iot,
|
||||
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
|
||||
AS new_ict,
|
||||
/* total_tokens 引用本行计算出的 new_iot,避免依赖 SET 顺序 */
|
||||
COALESCE(input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
+ COALESCE(cache_creation_input_tokens, 0)
|
||||
+ COALESCE(cache_read_input_tokens, 0)
|
||||
AS new_total,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_cc5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_cc1h,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_acc5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_acc1h,
|
||||
COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END AS new_cp5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END AS new_cp1h,
|
||||
COALESCE(cache_creation_cost_usd, 0)
|
||||
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
|
||||
FROM usage
|
||||
WHERE id IN (
|
||||
SELECT id FROM usage
|
||||
WHERE
|
||||
input_output_total_tokens IS DISTINCT FROM
|
||||
COALESCE(input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
OR input_context_tokens IS DISTINCT FROM
|
||||
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
|
||||
OR total_tokens IS DISTINCT FROM (
|
||||
COALESCE(input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
+ COALESCE(cache_creation_input_tokens, 0)
|
||||
+ COALESCE(cache_read_input_tokens, 0)
|
||||
)
|
||||
OR cache_creation_cost_usd_5m IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
OR cache_creation_cost_usd_1h IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
OR actual_cache_creation_cost_usd_5m IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
OR actual_cache_creation_cost_usd_1h IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
OR actual_cache_cost_usd IS DISTINCT FROM (
|
||||
COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
+ COALESCE(actual_cache_read_cost_usd, 0)
|
||||
)
|
||||
OR cache_creation_price_per_1m_5m IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
OR cache_creation_price_per_1m_1h IS DISTINCT FROM (
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
OR cache_cost_usd IS DISTINCT FROM (
|
||||
COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT :batch_size
|
||||
)
|
||||
) AS src
|
||||
WHERE usage.id = src.id
|
||||
"""
|
||||
)
|
||||
|
||||
_DOWNGRADE_BACKFILL_SQL = sa.text(
|
||||
"""
|
||||
UPDATE usage
|
||||
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM usage
|
||||
WHERE total_tokens IS DISTINCT FROM
|
||||
COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||
ORDER BY id
|
||||
LIMIT :batch_size
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def run_backfill_in_batches(sql: sa.TextClause, batch_size: int = BACKFILL_BATCH_SIZE) -> None:
|
||||
context = op.get_context()
|
||||
for _ in range(_MAX_BATCHES):
|
||||
# Commit the preceding schema transaction before each batch so PostgreSQL
|
||||
# does not keep ALTER TABLE locks for the entire data backfill.
|
||||
with context.autocommit_block():
|
||||
rowcount = op.get_bind().execute(sql, {"batch_size": batch_size}).rowcount
|
||||
if rowcount == 0:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Backfill did not converge after {_MAX_BATCHES} batches "
|
||||
f"(batch_size={batch_size}). Possible infinite loop due to data anomaly."
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
|
||||
with op.batch_alter_table("usage") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"total_tokens",
|
||||
new_column_name="input_output_total_tokens",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
with op.batch_alter_table("usage") as batch_op:
|
||||
if not column_exists("usage", "input_context_tokens"):
|
||||
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "total_tokens"):
|
||||
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "actual_cache_cost_usd"):
|
||||
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
|
||||
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
|
||||
|
||||
run_backfill_in_batches(_UPGRADE_BACKFILL_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_backfill_in_batches(_DOWNGRADE_BACKFILL_SQL)
|
||||
|
||||
with op.batch_alter_table("usage") as batch_op:
|
||||
if column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||
batch_op.drop_column("cache_creation_price_per_1m_1h")
|
||||
if column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||
batch_op.drop_column("cache_creation_price_per_1m_5m")
|
||||
if column_exists("usage", "actual_cache_cost_usd"):
|
||||
batch_op.drop_column("actual_cache_cost_usd")
|
||||
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
|
||||
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
|
||||
if column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||
batch_op.drop_column("cache_creation_cost_usd_1h")
|
||||
if column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||
batch_op.drop_column("cache_creation_cost_usd_5m")
|
||||
if column_exists("usage", "input_context_tokens"):
|
||||
batch_op.drop_column("input_context_tokens")
|
||||
if column_exists("usage", "total_tokens"):
|
||||
batch_op.drop_column("total_tokens")
|
||||
if column_exists("usage", "input_output_total_tokens"):
|
||||
batch_op.alter_column(
|
||||
"input_output_total_tokens",
|
||||
new_column_name="total_tokens",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
@@ -108,27 +108,26 @@ if [ -n "$HUB_TAG" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
|
||||
# - [build-system] requires / build-backend
|
||||
# - [project] requires-python / dependencies
|
||||
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
|
||||
# 提取 pyproject.toml 中会影响运行时依赖安装的字段指纹(纯 shell,无需 Python)
|
||||
# 用 sed 提取 dependencies / requires 数组块和单值字段,排序后输出稳定文本
|
||||
pyproject_deps_fingerprint() {
|
||||
python3 - <<'PY'
|
||||
import json, pathlib, tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
local file="pyproject.toml"
|
||||
# 提取 "key = [..." 多行数组块(从 key 行到 ] 行)
|
||||
extract_array() {
|
||||
sed -n "/^$1[[:space:]]*=[[:space:]]*\[/,/\]/p" "$file" | grep '"' | sed 's/.*"\(.*\)".*/\1/' | sort
|
||||
}
|
||||
# 提取 "key = "value"" 单行值
|
||||
extract_value() {
|
||||
grep -m1 "^$1[[:space:]]*=" "$file" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/'
|
||||
}
|
||||
{
|
||||
echo "requires-python=$(extract_value requires-python)"
|
||||
echo "build-backend=$(extract_value build-backend)"
|
||||
echo "dependencies:"
|
||||
extract_array dependencies
|
||||
echo "build-requires:"
|
||||
extract_array requires
|
||||
}
|
||||
}
|
||||
|
||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||
|
||||
@@ -380,102 +380,6 @@ export interface AdminApiKeysResponse {
|
||||
skip: number
|
||||
}
|
||||
|
||||
export interface LeaderboardItem {
|
||||
rank: number
|
||||
id: string
|
||||
name: string
|
||||
value: number
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
items: LeaderboardItem[]
|
||||
total: number
|
||||
metric: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
|
||||
export interface CostForecastResponse {
|
||||
history: Array<{ date: string; total_cost: number }>
|
||||
forecast: Array<{ date: string; total_cost: number }>
|
||||
slope: number
|
||||
intercept: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export interface CostSavingsResponse {
|
||||
cache_read_tokens: number
|
||||
cache_read_cost: number
|
||||
cache_creation_cost: number
|
||||
estimated_full_cost: number
|
||||
cache_savings: number
|
||||
}
|
||||
|
||||
export interface QuotaUsageProvider {
|
||||
id: string
|
||||
name: string
|
||||
quota_usd: number
|
||||
used_usd: number
|
||||
remaining_usd: number
|
||||
usage_percent: number
|
||||
quota_expires_at?: string | null
|
||||
estimated_exhaust_at?: string | null
|
||||
}
|
||||
|
||||
export interface QuotaUsageResponse {
|
||||
providers: QuotaUsageProvider[]
|
||||
}
|
||||
|
||||
export interface PercentileItem {
|
||||
date: string
|
||||
p50_response_time_ms?: number | null
|
||||
p90_response_time_ms?: number | null
|
||||
p99_response_time_ms?: number | null
|
||||
p50_first_byte_time_ms?: number | null
|
||||
p90_first_byte_time_ms?: number | null
|
||||
p99_first_byte_time_ms?: number | null
|
||||
}
|
||||
|
||||
export interface ErrorDistributionItem {
|
||||
category: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ErrorTrendItem {
|
||||
date: string
|
||||
total: number
|
||||
categories: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ErrorDistributionResponse {
|
||||
distribution: ErrorDistributionItem[]
|
||||
trend: ErrorTrendItem[]
|
||||
}
|
||||
|
||||
export interface ComparisonMetric {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_total_cost: number
|
||||
avg_response_time_ms: number
|
||||
error_requests: number
|
||||
}
|
||||
|
||||
export interface ComparisonResponse {
|
||||
current: ComparisonMetric
|
||||
comparison: ComparisonMetric
|
||||
change_percent: Record<string, number | null>
|
||||
current_start: string
|
||||
current_end: string
|
||||
comparison_start: string
|
||||
comparison_end: string
|
||||
}
|
||||
|
||||
|
||||
export interface ApiKeyToggleResponse {
|
||||
id: string // UUID
|
||||
is_active: boolean
|
||||
@@ -753,197 +657,6 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Stats / Leaderboards
|
||||
async getLeaderboardUsers(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardApiKeys(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardModels(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getCostForecast(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
days?: number
|
||||
forecast_days?: number
|
||||
}): Promise<CostForecastResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getCostSavings(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<CostSavingsResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getQuotaUsage(): Promise<QuotaUsageResponse> {
|
||||
return cachedRequest(
|
||||
'admin:stats:providers:quota-usage',
|
||||
async () => {
|
||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getPercentiles(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<PercentileItem[]> {
|
||||
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getErrorDistribution(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ErrorDistributionResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getComparison(params: {
|
||||
current_start: string
|
||||
current_end: string
|
||||
comparison_type?: 'period' | 'year'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ComparisonResponse> {
|
||||
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 数据清空
|
||||
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
|
||||
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
|
||||
@@ -952,26 +665,4 @@ export const adminApi = {
|
||||
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
|
||||
purgeStats: () => purge<{ message: string }>('stats'),
|
||||
|
||||
async getTimeSeries(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider_name?: string
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const cacheKey = buildCacheKey('admin:stats:time-series', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
|
||||
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
|
||||
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
|
||||
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
|
||||
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
|
||||
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
|
||||
|
||||
export interface AnalyticsScope {
|
||||
kind: AnalyticsScopeKind
|
||||
user_id?: string | null
|
||||
api_key_id?: string | null
|
||||
}
|
||||
|
||||
export interface AnalyticsTimeRange {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: AnalyticsGranularity
|
||||
timezone?: string | null
|
||||
tz_offset_minutes?: number
|
||||
}
|
||||
|
||||
export interface AnalyticsFilters {
|
||||
user_ids?: string[]
|
||||
provider_names?: string[]
|
||||
models?: string[]
|
||||
target_models?: string[]
|
||||
api_key_ids?: string[]
|
||||
api_formats?: string[]
|
||||
request_types?: string[]
|
||||
statuses?: string[]
|
||||
error_categories?: string[]
|
||||
is_stream?: boolean | null
|
||||
has_format_conversion?: boolean | null
|
||||
}
|
||||
|
||||
export interface AnalyticsBaseRequest {
|
||||
scope: AnalyticsScope
|
||||
time_range: AnalyticsTimeRange
|
||||
filters?: AnalyticsFilters
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
requests_total: number
|
||||
requests_success: number
|
||||
requests_error: number
|
||||
requests_stream: number
|
||||
success_rate: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
input_output_total_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_creation_input_tokens_5m: number
|
||||
cache_creation_input_tokens_1h: number
|
||||
cache_read_input_tokens: number
|
||||
input_context_tokens: number
|
||||
total_tokens: number
|
||||
cache_hit_rate: number
|
||||
input_cost_usd: number
|
||||
output_cost_usd: number
|
||||
cache_creation_cost_usd: number
|
||||
cache_creation_cost_usd_5m: number
|
||||
cache_creation_cost_usd_1h: number
|
||||
cache_read_cost_usd: number
|
||||
cache_cost_usd: number
|
||||
request_cost_usd: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd: number
|
||||
actual_cache_cost_usd: number
|
||||
avg_response_time_ms: number
|
||||
avg_first_byte_time_ms: number
|
||||
format_conversion_count: number
|
||||
models_used_count: number
|
||||
}
|
||||
|
||||
export interface AnalyticsCompositionSegment {
|
||||
key: string
|
||||
value: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface AnalyticsOverviewResponse {
|
||||
query_context: {
|
||||
scope: AnalyticsScope
|
||||
time_range: AnalyticsTimeRange
|
||||
}
|
||||
summary: AnalyticsSummary
|
||||
composition: {
|
||||
token_segments: AnalyticsCompositionSegment[]
|
||||
cost_segments: AnalyticsCompositionSegment[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
|
||||
bucket_start: string
|
||||
bucket_end: string
|
||||
}
|
||||
|
||||
export interface AnalyticsTimeseriesResponse {
|
||||
buckets: AnalyticsTimeseriesBucket[]
|
||||
}
|
||||
|
||||
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
|
||||
dimension: AnalyticsBreakdownDimension
|
||||
metric?: AnalyticsBreakdownMetric
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
|
||||
key: string
|
||||
label: string
|
||||
share_of_total_cost: number
|
||||
share_of_total_tokens: number
|
||||
}
|
||||
|
||||
export interface AnalyticsBreakdownResponse {
|
||||
dimension: AnalyticsBreakdownDimension
|
||||
metric: AnalyticsBreakdownMetric
|
||||
rows: AnalyticsBreakdownRow[]
|
||||
}
|
||||
|
||||
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
|
||||
search?: {
|
||||
text?: string | null
|
||||
request_id?: string | null
|
||||
}
|
||||
pagination?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnalyticsRecord {
|
||||
id: string
|
||||
request_id: string
|
||||
created_at: string | null
|
||||
user_id: string | null
|
||||
username: string | null
|
||||
api_key_id: string | null
|
||||
api_key_name: string | null
|
||||
provider_api_key_name: string | null
|
||||
provider_name: string | null
|
||||
model: string
|
||||
target_model: string | null
|
||||
api_format: string | null
|
||||
request_type: string | null
|
||||
status: string
|
||||
billing_status: string
|
||||
is_stream: boolean
|
||||
has_format_conversion: boolean | null
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
status_code: number | null
|
||||
error_message: string | null
|
||||
error_category: string | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
input_output_total_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_creation_input_tokens_5m: number
|
||||
cache_creation_input_tokens_1h: number
|
||||
cache_read_input_tokens: number
|
||||
input_context_tokens: number
|
||||
total_tokens: number
|
||||
input_cost_usd: number
|
||||
output_cost_usd: number
|
||||
cache_creation_cost_usd: number
|
||||
cache_creation_cost_usd_5m: number
|
||||
cache_creation_cost_usd_1h: number
|
||||
cache_read_cost_usd: number
|
||||
cache_cost_usd: number
|
||||
request_cost_usd: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd: number
|
||||
actual_cache_cost_usd: number
|
||||
rate_multiplier: number
|
||||
}
|
||||
|
||||
export interface AnalyticsRecordsResponse {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
records: AnalyticsRecord[]
|
||||
}
|
||||
|
||||
export interface AnalyticsFilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AnalyticsFilterOptionsResponse {
|
||||
providers: AnalyticsFilterOption[]
|
||||
models: AnalyticsFilterOption[]
|
||||
target_models: AnalyticsFilterOption[]
|
||||
api_formats: AnalyticsFilterOption[]
|
||||
request_types: AnalyticsFilterOption[]
|
||||
error_categories: AnalyticsFilterOption[]
|
||||
statuses: AnalyticsFilterOption[]
|
||||
users?: AnalyticsFilterOption[]
|
||||
api_keys?: AnalyticsFilterOption[]
|
||||
}
|
||||
|
||||
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
|
||||
entity: AnalyticsLeaderboardEntity
|
||||
metric: AnalyticsLeaderboardMetric
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface AnalyticsLeaderboardItem {
|
||||
rank: number
|
||||
id: string
|
||||
label: string
|
||||
requests_total: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd: number
|
||||
metric_value: number
|
||||
}
|
||||
|
||||
export interface AnalyticsLeaderboardResponse {
|
||||
entity: AnalyticsLeaderboardEntity
|
||||
metric: AnalyticsLeaderboardMetric
|
||||
items: AnalyticsLeaderboardItem[]
|
||||
}
|
||||
|
||||
export interface AnalyticsPercentilePoint {
|
||||
date: string
|
||||
p50_response_time_ms: number | null
|
||||
p90_response_time_ms: number | null
|
||||
p99_response_time_ms: number | null
|
||||
p50_first_byte_time_ms: number | null
|
||||
p90_first_byte_time_ms: number | null
|
||||
p99_first_byte_time_ms: number | null
|
||||
}
|
||||
|
||||
export interface AnalyticsErrorCategory {
|
||||
category: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AnalyticsErrorTrendItem {
|
||||
date: string
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnalyticsProviderHealthItem {
|
||||
provider_name: string
|
||||
requests_total: number
|
||||
success_rate: number
|
||||
error_rate: number
|
||||
avg_response_time_ms: number
|
||||
avg_first_byte_time_ms: number
|
||||
}
|
||||
|
||||
export interface AnalyticsPerformanceResponse {
|
||||
latency: {
|
||||
response_time_ms: {
|
||||
avg: number
|
||||
p50: number | null
|
||||
p90: number | null
|
||||
p99: number | null
|
||||
}
|
||||
first_byte_time_ms: {
|
||||
avg: number
|
||||
p50: number | null
|
||||
p90: number | null
|
||||
p99: number | null
|
||||
}
|
||||
}
|
||||
percentiles: AnalyticsPercentilePoint[]
|
||||
errors: {
|
||||
total: number
|
||||
rate: number
|
||||
categories: AnalyticsErrorCategory[]
|
||||
trend: AnalyticsErrorTrendItem[]
|
||||
}
|
||||
provider_health: AnalyticsProviderHealthItem[]
|
||||
}
|
||||
|
||||
export interface AnalyticsActiveRequest {
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
cost?: number
|
||||
total_cost_usd?: number
|
||||
actual_cost?: number | null
|
||||
actual_total_cost_usd?: number | null
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
provider?: string | null
|
||||
provider_name?: string | null
|
||||
api_key_name?: string | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
has_format_conversion?: boolean | null
|
||||
target_model?: string | null
|
||||
}
|
||||
|
||||
export interface AnalyticsActiveRequestsResponse {
|
||||
requests: AnalyticsActiveRequest[]
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
|
||||
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
|
||||
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
|
||||
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
|
||||
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
|
||||
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
|
||||
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
|
||||
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getHeatmap(payload: {
|
||||
scope: AnalyticsScope
|
||||
user_id?: string | null
|
||||
api_key_id?: string | null
|
||||
}): Promise<ActivityHeatmap> {
|
||||
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
|
||||
return response.data
|
||||
},
|
||||
60000,
|
||||
)
|
||||
},
|
||||
|
||||
async getActiveRequests(payload: {
|
||||
scope: AnalyticsScope
|
||||
ids?: string[]
|
||||
}): Promise<AnalyticsActiveRequestsResponse> {
|
||||
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
|
||||
...payload,
|
||||
ids: payload.ids ?? [],
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async analyzeCacheAffinityTTL(payload: {
|
||||
scope: AnalyticsScope
|
||||
user_id?: string | null
|
||||
api_key_id?: string | null
|
||||
hours?: number
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async analyzeCacheAffinityHit(payload: {
|
||||
scope: AnalyticsScope
|
||||
user_id?: string | null
|
||||
api_key_id?: string | null
|
||||
hours?: number
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -33,6 +33,203 @@ export interface AuditFilters {
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
|
||||
|
||||
export interface MonitoringCpuMetric {
|
||||
status: MonitoringMetricStatus
|
||||
label: string
|
||||
usage_percent: number | null
|
||||
load_percent: number | null
|
||||
core_count: number
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface MonitoringMemoryMetric {
|
||||
status: MonitoringMetricStatus
|
||||
label: string
|
||||
used_percent: number | null
|
||||
used_bytes: number | null
|
||||
available_bytes: number | null
|
||||
total_bytes: number | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface MonitoringServiceMetric {
|
||||
status: MonitoringMetricStatus
|
||||
label: string
|
||||
latency_ms: number | null
|
||||
memory_status?: MonitoringMetricStatus
|
||||
memory_label?: string | null
|
||||
used_memory_bytes?: number | null
|
||||
peak_memory_bytes?: number | null
|
||||
maxmemory_bytes?: number | null
|
||||
memory_ceiling_bytes?: number | null
|
||||
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
|
||||
available_memory_bytes?: number | null
|
||||
memory_percent?: number | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface MonitoringPostgresMetric {
|
||||
status: MonitoringMetricStatus
|
||||
label: string
|
||||
usage_percent: number | null
|
||||
pool_usage_percent?: number | null
|
||||
checked_out: number
|
||||
pool_size: number
|
||||
overflow: number
|
||||
max_capacity: number
|
||||
pool_timeout: number
|
||||
server_connections?: number | null
|
||||
server_max_connections?: number | null
|
||||
server_usage_percent?: number | null
|
||||
storage_status?: MonitoringMetricStatus
|
||||
storage_label?: string | null
|
||||
storage_total_bytes?: number | null
|
||||
storage_free_bytes?: number | null
|
||||
storage_free_percent?: number | null
|
||||
database_size_bytes?: number | null
|
||||
storage_message?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface MonitoringSystemStatus {
|
||||
timestamp: string
|
||||
users: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
providers: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
api_keys: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
today_stats: {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost_usd: number
|
||||
}
|
||||
recent_errors: number
|
||||
system_metrics?: {
|
||||
cpu: MonitoringCpuMetric
|
||||
memory: MonitoringMemoryMetric
|
||||
redis: MonitoringServiceMetric
|
||||
postgres: MonitoringPostgresMetric
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumericLike(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||
if (!normalized) return 0
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function parseNullableNumericLike(value: unknown): number | null {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||
if (!normalized) return null
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
|
||||
return {
|
||||
...data,
|
||||
users: {
|
||||
total: parseNumericLike(data.users?.total),
|
||||
active: parseNumericLike(data.users?.active),
|
||||
},
|
||||
providers: {
|
||||
total: parseNumericLike(data.providers?.total),
|
||||
active: parseNumericLike(data.providers?.active),
|
||||
},
|
||||
api_keys: {
|
||||
total: parseNumericLike(data.api_keys?.total),
|
||||
active: parseNumericLike(data.api_keys?.active),
|
||||
},
|
||||
today_stats: {
|
||||
requests: parseNumericLike(data.today_stats?.requests),
|
||||
tokens: parseNumericLike(data.today_stats?.tokens),
|
||||
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
|
||||
},
|
||||
recent_errors: parseNumericLike(data.recent_errors),
|
||||
system_metrics: data.system_metrics ? {
|
||||
cpu: {
|
||||
status: data.system_metrics.cpu?.status ?? 'unknown',
|
||||
label: data.system_metrics.cpu?.label ?? '未知',
|
||||
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
|
||||
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
|
||||
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
|
||||
message: data.system_metrics.cpu?.message ?? null,
|
||||
},
|
||||
memory: {
|
||||
status: data.system_metrics.memory?.status ?? 'unknown',
|
||||
label: data.system_metrics.memory?.label ?? '未知',
|
||||
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
|
||||
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
|
||||
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
|
||||
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
|
||||
message: data.system_metrics.memory?.message ?? null,
|
||||
},
|
||||
redis: {
|
||||
status: data.system_metrics.redis?.status ?? 'unknown',
|
||||
label: data.system_metrics.redis?.label ?? '未知',
|
||||
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
|
||||
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
|
||||
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
|
||||
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
|
||||
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
|
||||
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
|
||||
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
|
||||
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
|
||||
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
|
||||
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
|
||||
message: data.system_metrics.redis?.message ?? null,
|
||||
},
|
||||
postgres: {
|
||||
status: data.system_metrics.postgres?.status ?? 'unknown',
|
||||
label: data.system_metrics.postgres?.label ?? '未知',
|
||||
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
|
||||
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
|
||||
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
|
||||
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
|
||||
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
|
||||
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
|
||||
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
|
||||
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
|
||||
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
|
||||
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
|
||||
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
|
||||
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
|
||||
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
|
||||
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
|
||||
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
|
||||
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
|
||||
storage_message: data.system_metrics.postgres?.storage_message ?? null,
|
||||
message: data.system_metrics.postgres?.message ?? null,
|
||||
},
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
|
||||
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
||||
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
||||
@@ -78,6 +275,11 @@ export const auditApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getSystemStatus(): Promise<MonitoringSystemStatus> {
|
||||
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
|
||||
return normalizeMonitoringSystemStatus(response.data)
|
||||
},
|
||||
|
||||
// 分析用户行为 (管理员)
|
||||
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
||||
analysis: Record<string, unknown>
|
||||
|
||||
@@ -24,13 +24,6 @@ export interface UserPreferences {
|
||||
[key: string]: unknown // 允许扩展其他偏好设置
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_requests?: number
|
||||
total_cost?: number
|
||||
last_request_at?: string
|
||||
[key: string]: unknown // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
email: string
|
||||
}
|
||||
@@ -113,13 +106,9 @@ export interface User {
|
||||
role: string // 'admin' or 'user'
|
||||
is_active: boolean
|
||||
billing?: BillingSummary
|
||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
last_login_at?: string
|
||||
preferences?: UserPreferences
|
||||
stats?: UserStats
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
|
||||
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
|
||||
api_key_id?: string
|
||||
hours?: number
|
||||
}): Promise<TTLAnalysisResponse> {
|
||||
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
|
||||
const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
|
||||
scope: { kind: 'global' },
|
||||
user_id: params?.user_id ?? null,
|
||||
api_key_id: params?.api_key_id ?? null,
|
||||
hours: params?.hours,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
|
||||
api_key_id?: string
|
||||
hours?: number
|
||||
}): Promise<CacheHitAnalysisResponse> {
|
||||
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
|
||||
const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
|
||||
scope: { kind: 'global' },
|
||||
user_id: params?.user_id ?? null,
|
||||
api_key_id: params?.api_key_id ?? null,
|
||||
hours: params?.hours,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
||||
const response = await api.post('/api/analytics/interval-timeline', {
|
||||
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
|
||||
user_id: params?.user_id ?? null,
|
||||
hours: params?.hours,
|
||||
limit: params?.limit,
|
||||
include_user_info: params?.include_user_info ?? false,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30000
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
|
||||
export interface DashboardStat {
|
||||
name: string
|
||||
value: string
|
||||
subValue?: string
|
||||
change?: string
|
||||
changeType?: 'increase' | 'decrease' | 'neutral'
|
||||
extraBadge?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface RecentRequest {
|
||||
id: string // UUID
|
||||
user: string
|
||||
model: string
|
||||
tokens: number
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
name: string
|
||||
status: 'active' | 'inactive'
|
||||
requests: number
|
||||
}
|
||||
|
||||
// 系统健康指标(管理员专用)
|
||||
export interface SystemHealth {
|
||||
avg_response_time: number
|
||||
error_rate: number
|
||||
error_requests: number
|
||||
fallback_count: number
|
||||
total_requests: number
|
||||
}
|
||||
|
||||
// 成本统计(管理员专用)
|
||||
export interface CostStats {
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
cost_savings: number
|
||||
}
|
||||
|
||||
// 缓存统计
|
||||
export interface CacheStats {
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
cache_hit_rate?: number
|
||||
total_cache_tokens: number
|
||||
}
|
||||
|
||||
// 用户统计(管理员专用)
|
||||
export interface UserStats {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
|
||||
// Token 详细分类
|
||||
export interface TokenBreakdown {
|
||||
input: number
|
||||
output: number
|
||||
cache_creation: number
|
||||
cache_read: number
|
||||
}
|
||||
|
||||
export interface DashboardStatsResponse {
|
||||
stats: DashboardStat[]
|
||||
today?: {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
}
|
||||
api_keys?: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
tokens?: {
|
||||
month: number
|
||||
}
|
||||
// 管理员专用字段
|
||||
system_health?: SystemHealth
|
||||
cost_stats?: CostStats
|
||||
cache_stats?: CacheStats
|
||||
users?: UserStats
|
||||
token_breakdown?: TokenBreakdown
|
||||
// 普通用户专用字段
|
||||
monthly_cost?: number
|
||||
}
|
||||
|
||||
export interface RecentRequestsResponse {
|
||||
requests: RecentRequest[]
|
||||
}
|
||||
|
||||
export interface ProviderStatusResponse {
|
||||
providers: ProviderStatus[]
|
||||
}
|
||||
|
||||
// 视频/图像/音频计费信息
|
||||
export interface VideoBilling {
|
||||
task_type: 'video' | 'image' | 'audio'
|
||||
duration_seconds?: number // 视频时长(秒)
|
||||
resolution?: string // 分辨率
|
||||
video_price_per_second?: number // 每秒单价
|
||||
video_cost?: number // 视频费用
|
||||
cost?: number // 总费用
|
||||
rule_name?: string // 计费规则名称
|
||||
expression?: string // 计费公式
|
||||
status?: string // 计费状态
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
user: {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
api_key: {
|
||||
id: string // UUID
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
provider: string
|
||||
api_format?: string
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
// Additional token fields
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation_input_tokens_5m?: number
|
||||
cache_creation_input_tokens_1h?: number
|
||||
cache_read_input_tokens?: number
|
||||
// Additional cost fields
|
||||
input_cost?: number
|
||||
output_cost?: number
|
||||
total_cost?: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
request_cost?: number // 按次计费费用
|
||||
// Historical pricing fields (per 1M tokens)
|
||||
input_price_per_1m?: number
|
||||
output_price_per_1m?: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
request_type: string
|
||||
is_stream: boolean
|
||||
status_code: number
|
||||
status?: string // pending, streaming, completed, failed, cancelled
|
||||
error_message?: string
|
||||
response_time_ms: number
|
||||
created_at: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
provider_request_headers?: Record<string, unknown>
|
||||
provider_request_body?: Record<string, unknown>
|
||||
response_headers?: Record<string, unknown>
|
||||
client_response_headers?: Record<string, unknown>
|
||||
response_body?: Record<string, unknown>
|
||||
client_response_body?: Record<string, unknown>
|
||||
has_request_body?: boolean
|
||||
has_provider_request_body?: boolean
|
||||
has_response_body?: boolean
|
||||
has_client_response_body?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
// 阶梯计费信息
|
||||
tiered_pricing?: {
|
||||
total_input_context: number // 总输入上下文 (input + cache_read)
|
||||
tier_index: number // 命中的阶梯索引 (0-based)
|
||||
tier_count: number // 阶梯总数
|
||||
source?: 'provider' | 'global' // 定价来源: 提供商或全局
|
||||
current_tier: { // 当前命中的阶梯配置
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}
|
||||
tiers: Array<{ // 完整阶梯配置列表
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}>
|
||||
} | null
|
||||
// 视频/图像/音频计费信息
|
||||
video_billing?: VideoBilling | null
|
||||
}
|
||||
|
||||
export interface CurlData {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
curl: string
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
provider_id?: string
|
||||
endpoint_id?: string
|
||||
api_key_id?: string
|
||||
body_override?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ReplayResponse {
|
||||
url: string
|
||||
provider: string
|
||||
status_code: number
|
||||
response_headers: Record<string, string>
|
||||
response_body: Record<string, unknown>
|
||||
response_time_ms: number
|
||||
mapping?: {
|
||||
source_model: string
|
||||
original_target_model?: string | null
|
||||
resolved_model: string
|
||||
target_provider_id: string
|
||||
target_provider: string
|
||||
target_endpoint_id: string
|
||||
target_api_format: string
|
||||
replay_mode: string
|
||||
mapping_applied: boolean
|
||||
mapping_source: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelBreakdown {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
avg_response_time: number
|
||||
cost_per_request: number
|
||||
tokens_per_request: number
|
||||
}
|
||||
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface DailyStat {
|
||||
date: string // ISO date string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
avg_response_time: number // in seconds
|
||||
unique_models: number
|
||||
unique_providers?: number // 仅管理员返回
|
||||
model_breakdown: ModelBreakdown[]
|
||||
}
|
||||
|
||||
export interface DailyStatsResponse {
|
||||
daily_stats: DailyStat[]
|
||||
model_summary: ModelSummary[]
|
||||
provider_summary?: ProviderSummary[] // 仅管理员返回
|
||||
period: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
days: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimeRangeParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||
return response.data
|
||||
},
|
||||
10 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取最近的请求记录
|
||||
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
|
||||
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data.requests
|
||||
},
|
||||
|
||||
// 获取提供商状态
|
||||
async getProviderStatus(): Promise<ProviderStatus[]> {
|
||||
return cachedRequest(
|
||||
'dashboard:provider-status',
|
||||
async () => {
|
||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
||||
return response.data.providers
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取请求详情
|
||||
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
||||
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
||||
params: { include_bodies: options.includeBodies ?? true },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取每日统计数据
|
||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取 cURL 命令数据(含明文 API Key)
|
||||
async getCurlData(requestId: string): Promise<CurlData> {
|
||||
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 回放请求到提供商
|
||||
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
||||
const response = await apiClient.post<ReplayResponse>(
|
||||
`/api/admin/usage/${requestId}/replay`,
|
||||
params || {}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
|
||||
// Re-export types for convenience
|
||||
export type { EndpointAPIKey, AllowedModels }
|
||||
|
||||
export interface GroupedFormatKey {
|
||||
id: string
|
||||
provider_id: string
|
||||
name: string
|
||||
auth_type?: string
|
||||
api_key_masked: string
|
||||
internal_priority: number
|
||||
global_priority_by_format: Record<string, number> | null
|
||||
format_priority: number | null
|
||||
rate_multipliers: Record<string, number> | null
|
||||
is_active: boolean
|
||||
provider_active: boolean
|
||||
pool_enabled: boolean
|
||||
circuit_breaker_open: boolean
|
||||
provider_name: string
|
||||
api_format: string
|
||||
api_formats: string[]
|
||||
capabilities: string[]
|
||||
health_score: number | null
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
request_count: number
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力定义类型
|
||||
*/
|
||||
@@ -125,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
|
||||
return allKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按 API 格式分组的 Key 列表
|
||||
*/
|
||||
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
|
||||
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
|
||||
const result: Record<string, GroupedFormatKey[]> = {}
|
||||
|
||||
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
|
||||
if (!Array.isArray(keys)) continue
|
||||
|
||||
result[apiFormat] = keys.map((key) => ({
|
||||
id: String(key.id || ''),
|
||||
provider_id: String(key.provider_id || ''),
|
||||
name: String(key.name || 'Unnamed Key'),
|
||||
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
|
||||
api_key_masked: String(key.api_key_masked || '***'),
|
||||
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
|
||||
global_priority_by_format:
|
||||
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
|
||||
? (key.global_priority_by_format as Record<string, number>)
|
||||
: null,
|
||||
format_priority: toNumberOrNull(key.format_priority),
|
||||
rate_multipliers:
|
||||
key.rate_multipliers && typeof key.rate_multipliers === 'object'
|
||||
? (key.rate_multipliers as Record<string, number>)
|
||||
: null,
|
||||
is_active: key.is_active !== false,
|
||||
provider_active: key.provider_active !== false,
|
||||
pool_enabled: key.pool_enabled === true,
|
||||
circuit_breaker_open: key.circuit_breaker_open === true,
|
||||
provider_name: String(key.provider_name || 'Unknown Provider'),
|
||||
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
|
||||
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
|
||||
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
|
||||
health_score: toNumberOrNull(key.health_score),
|
||||
success_rate: toNumberOrNull(key.success_rate),
|
||||
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
|
||||
request_count: toNumberOrNull(key.request_count) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Provider 添加 Key
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import client from '../client'
|
||||
import { dedupedRequest } from '@/utils/cache'
|
||||
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
||||
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
|
||||
|
||||
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -99,13 +100,20 @@ export interface PoolKeyDetail {
|
||||
is_active: boolean
|
||||
auth_type: string
|
||||
oauth_expires_at?: number | null
|
||||
oauth_invalid_at?: number | null
|
||||
oauth_invalid_reason?: string | null
|
||||
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
oauth_plan_type?: string | null
|
||||
oauth_account_id?: string | null
|
||||
oauth_account_user_id?: string | null
|
||||
oauth_account_name?: string | null
|
||||
oauth_organizations?: OAuthOrganizationInfo[] | null
|
||||
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||
quota_updated_at?: number | null
|
||||
health_score?: number
|
||||
circuit_breaker_open?: boolean
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ProviderOAuthCompleteResponse {
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
email?: string | null
|
||||
account_state_recheck_attempted?: boolean
|
||||
account_state_recheck_error?: string | null
|
||||
}
|
||||
|
||||
export interface ProviderOAuthCompleteResponseWithKey {
|
||||
@@ -92,7 +94,7 @@ export async function completeProviderLevelOAuth(
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: { refresh_token: string; name?: string; proxy_node_id?: string }
|
||||
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface TestModelFailoverRequest {
|
||||
api_format?: string
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
@@ -212,6 +214,11 @@ export interface TestAttemptDetail {
|
||||
error_message?: string | null
|
||||
status_code?: number | null
|
||||
latency_ms?: number | null
|
||||
request_url?: string | null
|
||||
request_headers?: Record<string, unknown> | null
|
||||
request_body?: unknown
|
||||
response_headers?: Record<string, unknown> | null
|
||||
response_body?: unknown
|
||||
}
|
||||
|
||||
export interface TestModelFailoverResponse {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
|
||||
|
||||
/**
|
||||
* 代理配置类型
|
||||
* 支持两种模式:
|
||||
@@ -290,8 +292,10 @@ export interface EndpointAPIKey {
|
||||
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
|
||||
oauth_account_name?: string | null
|
||||
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
|
||||
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
||||
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
|
||||
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
||||
upstream_metadata?: UpstreamMetadata | null
|
||||
// Key 级别代理配置(覆盖 Provider 级别代理)
|
||||
@@ -591,6 +595,7 @@ export interface HealthSummary {
|
||||
total: number
|
||||
active: number
|
||||
unhealthy: number
|
||||
circuit_open?: number
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface OAuthStatusSnapshot {
|
||||
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
|
||||
label?: string | null
|
||||
reason?: string | null
|
||||
expires_at?: number | null
|
||||
invalid_at?: number | null
|
||||
source?: string | null
|
||||
requires_reauth?: boolean
|
||||
expiring_soon?: boolean
|
||||
}
|
||||
|
||||
export interface AccountStatusSnapshot {
|
||||
code: string
|
||||
label?: string | null
|
||||
reason?: string | null
|
||||
blocked: boolean
|
||||
source?: string | null
|
||||
recoverable?: boolean
|
||||
}
|
||||
|
||||
export interface QuotaStatusSnapshot {
|
||||
code: 'unknown' | 'ok' | 'exhausted'
|
||||
label?: string | null
|
||||
reason?: string | null
|
||||
exhausted: boolean
|
||||
usage_ratio?: number | null
|
||||
updated_at?: number | null
|
||||
reset_seconds?: number | null
|
||||
plan_type?: string | null
|
||||
}
|
||||
|
||||
export interface ProviderKeyStatusSnapshot {
|
||||
oauth: OAuthStatusSnapshot
|
||||
account: AccountStatusSnapshot
|
||||
quota: QuotaStatusSnapshot
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { UserSession } from '@/types/session'
|
||||
|
||||
@@ -25,8 +23,6 @@ export interface Profile {
|
||||
export interface UserPreferences {
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
default_provider_id?: string // UUID
|
||||
default_provider?: Record<string, unknown>
|
||||
theme: string
|
||||
language: string
|
||||
timezone?: string
|
||||
@@ -37,103 +33,6 @@ export interface UserPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
// 提供商配置接口
|
||||
export interface ProviderConfig {
|
||||
provider_id: string
|
||||
priority: number // 优先级(越高越优先)
|
||||
weight: number // 负载均衡权重
|
||||
enabled: boolean // 是否启用
|
||||
}
|
||||
|
||||
// 使用记录接口
|
||||
export interface UsageRecordDetail {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cost: number // 官方费率
|
||||
actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
||||
response_time_ms?: number
|
||||
is_stream: boolean
|
||||
created_at: string
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
status_code: number
|
||||
error_message?: string
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
api_key?: {
|
||||
id: string
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
}
|
||||
|
||||
// 模型统计接口
|
||||
export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||
}
|
||||
|
||||
// 提供商统计接口
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
}
|
||||
|
||||
// API 格式统计接口
|
||||
export interface ApiFormatSummary {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_hit_rate: number
|
||||
total_cost_usd: number
|
||||
avg_response_time_ms: number
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse {
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number // 官方费率
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
billing: BillingSummary
|
||||
summary_by_model: ModelSummary[]
|
||||
summary_by_provider?: ProviderSummary[]
|
||||
summary_by_api_format?: ApiFormatSummary[]
|
||||
pagination?: {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
records: UsageRecordDetail[]
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string // UUID
|
||||
name: string
|
||||
@@ -146,7 +45,6 @@ export interface ApiKey {
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
rate_limit?: number | null
|
||||
allowed_providers?: ProviderConfig[]
|
||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||
}
|
||||
|
||||
@@ -249,51 +147,6 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 使用统计
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string // 通用搜索:密钥名、模型名
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取活跃请求状态(用于轮询更新)
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
cost: number
|
||||
actual_cost?: number | null
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
has_format_conversion?: boolean | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取可用的提供商
|
||||
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await apiClient.get('/api/users/me/providers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取用户可用的模型列表
|
||||
async getAvailableModels(params?: {
|
||||
skip?: number
|
||||
@@ -334,16 +187,6 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
|
||||
|
||||
// API密钥提供商关联
|
||||
async updateApiKeyProviders(keyId: string, data: {
|
||||
allowed_providers?: ProviderConfig[]
|
||||
}): Promise<{ message: string }> {
|
||||
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// API密钥能力配置
|
||||
async updateApiKeyCapabilities(keyId: string, data: {
|
||||
force_capabilities?: Record<string, boolean> | null
|
||||
@@ -370,39 +213,4 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取请求间隔时间线(用于散点图)
|
||||
async getIntervalTimeline(params?: {
|
||||
hours?: number
|
||||
limit?: number
|
||||
}): Promise<{
|
||||
analysis_period_hours: number
|
||||
total_points: number
|
||||
points: Array<{ x: string; y: number; model?: string }>
|
||||
models?: string[]
|
||||
}> {
|
||||
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||
return response.data
|
||||
},
|
||||
30000
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(用户)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
return cachedRequest(
|
||||
'me-activity-heatmap',
|
||||
async () => {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||
return response.data
|
||||
},
|
||||
60000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface VideoBilling {
|
||||
task_type: 'video' | 'image' | 'audio'
|
||||
duration_seconds?: number
|
||||
resolution?: string
|
||||
video_price_per_second?: number
|
||||
video_cost?: number
|
||||
cost?: number
|
||||
rule_name?: string
|
||||
expression?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string
|
||||
request_id: string
|
||||
user: {
|
||||
id: string | null
|
||||
username: string | null
|
||||
email: string | null
|
||||
}
|
||||
api_key: {
|
||||
id: string | null
|
||||
name: string | null
|
||||
display: string | null
|
||||
}
|
||||
provider_api_key: {
|
||||
id: string | null
|
||||
name: string | null
|
||||
}
|
||||
provider: string
|
||||
api_format?: string
|
||||
model: string
|
||||
target_model?: string | null
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation_input_tokens_5m?: number
|
||||
cache_creation_input_tokens_1h?: number
|
||||
cache_read_input_tokens?: number
|
||||
input_cost?: number
|
||||
output_cost?: number
|
||||
total_cost?: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
request_cost?: number
|
||||
input_price_per_1m?: number
|
||||
output_price_per_1m?: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number
|
||||
request_type: string
|
||||
is_stream: boolean
|
||||
status_code: number
|
||||
status?: string
|
||||
error_message?: string
|
||||
response_time_ms: number
|
||||
created_at: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
provider_request_headers?: Record<string, unknown>
|
||||
provider_request_body?: Record<string, unknown>
|
||||
response_headers?: Record<string, unknown>
|
||||
client_response_headers?: Record<string, unknown>
|
||||
response_body?: Record<string, unknown>
|
||||
client_response_body?: Record<string, unknown>
|
||||
has_request_body?: boolean
|
||||
has_provider_request_body?: boolean
|
||||
has_response_body?: boolean
|
||||
has_client_response_body?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
tiered_pricing?: {
|
||||
total_input_context: number
|
||||
tier_index: number
|
||||
tier_count: number
|
||||
source?: 'provider' | 'global'
|
||||
current_tier: {
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}
|
||||
tiers: Array<{
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}>
|
||||
} | null
|
||||
video_billing?: VideoBilling | null
|
||||
}
|
||||
|
||||
export interface CurlData {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
curl: string
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
provider_id?: string
|
||||
endpoint_id?: string
|
||||
api_key_id?: string
|
||||
body_override?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ReplayResponse {
|
||||
url: string
|
||||
provider: string
|
||||
status_code: number
|
||||
response_headers: Record<string, string>
|
||||
response_body: Record<string, unknown>
|
||||
response_time_ms: number
|
||||
}
|
||||
|
||||
export const requestDetailsApi = {
|
||||
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
||||
params: { include_bodies: options.includeBodies ?? true },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getCurlData(requestId: string): Promise<CurlData> {
|
||||
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
||||
const response = await apiClient.post<ReplayResponse>(
|
||||
`/api/admin/usage/${requestId}/replay`,
|
||||
params || {},
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string // UUID
|
||||
user_id: string // UUID
|
||||
username?: string
|
||||
provider_id?: string // UUID
|
||||
provider_name?: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
total_tokens: number
|
||||
cost?: number
|
||||
response_time?: number
|
||||
created_at: string
|
||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost?: number
|
||||
avg_response_time: number
|
||||
today?: {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
export interface UsageByModel {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
avg_response_time?: number
|
||||
cache_read_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
}
|
||||
|
||||
export interface UsageByUser {
|
||||
user_id: string // UUID
|
||||
email: string
|
||||
username: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
}
|
||||
|
||||
export interface UsageByProvider {
|
||||
provider_id: string
|
||||
provider: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
success_rate: number
|
||||
error_count: number
|
||||
cache_read_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
}
|
||||
|
||||
export interface UsageByApiFormat {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
cache_read_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
}
|
||||
|
||||
export interface UsageFilters {
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
async getUsageRecords(filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/usage', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
||||
// 为统计数据添加30秒缓存
|
||||
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
30000 // 30秒缓存
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get usage aggregation by dimension (RESTful API)
|
||||
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
|
||||
* @param filters Optional filters
|
||||
*/
|
||||
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
|
||||
groupBy: 'model' | 'user' | 'provider' | 'api_format',
|
||||
filters?: UsageFilters & { limit?: number }
|
||||
): Promise<T> {
|
||||
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
|
||||
params: { group_by: groupBy, ...filters }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30000 // 30秒缓存
|
||||
)
|
||||
},
|
||||
|
||||
// Shorthand methods using getUsageAggregation
|
||||
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
|
||||
return this.getUsageAggregation<UsageByModel[]>('model', filters)
|
||||
},
|
||||
|
||||
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
|
||||
return this.getUsageAggregation<UsageByUser[]>('user', filters)
|
||||
},
|
||||
|
||||
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
|
||||
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
|
||||
},
|
||||
|
||||
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
|
||||
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
|
||||
},
|
||||
|
||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
stats: UsageStats
|
||||
}> {
|
||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/usage/export', {
|
||||
params: { ...filters, format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
records: Array<Record<string, unknown>>
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||
return response.data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
||||
*/
|
||||
async getActiveRequests(ids?: string[]): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
cost: number
|
||||
actual_cost?: number | null
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
provider?: string | null
|
||||
api_key_name?: string | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
has_format_conversion?: boolean | null
|
||||
target_model?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(管理员)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
return cachedRequest(
|
||||
'admin-usage-activity-heatmap',
|
||||
async () => {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
},
|
||||
60000
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,10 +141,5 @@ export const usersApi = {
|
||||
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
// 管理员统计
|
||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get('/api/admin/usage/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
@@ -44,6 +48,7 @@ interface Props {
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'bar'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
function buildChartOptions(): ChartOptions<'bar'> {
|
||||
const stackedOptions = props.stacked ? {
|
||||
scales: {
|
||||
x: { ...defaultOptions.scales?.x, stacked: true },
|
||||
@@ -106,20 +109,26 @@ function createChart() {
|
||||
}
|
||||
}
|
||||
|
||||
return resolveChartTheme({
|
||||
...defaultOptions,
|
||||
...stackedOptions,
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...stackedOptions,
|
||||
...props.options
|
||||
}
|
||||
data: resolveChartTheme(props.data),
|
||||
options: buildChartOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
@@ -127,9 +136,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
@@ -139,11 +156,8 @@ onUnmounted(() => {
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
chart.update()
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,10 +19,12 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
options: undefined
|
||||
options: undefined,
|
||||
showLegend: true,
|
||||
})
|
||||
|
||||
ChartJS.register(
|
||||
@@ -34,15 +39,22 @@ interface Props {
|
||||
data: ChartData<'doughnut'>
|
||||
options?: ChartOptions<'doughnut'>
|
||||
height?: number
|
||||
showLegend?: boolean
|
||||
}
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'doughnut'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
@@ -71,22 +83,59 @@ const defaultOptions: ChartOptions<'doughnut'> = {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedOptions(): ChartOptions<'doughnut'> {
|
||||
const incomingOptions = props.options ?? {}
|
||||
const incomingPlugins = (incomingOptions.plugins ?? {}) as Record<string, unknown>
|
||||
const incomingLegend = (incomingPlugins.legend ?? {}) as Record<string, unknown>
|
||||
const incomingTooltip = (incomingPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||
const defaultPlugins = (defaultOptions.plugins ?? {}) as Record<string, unknown>
|
||||
const defaultLegend = (defaultPlugins.legend ?? {}) as Record<string, unknown>
|
||||
const defaultTooltip = (defaultPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||
|
||||
return resolveChartTheme({
|
||||
...defaultOptions,
|
||||
...incomingOptions,
|
||||
interaction: {
|
||||
...defaultOptions.interaction,
|
||||
...incomingOptions.interaction,
|
||||
},
|
||||
plugins: {
|
||||
...defaultPlugins,
|
||||
...incomingPlugins,
|
||||
legend: {
|
||||
...defaultLegend,
|
||||
...incomingLegend,
|
||||
display: incomingLegend.display ?? props.showLegend,
|
||||
labels: {
|
||||
...(defaultLegend.labels as Record<string, unknown> | undefined),
|
||||
...(incomingLegend.labels as Record<string, unknown> | undefined),
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
...defaultTooltip,
|
||||
...incomingTooltip,
|
||||
callbacks: {
|
||||
...(defaultTooltip.callbacks as Record<string, unknown> | undefined),
|
||||
...(incomingTooltip.callbacks as Record<string, unknown> | undefined),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'doughnut',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
data: resolveChartTheme(props.data),
|
||||
options: resolvedOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
@@ -94,9 +143,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
@@ -106,8 +163,15 @@ onUnmounted(() => {
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = { ...defaultOptions, ...props.options }
|
||||
chart.update()
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.showLegend, () => {
|
||||
if (chart) {
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,9 +19,11 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
@@ -34,7 +39,8 @@ ChartJS.register(
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
interface Props {
|
||||
@@ -45,12 +51,17 @@ interface Props {
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'line'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
function buildChartOptions(): ChartOptions<'line'> {
|
||||
return {
|
||||
return resolveChartTheme({
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildChartData(): ChartData<'line'> {
|
||||
return resolveChartTheme(props.data)
|
||||
}
|
||||
|
||||
const defaultOptions: ChartOptions<'line'> = {
|
||||
@@ -95,14 +106,14 @@ function createChart() {
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'line',
|
||||
data: props.data,
|
||||
data: buildChartData(),
|
||||
options: buildChartOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = buildChartData()
|
||||
chart.update('none') // 禁用动画以提高性能
|
||||
}
|
||||
}
|
||||
@@ -110,9 +121,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = buildChartData()
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
:multiple="multiple"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
@@ -94,6 +94,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
resetKey?: string | number
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
dropTitle?: string
|
||||
dropHint?: string
|
||||
manualLabel?: string
|
||||
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled: false,
|
||||
resetKey: '',
|
||||
accept: '.json,.txt',
|
||||
multiple: true,
|
||||
dropTitle: '拖入导入文件或点击选择',
|
||||
dropHint: '支持 .json / .txt,可多选',
|
||||
manualLabel: '',
|
||||
@@ -218,13 +220,19 @@ function mergeFileContents(contents: string[]): string {
|
||||
}
|
||||
|
||||
async function readFiles(files: File[]) {
|
||||
const validFiles = files.filter(isValidFileType)
|
||||
const sourceFiles = props.multiple ? files : files.slice(0, 1)
|
||||
|
||||
if (!props.multiple && files.length > 1) {
|
||||
emitError('仅支持选择 1 个文件,已读取第一个文件', '提示')
|
||||
}
|
||||
|
||||
const validFiles = sourceFiles.filter(isValidFileType)
|
||||
if (validFiles.length === 0) {
|
||||
emitError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||
return
|
||||
}
|
||||
if (validFiles.length < files.length) {
|
||||
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
if (validFiles.length < sourceFiles.length) {
|
||||
emitError(`已忽略 ${sourceFiles.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,39 +3,24 @@
|
||||
<Select
|
||||
v-model="selectedPreset"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
||||
<SelectTrigger :class="presetTriggerClass">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">
|
||||
今天
|
||||
</SelectItem>
|
||||
<SelectItem value="yesterday">
|
||||
昨天
|
||||
</SelectItem>
|
||||
<SelectItem value="last7days">
|
||||
最近7天
|
||||
</SelectItem>
|
||||
<SelectItem value="last30days">
|
||||
最近30天
|
||||
</SelectItem>
|
||||
<SelectItem value="last90days">
|
||||
最近90天
|
||||
<SelectItem value="last180days">
|
||||
最近180天
|
||||
</SelectItem>
|
||||
<SelectItem value="this_week">
|
||||
本周
|
||||
</SelectItem>
|
||||
<SelectItem value="last_week">
|
||||
上周
|
||||
</SelectItem>
|
||||
<SelectItem value="this_month">
|
||||
本月
|
||||
</SelectItem>
|
||||
<SelectItem value="last_month">
|
||||
上月
|
||||
</SelectItem>
|
||||
<SelectItem value="this_year">
|
||||
今年
|
||||
<SelectItem value="last1year">
|
||||
最近一年
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
@@ -50,13 +35,13 @@
|
||||
<Input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:class="dateInputClass"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">至</span>
|
||||
<Input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:class="dateInputClass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +49,16 @@
|
||||
v-if="showGranularity"
|
||||
v-model="selectedGranularity"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
||||
<SelectTrigger :class="granularityTriggerClass">
|
||||
<SelectValue placeholder="粒度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-if="includeAutoGranularity"
|
||||
value="auto"
|
||||
>
|
||||
自动
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-if="allowHourly && canUseHourly"
|
||||
value="hour"
|
||||
@@ -100,26 +91,51 @@ import {
|
||||
} from '@/components/ui'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
showGranularity?: boolean
|
||||
allowHourly?: boolean
|
||||
includeAutoGranularity?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
}>()
|
||||
|
||||
const selectedPreset = ref(props.modelValue.preset || 'last7days')
|
||||
function getDefaultGranularity(): PickerGranularity {
|
||||
return props.includeAutoGranularity ? 'auto' : 'day'
|
||||
}
|
||||
|
||||
const selectedPreset = ref(props.modelValue.preset || 'today')
|
||||
const startDate = ref(props.modelValue.start_date || '')
|
||||
const endDate = ref(props.modelValue.end_date || '')
|
||||
const selectedGranularity = ref(props.modelValue.granularity || 'day')
|
||||
const selectedGranularity = ref<PickerGranularity>(props.modelValue.granularity || getDefaultGranularity())
|
||||
|
||||
const showGranularity = computed(() => props.showGranularity !== false)
|
||||
const allowHourly = computed(() => props.allowHourly === true)
|
||||
const includeAutoGranularity = computed(() => props.includeAutoGranularity === true)
|
||||
const compact = computed(() => props.compact === true)
|
||||
const presetTriggerClass = computed(() =>
|
||||
compact.value
|
||||
? 'h-8 w-28 text-xs border-border/60'
|
||||
: 'h-8 w-32 text-xs border-border/60',
|
||||
)
|
||||
const dateInputClass = computed(() =>
|
||||
compact.value
|
||||
? 'h-8 w-32 text-xs border-border/60'
|
||||
: 'h-8 w-36 text-xs border-border/60',
|
||||
)
|
||||
const granularityTriggerClass = computed(() =>
|
||||
compact.value
|
||||
? 'h-8 w-20 text-xs border-border/60'
|
||||
: 'h-8 w-24 text-xs border-border/60',
|
||||
)
|
||||
|
||||
const canUseHourly = computed(() => {
|
||||
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
|
||||
if (selectedPreset.value === 'today') return true
|
||||
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
|
||||
return startDate.value === endDate.value
|
||||
}
|
||||
@@ -165,7 +181,7 @@ watch(() => props.modelValue, (value) => {
|
||||
if (value.preset) selectedPreset.value = value.preset
|
||||
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
||||
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
||||
if (value.granularity) selectedGranularity.value = value.granularity
|
||||
selectedGranularity.value = value.granularity || getDefaultGranularity()
|
||||
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
|
||||
lastEmittedValue = getValueKey(value)
|
||||
}, { deep: true })
|
||||
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
|
||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||
if (!allowHourly.value || !canUseHourly.value) {
|
||||
if (selectedGranularity.value === 'hour') {
|
||||
selectedGranularity.value = 'day'
|
||||
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
<p class="mt-0.5">
|
||||
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
成本 {{ formatCurrency(tooltip.day.total_cost) }}
|
||||
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{{ valueLabel }} {{ formatCurrency(tooltip.day.total_cost) }}
|
||||
<template v-if="actualValueLabel && tooltip.day.actual_total_cost !== undefined">
|
||||
· {{ actualValueLabel }} {{ formatCurrency(tooltip.day.actual_total_cost) }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -90,7 +93,7 @@
|
||||
class="flex items-center invisible"
|
||||
>周六</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
ref="heatmapWrapper"
|
||||
class="relative block w-full"
|
||||
@@ -100,12 +103,12 @@
|
||||
:style="horizontalGapStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(week, weekIndex) in weekColumns"
|
||||
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||
:key="`month-${weekIndex}`"
|
||||
:style="monthCellStyle"
|
||||
class="text-center"
|
||||
>
|
||||
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
||||
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -113,7 +116,7 @@
|
||||
:style="horizontalGapStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(week, weekIndex) in weekColumns"
|
||||
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||
:key="weekIndex"
|
||||
class="flex flex-col"
|
||||
:style="verticalGapStyle"
|
||||
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
showHeader?: boolean
|
||||
valueLabel?: string
|
||||
actualValueLabel?: string
|
||||
}>(), {
|
||||
data: undefined,
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
showHeader: true
|
||||
showHeader: true,
|
||||
valueLabel: '费用',
|
||||
actualValueLabel: undefined,
|
||||
})
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
|
||||
if (month === lastMonth) {
|
||||
return
|
||||
}
|
||||
markers[index] = `${month + 1}月`
|
||||
markers[index] = String(month + 1)
|
||||
lastMonth = month
|
||||
})
|
||||
|
||||
return markers
|
||||
})
|
||||
|
||||
const MIN_CELL_SIZE = 6
|
||||
|
||||
// 根据容器宽度计算最多能放多少周,然后从后面截取(保留最新数据)
|
||||
const maxVisibleWeeks = computed(() => {
|
||||
const total = weekColumns.value.length
|
||||
if (!heatmapWidth.value || !total) return total
|
||||
// 先尝试用理想尺寸放全部
|
||||
const totalGapAll = Math.max(total - 1, 0) * cellGap.value
|
||||
const rawSizeAll = (heatmapWidth.value - totalGapAll) / total
|
||||
if (rawSizeAll >= MIN_CELL_SIZE) return total
|
||||
// 放不下,算容器最多能容纳几周(用最小尺寸)
|
||||
// n * MIN_CELL_SIZE + (n-1) * gap <= width
|
||||
const maxN = Math.floor((heatmapWidth.value + cellGap.value) / (MIN_CELL_SIZE + cellGap.value))
|
||||
return Math.max(1, Math.min(maxN, total))
|
||||
})
|
||||
|
||||
const visibleWeekColumns = computed(() => {
|
||||
const all = weekColumns.value
|
||||
const n = maxVisibleWeeks.value
|
||||
if (n >= all.length) return all
|
||||
return all.slice(all.length - n)
|
||||
})
|
||||
|
||||
const visibleMonthMarkers = computed(() => {
|
||||
const markers: Record<number, string> = {}
|
||||
const columns = visibleWeekColumns.value
|
||||
let lastMonth: number | null = null
|
||||
|
||||
columns.forEach((week, index) => {
|
||||
const firstValid = week.find((day): day is DayWithMeta => day !== null)
|
||||
if (!firstValid) return
|
||||
const month = firstValid.dateObj.getUTCMonth()
|
||||
if (month === lastMonth) return
|
||||
markers[index] = String(month + 1)
|
||||
lastMonth = month
|
||||
})
|
||||
|
||||
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
|
||||
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const recalcCellSize = () => {
|
||||
const columnCount = weekColumns.value.length
|
||||
const columnCount = visibleWeekColumns.value.length
|
||||
if (!columnCount || !heatmapWidth.value) {
|
||||
return
|
||||
}
|
||||
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
|
||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||
const rawSize = availableSpace / columnCount
|
||||
// 自适应尺寸,最小 6px
|
||||
cellSize.value = Math.max(6, rawSize)
|
||||
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
|
||||
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
||||
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
|
||||
() => {
|
||||
recalcCellSize()
|
||||
},
|
||||
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
||||
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const tooltipWidth = 200
|
||||
const tooltipWidth = 280
|
||||
const tooltipHeight = 72
|
||||
|
||||
// Calculate horizontal position (centered on cell)
|
||||
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
|
||||
|
||||
function buildTooltip(day: ActivityHeatmapDay): string {
|
||||
const dateLabel = day.date
|
||||
const costLabel = formatCurrency(day.total_cost || 0)
|
||||
const costLabel = `${props.valueLabel} ${formatCurrency(day.total_cost || 0)}`
|
||||
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
||||
if (day.actual_total_cost !== undefined) {
|
||||
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
|
||||
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
|
||||
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs"
|
||||
:class="changeClass"
|
||||
>
|
||||
<span v-if="changePercent !== null">{{ changePercent }}%</span>
|
||||
<span v-else>--</span>
|
||||
<span class="ml-1 text-muted-foreground">vs 对比期</span>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Card } from '@/components/ui'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
changePercent: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const changeClass = computed(() => {
|
||||
if (props.changePercent === null) return 'text-muted-foreground'
|
||||
if (props.changePercent > 0) return 'text-emerald-500'
|
||||
if (props.changePercent < 0) return 'text-rose-500'
|
||||
return 'text-muted-foreground'
|
||||
})
|
||||
</script>
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[280px]"
|
||||
>
|
||||
<LineChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { LoadingState } from '@/components/common'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
history: Array<{ date: string; total_cost: number }>
|
||||
forecast: Array<{ date: string; total_cost: number }>
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subtitle: undefined,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const labels = computed(() => [
|
||||
...props.history.map(item => item.date),
|
||||
...props.forecast.map(item => item.date)
|
||||
])
|
||||
|
||||
const chartData = computed(() => {
|
||||
const historyValues = props.history.map(item => item.total_cost)
|
||||
const forecastValues = props.forecast.map(item => item.total_cost)
|
||||
return {
|
||||
labels: labels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: '实际成本',
|
||||
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: '预测成本',
|
||||
data: new Array(historyValues.length).fill(null).concat(forecastValues),
|
||||
borderColor: 'rgb(234, 179, 8)',
|
||||
backgroundColor: 'rgba(234, 179, 8, 0.15)',
|
||||
borderDash: [6, 4],
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { parsed?: { y?: number }; dataset: { label?: string } }) => {
|
||||
const value = context.parsed?.y ?? 0
|
||||
return `${context.dataset.label}: ${formatCurrency(value)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
@@ -1,14 +1,34 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!unavailable"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
|
||||
总错误 {{ totalErrors }}
|
||||
</div>
|
||||
<div
|
||||
v-if="topCategory"
|
||||
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
TOP {{ topCategory.label }} · {{ topCategory.count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
@@ -16,57 +36,116 @@
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
v-else-if="unavailable"
|
||||
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||
>
|
||||
<DoughnutChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
{{ unavailableText }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!hasData"
|
||||
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||
>
|
||||
暂无错误分布数据
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[320px]">
|
||||
<DoughnutChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:show-legend="true"
|
||||
:height="248"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||
import { LoadingState } from '@/components/common'
|
||||
import type { ErrorDistributionItem } from '@/api/admin'
|
||||
|
||||
interface ErrorDistributionItem {
|
||||
category: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
distribution: ErrorDistributionItem[]
|
||||
loading?: boolean
|
||||
unavailable?: boolean
|
||||
unavailableText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subtitle: undefined,
|
||||
loading: false
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableText: '数据暂不可用',
|
||||
})
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.distribution.map(item => item.category),
|
||||
const chartColors = [
|
||||
'rgba(226, 85, 118, 0.88)',
|
||||
'rgba(84, 135, 237, 0.86)',
|
||||
'rgba(225, 165, 52, 0.84)',
|
||||
'rgba(68, 187, 132, 0.82)',
|
||||
'rgba(148, 163, 184, 0.8)',
|
||||
'rgba(145, 118, 214, 0.82)',
|
||||
]
|
||||
|
||||
const totalErrors = computed(() => props.distribution.reduce((sum, item) => sum + item.count, 0))
|
||||
const hasData = computed(() => totalErrors.value > 0)
|
||||
const distributionWithShare = computed(() => props.distribution.map(item => ({
|
||||
...item,
|
||||
label: item.label?.trim() || item.category,
|
||||
share: totalErrors.value > 0 ? `${((item.count / totalErrors.value) * 100).toFixed(1)}%` : '0.0%',
|
||||
})))
|
||||
const topCategory = computed(() => (
|
||||
distributionWithShare.value.slice().sort((left, right) => right.count - left.count)[0] ?? null
|
||||
))
|
||||
|
||||
const chartData = computed<ChartData<'doughnut'>>(() => ({
|
||||
labels: distributionWithShare.value.map(item => item.label),
|
||||
datasets: [
|
||||
{
|
||||
data: props.distribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'rgba(239, 68, 68, 0.7)',
|
||||
'rgba(59, 130, 246, 0.7)',
|
||||
'rgba(234, 179, 8, 0.7)',
|
||||
'rgba(34, 197, 94, 0.7)',
|
||||
'rgba(148, 163, 184, 0.7)'
|
||||
],
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
data: distributionWithShare.value.map(item => item.count),
|
||||
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
|
||||
borderColor: 'rgba(255,255,255,0.92)',
|
||||
borderWidth: 2,
|
||||
hoverOffset: 6,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
cutout: '68%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const
|
||||
}
|
||||
}
|
||||
position: 'bottom',
|
||||
align: 'center',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 10,
|
||||
padding: 14,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = Number(context.raw ?? 0)
|
||||
const percentage = totalErrors.value > 0 ? ((value / totalErrors.value) * 100).toFixed(1) : '0.0'
|
||||
return `${context.label ?? '错误'}: ${value} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
<template>
|
||||
<TableCard :title="title">
|
||||
<template #actions>
|
||||
<Select
|
||||
v-if="showMetricSelect"
|
||||
:model-value="metric"
|
||||
@update:model-value="emitMetric"
|
||||
<div
|
||||
v-if="isRefreshing"
|
||||
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-28">
|
||||
<SelectValue placeholder="指标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="requests">
|
||||
请求数
|
||||
</SelectItem>
|
||||
<SelectItem value="tokens">
|
||||
Tokens
|
||||
</SelectItem>
|
||||
<SelectItem value="cost">
|
||||
成本
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
更新中
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
v-if="showLoadingState"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showUnavailableState"
|
||||
class="p-6"
|
||||
>
|
||||
<EmptyState
|
||||
type="error"
|
||||
:title="unavailableTitle"
|
||||
:description="unavailableDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="p-6"
|
||||
@@ -38,20 +34,29 @@
|
||||
description="当前时间范围内没有统计结果"
|
||||
/>
|
||||
</div>
|
||||
<Table v-else>
|
||||
<Table
|
||||
v-else
|
||||
class="table-fixed"
|
||||
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-16">
|
||||
<TableHead class="w-14 px-2.5">
|
||||
排名
|
||||
</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<TableHead class="w-[34%] px-2.5">
|
||||
名称
|
||||
</TableHead>
|
||||
<TableHead class="w-[15%] px-2.5 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<TableHead class="w-[18%] px-2.5 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<TableHead class="w-[16%] px-2.5 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="w-[17%] px-2.5 text-right">
|
||||
成本
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -60,68 +65,118 @@
|
||||
<TableRow
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cursor-pointer"
|
||||
:class="item.id === selectedId
|
||||
? 'bg-[linear-gradient(90deg,rgba(232,145,89,0.16),rgba(232,145,89,0.05),transparent)] shadow-[inset_4px_0_0_0_rgba(232,145,89,0.9)] hover:bg-[linear-gradient(90deg,rgba(232,145,89,0.2),rgba(232,145,89,0.06),transparent)]'
|
||||
: 'hover:bg-muted/25'"
|
||||
@click="emit('select', item.id)"
|
||||
>
|
||||
<TableCell class="font-medium">
|
||||
{{ item.rank }}
|
||||
<TableCell class="px-2.5 py-2.5">
|
||||
<div
|
||||
class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums transition-colors"
|
||||
:class="item.id === selectedId
|
||||
? 'border-amber-500/70 bg-amber-500/15 text-amber-700 dark:text-amber-300'
|
||||
: 'border-border/70 bg-muted/35 text-muted-foreground'"
|
||||
>
|
||||
{{ item.rank }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{{ item.name }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ item.requests }}
|
||||
<TableCell class="px-2.5 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate text-sm font-medium"
|
||||
:title="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
费用优先
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||
{{ item.requests.toLocaleString() }}
|
||||
</TableCell>
|
||||
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||
{{ formatTokens(item.tokens) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<TableCell class="px-2.5 py-2.5 text-right text-[11px] font-medium tabular-nums text-amber-700 dark:text-amber-300">
|
||||
{{ formatCurrency(item.cost) }}
|
||||
</TableCell>
|
||||
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ formatCurrency(item.actualCost) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<template v-if="showPagination" #pagination>
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:total="totalItems"
|
||||
:page-size="pageSize"
|
||||
:show-page-size-selector="false"
|
||||
@update:current="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</template>
|
||||
</TableCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import { TableCard } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
|
||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||
import type { LeaderboardItem } from '@/api/admin'
|
||||
|
||||
interface LeaderboardItem {
|
||||
rank: number
|
||||
id: string
|
||||
name: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actualCost: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
items: LeaderboardItem[]
|
||||
metric: 'requests' | 'tokens' | 'cost'
|
||||
selectedId?: string
|
||||
loading?: boolean
|
||||
showMetricSelect?: boolean
|
||||
hasLoaded?: boolean
|
||||
currentPage?: number
|
||||
totalItems?: number
|
||||
pageSize?: number
|
||||
showPagination?: boolean
|
||||
unavailable?: boolean
|
||||
unavailableTitle?: string
|
||||
unavailableDescription?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedId: undefined,
|
||||
loading: false,
|
||||
showMetricSelect: true
|
||||
hasLoaded: false,
|
||||
currentPage: 1,
|
||||
totalItems: 0,
|
||||
pageSize: 5,
|
||||
showPagination: false,
|
||||
unavailable: false,
|
||||
unavailableTitle: '排行榜暂不可用',
|
||||
unavailableDescription: '接口未返回结果,请稍后重试',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
|
||||
(e: 'select', value: string): void
|
||||
(e: 'update:currentPage', value: number): void
|
||||
}>()
|
||||
|
||||
const metric = computed(() => props.metric)
|
||||
|
||||
function emitMetric(value: string) {
|
||||
if (value === 'requests' || value === 'tokens' || value === 'cost') {
|
||||
emit('update:metric', value)
|
||||
}
|
||||
}
|
||||
const selectedId = computed(() => props.selectedId)
|
||||
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
|
||||
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
|
||||
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
|
||||
const currentPage = computed(() => props.currentPage)
|
||||
const totalItems = computed(() => props.totalItems)
|
||||
const pageSize = computed(() => props.pageSize)
|
||||
const showPagination = computed(() => props.showPagination && props.totalItems > props.pageSize)
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="summaryMetrics.length > 0 && !unavailable"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="metric in summaryMetrics"
|
||||
:key="metric.label"
|
||||
class="rounded-full border px-2.5 py-1 text-[10px] font-medium"
|
||||
:class="metric.className"
|
||||
>
|
||||
{{ metric.label }} {{ metric.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="unavailable"
|
||||
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ unavailableText }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!hasData"
|
||||
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||
>
|
||||
暂无延迟数据
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||
>
|
||||
<LineChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:height="248"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,9 +61,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { LoadingState } from '@/components/common'
|
||||
import type { PercentileItem } from '@/api/admin'
|
||||
|
||||
interface PercentileItem {
|
||||
date: string
|
||||
p50_response_time_ms?: number | null
|
||||
p90_response_time_ms?: number | null
|
||||
p99_response_time_ms?: number | null
|
||||
p50_first_byte_time_ms?: number | null
|
||||
p90_first_byte_time_ms?: number | null
|
||||
p99_first_byte_time_ms?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -39,61 +81,159 @@ interface Props {
|
||||
series: PercentileItem[]
|
||||
mode: 'response' | 'ttfb'
|
||||
loading?: boolean
|
||||
unavailable?: boolean
|
||||
unavailableText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subtitle: undefined,
|
||||
loading: false
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableText: '数据暂不可用',
|
||||
})
|
||||
|
||||
const labels = computed(() => props.series.map(item => item.date))
|
||||
|
||||
// 毫秒转秒
|
||||
function msToSeconds(ms: number | null | undefined): number | null {
|
||||
if (ms == null) return null
|
||||
return ms / 1000
|
||||
return Number((ms / 1000).toFixed(3))
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
|
||||
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
|
||||
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
|
||||
|
||||
return {
|
||||
labels: labels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: 'P50',
|
||||
data: props.series.map(item => msToSeconds(item[p50Key])),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'P90',
|
||||
data: props.series.map(item => msToSeconds(item[p90Key])),
|
||||
borderColor: 'rgb(234, 179, 8)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: props.series.map(item => msToSeconds(item[p99Key])),
|
||||
borderColor: 'rgb(239, 68, 68)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
}
|
||||
]
|
||||
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
|
||||
if (props.mode === 'response') {
|
||||
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
|
||||
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
|
||||
return msToSeconds(item.p99_response_time_ms)
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
if (percentile === '50') return msToSeconds(item.p50_first_byte_time_ms)
|
||||
if (percentile === '90') return msToSeconds(item.p90_first_byte_time_ms)
|
||||
return msToSeconds(item.p99_first_byte_time_ms)
|
||||
}
|
||||
|
||||
function getLatestMetricValue(percentile: '50' | '90' | '99') {
|
||||
for (let index = props.series.length - 1; index >= 0; index -= 1) {
|
||||
const value = getPercentileValue(props.series[index], percentile)
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatSeconds(value: number | null) {
|
||||
if (value == null) return '--'
|
||||
if (value < 1) return `${Math.round(value * 1000)}ms`
|
||||
return `${value.toFixed(2)}s`
|
||||
}
|
||||
|
||||
const labels = computed(() => props.series.map(item => item.date?.slice(5) ?? ''))
|
||||
const hasData = computed(() => props.series.some(item => (
|
||||
getPercentileValue(item, '50') != null
|
||||
|| getPercentileValue(item, '90') != null
|
||||
|| getPercentileValue(item, '99') != null
|
||||
)))
|
||||
|
||||
const summaryMetrics = computed(() => ([
|
||||
{
|
||||
label: 'P50',
|
||||
value: formatSeconds(getLatestMetricValue('50')),
|
||||
className: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||
},
|
||||
{
|
||||
label: 'P90',
|
||||
value: formatSeconds(getLatestMetricValue('90')),
|
||||
className: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
value: formatSeconds(getLatestMetricValue('99')),
|
||||
className: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||
},
|
||||
]))
|
||||
|
||||
const chartData = computed<ChartData<'line'>>(() => ({
|
||||
labels: labels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: 'P50',
|
||||
data: props.series.map(item => getPercentileValue(item, '50')),
|
||||
borderColor: 'rgb(70, 136, 240)',
|
||||
backgroundColor: 'rgba(70, 136, 240, 0.10)',
|
||||
borderWidth: 2.4,
|
||||
fill: false,
|
||||
tension: 0.32,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
{
|
||||
label: 'P90',
|
||||
data: props.series.map(item => getPercentileValue(item, '90')),
|
||||
borderColor: 'rgb(223, 166, 55)',
|
||||
backgroundColor: 'rgba(223, 166, 55, 0.08)',
|
||||
borderWidth: 2.1,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: props.series.map(item => getPercentileValue(item, '99')),
|
||||
borderColor: 'rgb(214, 92, 111)',
|
||||
backgroundColor: 'rgba(214, 92, 111, 0.08)',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
fill: false,
|
||||
tension: 0.28,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
padding: 14,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: items => props.series[items[0]?.dataIndex ?? -1]?.date ?? '',
|
||||
label: context => `${context.dataset.label}: ${formatSeconds(Number(context.raw ?? 0))}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
callback: (value: number) => `${value}s`
|
||||
}
|
||||
}
|
||||
}
|
||||
color: 'rgb(100, 116, 139)',
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.07)',
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(100, 116, 139)',
|
||||
callback: value => formatSeconds(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<Card class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-4"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="providers.length === 0"
|
||||
class="p-4"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无数据"
|
||||
description="暂无月卡配额数据"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-medium">{{ provider.name }}</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted">
|
||||
<div
|
||||
class="h-2 rounded-full bg-primary"
|
||||
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
|
||||
<span v-if="provider.estimated_exhaust_at">
|
||||
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card } from '@/components/ui'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import type { QuotaUsageProvider } from '@/api/admin'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
providers: QuotaUsageProvider[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subtitle: undefined,
|
||||
loading: false
|
||||
})
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,4 @@
|
||||
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
||||
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
||||
export { default as CostForecastChart } from './CostForecastChart.vue'
|
||||
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
|
||||
export { default as PercentileChart } from './PercentileChart.vue'
|
||||
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
|
||||
export { default as ComparisonCard } from './ComparisonCard.vue'
|
||||
|
||||
@@ -109,6 +109,7 @@ const props = defineProps<{
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
persistent?: boolean // Prevent closing on backdrop click
|
||||
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -145,7 +146,7 @@ function handleClose() {
|
||||
|
||||
// 处理背景点击
|
||||
function handleBackdropClick() {
|
||||
if (!props.persistent) {
|
||||
if (!props.persistent && props.closeOnBackdrop !== false) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<input
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="maskStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
@@ -41,6 +42,7 @@
|
||||
v-else
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="maskStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
@@ -79,7 +81,7 @@ interface Props {
|
||||
size?: 'default' | 'sm'
|
||||
/**
|
||||
* 遮蔽显示内容(用于 API Key 等敏感信息)
|
||||
* 隐藏态使用真正的 password 输入框,显示态切换为 text
|
||||
* 始终使用 text 输入框,隐藏态通过样式进行遮蔽
|
||||
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
|
||||
*/
|
||||
masked?: boolean
|
||||
@@ -116,11 +118,20 @@ const shouldDisableAutofill = computed(() => {
|
||||
const effectiveType = computed(() => {
|
||||
const attrType = (attrs.type as string | undefined) ?? 'text'
|
||||
if (props.masked) {
|
||||
return isVisible.value ? 'text' : 'password'
|
||||
return 'text'
|
||||
}
|
||||
return attrType
|
||||
})
|
||||
|
||||
const maskStyle = computed(() => {
|
||||
if (!props.masked || isVisible.value) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
WebkitTextSecurity: 'disc'
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤掉 type 和 class 属性,因为我们会单独处理
|
||||
const filteredAttrs = computed(() => {
|
||||
const { type: _type, class: _class, ...rest } = attrs
|
||||
|
||||
@@ -59,7 +59,7 @@ const isHidden = computed(
|
||||
)
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'relative flex min-w-0 w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
||||
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
isHidden.value && 'hidden',
|
||||
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
<SelectItemText
|
||||
as="div"
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
class="block w-full min-w-0 truncate"
|
||||
:title="normalizedText"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</SelectItemText>
|
||||
</SelectItemPrimitive>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
|
||||
:class="triggerClass"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<slot />
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useLeaderboardData } from './useLeaderboardData'
|
||||
export type { LeaderboardDimension } from './useLeaderboardData'
|
||||
export { usePerformanceData } from './usePerformanceData'
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AnalyticsLeaderboardItem } from '@/api/analytics'
|
||||
|
||||
export interface LeaderboardTableRow {
|
||||
rank: number
|
||||
id: string
|
||||
name: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actualCost: number
|
||||
}
|
||||
|
||||
export function mapAnalyticsLeaderboardItem(item: AnalyticsLeaderboardItem): LeaderboardTableRow {
|
||||
return {
|
||||
rank: item.rank,
|
||||
id: item.id,
|
||||
name: item.label,
|
||||
requests: item.requests_total,
|
||||
tokens: item.total_tokens,
|
||||
cost: item.total_cost_usd,
|
||||
actualCost: item.actual_total_cost_usd,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
|
||||
import { analyticsApi, type AnalyticsFilterOption, type AnalyticsLeaderboardItem, type AnalyticsTimeseriesBucket } from '@/api/analytics'
|
||||
import { buildTimeRangeParams, createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||
import { isAnalyticsSingleDayRange, resolveAnalyticsAutoGranularity, type AnalyticsGranularityOption } from '@/utils/analyticsGranularity'
|
||||
import { mapAnalyticsLeaderboardItem, type LeaderboardTableRow } from './mappers'
|
||||
|
||||
export type LeaderboardDimension = 'users' | 'api_keys'
|
||||
|
||||
export interface LeaderboardSummary {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
avg_response_time: number
|
||||
}
|
||||
|
||||
function dimensionToEntity(dimension: LeaderboardDimension): 'user' | 'api_key' {
|
||||
return dimension === 'users' ? 'user' : 'api_key'
|
||||
}
|
||||
|
||||
export function useLeaderboardData(dimension: Ref<LeaderboardDimension>) {
|
||||
const filters = useInjectedAnalyticsFilters()
|
||||
|
||||
const leaderboardItems = ref<AnalyticsLeaderboardItem[]>([])
|
||||
const latestUserOptions = ref<AnalyticsFilterOption[]>([])
|
||||
const latestApiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||
const leaderboardLoading = ref(false)
|
||||
const leaderboardError = ref(false)
|
||||
const leaderboardHasLoaded = ref(false)
|
||||
const selectedId = ref<string>('')
|
||||
|
||||
const selectedSummary = ref<LeaderboardSummary | null>(null)
|
||||
const selectedTrendBuckets = ref<AnalyticsTimeseriesBucket[]>([])
|
||||
const panelLoading = ref(false)
|
||||
const panelError = ref(false)
|
||||
const panelHasLoaded = ref(false)
|
||||
|
||||
const leaderboardGuard = createRequestGuard()
|
||||
const filterOptionsGuard = createRequestGuard()
|
||||
const panelGuard = createRequestGuard()
|
||||
|
||||
const entityOptions = computed(() => (
|
||||
dimension.value === 'users' ? latestUserOptions.value : latestApiKeyOptions.value
|
||||
))
|
||||
const entityLabelMap = computed(() => (
|
||||
new Map(entityOptions.value.map(option => [option.value, option.label]))
|
||||
))
|
||||
|
||||
const leaderboard = computed<LeaderboardTableRow[]>(() => (
|
||||
leaderboardItems.value.map(item => ({
|
||||
...mapAnalyticsLeaderboardItem(item),
|
||||
name: entityLabelMap.value.get(item.id) ?? item.label,
|
||||
}))
|
||||
))
|
||||
|
||||
const selectedItem = computed(() => (
|
||||
leaderboard.value.find(item => item.id === selectedId.value) ?? null
|
||||
))
|
||||
|
||||
const selectedLabel = computed(() => (
|
||||
selectedItem.value?.name ?? entityLabelMap.value.get(selectedId.value) ?? null
|
||||
))
|
||||
|
||||
const resolvedTrendGranularity = computed(() => resolveAnalyticsAutoGranularity(filters.timeRange.value))
|
||||
const baseTimeRangePayload = computed(() => ({
|
||||
start_date: filters.timeRange.value.start_date,
|
||||
end_date: filters.timeRange.value.end_date,
|
||||
preset: filters.timeRange.value.preset,
|
||||
timezone: filters.timeRange.value.timezone,
|
||||
tz_offset_minutes: filters.timeRange.value.tz_offset_minutes,
|
||||
}))
|
||||
const trendTimeRangePayload = computed(() => ({
|
||||
...baseTimeRangePayload.value,
|
||||
granularity: resolvedTrendGranularity.value,
|
||||
}))
|
||||
|
||||
const currentTrendGranularitySelection = computed<AnalyticsGranularityOption>({
|
||||
get: () => filters.timeRange.value.granularity || 'auto',
|
||||
set: (value) => {
|
||||
if (value === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||
value = 'auto'
|
||||
}
|
||||
filters.timeRange.value = {
|
||||
...filters.timeRange.value,
|
||||
granularity: value,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function resetPanelData(invalidate = false) {
|
||||
if (invalidate) panelGuard.invalidate()
|
||||
panelLoading.value = false
|
||||
panelError.value = false
|
||||
selectedSummary.value = null
|
||||
selectedTrendBuckets.value = []
|
||||
}
|
||||
|
||||
async function loadFilterOptions() {
|
||||
const requestId = filterOptionsGuard.next()
|
||||
const response = await analyticsApi.getFilterOptions({
|
||||
scope: { kind: 'global' },
|
||||
time_range: baseTimeRangePayload.value,
|
||||
filters: {},
|
||||
}).catch(() => null)
|
||||
|
||||
if (filterOptionsGuard.isStale(requestId) || !response) return
|
||||
latestUserOptions.value = response.users ?? []
|
||||
latestApiKeyOptions.value = response.api_keys ?? []
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const requestId = leaderboardGuard.next()
|
||||
leaderboardLoading.value = true
|
||||
leaderboardError.value = false
|
||||
try {
|
||||
const response = await analyticsApi.getLeaderboard({
|
||||
scope: { kind: 'global' },
|
||||
time_range: baseTimeRangePayload.value,
|
||||
filters: {
|
||||
statuses: ['completed', 'failed', 'cancelled'],
|
||||
},
|
||||
entity: dimensionToEntity(dimension.value),
|
||||
metric: 'total_cost_usd',
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
if (leaderboardGuard.isStale(requestId)) return
|
||||
leaderboardItems.value = response.items
|
||||
|
||||
const nextSelectedId = response.items.some(item => item.id === selectedId.value)
|
||||
? selectedId.value
|
||||
: (response.items[0]?.id ?? '')
|
||||
|
||||
if (selectedId.value !== nextSelectedId) {
|
||||
selectedId.value = nextSelectedId
|
||||
}
|
||||
leaderboardHasLoaded.value = true
|
||||
} catch {
|
||||
if (leaderboardGuard.isStale(requestId)) return
|
||||
leaderboardError.value = true
|
||||
} finally {
|
||||
if (leaderboardGuard.isCurrent(requestId)) {
|
||||
leaderboardLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPanel() {
|
||||
if (!selectedId.value) {
|
||||
resetPanelData(true)
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = panelGuard.next()
|
||||
panelLoading.value = true
|
||||
panelError.value = false
|
||||
try {
|
||||
const scope = dimension.value === 'users'
|
||||
? { kind: 'user' as const, user_id: selectedId.value }
|
||||
: { kind: 'api_key' as const, api_key_id: selectedId.value }
|
||||
|
||||
const basePayload = {
|
||||
scope,
|
||||
time_range: baseTimeRangePayload.value,
|
||||
filters: {
|
||||
statuses: ['completed', 'failed', 'cancelled'],
|
||||
},
|
||||
}
|
||||
|
||||
const [overview, timeseries] = await Promise.all([
|
||||
analyticsApi.getOverview(basePayload),
|
||||
analyticsApi.getTimeseries({
|
||||
...basePayload,
|
||||
time_range: trendTimeRangePayload.value,
|
||||
}),
|
||||
])
|
||||
|
||||
if (panelGuard.isStale(requestId)) return
|
||||
selectedSummary.value = {
|
||||
total_requests: overview.summary.requests_total,
|
||||
total_tokens: overview.summary.total_tokens,
|
||||
total_cost: overview.summary.total_cost_usd,
|
||||
total_actual_cost: overview.summary.actual_total_cost_usd,
|
||||
avg_response_time: overview.summary.avg_response_time_ms / 1000,
|
||||
}
|
||||
selectedTrendBuckets.value = fillMissingTimeseriesBuckets(
|
||||
timeseries.buckets,
|
||||
trendTimeRangePayload.value,
|
||||
)
|
||||
panelHasLoaded.value = true
|
||||
} catch {
|
||||
if (panelGuard.isStale(requestId)) return
|
||||
panelError.value = true
|
||||
} finally {
|
||||
if (panelGuard.isCurrent(requestId)) {
|
||||
panelLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filterOptionsLoader = createLoader(loadFilterOptions)
|
||||
const leaderboardLoader = createLoader(loadLeaderboard)
|
||||
const panelLoader = createLoader(loadPanel)
|
||||
|
||||
watch(
|
||||
() => [filters.timeRange.value.preset, filters.timeRange.value.start_date, filters.timeRange.value.end_date],
|
||||
() => {
|
||||
if (filters.timeRange.value.granularity === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||
filters.timeRange.value = {
|
||||
...filters.timeRange.value,
|
||||
granularity: 'auto',
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [baseTimeRangePayload.value, dimension.value],
|
||||
() => {
|
||||
filterOptionsLoader.schedule()
|
||||
leaderboardLoader.schedule()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
selectedId.value,
|
||||
dimension.value,
|
||||
baseTimeRangePayload.value,
|
||||
trendTimeRangePayload.value,
|
||||
],
|
||||
() => panelLoader.schedule(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
await filterOptionsLoader.execute()
|
||||
await leaderboardLoader.execute()
|
||||
await panelLoader.execute()
|
||||
})()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
filterOptionsLoader.cleanup()
|
||||
leaderboardLoader.cleanup()
|
||||
panelLoader.cleanup()
|
||||
filterOptionsGuard.invalidate()
|
||||
leaderboardGuard.invalidate()
|
||||
panelGuard.invalidate()
|
||||
})
|
||||
|
||||
return {
|
||||
leaderboard,
|
||||
leaderboardLoading,
|
||||
leaderboardError,
|
||||
leaderboardHasLoaded,
|
||||
selectedId,
|
||||
selectedItem,
|
||||
selectedLabel,
|
||||
selectedSummary,
|
||||
selectedTrendBuckets,
|
||||
panelLoading,
|
||||
panelError,
|
||||
panelHasLoaded,
|
||||
resolvedTrendGranularity,
|
||||
currentTrendGranularitySelection,
|
||||
reloadLeaderboard: leaderboardLoader.schedule,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { analyticsApi, type AnalyticsErrorCategory, type AnalyticsErrorTrendItem, type AnalyticsPercentilePoint, type AnalyticsPerformanceResponse, type AnalyticsProviderHealthItem } from '@/api/analytics'
|
||||
import { auditApi, type MonitoringSystemStatus } from '@/api/audit'
|
||||
import { getHealthSummary, type HealthSummary } from '@/api/endpoints'
|
||||
import { createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||
|
||||
const EMPTY_LATENCY: AnalyticsPerformanceResponse['latency'] = {
|
||||
response_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||
first_byte_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||
}
|
||||
|
||||
export function usePerformanceData() {
|
||||
const filters = useInjectedAnalyticsFilters()
|
||||
|
||||
const latency = ref<AnalyticsPerformanceResponse['latency']>(EMPTY_LATENCY)
|
||||
const percentiles = ref<AnalyticsPercentilePoint[]>([])
|
||||
const errorDistribution = ref<AnalyticsErrorCategory[]>([])
|
||||
const errorTrend = ref<AnalyticsErrorTrendItem[]>([])
|
||||
const errorTotal = ref(0)
|
||||
const errorRate = ref(0)
|
||||
const providerStatus = ref<AnalyticsProviderHealthItem[]>([])
|
||||
const healthSummary = ref<HealthSummary | null>(null)
|
||||
const systemStatus = ref<MonitoringSystemStatus | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const guard = createRequestGuard()
|
||||
|
||||
async function load() {
|
||||
const requestId = guard.next()
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
const [response, summaryResponse, systemResponse] = await Promise.all([
|
||||
analyticsApi.getPerformance({
|
||||
scope: { kind: 'global' },
|
||||
time_range: filters.getTimeRangeParams(),
|
||||
filters: {
|
||||
statuses: ['completed', 'failed', 'cancelled'],
|
||||
},
|
||||
}),
|
||||
getHealthSummary().catch(() => null),
|
||||
auditApi.getSystemStatus().catch(() => null),
|
||||
])
|
||||
|
||||
if (guard.isStale(requestId)) return
|
||||
latency.value = response.latency
|
||||
percentiles.value = response.percentiles
|
||||
errorTotal.value = response.errors.total
|
||||
errorRate.value = response.errors.rate
|
||||
errorDistribution.value = response.errors.categories
|
||||
errorTrend.value = response.errors.trend
|
||||
providerStatus.value = response.provider_health
|
||||
if (summaryResponse) {
|
||||
healthSummary.value = summaryResponse
|
||||
}
|
||||
if (systemResponse) {
|
||||
systemStatus.value = systemResponse
|
||||
}
|
||||
hasLoaded.value = true
|
||||
} catch {
|
||||
if (guard.isStale(requestId)) return
|
||||
loadError.value = true
|
||||
} finally {
|
||||
if (guard.isCurrent(requestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loader = createLoader(load)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
filters.getTimeRangeParams(),
|
||||
],
|
||||
() => loader.schedule(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => { void loader.execute() })
|
||||
onUnmounted(() => { loader.cleanup(); guard.invalidate() })
|
||||
|
||||
return {
|
||||
latency,
|
||||
percentiles,
|
||||
errorDistribution,
|
||||
errorTrend,
|
||||
errorTotal,
|
||||
errorRate,
|
||||
providerStatus,
|
||||
healthSummary,
|
||||
systemStatus,
|
||||
loading,
|
||||
loadError,
|
||||
hasLoaded,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||
|
||||
/**
|
||||
* 构建时间范围 API 参数(从各页面提取的公共逻辑)
|
||||
*/
|
||||
export function buildTimeRangeParams(timeRange: DateRangeParams) {
|
||||
return {
|
||||
start_date: timeRange.start_date,
|
||||
end_date: timeRange.end_date,
|
||||
preset: timeRange.preset,
|
||||
timezone: timeRange.timezone,
|
||||
tz_offset_minutes: timeRange.tz_offset_minutes,
|
||||
granularity: timeRange.granularity && timeRange.granularity !== 'auto'
|
||||
? timeRange.granularity
|
||||
: 'day',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用防抖加载器工厂
|
||||
* 封装 120ms debounce + in-flight promise 去重 + requestId 过期检查
|
||||
*/
|
||||
export function createLoader(loadFn: () => Promise<void>) {
|
||||
let loadPromise: Promise<void> | null = null
|
||||
let hasPending = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function schedule() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void execute()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
if (loadPromise) {
|
||||
hasPending = true
|
||||
return loadPromise
|
||||
}
|
||||
loadPromise = loadFn()
|
||||
.finally(() => {
|
||||
loadPromise = null
|
||||
if (hasPending) {
|
||||
hasPending = false
|
||||
void execute()
|
||||
}
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
hasPending = false
|
||||
loadPromise = null
|
||||
}
|
||||
|
||||
return { schedule, execute, cleanup }
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求 ID 守卫:用于确保异步回调中的数据不被过期请求覆盖
|
||||
*/
|
||||
export function createRequestGuard() {
|
||||
let id = 0
|
||||
function next() { return ++id }
|
||||
function isStale(requestId: number) { return requestId !== id }
|
||||
function isCurrent(requestId: number) { return requestId === id }
|
||||
function invalidate() { id++ }
|
||||
return { next, isStale, isCurrent, invalidate }
|
||||
}
|
||||
|
||||
export interface AnalyticsFiltersOptions {
|
||||
defaultPreset?: PeriodValue
|
||||
syncToUrl?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的分析页面筛选状态
|
||||
* - timeRange: 时间范围
|
||||
* - modelFilter / providerFilter / apiKeyFilter / userFilter: 多维度筛选
|
||||
* - activeTab: 当前 Tab
|
||||
* - URL query 双向同步
|
||||
*/
|
||||
export function useAnalyticsFilters(options: AnalyticsFiltersOptions = {}) {
|
||||
const { defaultPreset = 'last30days', syncToUrl = true } = options
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 从 URL query 初始化,无则用默认值
|
||||
const initialPreset = (syncToUrl && route.query.preset as PeriodValue) || defaultPreset
|
||||
const initialTab = (syncToUrl && route.query.tab as string) || 'detail'
|
||||
|
||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod(initialPreset))
|
||||
const activeTab = ref(initialTab)
|
||||
const modelFilter = ref<string[]>([])
|
||||
const providerFilter = ref<string[]>([])
|
||||
const apiKeyFilter = ref<string[]>([])
|
||||
const userFilter = ref<string[]>([])
|
||||
|
||||
// URL 同步:仅同步 preset 和 tab(各类多选筛选太冗长不入 URL)
|
||||
if (syncToUrl) {
|
||||
watch([() => timeRange.value.preset, activeTab], ([preset, tab]) => {
|
||||
const query: Record<string, string> = {}
|
||||
if (preset && preset !== defaultPreset) query.preset = preset
|
||||
if (tab && tab !== 'detail') query.tab = tab
|
||||
// 保留其他 query 参数
|
||||
const currentQuery = { ...route.query }
|
||||
delete currentQuery.preset
|
||||
delete currentQuery.tab
|
||||
router.replace({ query: { ...currentQuery, ...query } })
|
||||
}, { flush: 'post' })
|
||||
}
|
||||
|
||||
function getTimeRangeParams() {
|
||||
return buildTimeRangeParams(timeRange.value)
|
||||
}
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
activeTab,
|
||||
modelFilter,
|
||||
providerFilter,
|
||||
apiKeyFilter,
|
||||
userFilter,
|
||||
getTimeRangeParams,
|
||||
}
|
||||
}
|
||||
|
||||
export type AnalyticsFilters = ReturnType<typeof useAnalyticsFilters>
|
||||
|
||||
export function useInjectedAnalyticsFilters(): AnalyticsFilters {
|
||||
const filters = inject<AnalyticsFilters>('analyticsFilters', null)
|
||||
if (!filters) {
|
||||
throw new Error('analyticsFilters provider is missing')
|
||||
}
|
||||
return filters
|
||||
}
|
||||
@@ -174,15 +174,18 @@ export function getOAuthExpiresCountdown(
|
||||
invalidReason?: string | null
|
||||
): OAuthStatusInfo | null {
|
||||
void _tick
|
||||
const normalizedInvalidReason = typeof invalidReason === 'string'
|
||||
? invalidReason.trim()
|
||||
: ''
|
||||
|
||||
// 优先检查失效状态(失效比过期更严重)
|
||||
if (invalidAt != null) {
|
||||
if (invalidAt != null || normalizedInvalidReason) {
|
||||
return {
|
||||
text: '已失效',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: true,
|
||||
invalidReason: invalidReason || undefined
|
||||
invalidReason: normalizedInvalidReason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface StartTestParams {
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
message?: string
|
||||
requestHeaders?: Record<string, unknown>
|
||||
requestBody?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||
@@ -61,6 +63,16 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTraceSnapshot(reqId: string) {
|
||||
try {
|
||||
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||
if (requestId.value !== reqId) return
|
||||
testTrace.value = trace
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(opts: { clearState?: boolean } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
@@ -117,13 +129,19 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
startPolling(reqId)
|
||||
|
||||
try {
|
||||
const normalizedMessage = typeof params.message === 'string' && params.message.trim()
|
||||
? params.message.trim()
|
||||
: undefined
|
||||
|
||||
const result = await testModelFailover({
|
||||
provider_id: providerId(),
|
||||
mode: params.mode,
|
||||
model_name: params.modelName,
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
message: params.message ?? 'hello',
|
||||
...(normalizedMessage ? { message: normalizedMessage } : {}),
|
||||
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
@@ -131,6 +149,9 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
testResult.value = result
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||
@@ -138,10 +159,10 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
: ''
|
||||
params.onSuccess?.(result)
|
||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
const handled = params.onFailure?.(result)
|
||||
if (!handled) {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { computed, ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
import {
|
||||
analyticsApi,
|
||||
type AnalyticsBreakdownRow,
|
||||
type AnalyticsFilterOption,
|
||||
type AnalyticsScope,
|
||||
type AnalyticsSummary,
|
||||
type AnalyticsTimeseriesBucket,
|
||||
} from '@/api/analytics'
|
||||
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||
import { createLoader, createRequestGuard, buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
|
||||
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||
import { getAnalyticsRangeDaysInclusive, resolveAnalyticsAutoGranularity, type ResolvedAnalyticsGranularity } from '@/utils/analyticsGranularity'
|
||||
import { type DailyUsageBreakdown } from '@/utils/usageBreakdown'
|
||||
|
||||
export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheHitRate: number
|
||||
avg_response_time?: number
|
||||
avg_first_byte_time?: number
|
||||
}
|
||||
|
||||
export interface ReportBucketStat {
|
||||
bucket_start: string
|
||||
bucket_end: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
avg_response_time: number
|
||||
avg_first_byte_time: number
|
||||
models_used_count: number
|
||||
}
|
||||
|
||||
function mapBucketToReportStat(bucket: AnalyticsTimeseriesBucket): ReportBucketStat {
|
||||
return {
|
||||
bucket_start: bucket.bucket_start,
|
||||
bucket_end: bucket.bucket_end,
|
||||
requests: bucket.requests_total,
|
||||
tokens: bucket.total_tokens,
|
||||
cost: bucket.total_cost_usd,
|
||||
avg_response_time: bucket.avg_response_time_ms / 1000,
|
||||
avg_first_byte_time: bucket.avg_first_byte_time_ms / 1000,
|
||||
models_used_count: bucket.models_used_count,
|
||||
}
|
||||
}
|
||||
|
||||
function mapBucketToBreakdown(bucket: AnalyticsTimeseriesBucket): DailyUsageBreakdown {
|
||||
return {
|
||||
date: bucket.bucket_start,
|
||||
inputTokens: bucket.input_tokens,
|
||||
outputTokens: bucket.output_tokens,
|
||||
cacheCreationTokens: bucket.cache_creation_input_tokens,
|
||||
cacheReadTokens: bucket.cache_read_input_tokens,
|
||||
cacheCreationCost: bucket.cache_creation_cost_usd,
|
||||
cacheReadCost: bucket.cache_read_cost_usd,
|
||||
cacheHitRate: bucket.cache_hit_rate,
|
||||
totalCacheCost: bucket.cache_cost_usd,
|
||||
totalCacheTokens: bucket.cache_creation_input_tokens + bucket.cache_read_input_tokens,
|
||||
totalTrackedTokens: bucket.total_tokens,
|
||||
baseTokens: bucket.input_output_total_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
function mapBreakdownRowToModelSummary(row: AnalyticsBreakdownRow): ModelSummary {
|
||||
const requests = row.requests_total
|
||||
const tokens = row.total_tokens
|
||||
const cost = row.total_cost_usd
|
||||
return {
|
||||
model: row.label,
|
||||
requests,
|
||||
tokens,
|
||||
cost,
|
||||
inputTokens: row.input_tokens,
|
||||
outputTokens: row.output_tokens,
|
||||
cacheCreationTokens: row.cache_creation_input_tokens,
|
||||
cacheReadTokens: row.cache_read_input_tokens,
|
||||
cacheHitRate: row.cache_hit_rate,
|
||||
avg_response_time: row.avg_response_time_ms / 1000,
|
||||
avg_first_byte_time: row.avg_first_byte_time_ms / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
type ReportGranularity = ResolvedAnalyticsGranularity
|
||||
|
||||
function resolveReportGranularity(range: DateRangeParams): ReportGranularity {
|
||||
return resolveAnalyticsAutoGranularity(range)
|
||||
}
|
||||
|
||||
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
export interface UseReportsDataOptions {
|
||||
scope?: AnalyticsScope
|
||||
defaultPreset?: PeriodValue
|
||||
timeRange?: Ref<DateRangeParams>
|
||||
userFilter?: Ref<string[]>
|
||||
apiKeyFilter?: Ref<string[]>
|
||||
loadApiKeyOptions?: boolean
|
||||
}
|
||||
|
||||
export function useReportsData(options: UseReportsDataOptions = {}) {
|
||||
const {
|
||||
scope = { kind: 'me' as const },
|
||||
defaultPreset = 'today',
|
||||
timeRange: externalTimeRange,
|
||||
userFilter: externalUserFilter,
|
||||
apiKeyFilter: externalApiKeyFilter,
|
||||
loadApiKeyOptions = scope.kind === 'me',
|
||||
} = options
|
||||
|
||||
const timeRange = externalTimeRange ?? ref<DateRangeParams>({
|
||||
...getDateRangeFromPeriod(defaultPreset),
|
||||
granularity: 'auto',
|
||||
})
|
||||
const userFilter = externalUserFilter ?? ref<string[]>([])
|
||||
const apiKeyFilter = externalApiKeyFilter ?? ref<string[]>([])
|
||||
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||
|
||||
const summary = ref<AnalyticsSummary | null>(null)
|
||||
const bucketStats = ref<ReportBucketStat[]>([])
|
||||
const bucketBreakdowns = ref<Record<string, DailyUsageBreakdown>>({})
|
||||
const modelSummary = ref<ModelSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const guard = createRequestGuard()
|
||||
const apiKeysGuard = createRequestGuard()
|
||||
let suppressNextApiKeyFilterLoad = false
|
||||
const resolvedGranularity = computed(() => resolveReportGranularity(timeRange.value))
|
||||
const timeseriesTimeRange = computed(() => ({
|
||||
...buildTimeRangeParams(timeRange.value),
|
||||
granularity: resolvedGranularity.value,
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => [timeRange.value.preset, timeRange.value.start_date, timeRange.value.end_date],
|
||||
() => {
|
||||
if (timeRange.value.granularity === 'hour' && getAnalyticsRangeDaysInclusive(timeRange.value) !== 1) {
|
||||
timeRange.value = {
|
||||
...timeRange.value,
|
||||
granularity: 'auto',
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function load() {
|
||||
const requestId = guard.next()
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
const params = buildTimeRangeParams(timeRange.value)
|
||||
const timeseriesParams = timeseriesTimeRange.value
|
||||
const basePayload = {
|
||||
scope,
|
||||
time_range: params,
|
||||
filters: {
|
||||
user_ids: userFilter.value,
|
||||
api_key_ids: apiKeyFilter.value,
|
||||
},
|
||||
}
|
||||
|
||||
const [overviewData, timeseriesData, modelBreakdownData] = await Promise.all([
|
||||
analyticsApi.getOverview(basePayload),
|
||||
analyticsApi.getTimeseries({
|
||||
...basePayload,
|
||||
time_range: timeseriesParams,
|
||||
}),
|
||||
analyticsApi.getBreakdown({
|
||||
...basePayload,
|
||||
dimension: 'model',
|
||||
limit: 50,
|
||||
}),
|
||||
])
|
||||
|
||||
if (guard.isStale(requestId)) return
|
||||
|
||||
const filledBuckets = fillMissingTimeseriesBuckets(timeseriesData.buckets, timeseriesParams)
|
||||
|
||||
summary.value = overviewData.summary
|
||||
bucketStats.value = filledBuckets.map(mapBucketToReportStat)
|
||||
bucketBreakdowns.value = Object.fromEntries(
|
||||
filledBuckets.map(bucket => {
|
||||
const breakdown = mapBucketToBreakdown(bucket)
|
||||
return [breakdown.date, breakdown]
|
||||
}),
|
||||
)
|
||||
modelSummary.value = modelBreakdownData.rows.map(mapBreakdownRowToModelSummary)
|
||||
hasLoaded.value = true
|
||||
} catch {
|
||||
if (guard.isStale(requestId)) return
|
||||
loadError.value = true
|
||||
} finally {
|
||||
if (guard.isCurrent(requestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
if (!loadApiKeyOptions) return
|
||||
const requestId = apiKeysGuard.next()
|
||||
const filterOptions = await analyticsApi.getFilterOptions({
|
||||
scope,
|
||||
time_range: buildTimeRangeParams(timeRange.value),
|
||||
filters: {},
|
||||
}).catch(() => null)
|
||||
if (apiKeysGuard.isStale(requestId)) return
|
||||
|
||||
const nextOptions = filterOptions?.api_keys ?? []
|
||||
|
||||
apiKeyOptions.value = nextOptions
|
||||
const validOptionValues = new Set(nextOptions.map(option => option.value))
|
||||
const nextFilter = apiKeyFilter.value.filter(value => validOptionValues.has(value))
|
||||
if (!areStringArraysEqual(nextFilter, apiKeyFilter.value)) {
|
||||
suppressNextApiKeyFilterLoad = true
|
||||
apiKeyFilter.value = nextFilter
|
||||
}
|
||||
}
|
||||
|
||||
const loader = createLoader(load)
|
||||
const apiKeysLoader = createLoader(loadApiKeys)
|
||||
|
||||
watch(
|
||||
timeRange,
|
||||
() => {
|
||||
void (async () => {
|
||||
if (loadApiKeyOptions) {
|
||||
await apiKeysLoader.execute()
|
||||
}
|
||||
loader.schedule()
|
||||
})()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[userFilter, apiKeyFilter],
|
||||
() => {
|
||||
if (suppressNextApiKeyFilterLoad) {
|
||||
suppressNextApiKeyFilterLoad = false
|
||||
return
|
||||
}
|
||||
loader.schedule()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
if (loadApiKeyOptions) {
|
||||
await apiKeysLoader.execute()
|
||||
}
|
||||
await loader.execute()
|
||||
})()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
loader.cleanup()
|
||||
apiKeysLoader.cleanup()
|
||||
guard.invalidate()
|
||||
apiKeysGuard.invalidate()
|
||||
})
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
apiKeyFilter,
|
||||
apiKeyOptions,
|
||||
summary,
|
||||
bucketStats,
|
||||
bucketBreakdowns,
|
||||
modelSummary,
|
||||
loading,
|
||||
loadError,
|
||||
hasLoaded,
|
||||
resolvedGranularity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRoute, useRouter, type LocationQueryValue, type LocationQuery } from 'vue-router'
|
||||
|
||||
type QueryValue = LocationQueryValue | LocationQueryValue[]
|
||||
|
||||
function normalizeQueryValue(value: QueryValue): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
||||
for (const key of keys) {
|
||||
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function useRouteQuery() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function getQueryValue(key: string): string | undefined {
|
||||
return normalizeQueryValue(route.query[key])
|
||||
}
|
||||
|
||||
function patchQuery(patch: Record<string, string | undefined | null>) {
|
||||
const next: LocationQuery = { ...route.query }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value == null || value.trim() === '') {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
if (queriesEqual(route.query, next)) return
|
||||
void router.replace({ query: next }).catch(() => {})
|
||||
}
|
||||
|
||||
return { route, router, getQueryValue, patchQuery }
|
||||
}
|
||||
@@ -179,7 +179,8 @@
|
||||
<Input
|
||||
id="login-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
type="text"
|
||||
masked
|
||||
required
|
||||
placeholder="输入密码"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -107,15 +107,15 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
v-if="isOAuthInvalid(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>OAuth失效</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
|
||||
<Badge
|
||||
v-if="getStatusBadgeLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getStatusBadgeTitle(key)"
|
||||
>{{ getStatusBadgeLabel(key) }}</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
@@ -127,11 +127,6 @@
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getOAuthOrgBadge(key)?.title"
|
||||
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
|
||||
<Badge
|
||||
v-if="isBannedKey(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>封号</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
@@ -284,6 +279,12 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getAccountStatusTitle,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
type QuickSelectorValue =
|
||||
| 'banned'
|
||||
@@ -321,12 +322,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||
{ value: 'banned', label: '已封号' },
|
||||
{ value: 'banned', label: '账号异常' },
|
||||
{ value: 'no_5h_limit', label: '无5H限额' },
|
||||
{ value: 'no_weekly_limit', label: '无周限额' },
|
||||
{ value: 'plan_free', label: '全部 Free' },
|
||||
{ value: 'plan_team', label: '全部 Team' },
|
||||
{ value: 'oauth_invalid', label: 'OAuth 失效' },
|
||||
{ value: 'oauth_invalid', label: 'Token 异常' },
|
||||
{ value: 'proxy_unset', label: '未配置代理' },
|
||||
{ value: 'proxy_set', label: '已配置独立代理' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
@@ -426,25 +427,25 @@ function normalizeAuthTypeLabel(authType: string): string {
|
||||
return 'API Key'
|
||||
}
|
||||
|
||||
function isBannedKey(key: PoolKeyDetail): boolean {
|
||||
const reason = normalizeText(key.oauth_invalid_reason)
|
||||
if (reason && /(banned|forbidden|blocked|suspend|封|禁|受限)/.test(reason)) return true
|
||||
if (Array.isArray(key.scheduling_reasons)) {
|
||||
return key.scheduling_reasons.some((item) => {
|
||||
const code = normalizeText(item.code)
|
||||
return code === 'account_banned' || code === 'account_forbidden' || code === 'account_blocked'
|
||||
})
|
||||
}
|
||||
return false
|
||||
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
|
||||
const account = getAccountStatusDisplay(key)
|
||||
if (account.blocked && account.label) return account.label
|
||||
|
||||
const oauth = getOAuthStatusDisplay(key, 0)
|
||||
if (oauth?.isInvalid) return 'Token 失效'
|
||||
if (oauth?.isExpired) return 'Token 过期'
|
||||
return null
|
||||
}
|
||||
|
||||
function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
||||
if (normalizeText(key.auth_type) !== 'oauth') return false
|
||||
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
|
||||
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
|
||||
return key.oauth_expires_at * 1000 <= Date.now()
|
||||
}
|
||||
return false
|
||||
function getStatusBadgeTitle(key: PoolKeyDetail): string {
|
||||
const label = getStatusBadgeLabel(key)
|
||||
if (!label) return ''
|
||||
|
||||
const accountTitle = getAccountStatusTitle(key)
|
||||
if (accountTitle) return accountTitle
|
||||
|
||||
const oauthTitle = getOAuthStatusTitle(key, 0)
|
||||
return oauthTitle || label
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">主动探测</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
定期检查 Key 可用性,提前发现异常
|
||||
按固定间隔主动刷新 Key 的账号状态与额度
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -57,9 +57,9 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">封号自动清除</span>
|
||||
<span class="text-sm font-medium">异常自动清除</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
检测到账号被封禁时自动从号池中移除
|
||||
仅在检测到不可恢复的账号异常时自动从号池中移除,不处理纯 Token 失效
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -192,7 +192,6 @@ async function handleTestModel(modelName: string) {
|
||||
model_name: modelName,
|
||||
api_key_id: props.keyId,
|
||||
api_format: 'gemini:chat',
|
||||
message: 'hello',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||
<Input
|
||||
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
|
||||
:model-value="getDisplayedPath(endpoint)"
|
||||
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
|
||||
:disabled="isFixedProvider"
|
||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||
@@ -1159,7 +1159,7 @@ function restoreOriginalPlaceholder(value: unknown): unknown {
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(restoreOriginalPlaceholder)
|
||||
if (Array.isArray(value)) return value.map(item => restoreOriginalPlaceholder(item))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) result[k] = restoreOriginalPlaceholder(v)
|
||||
@@ -1262,7 +1262,7 @@ async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false):
|
||||
} catch (error: unknown) {
|
||||
defaultBodyRulesByFormat.value[cacheKey] = []
|
||||
defaultBodyRulesLoaded.value[cacheKey] = true
|
||||
log.warn('加载默认请求体规则失败', apiFormat, error)
|
||||
log.warn('加载默认请求体规则失败', { apiFormat, error })
|
||||
return []
|
||||
} finally {
|
||||
loadingDefaultBodyRulesByFormat.value[cacheKey] = false
|
||||
@@ -1276,10 +1276,19 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
|
||||
|
||||
// 获取指定 API 格式的默认路径
|
||||
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
if (providerType === 'vertex_ai') {
|
||||
if (apiFormat === 'gemini:chat') {
|
||||
return '/v1/publishers/google/models/{model}:{action}'
|
||||
}
|
||||
if (apiFormat === 'claude:chat') {
|
||||
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
|
||||
}
|
||||
}
|
||||
|
||||
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||
const defaultPath = format?.default_path || ''
|
||||
// Codex 端点使用 /responses 而非 /v1/responses
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
const isCodex = providerType
|
||||
? providerType === 'codex'
|
||||
: (!!baseUrl && isCodexUrl(baseUrl))
|
||||
@@ -1289,6 +1298,13 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
if (isFixedProvider.value) {
|
||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
||||
}
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
|
||||
// 判断是否是 Codex OAuth 端点
|
||||
function isCodexUrl(baseUrl: string): boolean {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
|
||||
@@ -3,50 +3,231 @@
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
|
||||
<Select
|
||||
v-model="lookbackHours"
|
||||
<div class="border-b border-border/60 bg-muted/20 px-6 py-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] uppercase tracking-[0.25em]"
|
||||
>
|
||||
{{ isAdmin ? 'Admin' : 'Public' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
异常优先排序,支持状态筛选、详情诊断与自动刷新,首屏先看出问题的格式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最后刷新 {{ generatedAt ? formatRelativeTime(generatedAt) : '未加载' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-border/60 bg-card/70 px-3 py-1.5">
|
||||
<Switch v-model="autoRefreshEnabled" />
|
||||
<span class="text-xs font-medium">
|
||||
{{ autoRefreshEnabled ? '自动刷新 15s' : '自动刷新已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3 text-xs"
|
||||
:disabled="recoveringAllKeys || (healthSummary?.keys.circuit_open ?? 0) <= 0"
|
||||
@click="recoverAllConfirmOpen = true"
|
||||
>
|
||||
<Loader2
|
||||
v-if="recoveringAllKeys"
|
||||
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
恢复全部熔断 Key
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loadingMonitors"
|
||||
@click="refreshData({ silent: false })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 xl:grid-cols-6">
|
||||
<Card
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
1 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
6 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
12 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
24 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
48 小时
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="refreshData"
|
||||
/>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 text-xl font-semibold"
|
||||
:class="item.valueClass"
|
||||
>
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ item.help }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-border/60 p-2"
|
||||
:class="item.iconClass"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">回溯时间</Label>
|
||||
<Select v-model="lookbackHours">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
1 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
6 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
12 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
24 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
48 小时
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">状态筛选</Label>
|
||||
<Select v-model="statusFilter">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="unhealthy">
|
||||
仅看异常
|
||||
</SelectItem>
|
||||
<SelectItem value="warning">
|
||||
仅看预警
|
||||
</SelectItem>
|
||||
<SelectItem value="healthy">
|
||||
仅看健康
|
||||
</SelectItem>
|
||||
<SelectItem value="unknown">
|
||||
仅看无流量
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">格式族</Label>
|
||||
<Select v-model="familyFilter">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部格式
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
Claude
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">
|
||||
OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
Gemini
|
||||
</SelectItem>
|
||||
<SelectItem value="other">
|
||||
其他
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">显示范围</Label>
|
||||
<div class="flex h-9 items-center justify-between rounded-xl border border-border/60 bg-card/70 px-3">
|
||||
<span class="text-xs font-medium">仅看有流量</span>
|
||||
<Switch v-model="onlyTraffic" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>显示 {{ filteredMonitors.length }} / {{ monitors.length }} 个格式</span>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge
|
||||
variant="success"
|
||||
class="gap-1"
|
||||
>
|
||||
健康
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">成功率 ≥ 95%</span>
|
||||
<Badge
|
||||
variant="warning"
|
||||
class="gap-1"
|
||||
>
|
||||
预警
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">70% - 95%</span>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="gap-1"
|
||||
>
|
||||
异常
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">成功率 < 70%</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="gap-1"
|
||||
>
|
||||
无流量
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">窗口内没有请求</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="p-6">
|
||||
<div
|
||||
v-if="loadingMonitors"
|
||||
v-if="showInitialLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
@@ -57,81 +238,149 @@
|
||||
v-else-if="monitors.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<Activity class="w-12 h-12 mb-3 opacity-30" />
|
||||
<Activity class="mb-3 h-12 w-12 opacity-30" />
|
||||
<p>暂无健康监控数据</p>
|
||||
<p class="text-xs mt-1">
|
||||
<p class="mt-1 text-xs">
|
||||
端点尚未产生请求记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredMonitors.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<AlertTriangle class="mb-3 h-12 w-12 opacity-30" />
|
||||
<p>当前筛选条件下没有匹配项</p>
|
||||
<p class="mt-1 text-xs">
|
||||
可以放宽状态、格式族或流量条件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="monitor in monitors"
|
||||
v-for="monitor in filteredMonitors"
|
||||
:key="monitor.api_format"
|
||||
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
|
||||
class="rounded-xl border p-4 transition-colors"
|
||||
:class="getMonitorCardClass(monitor)"
|
||||
>
|
||||
<!-- 响应式布局:窄屏上下两行,宽屏左右结构 -->
|
||||
<div class="flex flex-col sm:flex-row sm:gap-6 sm:items-center">
|
||||
<!-- 第一行/左侧:信息区域 -->
|
||||
<div class="sm:w-52 flex-shrink-0 space-y-1.5 mb-3 sm:mb-0">
|
||||
<!-- API 格式标签和成功率 -->
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-6">
|
||||
<div class="space-y-1.5 xl:w-64 xl:flex-shrink-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono text-xs whitespace-nowrap"
|
||||
class="whitespace-nowrap font-mono text-xs"
|
||||
>
|
||||
{{ formatApiFormat(monitor.api_format) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
:variant="getMonitorStatusVariant(monitor)"
|
||||
class="text-xs whitespace-nowrap"
|
||||
>
|
||||
{{ getMonitorStatusLabel(monitor) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="monitor.total_attempts > 0"
|
||||
:variant="getSuccessRateVariant(monitor.success_rate)"
|
||||
class="text-xs whitespace-nowrap"
|
||||
>
|
||||
{{ (monitor.success_rate * 100).toFixed(0) }}%
|
||||
{{ formatPercent(monitor.success_rate) }}
|
||||
</Badge>
|
||||
<!-- 提供商信息(仅管理员可见)- 窄屏时显示在同一行 -->
|
||||
<span
|
||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||
class="text-xs text-muted-foreground sm:hidden"
|
||||
class="text-xs text-muted-foreground xl:hidden"
|
||||
>
|
||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 提供商信息(仅管理员可见)- 宽屏时显示在下方 -->
|
||||
<div
|
||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||
class="text-xs text-muted-foreground hidden sm:block"
|
||||
class="hidden text-xs text-muted-foreground xl:block"
|
||||
>
|
||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ monitor.total_attempts }} 次请求
|
||||
<span class="mx-1">·</span>
|
||||
失败 {{ monitor.failed_count }}
|
||||
<span class="mx-1">·</span>
|
||||
跳过 {{ monitor.skipped_count }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最近事件 {{ monitor.last_event_at ? formatRelativeTime(monitor.last_event_at) : '暂无' }}
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-3 text-xs"
|
||||
@click="openMonitorDetail(monitor)"
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行/右侧:时间线区域 -->
|
||||
<div class="flex-1 min-w-0 sm:flex sm:justify-end">
|
||||
<div class="w-full sm:max-w-5xl">
|
||||
<EndpointHealthTimeline
|
||||
:monitor="monitor"
|
||||
:lookback-hours="parseInt(lookbackHours)"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<EndpointHealthTimeline
|
||||
:monitor="monitor"
|
||||
:lookback-hours="parseInt(lookbackHours)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<HealthMonitorDetailDialog
|
||||
v-model="detailDialogOpen"
|
||||
:monitor="selectedMonitor"
|
||||
:is-admin="isAdmin"
|
||||
:format-keys="selectedFormatKeys"
|
||||
:loading-format-keys="loadingFormatKeys"
|
||||
:recovering-key-id="recoveringKeyId"
|
||||
@navigate-provider="goToProviderManagement"
|
||||
@navigate-pool="goToPoolManagement"
|
||||
@recover-key="recoverFormatKey"
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
v-model="recoverAllConfirmOpen"
|
||||
title="恢复全部熔断 Key"
|
||||
:description="recoverAllDialogDescription"
|
||||
type="warning"
|
||||
confirm-text="立即恢复"
|
||||
:loading="recoveringAllKeys"
|
||||
@confirm="confirmRecoverAllKeys"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Activity, Loader2 } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Activity, AlertTriangle, Gauge, KeyRound, Loader2, Radio, ShieldAlert } from 'lucide-vue-next'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import HealthMonitorDetailDialog from './HealthMonitorDetailDialog.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
@@ -139,12 +388,24 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
|
||||
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
|
||||
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||
import { getKeysGroupedByFormat, type GroupedFormatKey } from '@/api/endpoints/keys'
|
||||
import { getEndpointStatusMonitor, getHealthSummary, getPublicEndpointStatusMonitor, recoverAllKeysHealth, recoverKeyHealth } from '@/api/endpoints/health'
|
||||
import type { EndpointStatusMonitor, HealthSummary, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
|
||||
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||
type SummaryCardItem = {
|
||||
label: string
|
||||
value: string
|
||||
help: string
|
||||
icon: Component
|
||||
valueClass: string
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
isAdmin?: boolean
|
||||
@@ -155,55 +416,510 @@ const props = withDefaults(defineProps<{
|
||||
showProviderInfo: false
|
||||
})
|
||||
|
||||
const { error: showError } = useToast()
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15_000
|
||||
const STATUS_SORT_ORDER: Record<MonitorStatus, number> = {
|
||||
unhealthy: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
healthy: 3
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMonitors = ref(false)
|
||||
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
|
||||
const monitors = ref<MonitorItem[]>([])
|
||||
const generatedAt = ref<string | null>(null)
|
||||
const healthSummary = ref<HealthSummary | null>(null)
|
||||
const lookbackHours = ref('6')
|
||||
const statusFilter = ref<'all' | MonitorStatus>('all')
|
||||
const familyFilter = ref<'all' | 'claude' | 'openai' | 'gemini' | 'other'>('all')
|
||||
const onlyTraffic = ref(false)
|
||||
const autoRefreshEnabled = ref(true)
|
||||
const detailDialogOpen = ref(false)
|
||||
const selectedMonitorFormat = ref<string | null>(null)
|
||||
const recoverAllConfirmOpen = ref(false)
|
||||
const recoveringAllKeys = ref(false)
|
||||
const keysByFormat = ref<Record<string, GroupedFormatKey[]>>({})
|
||||
const loadingFormatKeys = ref(false)
|
||||
const recoveringKeyId = ref<string | null>(null)
|
||||
|
||||
async function loadMonitors() {
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let loadRequestId = 0
|
||||
|
||||
const isAdmin = computed(() => props.isAdmin)
|
||||
const showInitialLoading = computed(() => loadingMonitors.value && monitors.value.length === 0)
|
||||
|
||||
const unhealthyCount = computed(() =>
|
||||
monitors.value.filter(item => getMonitorStatus(item) === 'unhealthy').length
|
||||
)
|
||||
|
||||
const warningCount = computed(() =>
|
||||
monitors.value.filter(item => getMonitorStatus(item) === 'warning').length
|
||||
)
|
||||
|
||||
const trafficCount = computed(() =>
|
||||
monitors.value.filter(item => item.total_attempts > 0).length
|
||||
)
|
||||
|
||||
const overallSuccessRate = computed(() => {
|
||||
const totals = monitors.value.reduce((acc, item) => {
|
||||
acc.success += item.success_count
|
||||
acc.failed += item.failed_count
|
||||
return acc
|
||||
}, { success: 0, failed: 0 })
|
||||
|
||||
const denominator = totals.success + totals.failed
|
||||
if (denominator <= 0) return null
|
||||
return totals.success / denominator
|
||||
})
|
||||
|
||||
const summaryCards = computed<SummaryCardItem[]>(() => {
|
||||
const items: SummaryCardItem[] = [
|
||||
{
|
||||
label: '异常格式',
|
||||
value: `${unhealthyCount.value}`,
|
||||
help: `共 ${monitors.value.length} 个格式`,
|
||||
icon: AlertTriangle,
|
||||
valueClass: unhealthyCount.value > 0 ? 'text-destructive' : 'text-foreground',
|
||||
iconClass: unhealthyCount.value > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '预警格式',
|
||||
value: `${warningCount.value}`,
|
||||
help: '建议优先观察',
|
||||
icon: Radio,
|
||||
valueClass: warningCount.value > 0 ? 'text-yellow-600 dark:text-yellow-400' : 'text-foreground',
|
||||
iconClass: warningCount.value > 0 ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '有流量格式',
|
||||
value: `${trafficCount.value}`,
|
||||
help: `最近 ${lookbackHours.value} 小时`,
|
||||
icon: Activity,
|
||||
valueClass: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
},
|
||||
{
|
||||
label: '整体成功率',
|
||||
value: overallSuccessRate.value == null ? '--' : formatPercent(overallSuccessRate.value),
|
||||
help: '按 success / (success + failed)',
|
||||
icon: Gauge,
|
||||
valueClass: overallSuccessRate.value != null && overallSuccessRate.value < 0.95
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
}
|
||||
]
|
||||
|
||||
if (props.isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
label: '熔断 Key',
|
||||
value: `${healthSummary.value?.keys.circuit_open ?? 0}`,
|
||||
help: '已打开熔断器',
|
||||
icon: ShieldAlert,
|
||||
valueClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'text-destructive' : 'text-foreground',
|
||||
iconClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '活跃 Key',
|
||||
value: `${healthSummary.value?.keys.active ?? 0}`,
|
||||
help: '当前可调度密钥',
|
||||
icon: KeyRound,
|
||||
valueClass: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const filteredMonitors = computed(() => {
|
||||
const filtered = monitors.value.filter((monitor) => {
|
||||
const status = getMonitorStatus(monitor)
|
||||
const family = getMonitorFamily(monitor.api_format)
|
||||
|
||||
if (statusFilter.value !== 'all' && status !== statusFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (familyFilter.value !== 'all' && family !== familyFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (onlyTraffic.value && monitor.total_attempts <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return filtered.sort((left, right) => {
|
||||
const statusGap = STATUS_SORT_ORDER[getMonitorStatus(left)] - STATUS_SORT_ORDER[getMonitorStatus(right)]
|
||||
if (statusGap !== 0) return statusGap
|
||||
|
||||
const leftTime = left.last_event_at ? new Date(left.last_event_at).getTime() : 0
|
||||
const rightTime = right.last_event_at ? new Date(right.last_event_at).getTime() : 0
|
||||
if (leftTime !== rightTime) return rightTime - leftTime
|
||||
|
||||
if (left.total_attempts !== right.total_attempts) {
|
||||
return right.total_attempts - left.total_attempts
|
||||
}
|
||||
|
||||
return left.api_format.localeCompare(right.api_format)
|
||||
})
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
statusFilter.value !== 'all' || familyFilter.value !== 'all' || onlyTraffic.value
|
||||
)
|
||||
|
||||
const selectedMonitor = computed<MonitorItem | null>(() => {
|
||||
if (!selectedMonitorFormat.value) return null
|
||||
return monitors.value.find(item => item.api_format === selectedMonitorFormat.value) ?? null
|
||||
})
|
||||
|
||||
|
||||
const selectedFormatKeys = computed<GroupedFormatKey[]>(() => {
|
||||
if (!selectedMonitorFormat.value) return []
|
||||
const keys = keysByFormat.value[selectedMonitorFormat.value] ?? []
|
||||
return [...keys].sort((left, right) => {
|
||||
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||
if (circuitGap !== 0) return circuitGap
|
||||
|
||||
const leftHealth = left.health_score ?? 1
|
||||
const rightHealth = right.health_score ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
if (left.request_count !== right.request_count) {
|
||||
return right.request_count - left.request_count
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const recoverAllDialogDescription = computed(() => {
|
||||
const circuitOpen = healthSummary.value?.keys.circuit_open ?? 0
|
||||
return [
|
||||
'这会重置所有已熔断 Key 的健康状态并关闭熔断器。',
|
||||
`当前检测到 **${circuitOpen}** 个熔断 Key。`,
|
||||
'建议在确认上游服务恢复后执行。'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
async function loadMonitors(silent = false) {
|
||||
const currentRequestId = ++loadRequestId
|
||||
loadingMonitors.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
lookback_hours: parseInt(lookbackHours.value),
|
||||
per_format_limit: 100
|
||||
per_format_limit: 60
|
||||
}
|
||||
|
||||
const monitorPromise = props.isAdmin
|
||||
? getEndpointStatusMonitor(params)
|
||||
: getPublicEndpointStatusMonitor(params)
|
||||
|
||||
const [monitorResult, summaryResult] = await Promise.allSettled([
|
||||
monitorPromise,
|
||||
props.isAdmin ? getHealthSummary() : Promise.resolve(null)
|
||||
])
|
||||
|
||||
if (currentRequestId !== loadRequestId) return
|
||||
|
||||
if (monitorResult.status === 'fulfilled') {
|
||||
monitors.value = monitorResult.value.formats || []
|
||||
generatedAt.value = monitorResult.value.generated_at ?? null
|
||||
} else if (!silent) {
|
||||
showError(parseApiError(monitorResult.reason, '加载健康监控数据失败'), '错误')
|
||||
}
|
||||
|
||||
if (props.isAdmin) {
|
||||
const data = await getEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
if (summaryResult.status === 'fulfilled') {
|
||||
healthSummary.value = summaryResult.value
|
||||
}
|
||||
} else {
|
||||
const data = await getPublicEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
healthSummary.value = null
|
||||
}
|
||||
|
||||
if (selectedMonitorFormat.value && !monitors.value.some(item => item.api_format === selectedMonitorFormat.value)) {
|
||||
detailDialogOpen.value = false
|
||||
selectedMonitorFormat.value = null
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
||||
if (!silent) {
|
||||
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
||||
}
|
||||
} finally {
|
||||
loadingMonitors.value = false
|
||||
if (currentRequestId === loadRequestId) {
|
||||
loadingMonitors.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
loading.value = true
|
||||
async function refreshData(options: { silent?: boolean } = {}) {
|
||||
await loadMonitors(Boolean(options.silent))
|
||||
}
|
||||
|
||||
async function loadKeysByFormat(force = false) {
|
||||
if (!props.isAdmin) return
|
||||
if (!force && Object.keys(keysByFormat.value).length > 0) return
|
||||
|
||||
loadingFormatKeys.value = true
|
||||
try {
|
||||
await loadMonitors()
|
||||
keysByFormat.value = await getKeysGroupedByFormat()
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '加载 Key 诊断数据失败'), '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingFormatKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (rate >= 0.95) return 'default'
|
||||
if (rate >= 0.8) return 'secondary'
|
||||
return 'destructive'
|
||||
function resetFilters() {
|
||||
statusFilter.value = 'all'
|
||||
familyFilter.value = 'all'
|
||||
onlyTraffic.value = false
|
||||
}
|
||||
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (!autoRefreshTimer) return
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
if (!autoRefreshEnabled.value) return
|
||||
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
void refreshData({ silent: true })
|
||||
}, AUTO_REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && autoRefreshEnabled.value) {
|
||||
void refreshData({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function openMonitorDetail(monitor: MonitorItem) {
|
||||
selectedMonitorFormat.value = monitor.api_format
|
||||
detailDialogOpen.value = true
|
||||
if (props.isAdmin) {
|
||||
await loadKeysByFormat()
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRecoverAllKeys() {
|
||||
if (recoveringAllKeys.value) return
|
||||
|
||||
recoveringAllKeys.value = true
|
||||
try {
|
||||
const result = await recoverAllKeysHealth()
|
||||
recoverAllConfirmOpen.value = false
|
||||
|
||||
if (result.recovered_count > 0) {
|
||||
showSuccess(result.message || `已恢复 ${result.recovered_count} 个熔断 Key`)
|
||||
} else {
|
||||
showWarning(result.message || '当前没有需要恢复的熔断 Key')
|
||||
}
|
||||
|
||||
await refreshData({ silent: false })
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '批量恢复熔断 Key 失败'), '错误')
|
||||
} finally {
|
||||
recoveringAllKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverFormatKey(key: GroupedFormatKey) {
|
||||
if (recoveringKeyId.value || !selectedMonitorFormat.value) return
|
||||
|
||||
recoveringKeyId.value = key.id
|
||||
try {
|
||||
const result = await recoverKeyHealth(key.id, selectedMonitorFormat.value)
|
||||
showSuccess(result.message || '当前格式已恢复')
|
||||
await Promise.all([
|
||||
refreshData({ silent: true }),
|
||||
loadKeysByFormat(true)
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '恢复 Key 健康状态失败'), '错误')
|
||||
} finally {
|
||||
recoveringKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function goToProviderManagement(providerId: string) {
|
||||
void router.push({
|
||||
name: 'ProviderManagement',
|
||||
query: { providerId }
|
||||
})
|
||||
}
|
||||
|
||||
function goToPoolManagement(providerId: string, search?: string) {
|
||||
const query: Record<string, string> = { providerId }
|
||||
const normalizedSearch = search?.trim()
|
||||
if (normalizedSearch) {
|
||||
query.search = normalizedSearch
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: 'PoolManagement',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
function getMonitorFamily(apiFormat: string): 'claude' | 'openai' | 'gemini' | 'other' {
|
||||
if (apiFormat.startsWith('claude:')) return 'claude'
|
||||
if (apiFormat.startsWith('openai:')) return 'openai'
|
||||
if (apiFormat.startsWith('gemini:')) return 'gemini'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||
const timeline = monitor.timeline ?? []
|
||||
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||
const status = timeline[index]
|
||||
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||
if (timelineStatus === 'warning') return 'warning'
|
||||
|
||||
if (monitor.total_attempts <= 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||
if (monitor.success_rate < 0.95) return 'warning'
|
||||
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return '异常'
|
||||
case 'warning':
|
||||
return '预警'
|
||||
case 'healthy':
|
||||
return '健康'
|
||||
default:
|
||||
return '无流量'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'unhealthy':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorCardClass(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return 'border-red-300/70 bg-red-500/5 hover:border-red-400/80'
|
||||
case 'warning':
|
||||
return 'border-yellow-300/70 bg-yellow-500/5 hover:border-yellow-400/80'
|
||||
case 'healthy':
|
||||
return 'border-border/60 hover:border-primary/50'
|
||||
default:
|
||||
return 'border-border/40 bg-muted/20 hover:border-border/70'
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessRateVariant(rate: number): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
if (rate >= 0.95) return 'success'
|
||||
if (rate >= 0.8) return 'warning'
|
||||
if (rate >= 0) return 'destructive'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const diff = Date.now() - new Date(dateString).getTime()
|
||||
if (Number.isNaN(diff)) return '未知'
|
||||
if (diff < 60_000) return '刚刚'
|
||||
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days} 天前`
|
||||
|
||||
return formatAbsoluteTime(dateString)
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
watch(lookbackHours, () => {
|
||||
loadMonitors()
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
watch(() => props.isAdmin, () => {
|
||||
keysByFormat.value = {}
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
watch(autoRefreshEnabled, () => {
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
watch(detailDialogOpen, (open) => {
|
||||
if (!open) {
|
||||
selectedMonitorFormat.value = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
startAutoRefresh()
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="open"
|
||||
size="5xl"
|
||||
close-on-backdrop
|
||||
:title="monitor ? `${formatApiFormat(monitor.api_format)} 详情` : '监控详情'"
|
||||
:description="dialogDescription"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
aria-label="关闭详情"
|
||||
@click="open = false"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="monitor"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
当前状态
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Badge :variant="getMonitorStatusVariant(monitor)">
|
||||
{{ getMonitorStatusLabel(monitor) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
成功率
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ formatPercent(monitor.success_rate) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
请求总数
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ monitor.total_attempts }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最近失败
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ failureEvents.length }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最近事件
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium">
|
||||
{{ monitor.last_event_at ? formatAbsoluteTime(monitor.last_event_at) : '暂无' }}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="monitorApiPath"
|
||||
class="rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
请求入口
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-foreground">
|
||||
{{ monitorApiPath }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"
|
||||
>
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
受影响 Provider
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ providerSummaries.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingFormatKeys"
|
||||
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Key 诊断数据...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="providerSummaries.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前格式下没有可诊断的 provider/key 数据。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providerSummaries"
|
||||
:key="provider.provider_id"
|
||||
class="rounded-xl border p-3 transition-colors"
|
||||
:class="providerFilter === provider.provider_id
|
||||
? 'border-primary/60 bg-primary/5'
|
||||
: 'border-border/50 bg-card/70'"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium">
|
||||
{{ provider.provider_name }}
|
||||
</span>
|
||||
<Badge :variant="provider.circuitOpenKeys > 0 ? 'destructive' : provider.providerActive ? 'success' : 'outline'">
|
||||
{{ provider.circuitOpenKeys > 0 ? `${provider.circuitOpenKeys} 熔断` : provider.providerActive ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ provider.totalKeys }} 个 Key
|
||||
<span class="mx-1">·</span>
|
||||
活跃 {{ provider.activeKeys }}
|
||||
<span class="mx-1">·</span>
|
||||
平均健康 {{ formatScore(provider.avgHealthScore) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-muted-foreground">
|
||||
<div>{{ provider.requestCount }} 请求</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="toggleProviderFilter(provider.provider_id)"
|
||||
>
|
||||
{{ providerFilter === provider.provider_id ? '取消筛选' : '只看该 Provider' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="$emit('navigateProvider', provider.provider_id)"
|
||||
>
|
||||
前往 Provider
|
||||
</Button>
|
||||
<Button
|
||||
v-if="provider.poolEnabled"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="$emit('navigatePool', provider.provider_id)"
|
||||
>
|
||||
前往号池
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Key 诊断
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ filteredFormatKeys.length }} / {{ sortedFormatKeys.length }} 个 Key
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'all' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'circuit-open' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'circuit-open'"
|
||||
>
|
||||
仅看熔断
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'recoverable' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'recoverable'"
|
||||
>
|
||||
仅看可恢复项
|
||||
</Button>
|
||||
<Button
|
||||
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="resetFilters"
|
||||
>
|
||||
清空局部筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="providerFilter !== 'all'">
|
||||
Provider:{{ providerFilterName }}
|
||||
</span>
|
||||
<span v-if="providerFilter !== 'all' && keyScopeFilter !== 'all'"> · </span>
|
||||
<span v-if="keyScopeFilter === 'circuit-open'">范围:仅熔断项</span>
|
||||
<span v-else-if="keyScopeFilter === 'recoverable'">范围:仅可恢复项</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingFormatKeys"
|
||||
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Key 诊断数据...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="sortedFormatKeys.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前格式下没有 Key 样本。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredFormatKeys.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前局部筛选下没有匹配的 Key。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="key in filteredFormatKeys"
|
||||
:key="key.id"
|
||||
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium">
|
||||
{{ key.name }}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{{ key.provider_name }}
|
||||
</Badge>
|
||||
<Badge :variant="key.circuit_breaker_open ? 'destructive' : key.is_active && key.provider_active ? 'success' : 'outline'">
|
||||
{{ key.circuit_breaker_open ? '已熔断' : key.is_active && key.provider_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="shouldShowRecoverAction(key)"
|
||||
variant="warning"
|
||||
>
|
||||
建议恢复
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{{ key.api_key_masked }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>健康 {{ formatScore(key.health_score) }}</span>
|
||||
<span v-if="key.success_rate != null">{{ formatPercent(key.success_rate) }}</span>
|
||||
<span>{{ key.request_count }} 请求</span>
|
||||
<span v-if="key.avg_response_time_ms != null">均延迟 {{ formatLatency(key.avg_response_time_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
v-if="key.pool_enabled"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="ACTION_BUTTON_CLASS"
|
||||
@click="$emit('navigatePool', key.provider_id, key.name)"
|
||||
>
|
||||
号池内查看
|
||||
</Button>
|
||||
<Button
|
||||
v-if="shouldShowRecoverAction(key)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="ACTION_BUTTON_CLASS"
|
||||
:disabled="recoveringKeyId !== null"
|
||||
@click="$emit('recoverKey', key)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="recoveringKeyId === key.id"
|
||||
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
恢复健康度
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
最近失败
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
最近 {{ Math.min(failureEvents.length, 8) }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="failureEvents.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前窗口内没有失败事件。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="event in failureEvents.slice(0, 8)"
|
||||
:key="buildEventKey(event)"
|
||||
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
失败
|
||||
</Badge>
|
||||
<span
|
||||
v-if="event.status_code"
|
||||
class="text-xs font-medium text-foreground"
|
||||
>
|
||||
HTTP {{ event.status_code }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatAbsoluteTime(event.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-foreground">
|
||||
{{ event.error_type || event.error_message || '未提供错误类型' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ event.latency_ms != null ? `延迟 ${formatLatency(event.latency_ms)}` : '延迟未知' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h4 class="text-sm font-semibold">
|
||||
错误分布
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Error Type
|
||||
</div>
|
||||
<div
|
||||
v-if="errorTypes.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有可归类的错误类型。
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in errorTypes"
|
||||
:key="item.label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ item.label }} × {{ item.count }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Status Code
|
||||
</div>
|
||||
<div
|
||||
v-if="statusCodes.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有状态码样本。
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in statusCodes"
|
||||
:key="item.label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ item.label }} × {{ item.count }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
延迟样本
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
P50
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.p50 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
P95
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.p95 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
Max
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.max }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
最近活动
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
最近 {{ Math.min(sortedEvents.length, 12) }} 条事件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedEvents.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有事件样本。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="event in sortedEvents.slice(0, 12)"
|
||||
:key="buildEventKey(event)"
|
||||
class="flex flex-col gap-2 rounded-xl border border-border/50 bg-card/70 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2 flex-wrap">
|
||||
<Badge :variant="getEventStatusVariant(event.status)">
|
||||
{{ getEventStatusLabel(event.status) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="event.status_code"
|
||||
class="text-xs text-foreground"
|
||||
>
|
||||
HTTP {{ event.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="event.latency_ms != null"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatLatency(event.latency_ms) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="event.error_type"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ event.error_type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ formatAbsoluteTime(event.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Loader2, X } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import type { GroupedFormatKey } from '@/api/endpoints/keys'
|
||||
import type { EndpointHealthEvent, EndpointStatusMonitor, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
|
||||
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||
type MonitorEvent = EndpointHealthEvent | PublicHealthEvent
|
||||
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||
type CountItem = { label: string; count: number }
|
||||
type KeyScopeFilter = 'all' | 'circuit-open' | 'recoverable'
|
||||
type ProviderImpactSummary = {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
providerActive: boolean
|
||||
poolEnabled: boolean
|
||||
totalKeys: number
|
||||
activeKeys: number
|
||||
circuitOpenKeys: number
|
||||
avgHealthScore: number | null
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
monitor: MonitorItem | null
|
||||
isAdmin: boolean
|
||||
formatKeys: GroupedFormatKey[]
|
||||
loadingFormatKeys: boolean
|
||||
recoveringKeyId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigateProvider: [providerId: string]
|
||||
navigatePool: [providerId: string, search?: string]
|
||||
recoverKey: [key: GroupedFormatKey]
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const FILTER_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||
const ACTION_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||
|
||||
const providerFilter = ref<string>('all')
|
||||
const keyScopeFilter = ref<KeyScopeFilter>('all')
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
providerFilter.value = 'all'
|
||||
keyScopeFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
const monitorApiPath = computed(() =>
|
||||
props.monitor && 'api_path' in props.monitor ? props.monitor.api_path : null
|
||||
)
|
||||
|
||||
const sortedEvents = computed<MonitorEvent[]>(() => {
|
||||
const events = props.monitor?.events ?? []
|
||||
return [...events].sort((left, right) =>
|
||||
new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
const failureEvents = computed<MonitorEvent[]>(() =>
|
||||
sortedEvents.value.filter(event => event.status === 'failed')
|
||||
)
|
||||
|
||||
const sortedFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||
[...props.formatKeys].sort((left, right) => {
|
||||
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||
if (circuitGap !== 0) return circuitGap
|
||||
|
||||
const leftHealth = left.health_score ?? 1
|
||||
const rightHealth = right.health_score ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
if (left.request_count !== right.request_count) {
|
||||
return right.request_count - left.request_count
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
)
|
||||
|
||||
const providerSummaries = computed<ProviderImpactSummary[]>(() => {
|
||||
const grouped = new Map<string, ProviderImpactSummary & { healthValues: number[] }>()
|
||||
|
||||
for (const key of sortedFormatKeys.value) {
|
||||
const current = grouped.get(key.provider_id) ?? {
|
||||
provider_id: key.provider_id,
|
||||
provider_name: key.provider_name,
|
||||
providerActive: key.provider_active,
|
||||
poolEnabled: key.pool_enabled,
|
||||
totalKeys: 0,
|
||||
activeKeys: 0,
|
||||
circuitOpenKeys: 0,
|
||||
avgHealthScore: null,
|
||||
requestCount: 0,
|
||||
healthValues: []
|
||||
}
|
||||
|
||||
current.totalKeys += 1
|
||||
current.requestCount += key.request_count
|
||||
current.providerActive = current.providerActive || key.provider_active
|
||||
current.poolEnabled = current.poolEnabled || key.pool_enabled
|
||||
|
||||
if (key.is_active) current.activeKeys += 1
|
||||
if (key.circuit_breaker_open) current.circuitOpenKeys += 1
|
||||
if (key.health_score != null) current.healthValues.push(key.health_score)
|
||||
|
||||
grouped.set(key.provider_id, current)
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((provider) => ({
|
||||
provider_id: provider.provider_id,
|
||||
provider_name: provider.provider_name,
|
||||
providerActive: provider.providerActive,
|
||||
poolEnabled: provider.poolEnabled,
|
||||
totalKeys: provider.totalKeys,
|
||||
activeKeys: provider.activeKeys,
|
||||
circuitOpenKeys: provider.circuitOpenKeys,
|
||||
avgHealthScore: provider.healthValues.length > 0
|
||||
? provider.healthValues.reduce((sum, value) => sum + value, 0) / provider.healthValues.length
|
||||
: null,
|
||||
requestCount: provider.requestCount,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.circuitOpenKeys !== right.circuitOpenKeys) {
|
||||
return right.circuitOpenKeys - left.circuitOpenKeys
|
||||
}
|
||||
|
||||
const leftHealth = left.avgHealthScore ?? 1
|
||||
const rightHealth = right.avgHealthScore ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
return right.requestCount - left.requestCount
|
||||
})
|
||||
})
|
||||
|
||||
const providerFilterName = computed(() =>
|
||||
providerSummaries.value.find(p => p.provider_id === providerFilter.value)?.provider_name ?? '当前 Provider'
|
||||
)
|
||||
|
||||
const filteredFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||
sortedFormatKeys.value.filter((key) => {
|
||||
if (providerFilter.value !== 'all' && key.provider_id !== providerFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (keyScopeFilter.value === 'circuit-open') {
|
||||
return key.circuit_breaker_open
|
||||
}
|
||||
|
||||
if (keyScopeFilter.value === 'recoverable') {
|
||||
return shouldShowRecoverAction(key)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (!props.monitor) return '查看最近失败、错误分布与事件样本'
|
||||
const timeText = props.monitor.last_event_at
|
||||
? `最近事件 ${formatRelativeTime(props.monitor.last_event_at)}`
|
||||
: '当前窗口没有事件'
|
||||
return `${getMonitorStatusLabel(props.monitor)} · ${props.monitor.total_attempts} 次请求 · ${timeText}`
|
||||
})
|
||||
|
||||
const errorTypes = computed<CountItem[]>(() =>
|
||||
buildCountItems(
|
||||
failureEvents.value
|
||||
.map(event => event.error_type?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
const statusCodes = computed<CountItem[]>(() =>
|
||||
buildCountItems(
|
||||
failureEvents.value
|
||||
.map(event => event.status_code != null ? String(event.status_code) : null)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
const latencyStats = computed(() => {
|
||||
const values = sortedEvents.value
|
||||
.map(event => event.latency_ms)
|
||||
.filter((value): value is number => value != null)
|
||||
.sort((left, right) => left - right)
|
||||
|
||||
if (values.length === 0) {
|
||||
return { p50: '--', p95: '--', max: '--' }
|
||||
}
|
||||
|
||||
return {
|
||||
p50: formatLatency(pickPercentile(values, 0.5)),
|
||||
p95: formatLatency(pickPercentile(values, 0.95)),
|
||||
max: formatLatency(values[values.length - 1])
|
||||
}
|
||||
})
|
||||
|
||||
watch(sortedFormatKeys, (keys) => {
|
||||
if (providerFilter.value === 'all') return
|
||||
if (!keys.some(key => key.provider_id === providerFilter.value)) {
|
||||
providerFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
providerFilter.value = 'all'
|
||||
keyScopeFilter.value = 'all'
|
||||
}
|
||||
|
||||
function toggleProviderFilter(providerId: string) {
|
||||
providerFilter.value = providerFilter.value === providerId ? 'all' : providerId
|
||||
}
|
||||
|
||||
function shouldShowRecoverAction(key: GroupedFormatKey): boolean {
|
||||
if (key.health_score == null) return false
|
||||
return key.health_score < 0.999
|
||||
}
|
||||
|
||||
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||
const timeline = monitor.timeline ?? []
|
||||
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||
const status = timeline[index]
|
||||
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||
if (timelineStatus === 'warning') return 'warning'
|
||||
|
||||
if (monitor.total_attempts <= 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||
if (monitor.success_rate < 0.95) return 'warning'
|
||||
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return '异常'
|
||||
case 'warning':
|
||||
return '预警'
|
||||
case 'healthy':
|
||||
return '健康'
|
||||
default:
|
||||
return '无流量'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'unhealthy':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getEventStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'destructive'
|
||||
case 'skipped':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getEventStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
case 'skipped':
|
||||
return '跳过'
|
||||
case 'started':
|
||||
return '执行中'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function buildCountItems(values: string[]): CountItem[] {
|
||||
const counter = new Map<string, number>()
|
||||
for (const value of values) {
|
||||
counter.set(value, (counter.get(value) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counter.entries())
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
function pickPercentile(sortedValues: number[], percentile: number): number {
|
||||
if (sortedValues.length === 0) return 0
|
||||
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * percentile) - 1))
|
||||
return sortedValues[index]
|
||||
}
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatScore(score: number | null | undefined): string {
|
||||
if (score == null) return '--'
|
||||
return `${(score * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatLatency(latencyMs: number): string {
|
||||
if (latencyMs < 1000) return `${latencyMs}ms`
|
||||
return `${(latencyMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const diff = Date.now() - new Date(dateString).getTime()
|
||||
if (Number.isNaN(diff)) return '未知'
|
||||
if (diff < 60_000) return '刚刚'
|
||||
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days} 天前`
|
||||
|
||||
return formatAbsoluteTime(dateString)
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function buildEventKey(event: MonitorEvent): string {
|
||||
return `${event.timestamp}-${event.status}-${event.status_code ?? 'na'}-${event.error_type ?? 'none'}-${event.latency_ms ?? 'na'}`
|
||||
}
|
||||
</script>
|
||||
@@ -79,18 +79,19 @@
|
||||
{{ editingKey ? '' : '*' }}
|
||||
</Label>
|
||||
<template v-if="form.auth_type === 'service_account'">
|
||||
<Textarea
|
||||
:id="apiKeyInputId"
|
||||
<JsonImportInput
|
||||
v-model="form.auth_config_text"
|
||||
:required="!editingKey"
|
||||
:placeholder="editingKey ? '留空表示不修改' : '粘贴完整的 Service Account JSON'"
|
||||
class="min-h-[120px] font-mono text-xs"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="saving"
|
||||
:reset-key="formNonce"
|
||||
accept=".json,.txt,application/json,text/plain"
|
||||
:multiple="false"
|
||||
drop-title="拖入 Service Account JSON 或点击选择"
|
||||
drop-hint="支持 .json / .txt,单文件导入"
|
||||
:manual-placeholder="editingKey ? '留空表示不修改,或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
|
||||
:manual-description="serviceAccountDescription"
|
||||
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
|
||||
@error="handleServiceAccountImportError"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
JSON 格式,包含 project_id、private_key 等字段
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input
|
||||
@@ -330,13 +331,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Textarea } from '@/components/ui'
|
||||
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import JsonImportInput from '@/components/common/JsonImportInput.vue'
|
||||
import {
|
||||
addProviderKey,
|
||||
updateProviderKey,
|
||||
@@ -415,6 +417,12 @@ const visibleApiFormats = computed(() => {
|
||||
|
||||
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
||||
|
||||
const serviceAccountDescription = computed(() => (
|
||||
props.editingKey
|
||||
? '留空表示不修改;JSON 格式,包含 project_id、private_key 等字段'
|
||||
: 'JSON 格式,包含 project_id、private_key 等字段'
|
||||
))
|
||||
|
||||
// 默认认证类型
|
||||
const defaultAuthType = 'api_key' as const
|
||||
|
||||
@@ -676,6 +684,10 @@ function parseAuthConfig(): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
function handleServiceAccountImportError(payload: { message: string, title?: string }) {
|
||||
showError(payload.message, payload.title || '错误')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 必须有 providerId
|
||||
if (!props.providerId) {
|
||||
|
||||
@@ -282,8 +282,6 @@ export interface AliasGroup {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
/** @deprecated */
|
||||
providerApiFormats?: string[]
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
preselectedModelId?: string | null
|
||||
|
||||
@@ -410,8 +410,8 @@
|
||||
:reset-key="importInputResetKey"
|
||||
drop-title="拖入授权文件或点击选择"
|
||||
drop-hint="支持 .json / .txt,可多选"
|
||||
manual-placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||
paste-toggle-text="或手动粘贴 Refresh Token"
|
||||
manual-placeholder="粘贴 Refresh Token / Access Token 或 JSON 内容"
|
||||
paste-toggle-text="或手动粘贴 Token"
|
||||
file-toggle-text="或选择 JSON 文件导入"
|
||||
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
|
||||
@error="handleImportInputError"
|
||||
@@ -893,7 +893,7 @@ function isBatchImport(text: string): boolean {
|
||||
return lines.length > 1
|
||||
}
|
||||
|
||||
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
|
||||
function parseImportText(text: string): { refresh_token?: string; access_token?: string; name?: string } | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
@@ -907,9 +907,19 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const refreshToken = obj.refresh_token
|
||||
if (typeof refreshToken === 'string' && refreshToken.trim()) {
|
||||
const refreshTokenCamel = obj.refreshToken
|
||||
const accessToken = obj.access_token
|
||||
const accessTokenCamel = obj.accessToken
|
||||
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
|
||||
? refreshToken.trim()
|
||||
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
|
||||
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
|
||||
? accessToken.trim()
|
||||
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
|
||||
if (normalizedRefreshToken || normalizedAccessToken) {
|
||||
return {
|
||||
refresh_token: refreshToken.trim(),
|
||||
refresh_token: normalizedRefreshToken,
|
||||
access_token: normalizedAccessToken,
|
||||
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
|
||||
}
|
||||
}
|
||||
@@ -919,9 +929,34 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
|
||||
// Not JSON: treat as raw token.
|
||||
}
|
||||
|
||||
if (isLikelyJwtToken(trimmed)) {
|
||||
return { access_token: trimmed }
|
||||
}
|
||||
|
||||
return { refresh_token: trimmed }
|
||||
}
|
||||
|
||||
function isLikelyJwtToken(token: string): boolean {
|
||||
const parts = token.trim().split('.')
|
||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||
|
||||
try {
|
||||
const header = JSON.parse(decodeBase64Url(parts[0])) as Record<string, unknown>
|
||||
const payload = JSON.parse(decodeBase64Url(parts[1])) as Record<string, unknown>
|
||||
const tokenType = typeof header.typ === 'string' ? header.typ.toLowerCase() : ''
|
||||
if (tokenType && tokenType !== 'jwt' && tokenType !== 'at+jwt') return false
|
||||
return ['exp', 'aud', 'iss', 'scope', 'scp'].some(key => key in payload)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
function handleImportInputError(payload: { message: string; title?: string }) {
|
||||
showError(payload.message, payload.title)
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)">
|
||||
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)?.isInvalid && isAccountLevelBlock(key)">
|
||||
<template v-if="isAccountLevelBlock(key)">
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
|
||||
@@ -370,12 +370,21 @@
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="key.oauth_temporary"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
title="仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入"
|
||||
>
|
||||
临时
|
||||
</Badge>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.id"
|
||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
||||
:title="getOAuthRefreshButtonTitle(key)"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -544,12 +553,12 @@
|
||||
v-if="refreshingQuota"
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="key.upstream_metadata.codex?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="key.upstream_metadata.codex?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||
@@ -674,7 +683,7 @@
|
||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||
{{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -937,6 +946,7 @@
|
||||
ref="modelMappingTabRef"
|
||||
:key="`mapping-${provider.id}`"
|
||||
:provider="provider"
|
||||
:endpoints="endpoints"
|
||||
:provider-keys="providerKeys"
|
||||
:models="providerModels"
|
||||
:mapping-preview="providerMappingPreview"
|
||||
@@ -1088,7 +1098,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||
import {
|
||||
getProvider,
|
||||
getProviderEndpoints,
|
||||
@@ -1135,8 +1145,15 @@ import {
|
||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getAccountStatusTitle,
|
||||
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -1157,7 +1174,7 @@ const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
|
||||
@@ -1642,7 +1659,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
refreshingOAuthKeyId.value = key.id
|
||||
try {
|
||||
const result = await refreshProviderOAuth(key.id)
|
||||
showSuccess('Token 刷新成功')
|
||||
let refreshedKey: EndpointAPIKey | null = null
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
@@ -1654,8 +1671,19 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
if (freshKeys) {
|
||||
providerKeys.value = freshKeys
|
||||
syncCurrentSelections(endpoints.value, freshKeys)
|
||||
refreshedKey = freshKeys.find(item => item.id === key.id) ?? null
|
||||
}
|
||||
}
|
||||
const feedback = getOAuthRefreshFeedback({
|
||||
accountStateRecheckAttempted: result.account_state_recheck_attempted,
|
||||
accountStateRecheckError: result.account_state_recheck_error,
|
||||
snapshot: refreshedKey,
|
||||
})
|
||||
if (feedback.tone === 'warning') {
|
||||
showWarning(feedback.message)
|
||||
} else {
|
||||
showSuccess(feedback.message)
|
||||
}
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
||||
void autoRefreshQuotaInBackground()
|
||||
@@ -1668,7 +1696,9 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
|
||||
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
||||
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
||||
return isAccountLevelBlockReason(key.oauth_invalid_reason)
|
||||
const account = getAccountStatusDisplay(key)
|
||||
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
|
||||
return account.blocked && !oauth?.isInvalid
|
||||
}
|
||||
|
||||
// 清除 OAuth 失效标记
|
||||
@@ -1677,7 +1707,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '清除账号异常标记',
|
||||
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后该 Key 将恢复正常调度。`,
|
||||
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后系统会按当前手动开关和调度状态重新评估该 Key。`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'default',
|
||||
})
|
||||
@@ -1686,13 +1716,33 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
clearingOAuthInvalidKeyId.value = key.id
|
||||
try {
|
||||
await clearOAuthInvalid(key.id)
|
||||
showSuccess('已清除 OAuth 异常标记,Key 已自动启用')
|
||||
showSuccess('已清除 OAuth 异常标记')
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
keyInList.oauth_invalid_at = null
|
||||
keyInList.oauth_invalid_reason = null
|
||||
keyInList.is_active = true
|
||||
if (keyInList.status_snapshot) {
|
||||
keyInList.status_snapshot = {
|
||||
...keyInList.status_snapshot,
|
||||
oauth: {
|
||||
...keyInList.status_snapshot.oauth,
|
||||
code: 'none',
|
||||
label: null,
|
||||
reason: null,
|
||||
invalid_at: null,
|
||||
requires_reauth: false,
|
||||
},
|
||||
account: {
|
||||
...keyInList.status_snapshot.account,
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
blocked: false,
|
||||
recoverable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadEndpoints()
|
||||
} catch (err: unknown) {
|
||||
@@ -1824,8 +1874,11 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro)
|
||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||
return key.oauth_invalid_at == null
|
||||
&& typeof key.oauth_expires_at === 'number'
|
||||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||
return false
|
||||
}
|
||||
return typeof key.oauth_expires_at === 'number'
|
||||
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
||||
}
|
||||
|
||||
@@ -2427,10 +2480,6 @@ function formatUpdatedAt(updatedAt: number): string {
|
||||
return `${days}天前更新`
|
||||
}
|
||||
|
||||
// 兼容旧函数名
|
||||
const formatCodexUpdatedAt = formatUpdatedAt
|
||||
const formatAntigravityUpdatedAt = formatUpdatedAt
|
||||
|
||||
function secondsUntilReset(resetTime: string): number | null {
|
||||
if (!resetTime) return null
|
||||
const ts = Date.parse(resetTime)
|
||||
@@ -2633,31 +2682,20 @@ function getOAuthPlanTypeClass(planType: string): string {
|
||||
|
||||
// OAuth 状态信息(包括失效和过期)
|
||||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||||
if (key.auth_type !== 'oauth') return null
|
||||
// 即使没有 expires_at,也要检查 invalid_at
|
||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
||||
return getOAuthExpiresCountdown(
|
||||
key.oauth_expires_at,
|
||||
countdownTick.value,
|
||||
key.oauth_invalid_at,
|
||||
key.oauth_invalid_reason
|
||||
)
|
||||
return getOAuthStatusDisplay(key, countdownTick.value)
|
||||
}
|
||||
|
||||
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
|
||||
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
||||
}
|
||||
|
||||
// OAuth 状态的 title 提示
|
||||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||
const status = getKeyOAuthExpires(key)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
|
||||
? cleanAccountBlockReason(status.invalidReason)
|
||||
: status.invalidReason
|
||||
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
|
||||
const accountTitle = getAccountStatusTitle(key)
|
||||
if (accountTitle && isAccountLevelBlock(key)) {
|
||||
return accountTitle
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
}
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
return resolveOAuthStatusTitle(key, countdownTick.value)
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
|
||||
@@ -45,8 +45,11 @@
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
<SelectItem
|
||||
value="claude_code"
|
||||
disabled
|
||||
>
|
||||
ClaudeCode(暂不可用)
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -432,6 +435,11 @@ watch(() => form.value.provider_type, () => {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
|
||||
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError('月卡类型必须设置周期开始时间', '验证失败')
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
<ModelMappingDialog
|
||||
v-model:open="dialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:provider-api-formats="providerApiFormats"
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
:preselected-model-id="preselectedModelId"
|
||||
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getProviderModels,
|
||||
testModel,
|
||||
API_FORMAT_LABELS,
|
||||
type Model,
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
|
||||
// 列表展开状态
|
||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 获取 Provider 支持的 API 格式
|
||||
const providerApiFormats = computed(() => {
|
||||
const formats = props.provider?.api_formats
|
||||
if (Array.isArray(formats) && formats.length > 0) {
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
@@ -391,7 +379,6 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: mapping.name, // 使用映射名称进行测试
|
||||
message: "hello",
|
||||
api_format: apiFormat
|
||||
})
|
||||
|
||||
|
||||
@@ -314,10 +314,24 @@
|
||||
:result="modelTest.testResult.value"
|
||||
mode="direct"
|
||||
:selecting-model-name="testingModelName"
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:request-headers-draft="testRequestHeadersDraft"
|
||||
:request-headers-reset-value="testRequestHeadersResetValue"
|
||||
:request-headers-error="testRequestHeadersError"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-reset-value="testRequestBodyResetValue"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@start="handleStartMappingTest"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -335,6 +349,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
type ProviderModelAlias,
|
||||
type ProviderMappingPreviewResponse,
|
||||
} from '@/api/endpoints'
|
||||
@@ -342,6 +357,14 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import {
|
||||
buildDefaultModelTestRequestHeaders,
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
POOL_TEST_CONCURRENCY,
|
||||
SINGLE_TEST_CONCURRENCY,
|
||||
} from './model-test-request'
|
||||
|
||||
interface MappingItem {
|
||||
name: string
|
||||
@@ -369,6 +392,7 @@ interface CombinedMapping {
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
endpoints?: ProviderEndpoint[]
|
||||
providerKeys?: EndpointAPIKey[]
|
||||
models?: Model[]
|
||||
mappingPreview?: ProviderMappingPreviewResponse | null
|
||||
@@ -390,8 +414,20 @@ const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
const pendingMappingKey = ref<string | null>(null)
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestHeadersDraft = ref('')
|
||||
const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const testRequestBodyResetValue = ref('')
|
||||
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
|
||||
// 使用 props 传入的数据
|
||||
const models = computed(() => props.models ?? [])
|
||||
@@ -626,22 +662,81 @@ async function onDialogSaved() {
|
||||
|
||||
function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
testRequestBodyDraft.value = ''
|
||||
testRequestBodyResetValue.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移和实时进度)
|
||||
async function runMappingTest(testingKey: string, modelName: string) {
|
||||
testingMapping.value = testingKey
|
||||
function runMappingTest(testingKey: string, modelName: string) {
|
||||
if (activeEndpoints.value.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
return
|
||||
}
|
||||
pendingMappingKey.value = testingKey
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
testingMapping.value = null
|
||||
testingModelName.value = modelName
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
}
|
||||
|
||||
async function handleStartMappingTest() {
|
||||
if (modelTest.testing.value || !testingModelName.value) return
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
|
||||
if (!requestHeaders || requestHeadersError) {
|
||||
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentMappingKey = pendingMappingKey.value || testingModelName.value
|
||||
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
|
||||
await modelTest.startTest({
|
||||
mode: 'direct',
|
||||
modelName,
|
||||
displayLabel: `映射 "${modelName}"`,
|
||||
message: 'hello',
|
||||
onSuccess: () => {
|
||||
testingModelName.value = null
|
||||
},
|
||||
modelName: testingModelName.value,
|
||||
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||
})
|
||||
if (pendingMappingKey.value === currentMappingKey) {
|
||||
pendingMappingKey.value = null
|
||||
}
|
||||
testingMapping.value = null
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -221,10 +221,19 @@
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
:request-headers-draft="testRequestHeadersDraft"
|
||||
:request-headers-reset-value="testRequestHeadersResetValue"
|
||||
:request-headers-error="testRequestHeadersError"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-reset-value="testRequestBodyResetValue"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@start="handleStartPendingTest"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -247,6 +256,14 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import {
|
||||
buildDefaultModelTestRequestHeaders,
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
POOL_TEST_CONCURRENCY,
|
||||
SINGLE_TEST_CONCURRENCY,
|
||||
} from './model-test-request'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -272,7 +289,16 @@ const localModels = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const pendingTestModel = ref<Model | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestHeadersDraft = ref('')
|
||||
const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const testRequestBodyResetValue = ref('')
|
||||
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -438,19 +464,47 @@ function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
testRequestBodyDraft.value = ''
|
||||
testRequestBodyResetValue.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
async function handleSelectTestEndpoint(endpointId: string) {
|
||||
if (!pendingTestModel.value) return
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
async function handleStartPendingTest() {
|
||||
if (modelTest.testing.value) return
|
||||
if (!pendingTestModel.value) return
|
||||
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
|
||||
if (!requestHeaders || requestHeadersError) {
|
||||
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
selectedTestEndpoint.value = endpoint
|
||||
const model = pendingTestModel.value
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
|
||||
@@ -460,15 +514,11 @@ async function handleSelectTestEndpoint(endpointId: string) {
|
||||
displayLabel: `${endpointPrefix}${modelName}`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
message: 'hello',
|
||||
concurrency: 5,
|
||||
onSuccess: () => {
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
},
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||
onError: () => {
|
||||
if (activeEndpoints.value.length > 1) {
|
||||
selectedTestEndpoint.value = null
|
||||
return true
|
||||
}
|
||||
},
|
||||
@@ -484,13 +534,15 @@ async function testModelConnection(model: Model) {
|
||||
}
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = null
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
model.global_model_name || model.provider_model_name,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
|
||||
if (activeEndpoints.value.length === 1) {
|
||||
await handleSelectTestEndpoint(activeEndpoints.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||
|
||||
/** Pool-managed provider runs concurrent checks; single-key provider does not. */
|
||||
export const POOL_TEST_CONCURRENCY = 5
|
||||
export const SINGLE_TEST_CONCURRENCY = 1
|
||||
|
||||
export function buildDefaultModelTestRequestBody(modelName: string): string {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
},
|
||||
],
|
||||
max_tokens: 30,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
export function buildDefaultModelTestRequestHeaders(): string {
|
||||
return JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
function parseModelTestJsonObjectDraft(
|
||||
draft: string,
|
||||
options: {
|
||||
emptyValue: Record<string, unknown> | null
|
||||
emptyError: string | null
|
||||
invalidTypeError: string
|
||||
},
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
const normalized = draft.trim()
|
||||
if (!normalized) {
|
||||
return {
|
||||
value: options.emptyValue,
|
||||
error: options.emptyError,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {
|
||||
value: null,
|
||||
error: options.invalidTypeError,
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: parsed as Record<string, unknown>,
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : '无效的 JSON',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModelTestRequestBodyDraft(
|
||||
draft: string,
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
return parseModelTestJsonObjectDraft(draft, {
|
||||
emptyValue: null,
|
||||
emptyError: '测试请求体不能为空',
|
||||
invalidTypeError: '测试请求体必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
|
||||
export function parseModelTestRequestHeadersDraft(
|
||||
draft: string,
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
return parseModelTestJsonObjectDraft(draft, {
|
||||
emptyValue: {},
|
||||
emptyError: null,
|
||||
invalidTypeError: '测试请求头必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
@@ -20,26 +20,31 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
||||
class="flex items-center justify-center text-sm text-destructive"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||
加载失败
|
||||
数据暂不可用
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:value-label="valueLabel"
|
||||
:actual-value-label="actualValueLabel"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
暂无活跃数据
|
||||
</div>
|
||||
@@ -58,6 +63,9 @@ const props = defineProps<{
|
||||
title: string
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
minHeight?: number
|
||||
valueLabel?: string
|
||||
actualValueLabel?: string
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
@@ -65,4 +73,6 @@ const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
const hasData = computed(() =>
|
||||
props.data && props.data.days && props.data.days.length > 0
|
||||
)
|
||||
|
||||
const minHeight = computed(() => props.minHeight ?? 160)
|
||||
</script>
|
||||
|
||||
@@ -240,6 +240,10 @@
|
||||
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
|
||||
-->)</span>
|
||||
</span>
|
||||
<code
|
||||
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
|
||||
class="text-xs font-mono text-muted-foreground"
|
||||
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -462,7 +466,7 @@ interface UsageData {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
requestId: string
|
||||
requestId?: string | null
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
overrideStatusCode?: number
|
||||
/** 请求侧 API 格式(客户端入口格式) */
|
||||
@@ -471,6 +475,12 @@ const props = defineProps<{
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
/** 已获取的追踪数据;传入时不再内部拉取 */
|
||||
traceData?: RequestTrace | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAttempt: [attempt: CandidateRecord | null]
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -525,7 +535,8 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const trace = ref<RequestTrace | null>(null)
|
||||
const internalTrace = ref<RequestTrace | null>(null)
|
||||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||||
const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
const hoveredGroupIndex = ref<number | null>(null)
|
||||
@@ -769,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
}
|
||||
|
||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (!merged.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (!merged.key_name && typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
||||
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
||||
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
|
||||
@@ -1045,6 +1056,10 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
watch(currentAttempt, (attempt) => {
|
||||
emit('selectAttempt', attempt ?? null)
|
||||
}, { immediate: true })
|
||||
|
||||
const currentGroupTitle = computed(() => {
|
||||
if (!selectedGroup.value || !currentAttempt.value) return ''
|
||||
if (selectedGroup.value.isPoolGroup) {
|
||||
@@ -1224,7 +1239,7 @@ const navigateGroup = (direction: number) => {
|
||||
// 加载请求追踪数据
|
||||
const isSilentRefresh = ref(false)
|
||||
const loadTrace = async (silent = false) => {
|
||||
if (!props.requestId) return
|
||||
if (!props.requestId || props.traceData) return
|
||||
|
||||
isSilentRefresh.value = silent
|
||||
|
||||
@@ -1234,7 +1249,7 @@ const loadTrace = async (silent = false) => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: unknown) {
|
||||
if (!silent) {
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
@@ -1304,12 +1319,31 @@ watch(groupedTimeline, (newGroups) => {
|
||||
selectedAttemptIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 requestId 变化
|
||||
watch(() => props.requestId, () => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
loadTrace()
|
||||
}, { immediate: true })
|
||||
// 监听 requestId / 外部 trace 变化
|
||||
watch(
|
||||
[() => props.requestId, () => props.traceData],
|
||||
() => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
|
||||
if (props.traceData) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.requestId) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
void loadTrace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ refresh: () => loadTrace(true) })
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
<template>
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="displayLegendItems.length > 0"
|
||||
class="flex items-center gap-2 flex-wrap justify-end text-[11px]"
|
||||
>
|
||||
<div
|
||||
v-for="item in displayLegendItems"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
<span class="text-muted-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="hiddenLegendCount > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
+{{ hiddenLegendCount }} 更多
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="h-[160px] flex items-center justify-center"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasData"
|
||||
class="h-[160px]"
|
||||
>
|
||||
<ScatterChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无请求间隔数据
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
||||
import { meApi } from '@/api/me'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
isAdmin: boolean
|
||||
hours?: number
|
||||
}>(), {
|
||||
hours: 24 // 默认当天
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||
let loadRequestId = 0
|
||||
|
||||
const ADMIN_TIMELINE_LIMIT = 1500
|
||||
const USER_TIMELINE_LIMIT = 1200
|
||||
|
||||
// 获取主题色
|
||||
function getPrimaryColor(): string {
|
||||
if (typeof window === 'undefined') return '201, 100, 66'
|
||||
// CSS 变量定义在 body 上,不是 documentElement
|
||||
const body = document.body
|
||||
const style = getComputedStyle(body)
|
||||
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
|
||||
return rgb || '201, 100, 66'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
primaryColor.value = getPrimaryColor()
|
||||
void loadData()
|
||||
})
|
||||
|
||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||
const COLORS = [
|
||||
'rgba(59, 130, 246, 0.7)', // blue
|
||||
'rgba(236, 72, 153, 0.7)', // pink
|
||||
'rgba(34, 197, 94, 0.7)', // green
|
||||
'rgba(249, 115, 22, 0.7)', // orange
|
||||
'rgba(168, 85, 247, 0.7)', // purple
|
||||
'rgba(234, 179, 8, 0.7)', // yellow
|
||||
'rgba(14, 165, 233, 0.7)', // sky
|
||||
'rgba(239, 68, 68, 0.7)', // red
|
||||
'rgba(20, 184, 166, 0.7)', // teal
|
||||
'rgba(99, 102, 241, 0.7)', // indigo
|
||||
]
|
||||
|
||||
const hasData = computed(() =>
|
||||
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
||||
)
|
||||
|
||||
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
|
||||
const hasMultipleGroups = computed(() => {
|
||||
if (props.isAdmin) {
|
||||
// 管理员视图:按用户分组
|
||||
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
||||
} else {
|
||||
// 用户视图:按模型分组
|
||||
return timelineData.value?.models && timelineData.value.models.length > 1
|
||||
}
|
||||
})
|
||||
|
||||
// 图例最多显示数量
|
||||
const MAX_LEGEND_ITEMS = 6
|
||||
|
||||
// 图例项(管理员显示用户,普通用户显示模型)
|
||||
const legendItems = computed(() => {
|
||||
if (props.isAdmin && timelineData.value?.users) {
|
||||
// 管理员视图:显示用户图例
|
||||
const users = Object.entries(timelineData.value.users)
|
||||
if (users.length <= 1) return []
|
||||
return users.map(([userId, username], index) => ({
|
||||
id: userId,
|
||||
name: username || userId.slice(0, 8),
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))
|
||||
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
|
||||
// 用户视图:显示模型图例
|
||||
return timelineData.value.models.map((model, index) => ({
|
||||
id: model,
|
||||
name: formatModelName(model),
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 显示的图例项(限制数量)
|
||||
const displayLegendItems = computed(() => {
|
||||
return legendItems.value.slice(0, MAX_LEGEND_ITEMS)
|
||||
})
|
||||
|
||||
// 隐藏的图例数量
|
||||
const hiddenLegendCount = computed(() => {
|
||||
return Math.max(0, legendItems.value.length - MAX_LEGEND_ITEMS)
|
||||
})
|
||||
|
||||
// 格式化模型名称,使其更简洁
|
||||
function formatModelName(model: string): string {
|
||||
// 常见的简化规则
|
||||
if (model.includes('claude')) {
|
||||
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
|
||||
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
|
||||
if (match) {
|
||||
const major = match[1]
|
||||
const minor = match[2]
|
||||
const variant = match[3]
|
||||
let name = `Claude ${major}`
|
||||
if (minor) name += `.${minor}`
|
||||
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
|
||||
return name
|
||||
}
|
||||
}
|
||||
// 其他模型保持原样但截断
|
||||
return model.length > 20 ? `${model.slice(0, 17) }...` : model
|
||||
}
|
||||
|
||||
// 构建图表数据
|
||||
const chartData = computed<ChartData<'scatter'>>(() => {
|
||||
if (!timelineData.value?.points) {
|
||||
return { datasets: [] }
|
||||
}
|
||||
|
||||
const points = timelineData.value.points
|
||||
|
||||
// 管理员视图且有多个用户:按用户分组
|
||||
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
||||
const userIds = Object.keys(timelineData.value.users)
|
||||
const userColorMap: Record<string, string> = {}
|
||||
userIds.forEach((userId, index) => {
|
||||
userColorMap[userId] = COLORS[index % COLORS.length]
|
||||
})
|
||||
|
||||
// 按用户分组数据
|
||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||
for (const point of points) {
|
||||
const userId = point.user_id || 'unknown'
|
||||
if (!groupedData[userId]) {
|
||||
groupedData[userId] = []
|
||||
}
|
||||
groupedData[userId].push({ x: point.x, y: point.y })
|
||||
}
|
||||
|
||||
// 创建每个用户的 dataset
|
||||
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
|
||||
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
|
||||
data,
|
||||
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}))
|
||||
|
||||
return { datasets }
|
||||
}
|
||||
|
||||
// 用户视图且有多个模型:按模型分组
|
||||
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
|
||||
const models = timelineData.value.models
|
||||
const modelColorMap: Record<string, string> = {}
|
||||
models.forEach((model, index) => {
|
||||
modelColorMap[model] = COLORS[index % COLORS.length]
|
||||
})
|
||||
|
||||
// 按模型分组数据
|
||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||
for (const point of points) {
|
||||
const model = point.model || 'unknown'
|
||||
if (!groupedData[model]) {
|
||||
groupedData[model] = []
|
||||
}
|
||||
groupedData[model].push({ x: point.x, y: point.y })
|
||||
}
|
||||
|
||||
// 创建每个模型的 dataset
|
||||
const datasets = Object.entries(groupedData).map(([model, data]) => ({
|
||||
label: formatModelName(model),
|
||||
data,
|
||||
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}))
|
||||
|
||||
return { datasets }
|
||||
}
|
||||
|
||||
// 单用户或单模型:使用主题色
|
||||
return {
|
||||
datasets: [{
|
||||
label: '请求间隔',
|
||||
data: points.map(p => ({ x: p.x, y: p.y })),
|
||||
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
|
||||
borderColor: `rgba(${primaryColor.value}, 0.8)`,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // 使用自定义图例
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||
const realY = point._originalY ?? point.y
|
||||
const datasetLabel = context.dataset.label || ''
|
||||
if (hasMultipleGroups.value) {
|
||||
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
||||
}
|
||||
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
if (props.isAdmin) {
|
||||
// 管理员:获取所有用户数据(按比例采样)
|
||||
const data = await cacheAnalysisApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
include_user_info: true,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
} else {
|
||||
// 普通用户:获取自己的数据
|
||||
const data = await meApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
log.error('加载请求间隔时间线失败:', error)
|
||||
timelineData.value = null
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadRequestId++
|
||||
})
|
||||
</script>
|
||||
@@ -322,7 +322,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard'
|
||||
import { requestDetailsApi, type ReplayResponse, type RequestDetail } from '@/api/request-details'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getProviderKeys } from '@/api/endpoints/keys'
|
||||
import type { EndpointAPIKey } from '@/api/endpoints/types'
|
||||
@@ -482,7 +482,7 @@ async function doReplay() {
|
||||
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
|
||||
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
|
||||
|
||||
replayResult.value = await dashboardApi.replayRequest(
|
||||
replayResult.value = await requestDetailsApi.replayRequest(
|
||||
props.requestId,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
)
|
||||
|
||||
@@ -696,7 +696,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { requestDetailsApi, type RequestDetail } from '@/api/request-details'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
@@ -1319,7 +1319,7 @@ async function ensureBodyContentLoaded() {
|
||||
const requestId = ++bodyLoadRequestId
|
||||
bodyLoading.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
|
||||
const response = await requestDetailsApi.getRequestDetail(props.requestId, { includeBodies: true })
|
||||
if (requestId !== bodyLoadRequestId || !detail.value) return
|
||||
detail.value = {
|
||||
...detail.value,
|
||||
@@ -1359,7 +1359,7 @@ async function loadDetail(id: string, silent = false) {
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
|
||||
const response = await requestDetailsApi.getRequestDetail(id, { includeBodies: false })
|
||||
if (requestId !== loadDetailRequestId) return
|
||||
|
||||
const previousDetail = detail.value
|
||||
@@ -1737,7 +1737,7 @@ async function copyCurlCommand() {
|
||||
if (!props.requestId || curlCopying.value) return
|
||||
curlCopying.value = true
|
||||
try {
|
||||
const data = await dashboardApi.getCurlData(props.requestId)
|
||||
const data = await requestDetailsApi.getCurlData(props.requestId)
|
||||
if (data.curl) {
|
||||
copyToClipboard(data.curl, false)
|
||||
curlCopied.value = true
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import JsonContent from './JsonContent.vue'
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
import type { RequestDetail } from '@/api/request-details'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
detail: RequestDetail
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按API格式分析
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
API格式
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>输入/输出</span>
|
||||
<span class="text-muted-foreground font-normal">缓存</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
缓存命中率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="6"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无API格式统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="item in data"
|
||||
:key="item.api_format"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ formatApiFormat(item.api_format) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ item.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && item.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(item.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatHitRate(item.cache_hit_rate) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ item.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ApiFormatStatsItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
data: ApiFormatStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按模型分析
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
模型
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>输入/输出</span>
|
||||
<span class="text-muted-foreground font-normal">缓存</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
缓存命中率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
效率
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="6"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无模型统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="model in data"
|
||||
:key="model.model"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ model.model.replace('claude-', '') }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ model.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && model.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(model.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatHitRate(model.cache_hit_rate) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ model.costPerToken }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
||||
import type { EnhancedModelStatsItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
data: EnhancedModelStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按提供商分析
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>输入/输出</span>
|
||||
<span class="text-muted-foreground font-normal">缓存</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
缓存命中率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
成功率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="7"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无提供商统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="provider in data"
|
||||
:key="provider.provider"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ provider.provider }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ provider.requests }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && provider.actualCost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(provider.actualCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatHitRate(provider.cacheHitRate) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ provider.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
|
||||
import type { ProviderStatsItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
data: ProviderStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
// 成功率样式 - 简化为两种状态
|
||||
function getSuccessRateClass(rate: number): string {
|
||||
if (rate < 90) return 'text-destructive'
|
||||
return '' // 默认颜色
|
||||
}
|
||||
</script>
|
||||
@@ -36,10 +36,33 @@
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
:key="user.value"
|
||||
:value="user.value"
|
||||
>
|
||||
{{ user.username || user.email }}
|
||||
{{ user.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Key 筛选 -->
|
||||
<Select
|
||||
v-if="availableApiKeys.length > 0"
|
||||
:model-value="filterApiKey"
|
||||
@update:model-value="$emit('update:filterApiKey', $event)"
|
||||
>
|
||||
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">
|
||||
全部 Key
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="apiKey in availableApiKeys"
|
||||
:key="apiKey.value"
|
||||
:value="apiKey.value"
|
||||
>
|
||||
{{ apiKey.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -58,10 +81,10 @@
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="model in availableModels"
|
||||
:key="model"
|
||||
:value="model"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.replace('claude-', '') }}
|
||||
{{ model.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -81,10 +104,10 @@
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider"
|
||||
:value="provider"
|
||||
:key="provider.value"
|
||||
:value="provider.value"
|
||||
>
|
||||
{{ provider }}
|
||||
{{ provider.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -106,7 +129,7 @@
|
||||
:key="format.value"
|
||||
:value="format.value"
|
||||
>
|
||||
{{ format.label }}
|
||||
{{ getApiFormatOptionLabel(format) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -123,26 +146,12 @@
|
||||
<SelectItem value="__all__">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="stream">
|
||||
流式
|
||||
</SelectItem>
|
||||
<SelectItem value="standard">
|
||||
标准
|
||||
</SelectItem>
|
||||
<SelectItem value="active">
|
||||
活跃
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
失败
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
已取消
|
||||
</SelectItem>
|
||||
<SelectItem value="has_retry">
|
||||
发生重试
|
||||
</SelectItem>
|
||||
<SelectItem value="has_fallback">
|
||||
发生转移
|
||||
<SelectItem
|
||||
v-for="status in availableStatuses"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ getStatusOptionLabel(status) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -350,18 +359,18 @@
|
||||
<TableCell
|
||||
v-if="isAdmin"
|
||||
class="py-4 w-[100px] truncate"
|
||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
||||
:title="getUserDisplayName(record)"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span class="truncate">
|
||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||
{{ getUserDisplayName(record) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="record.api_key?.name"
|
||||
v-if="getUserApiKeyLabel(record)"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.api_key.name"
|
||||
:title="getUserApiKeyLabel(record) || undefined"
|
||||
>
|
||||
{{ record.api_key.name }}
|
||||
{{ getUserApiKeyLabel(record) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -369,10 +378,10 @@
|
||||
<TableCell
|
||||
v-if="!isAdmin"
|
||||
class="py-4 w-[100px]"
|
||||
:title="record.api_key?.name || '-'"
|
||||
:title="getUserApiKeyLabel(record) || '-'"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
||||
<span class="truncate">{{ getUserApiKeyLabel(record) || '-' }}</span>
|
||||
<span
|
||||
v-if="record.api_key?.display"
|
||||
class="text-muted-foreground truncate"
|
||||
@@ -417,7 +426,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span>{{ record.provider }}</span>
|
||||
<span>{{ formatProviderLabel(record.provider) }}</span>
|
||||
<span
|
||||
v-if="record.api_key_name"
|
||||
class="text-muted-foreground truncate"
|
||||
@@ -672,16 +681,11 @@ import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { AnalyticsFilterOption } from '@/api/analytics'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import ElapsedTimeText from './ElapsedTimeText.vue'
|
||||
|
||||
export interface UserOption {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
records: UsageRecord[]
|
||||
isAdmin: boolean
|
||||
@@ -692,13 +696,17 @@ const props = defineProps<{
|
||||
// 筛选
|
||||
filterSearch: string
|
||||
filterUser: string
|
||||
filterApiKey: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
filterApiFormat: string
|
||||
filterStatus: string
|
||||
availableUsers: UserOption[]
|
||||
availableModels: string[]
|
||||
availableProviders: string[]
|
||||
availableUsers: AnalyticsFilterOption[]
|
||||
availableApiKeys: AnalyticsFilterOption[]
|
||||
availableModels: AnalyticsFilterOption[]
|
||||
availableApiFormats: AnalyticsFilterOption[]
|
||||
availableProviders: AnalyticsFilterOption[]
|
||||
availableStatuses: AnalyticsFilterOption[]
|
||||
// 分页
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
@@ -712,6 +720,7 @@ const emit = defineEmits<{
|
||||
'update:timeRange': [value: DateRangeParams]
|
||||
'update:filterSearch': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterApiKey': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
'update:filterApiFormat': [value: string]
|
||||
@@ -723,22 +732,6 @@ const emit = defineEmits<{
|
||||
'showDetail': [id: string]
|
||||
}>()
|
||||
|
||||
// 静态常量(放在 defineProps/defineEmits 之后)
|
||||
const AVAILABLE_API_FORMATS = [
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||
{ value: 'openai:compact', label: 'OpenAI Compact' },
|
||||
{ value: 'openai:video', label: 'OpenAI Video' },
|
||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
||||
{ value: 'gemini:video', label: 'Gemini Video' },
|
||||
] as const
|
||||
|
||||
// 使用模块级常量
|
||||
const availableApiFormats = AVAILABLE_API_FORMATS
|
||||
|
||||
const timeRangeModel = computed({
|
||||
get: () => props.timeRange,
|
||||
set: (value: DateRangeParams) => emit('update:timeRange', value)
|
||||
@@ -760,6 +753,44 @@ watch(localSearch, (value) => {
|
||||
emitSearchDebounced(value)
|
||||
})
|
||||
|
||||
function getApiFormatOptionLabel(option: AnalyticsFilterOption): string {
|
||||
return formatApiFormat(option.label || option.value)
|
||||
}
|
||||
|
||||
function getStatusOptionLabel(option: AnalyticsFilterOption): string {
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
pending: '等待',
|
||||
streaming: '传输中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
active: '活跃',
|
||||
stream: '流式',
|
||||
standard: '标准',
|
||||
has_retry: '发生重试',
|
||||
has_fallback: '发生转移',
|
||||
}
|
||||
|
||||
return statusLabelMap[option.value] || option.label || option.value
|
||||
}
|
||||
|
||||
function formatProviderLabel(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
if (value === 'pending') return '待分配提供商'
|
||||
if (value === 'unknown') return '未识别提供商'
|
||||
return value
|
||||
}
|
||||
|
||||
function getUserDisplayName(record: UsageRecord): string {
|
||||
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
|
||||
}
|
||||
|
||||
function getUserApiKeyLabel(record: UsageRecord): string | null {
|
||||
const name = record.api_key?.name?.trim()
|
||||
if (name) return name
|
||||
return record.api_key?.id ? null : '已删除Key'
|
||||
}
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export { default as UsageModelTable } from './UsageModelTable.vue'
|
||||
export { default as UsageProviderTable } from './UsageProviderTable.vue'
|
||||
export { default as UsageApiFormatTable } from './UsageApiFormatTable.vue'
|
||||
export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
||||
export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue'
|
||||
|
||||
@@ -28,18 +28,17 @@ export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'last7days':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last30days':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last90days':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
case 'last180days':
|
||||
startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last1year':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||
break
|
||||
default:
|
||||
return {} // 返回空对象表示不过滤时间
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { meApi } from '@/api/me'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { analyticsApi, type AnalyticsBaseRequest, type AnalyticsFilterOption, type AnalyticsRecord, type AnalyticsScope } from '@/api/analytics'
|
||||
import { buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
|
||||
import type {
|
||||
UsageStatsState,
|
||||
ModelStatsItem,
|
||||
ProviderStatsItem,
|
||||
ApiFormatStatsItem,
|
||||
UsageRecord,
|
||||
DateRangeParams,
|
||||
EnhancedModelStatsItem
|
||||
} from '../types'
|
||||
import { createDefaultStats } from '../types'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
|
||||
@@ -26,27 +20,21 @@ export interface PaginationParams {
|
||||
export interface FilterParams {
|
||||
search?: string
|
||||
user_id?: string
|
||||
api_key_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
api_format?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type FilterOptionDimension = 'user' | 'api_key' | 'model' | 'provider' | 'api_format' | 'status'
|
||||
|
||||
export function useUsageData(options: UseUsageDataOptions) {
|
||||
const { isAdminPage } = options
|
||||
|
||||
// 加载状态
|
||||
const isLoadingStats = ref(true)
|
||||
const isLoadingRecords = ref(false)
|
||||
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<UsageStatsState>(createDefaultStats())
|
||||
const modelStats = ref<ModelStatsItem[]>([])
|
||||
const providerStats = ref<ProviderStatsItem[]>([])
|
||||
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
|
||||
|
||||
// 记录数据 - 只存储当前页
|
||||
// 记录数据:前后端统一按当前页维护
|
||||
const currentRecords = ref<UsageRecord[]>([])
|
||||
const totalRecords = ref(0)
|
||||
|
||||
@@ -56,195 +44,93 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
let loadRecordsRequestId = 0
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
const availableUsers = ref<AnalyticsFilterOption[]>([])
|
||||
const availableModels = ref<AnalyticsFilterOption[]>([])
|
||||
const availableProviders = ref<AnalyticsFilterOption[]>([])
|
||||
const availableApiKeys = ref<AnalyticsFilterOption[]>([])
|
||||
const availableApiFormats = ref<AnalyticsFilterOption[]>([])
|
||||
const availableStatuses = ref<AnalyticsFilterOption[]>([])
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
return modelStats.value.map(model => ({
|
||||
...model,
|
||||
costPerToken: model.total_tokens > 0
|
||||
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
|
||||
: '-'
|
||||
}))
|
||||
})
|
||||
function mapAnalyticsRecord(record: AnalyticsRecord): UsageRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
user_id: record.user_id || undefined,
|
||||
username: record.username || undefined,
|
||||
api_key: {
|
||||
id: record.api_key_id,
|
||||
name: record.api_key_name,
|
||||
display: null,
|
||||
},
|
||||
provider: record.provider_name,
|
||||
api_key_name: record.provider_api_key_name || undefined,
|
||||
rate_multiplier: record.rate_multiplier,
|
||||
model: record.model,
|
||||
target_model: record.target_model,
|
||||
api_format: record.api_format || undefined,
|
||||
endpoint_api_format: undefined,
|
||||
has_format_conversion: record.has_format_conversion ?? undefined,
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cache_creation_input_tokens: record.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: record.cache_read_input_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
cost: record.total_cost_usd,
|
||||
actual_cost: record.actual_total_cost_usd,
|
||||
response_time_ms: record.response_time_ms ?? undefined,
|
||||
first_byte_time_ms: record.first_byte_time_ms ?? undefined,
|
||||
is_stream: record.is_stream,
|
||||
status_code: record.status_code ?? undefined,
|
||||
error_message: record.error_message ?? undefined,
|
||||
status: record.status as UsageRecord['status'],
|
||||
created_at: record.created_at || '',
|
||||
has_fallback: record.has_fallback ?? undefined,
|
||||
has_retry: record.has_retry ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function buildScope(): AnalyticsScope {
|
||||
if (isAdminPage.value) {
|
||||
return { kind: 'global' }
|
||||
}
|
||||
return { kind: 'me' }
|
||||
}
|
||||
|
||||
function buildAnalyticsFilters(
|
||||
filters?: FilterParams,
|
||||
excludeDimension?: FilterOptionDimension,
|
||||
): NonNullable<AnalyticsBaseRequest['filters']> {
|
||||
return {
|
||||
user_ids: filters?.user_id && excludeDimension !== 'user' ? [filters.user_id] : [],
|
||||
models: filters?.model && excludeDimension !== 'model' ? [filters.model] : [],
|
||||
provider_names: filters?.provider && excludeDimension !== 'provider' ? [filters.provider] : [],
|
||||
api_key_ids: filters?.api_key_id && excludeDimension !== 'api_key' ? [filters.api_key_id] : [],
|
||||
api_formats: filters?.api_format && excludeDimension !== 'api_format' ? [filters.api_format] : [],
|
||||
statuses: filters?.status && excludeDimension !== 'status' ? [filters.status] : [],
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
async function loadStats(dateRange?: DateRangeParams, filters?: FilterParams) {
|
||||
const requestId = ++loadStatsRequestId
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
|
||||
const statsData = await usageApi.getUsageStats(dateRange)
|
||||
const response = await analyticsApi.getFilterOptions({
|
||||
scope: buildScope(),
|
||||
time_range: buildTimeRangeParams(dateRange || currentDateRange.value || {}),
|
||||
filters: buildAnalyticsFilters(filters),
|
||||
})
|
||||
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// statsData may contain additional fields not declared in UsageStats
|
||||
const statsRaw = statsData as Record<string, unknown>
|
||||
stats.value = {
|
||||
total_requests: statsData.total_requests || 0,
|
||||
total_tokens: statsData.total_tokens || 0,
|
||||
total_cost: statsData.total_cost || 0,
|
||||
total_actual_cost: statsData.total_actual_cost,
|
||||
avg_response_time: statsData.avg_response_time || 0,
|
||||
error_count: typeof statsRaw.error_count === 'number' ? statsRaw.error_count : undefined,
|
||||
error_rate: typeof statsRaw.error_rate === 'number' ? statsRaw.error_rate : undefined,
|
||||
cache_stats: statsRaw.cache_stats as UsageStatsState['cache_stats'],
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
}
|
||||
|
||||
const modelData = await usageApi.getUsageByModel(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => {
|
||||
const raw = item as Record<string, unknown>
|
||||
return {
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
|
||||
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
|
||||
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
|
||||
cache_creation_tokens: typeof raw.cache_creation_tokens === 'number' ? raw.cache_creation_tokens : 0,
|
||||
cache_hit_rate: typeof raw.cache_hit_rate === 'number' ? raw.cache_hit_rate : 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
|
||||
}
|
||||
})
|
||||
|
||||
const providerData = await usageApi.getUsageByProvider(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
providerStats.value = providerData.map(item => ({
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
cacheCreationTokens: item.cache_creation_tokens || 0,
|
||||
cacheHitRate: item.cache_hit_rate || 0,
|
||||
totalCost: item.total_cost,
|
||||
actualCost: item.actual_cost,
|
||||
successRate: item.success_rate,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
apiFormatStats.value = apiFormatData.map(item => ({
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
||||
cache_hit_rate: item.cache_hit_rate || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: item.actual_cost,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 从统计数据中提取可用的筛选选项
|
||||
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
|
||||
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
|
||||
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
total_tokens: userData.total_tokens || 0,
|
||||
total_cost: userData.total_cost || 0,
|
||||
total_actual_cost: userData.total_actual_cost,
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item) => ({
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
||||
cache_hit_rate: item.cache_hit_rate || 0,
|
||||
total_cost: item.total_cost_usd || 0,
|
||||
actual_cost: item.actual_total_cost_usd
|
||||
}))
|
||||
|
||||
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
cacheCreationTokens: item.cache_creation_tokens || 0,
|
||||
cacheHitRate: item.cache_hit_rate || 0,
|
||||
totalCost: item.total_cost_usd || 0,
|
||||
successRate: item.success_rate || 0,
|
||||
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
|
||||
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
||||
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
|
||||
|
||||
// 从记录中提取筛选选项
|
||||
const models = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
currentRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
if (record.provider) providers.add(record.provider)
|
||||
})
|
||||
availableModels.value = Array.from(models).sort()
|
||||
availableProviders.value = Array.from(providers).sort()
|
||||
|
||||
// API 格式统计直接使用后端聚合数据
|
||||
apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
cache_creation_tokens: item.cache_creation_tokens || 0,
|
||||
cache_hit_rate: item.cache_hit_rate || 0,
|
||||
total_cost: item.total_cost_usd || 0,
|
||||
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
|
||||
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
availableUsers.value = response.users ?? []
|
||||
availableModels.value = response.models ?? []
|
||||
availableProviders.value = response.providers ?? []
|
||||
availableApiKeys.value = response.api_keys ?? []
|
||||
availableApiFormats.value = response.api_formats ?? []
|
||||
availableStatuses.value = response.statuses ?? []
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
@@ -252,17 +138,17 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (getErrorStatus(error) !== 403) {
|
||||
log.error('加载统计数据失败:', error)
|
||||
}
|
||||
stats.value = createDefaultStats()
|
||||
modelStats.value = []
|
||||
availableUsers.value = []
|
||||
availableModels.value = []
|
||||
availableProviders.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
if (requestId === loadStatsRequestId) {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
availableApiKeys.value = []
|
||||
availableApiFormats.value = []
|
||||
availableStatuses.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载记录(真正的后端分页)
|
||||
// 加载记录:前后端统一由后端分页和筛选
|
||||
async function loadRecords(
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams,
|
||||
@@ -278,53 +164,24 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
currentDateRange.value = dateRange
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
const params: Record<string, unknown> = {
|
||||
limit: pagination.pageSize,
|
||||
offset,
|
||||
...effectiveDateRange
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.search?.trim()) {
|
||||
params.search = filters.search.trim()
|
||||
}
|
||||
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.api_format) {
|
||||
params.api_format = filters.api_format
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
const response = await analyticsApi.getRecords({
|
||||
scope: buildScope(),
|
||||
time_range: buildTimeRangeParams(currentDateRange.value || {}),
|
||||
filters: buildAnalyticsFilters(filters),
|
||||
search: {
|
||||
text: filters?.search || null,
|
||||
},
|
||||
pagination: {
|
||||
limit: pagination.pageSize,
|
||||
offset,
|
||||
},
|
||||
})
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []).map(mapAnalyticsRecord)
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
} catch (error) {
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
@@ -412,33 +269,19 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData(dateRange?: DateRangeParams) {
|
||||
await loadStats(dateRange)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
isLoadingStats,
|
||||
isLoadingRecords,
|
||||
stats,
|
||||
modelStats,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
|
||||
// 筛选选项
|
||||
availableUsers,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
availableApiKeys,
|
||||
availableApiFormats,
|
||||
availableStatuses,
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,3 @@
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
error_count?: number
|
||||
error_rate?: number
|
||||
cache_stats?: {
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost: number
|
||||
cache_read_cost: number
|
||||
}
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
// 模型统计
|
||||
export interface ModelStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost: number
|
||||
actual_cost?: number // 倍率消耗
|
||||
}
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
export interface EnhancedModelStatsItem extends ModelStatsItem {
|
||||
costPerToken: string
|
||||
}
|
||||
|
||||
// 提供商统计
|
||||
export interface ProviderStatsItem {
|
||||
provider: string
|
||||
requests: number
|
||||
totalTokens: number
|
||||
totalInputContext?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheCreationTokens?: number
|
||||
cacheHitRate?: number
|
||||
totalCost: number
|
||||
actualCost?: number
|
||||
successRate: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// API格式统计
|
||||
export interface ApiFormatStatsItem {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost: number
|
||||
actual_cost?: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// 请求记录
|
||||
// 请求状态类型
|
||||
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
export interface UsageRecord {
|
||||
@@ -82,7 +11,7 @@ export interface UsageRecord {
|
||||
display: string | null
|
||||
} | null
|
||||
provider?: string // 仅管理员可见
|
||||
api_key_name?: string
|
||||
api_key_name?: string // 提供商 Key 名称(管理员列使用)
|
||||
rate_multiplier?: number
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
@@ -113,29 +42,13 @@ export interface DateRangeParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
granularity?: 'auto' | 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}
|
||||
|
||||
// 时间段选项
|
||||
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
|
||||
export type PeriodValue = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
|
||||
|
||||
// 筛选状态(简化为常用维度)
|
||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
|
||||
|
||||
// 默认统计状态
|
||||
export function createDefaultStats(): UsageStatsState {
|
||||
return {
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_actual_cost: undefined,
|
||||
avg_response_time: 0,
|
||||
error_count: undefined,
|
||||
error_rate: undefined,
|
||||
cache_stats: undefined,
|
||||
period_start: '',
|
||||
period_end: ''
|
||||
}
|
||||
}
|
||||
// 筛选状态由后端 filter-options 决定
|
||||
export type FilterStatusValue = string
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
type="text"
|
||||
masked
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
|
||||
@@ -516,6 +516,7 @@ const navigation = computed(() => {
|
||||
items: [
|
||||
{ name: '仪表盘', href: '/dashboard', icon: Home },
|
||||
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
|
||||
{ name: '综合报表', href: '/dashboard/reports', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -529,7 +530,7 @@ const navigation = computed(() => {
|
||||
title: '账户',
|
||||
items: [
|
||||
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
|
||||
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
|
||||
{ name: '使用记录', href: '/dashboard/usage', icon: Activity },
|
||||
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
|
||||
]
|
||||
}
|
||||
@@ -573,10 +574,8 @@ const navigation = computed(() => {
|
||||
title: '概览',
|
||||
items: [
|
||||
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
||||
{ name: '数据分析', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
|
||||
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
|
||||
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-210
@@ -4,10 +4,9 @@
|
||||
*/
|
||||
|
||||
import type { User, LoginResponse } from '@/api/auth'
|
||||
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
||||
import type { User as AdminUser } from '@/api/users'
|
||||
import type { AdminApiKeysResponse } from '@/api/admin'
|
||||
import type { Profile, UsageResponse } from '@/api/me'
|
||||
import type { Profile } from '@/api/me'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
// ========== 用户数据 ==========
|
||||
@@ -128,163 +127,6 @@ export const MOCK_USER_PROFILE: Profile = {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Dashboard 数据 ==========
|
||||
|
||||
export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
||||
stats: [
|
||||
{
|
||||
name: '今日请求',
|
||||
value: '1,234',
|
||||
subValue: '成功率 99.2%',
|
||||
change: '+12.5%',
|
||||
changeType: 'increase',
|
||||
icon: 'Activity'
|
||||
},
|
||||
{
|
||||
name: '今日 Token',
|
||||
value: '2.5M',
|
||||
subValue: '输入 1.8M / 输出 0.7M',
|
||||
change: '+8.3%',
|
||||
changeType: 'increase',
|
||||
icon: 'Zap'
|
||||
},
|
||||
{
|
||||
name: '今日费用',
|
||||
value: '$45.67',
|
||||
subValue: '节省 $12.34 (21%)',
|
||||
change: '-5.2%',
|
||||
changeType: 'decrease',
|
||||
icon: 'DollarSign'
|
||||
},
|
||||
{
|
||||
name: '活跃用户',
|
||||
value: '28',
|
||||
subValue: '总用户 156',
|
||||
change: '+3',
|
||||
changeType: 'increase',
|
||||
icon: 'Users'
|
||||
}
|
||||
],
|
||||
today: {
|
||||
requests: 1234,
|
||||
tokens: 2500000,
|
||||
cost: 45.67,
|
||||
actual_cost: 33.33,
|
||||
cache_creation_tokens: 50000,
|
||||
cache_read_tokens: 200000
|
||||
},
|
||||
api_keys: {
|
||||
total: 45,
|
||||
active: 38
|
||||
},
|
||||
tokens: {
|
||||
month: 75000000
|
||||
},
|
||||
system_health: {
|
||||
avg_response_time: 1.23,
|
||||
error_rate: 0.8,
|
||||
error_requests: 10,
|
||||
fallback_count: 5,
|
||||
total_requests: 1234
|
||||
},
|
||||
cost_stats: {
|
||||
total_cost: 45.67,
|
||||
total_actual_cost: 33.33,
|
||||
cost_savings: 12.34
|
||||
},
|
||||
cache_stats: {
|
||||
cache_creation_tokens: 50000,
|
||||
cache_read_tokens: 200000,
|
||||
cache_creation_cost: 0.25,
|
||||
cache_read_cost: 0.10,
|
||||
cache_hit_rate: 0.35,
|
||||
total_cache_tokens: 250000
|
||||
},
|
||||
users: {
|
||||
total: 156,
|
||||
active: 28
|
||||
},
|
||||
token_breakdown: {
|
||||
input: 1800000,
|
||||
output: 700000,
|
||||
cache_creation: 50000,
|
||||
cache_read: 200000
|
||||
},
|
||||
// 普通用户专用字段
|
||||
monthly_cost: 45.67
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
||||
]
|
||||
|
||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
||||
{ name: 'Anthropic Official', status: 'active', requests: 456 },
|
||||
{ name: 'OpenAI Official', status: 'active', requests: 389 },
|
||||
{ name: 'Google AI', status: 'active', requests: 234 },
|
||||
{ name: 'AWS Bedrock', status: 'active', requests: 89 },
|
||||
{ name: 'Azure OpenAI', status: 'inactive', requests: 0 },
|
||||
{ name: 'Vertex AI', status: 'active', requests: 66 }
|
||||
]
|
||||
|
||||
// 生成过去7天的每日统计数据
|
||||
function generateDailyStats(): DailyStatsResponse {
|
||||
const dailyStats = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
const baseRequests = 800 + Math.floor(Math.random() * 600)
|
||||
const baseTokens = 1500000 + Math.floor(Math.random() * 1500000)
|
||||
const baseCost = 30 + Math.random() * 30
|
||||
|
||||
dailyStats.push({
|
||||
date: dateStr,
|
||||
requests: baseRequests,
|
||||
tokens: baseTokens,
|
||||
cost: Number(baseCost.toFixed(2)),
|
||||
avg_response_time: 0.8 + Math.random() * 0.8,
|
||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
||||
model_breakdown: [
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
daily_stats: dailyStats,
|
||||
model_summary: [
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||
],
|
||||
period: {
|
||||
start_date: dailyStats[0].date,
|
||||
end_date: dailyStats[dailyStats.length - 1].date,
|
||||
days: 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MOCK_DAILY_STATS = generateDailyStats()
|
||||
|
||||
// ========== 用户管理数据 ==========
|
||||
|
||||
export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
@@ -808,57 +650,6 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
|
||||
}
|
||||
]
|
||||
|
||||
// ========== Usage 数据 ==========
|
||||
|
||||
export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
total_requests: 1234,
|
||||
total_input_tokens: 1800000,
|
||||
total_output_tokens: 700000,
|
||||
total_tokens: 2500000,
|
||||
total_cost: 45.67,
|
||||
total_actual_cost: 33.33,
|
||||
avg_response_time: 1.23,
|
||||
billing: MOCK_USER_BILLING,
|
||||
summary_by_model: [
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 'usage-001',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
input_tokens: 1500,
|
||||
output_tokens: 800,
|
||||
total_tokens: 2300,
|
||||
cost: 0.0165,
|
||||
response_time_ms: 1234,
|
||||
is_stream: true,
|
||||
created_at: new Date().toISOString(),
|
||||
status_code: 200,
|
||||
input_price_per_1m: 3,
|
||||
output_price_per_1m: 15
|
||||
},
|
||||
{
|
||||
id: 'usage-002',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
input_tokens: 2000,
|
||||
output_tokens: 500,
|
||||
total_tokens: 2500,
|
||||
cost: 0.01,
|
||||
response_time_ms: 890,
|
||||
is_stream: false,
|
||||
created_at: new Date(Date.now() - 300000).toISOString(),
|
||||
status_code: 200,
|
||||
input_price_per_1m: 2.5,
|
||||
output_price_per_1m: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ========== 系统配置 ==========
|
||||
|
||||
export const MOCK_SYSTEM_CONFIGS = [
|
||||
|
||||
+1236
-502
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'MyUsage',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'Reports',
|
||||
component: () => importWithRetry(() => import('@/views/user/Reports.vue'))
|
||||
},
|
||||
{
|
||||
path: 'endpoint-status',
|
||||
name: 'EndpointStatus',
|
||||
@@ -196,19 +201,9 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'user-stats',
|
||||
name: 'UserStats',
|
||||
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cost-analysis',
|
||||
name: 'CostAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'performance-analysis',
|
||||
name: 'PerformanceAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
||||
path: 'analytics',
|
||||
name: 'Analytics',
|
||||
component: () => importWithRetry(() => import('@/views/admin/Analytics.vue'))
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
@@ -272,11 +267,6 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'gemini-files',
|
||||
name: 'GeminiFilesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
||||
},
|
||||
// 保留旧路由兼容性
|
||||
{
|
||||
path: 'video-tasks',
|
||||
redirect: '/admin/async-tasks'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cleanAccountBlockReason, isRefreshFailedReason } from '@/utils/accountBlock'
|
||||
|
||||
describe('accountBlock helpers', () => {
|
||||
it('detects refresh failure markers even when account block is also present', () => {
|
||||
expect(
|
||||
isRefreshFailedReason(
|
||||
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps account block reason clean when refresh failure is appended', () => {
|
||||
expect(
|
||||
cleanAccountBlockReason(
|
||||
'[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)\n[REFRESH_FAILED] Token 续期失败',
|
||||
),
|
||||
).toBe('工作区已停用 (deactivated_workspace)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
describe('errorParser', () => {
|
||||
it('normalizes reused refresh token errors from legacy string details', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
detail: "token refresh 失败: {'message': 'Your refresh token has already been used to generate a new access token. Please try signing in again.', 'type': 'invalid_request_error', 'param': None, 'code': 'refresh_token_reused'}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(parseApiError(error, 'Token 刷新失败')).toBe(
|
||||
'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps normalized Chinese refresh failures intact', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
detail: 'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(parseApiError(error, 'Token 刷新失败')).toBe(
|
||||
'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
|
||||
|
||||
describe('getOAuthExpiresCountdown', () => {
|
||||
it('treats invalid reason as invalid even without invalid timestamp', () => {
|
||||
expect(
|
||||
getOAuthExpiresCountdown(
|
||||
Math.floor(Date.now() / 1000) + 3600,
|
||||
0,
|
||||
null,
|
||||
'[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
|
||||
),
|
||||
).toMatchObject({
|
||||
text: '已失效',
|
||||
isInvalid: true,
|
||||
invalidReason: '[REFRESH_FAILED] Token 续期失败 (400): refresh_token_reused',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getOAuthRefreshFeedback,
|
||||
resolveOAuthAccountBlockDisplay,
|
||||
} from '@/utils/oauthRefreshFeedback'
|
||||
|
||||
describe('oauthRefreshFeedback', () => {
|
||||
it('prefers explicit blocked account status from pool data', () => {
|
||||
expect(
|
||||
resolveOAuthAccountBlockDisplay({
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: {
|
||||
code: 'account_disabled',
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'unknown', exhausted: false },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies account-level OAuth invalid reasons', () => {
|
||||
expect(
|
||||
resolveOAuthAccountBlockDisplay({
|
||||
oauth_invalid_reason: '[ACCOUNT_BLOCK] account has been deactivated',
|
||||
}),
|
||||
).toEqual({
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores recoverable refresh failures', () => {
|
||||
expect(
|
||||
resolveOAuthAccountBlockDisplay({
|
||||
oauth_invalid_reason: '[REFRESH_FAILED] Token 续期失败',
|
||||
}),
|
||||
).toEqual({
|
||||
label: null,
|
||||
reason: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports blocked result after successful recheck', () => {
|
||||
expect(
|
||||
getOAuthRefreshFeedback({
|
||||
accountStateRecheckAttempted: true,
|
||||
snapshot: {
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: {
|
||||
code: 'account_disabled',
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'unknown', exhausted: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
tone: 'warning',
|
||||
message: 'Token 刷新成功,已重新检查额度/账号状态;当前状态仍是账号停用',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports plain success when no blocked state remains', () => {
|
||||
expect(
|
||||
getOAuthRefreshFeedback({
|
||||
accountStateRecheckAttempted: true,
|
||||
accountStateRecheckError: null,
|
||||
}),
|
||||
).toEqual({
|
||||
tone: 'success',
|
||||
message: 'Token 刷新成功,已重新检查额度/账号状态',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
describe('providerKeyStatus', () => {
|
||||
it('uses status_snapshot account state as the primary source', () => {
|
||||
expect(
|
||||
getAccountStatusDisplay({
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: {
|
||||
code: 'workspace_deactivated',
|
||||
label: '工作区停用',
|
||||
reason: 'deactivated_workspace',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'ok', exhausted: false },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
code: 'workspace_deactivated',
|
||||
label: '工作区停用',
|
||||
reason: 'deactivated_workspace',
|
||||
blocked: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows oauth invalid when refresh failure exists beside account block', () => {
|
||||
const status = getOAuthStatusDisplay(
|
||||
{
|
||||
auth_type: 'oauth',
|
||||
oauth_expires_at: 2_000_000_000,
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'invalid',
|
||||
reason: 'Token 续期失败 (400): refresh_token_reused',
|
||||
expires_at: 2_000_000_000,
|
||||
requires_reauth: true,
|
||||
},
|
||||
account: {
|
||||
code: 'workspace_deactivated',
|
||||
label: '工作区停用',
|
||||
reason: 'deactivated_workspace',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'ok', exhausted: false },
|
||||
},
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
expect(status).toEqual({
|
||||
text: '已失效',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: true,
|
||||
invalidReason: 'Token 续期失败 (400): refresh_token_reused',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to countdown for account block without oauth invalidation', () => {
|
||||
const status = getOAuthStatusDisplay(
|
||||
{
|
||||
auth_type: 'oauth',
|
||||
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||
},
|
||||
account: {
|
||||
code: 'account_disabled',
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'ok', exhausted: false },
|
||||
},
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
expect(status?.isInvalid).toBe(false)
|
||||
expect(status?.isExpired).toBe(false)
|
||||
expect(getOAuthStatusTitle({
|
||||
auth_type: 'oauth',
|
||||
oauth_expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 3600,
|
||||
},
|
||||
account: {
|
||||
code: 'account_disabled',
|
||||
label: '账号停用',
|
||||
reason: 'account has been deactivated',
|
||||
blocked: true,
|
||||
},
|
||||
quota: { code: 'ok', exhausted: false },
|
||||
},
|
||||
}, 0)).toContain('Token 剩余有效期:')
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,9 @@ const KEYWORDS_TOKEN_INVALID = [
|
||||
const KEYWORDS_VERIFICATION = [
|
||||
'validation_required',
|
||||
'verify your account',
|
||||
'需要验证',
|
||||
'验证账号',
|
||||
'验证身份',
|
||||
]
|
||||
|
||||
// 合并的完整列表
|
||||
@@ -69,12 +72,15 @@ export function classifyAccountBlockLabel(reason: string): string {
|
||||
}
|
||||
|
||||
export function cleanAccountBlockReason(reason: string): string {
|
||||
return reason.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '').trim()
|
||||
return reason
|
||||
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
|
||||
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
|
||||
if (!reason) return false
|
||||
return reason.trim().startsWith('[REFRESH_FAILED]')
|
||||
return reason.includes('[REFRESH_FAILED]')
|
||||
}
|
||||
|
||||
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
export type AnalyticsGranularityOption = NonNullable<DateRangeParams['granularity']>
|
||||
export type ResolvedAnalyticsGranularity = Exclude<AnalyticsGranularityOption, 'auto'>
|
||||
|
||||
export const analyticsGranularityTabs: Array<{ value: AnalyticsGranularityOption; label: string }> = [
|
||||
{ value: 'auto', label: '自动' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'day', label: '天' },
|
||||
{ value: 'week', label: '周' },
|
||||
{ value: 'month', label: '月' },
|
||||
]
|
||||
|
||||
export function parseAnalyticsDateKey(dateKey?: string): Date | null {
|
||||
if (!dateKey) return null
|
||||
return new Date(`${dateKey}T00:00:00`)
|
||||
}
|
||||
|
||||
export function resolveAnalyticsRangeBounds(range: DateRangeParams): { startDate: Date | null; endDate: Date | null } {
|
||||
const today = new Date()
|
||||
|
||||
switch (range.preset) {
|
||||
case 'today':
|
||||
return {
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
}
|
||||
case 'last7days':
|
||||
return {
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
}
|
||||
case 'last30days':
|
||||
return {
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
}
|
||||
case 'last180days':
|
||||
return {
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 179),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
}
|
||||
case 'last1year':
|
||||
return {
|
||||
startDate: new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
startDate: parseAnalyticsDateKey(range.start_date),
|
||||
endDate: parseAnalyticsDateKey(range.end_date),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsRangeDaysInclusive(range: DateRangeParams): number | null {
|
||||
const { startDate, endDate } = resolveAnalyticsRangeBounds(range)
|
||||
if (!startDate || !endDate) return null
|
||||
const diff = endDate.getTime() - startDate.getTime()
|
||||
if (diff < 0) return null
|
||||
return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1
|
||||
}
|
||||
|
||||
export function isAnalyticsSingleDayRange(range: DateRangeParams): boolean {
|
||||
return getAnalyticsRangeDaysInclusive(range) === 1
|
||||
}
|
||||
|
||||
export function resolveAnalyticsAutoGranularity(range: DateRangeParams): ResolvedAnalyticsGranularity {
|
||||
if (range.granularity && range.granularity !== 'auto') {
|
||||
if (range.granularity === 'hour') {
|
||||
return isAnalyticsSingleDayRange(range) ? 'hour' : 'day'
|
||||
}
|
||||
return range.granularity
|
||||
}
|
||||
|
||||
const rangeDays = getAnalyticsRangeDaysInclusive(range)
|
||||
if (rangeDays == null) return 'day'
|
||||
if (rangeDays === 1) return 'hour'
|
||||
if (rangeDays > 180) return 'month'
|
||||
if (rangeDays > 31) return 'week'
|
||||
return 'day'
|
||||
}
|
||||
|
||||
export function formatAnalyticsTrendDescription(granularity: ResolvedAnalyticsGranularity, metricsLabel: string): string {
|
||||
if (granularity === 'hour') {
|
||||
return `按小时观察今天截至当前时刻的${metricsLabel}`
|
||||
}
|
||||
if (granularity === 'week') {
|
||||
return `按周观察${metricsLabel}`
|
||||
}
|
||||
if (granularity === 'month') {
|
||||
return `按月观察${metricsLabel}`
|
||||
}
|
||||
return `按日观察${metricsLabel}`
|
||||
}
|
||||
|
||||
export function formatAnalyticsTrendBucketLabel(
|
||||
bucketStart: string,
|
||||
granularity: ResolvedAnalyticsGranularity,
|
||||
): string {
|
||||
if (granularity === 'hour') {
|
||||
return `${bucketStart.slice(11, 13)}:00`
|
||||
}
|
||||
if (granularity === 'month') {
|
||||
return bucketStart.slice(0, 7)
|
||||
}
|
||||
return bucketStart.slice(5, 10)
|
||||
}
|
||||
|
||||
function formatAnalyticsDateRangeLabel(start: Date, end: Date): string {
|
||||
const startLabel = `${String(start.getMonth() + 1).padStart(2, '0')}/${String(start.getDate()).padStart(2, '0')}`
|
||||
const endLabel = `${String(end.getMonth() + 1).padStart(2, '0')}/${String(end.getDate()).padStart(2, '0')}`
|
||||
return `${startLabel} - ${endLabel}`
|
||||
}
|
||||
|
||||
export function formatAnalyticsTrendTooltipTitle(
|
||||
bucketStart: string,
|
||||
granularity: ResolvedAnalyticsGranularity,
|
||||
): string {
|
||||
if (granularity === 'hour') {
|
||||
return `${bucketStart.slice(0, 10)} ${bucketStart.slice(11, 13)}:00`
|
||||
}
|
||||
|
||||
if (granularity === 'month') {
|
||||
return bucketStart.slice(0, 7)
|
||||
}
|
||||
|
||||
if (granularity === 'week') {
|
||||
const start = parseAnalyticsDateKey(bucketStart.slice(0, 10))
|
||||
if (!start) return bucketStart.slice(0, 10)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 6)
|
||||
return formatAnalyticsDateRangeLabel(start, end)
|
||||
}
|
||||
|
||||
return bucketStart.slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { AnalyticsGranularity, AnalyticsSummary, AnalyticsTimeRange, AnalyticsTimeseriesBucket } from '@/api/analytics'
|
||||
|
||||
type DailyPreset = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
|
||||
|
||||
const ZERO_ANALYTICS_SUMMARY: AnalyticsSummary = {
|
||||
requests_total: 0,
|
||||
requests_success: 0,
|
||||
requests_error: 0,
|
||||
requests_stream: 0,
|
||||
success_rate: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
input_output_total_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_creation_input_tokens_5m: 0,
|
||||
cache_creation_input_tokens_1h: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
input_context_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cache_hit_rate: 0,
|
||||
input_cost_usd: 0,
|
||||
output_cost_usd: 0,
|
||||
cache_creation_cost_usd: 0,
|
||||
cache_creation_cost_usd_5m: 0,
|
||||
cache_creation_cost_usd_1h: 0,
|
||||
cache_read_cost_usd: 0,
|
||||
cache_cost_usd: 0,
|
||||
request_cost_usd: 0,
|
||||
total_cost_usd: 0,
|
||||
actual_total_cost_usd: 0,
|
||||
actual_cache_cost_usd: 0,
|
||||
avg_response_time_ms: 0,
|
||||
avg_first_byte_time_ms: 0,
|
||||
format_conversion_count: 0,
|
||||
models_used_count: 0,
|
||||
}
|
||||
|
||||
function parseDateKey(dateKey: string | undefined): Date | null {
|
||||
if (!dateKey) return null
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey.trim())
|
||||
if (!match) return null
|
||||
const [, year, month, day] = match
|
||||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
}
|
||||
|
||||
function formatDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function formatLocalDateKey(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatHourKey(dateKey: string, hour: number): string {
|
||||
return `${dateKey}T${String(hour).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getStartOfWeekDateKey(date: Date): string {
|
||||
const weekday = date.getUTCDay()
|
||||
const diff = weekday === 0 ? -6 : 1 - weekday
|
||||
const result = new Date(date)
|
||||
result.setUTCDate(result.getUTCDate() + diff)
|
||||
return formatDateKey(result)
|
||||
}
|
||||
|
||||
function getStartOfMonthDateKey(date: Date): string {
|
||||
const result = new Date(date)
|
||||
result.setUTCDate(1)
|
||||
return formatDateKey(result)
|
||||
}
|
||||
|
||||
function getNextDateKey(dateKey: string): string {
|
||||
const date = parseDateKey(dateKey)
|
||||
if (!date) return dateKey
|
||||
date.setUTCDate(date.getUTCDate() + 1)
|
||||
return formatDateKey(date)
|
||||
}
|
||||
|
||||
function getNextWeekKey(dateKey: string): string {
|
||||
const date = parseDateKey(dateKey)
|
||||
if (!date) return dateKey
|
||||
date.setUTCDate(date.getUTCDate() + 7)
|
||||
return formatDateKey(date)
|
||||
}
|
||||
|
||||
function getNextMonthKey(dateKey: string): string {
|
||||
const date = parseDateKey(dateKey)
|
||||
if (!date) return dateKey
|
||||
date.setUTCMonth(date.getUTCMonth() + 1, 1)
|
||||
return formatDateKey(date)
|
||||
}
|
||||
|
||||
function resolvePresetDateRange(preset?: string): { start_date: string; end_date: string } | null {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
const endDate = new Date(now)
|
||||
|
||||
switch (preset as DailyPreset | undefined) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'last7days':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)
|
||||
break
|
||||
case 'last30days':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)
|
||||
break
|
||||
case 'last180days':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 179)
|
||||
break
|
||||
case 'last1year':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: formatLocalDateKey(startDate),
|
||||
end_date: formatLocalDateKey(endDate),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDateBounds(
|
||||
timeRange: AnalyticsTimeRange,
|
||||
fallbackBuckets: AnalyticsTimeseriesBucket[],
|
||||
): { startDate: Date | null; endDate: Date | null } {
|
||||
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||
const fallbackStart = sortedBuckets[0]?.bucket_start.slice(0, 10)
|
||||
const fallbackEnd = sortedBuckets[sortedBuckets.length - 1]?.bucket_start.slice(0, 10)
|
||||
const presetRange = resolvePresetDateRange(timeRange.preset)
|
||||
const startDate = parseDateKey(timeRange.start_date ?? presetRange?.start_date ?? fallbackStart)
|
||||
const endDate = parseDateKey(timeRange.end_date ?? presetRange?.end_date ?? fallbackEnd)
|
||||
return { startDate, endDate }
|
||||
}
|
||||
|
||||
function sortBuckets(buckets: AnalyticsTimeseriesBucket[]): AnalyticsTimeseriesBucket[] {
|
||||
return buckets.slice().sort((left, right) => left.bucket_start.localeCompare(right.bucket_start))
|
||||
}
|
||||
|
||||
function normalizeGranularity(granularity?: string): AnalyticsGranularity {
|
||||
if (granularity === 'hour' || granularity === 'week' || granularity === 'month') {
|
||||
return granularity
|
||||
}
|
||||
return 'day'
|
||||
}
|
||||
|
||||
function resolveTodayDateKey(): string {
|
||||
return formatLocalDateKey(new Date())
|
||||
}
|
||||
|
||||
export function getDateKeysInRange(
|
||||
timeRange: AnalyticsTimeRange,
|
||||
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||
): string[] {
|
||||
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||
|
||||
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||
}
|
||||
|
||||
const dates: string[] = []
|
||||
const cursor = new Date(startDate)
|
||||
|
||||
while (cursor.getTime() <= endDate.getTime()) {
|
||||
dates.push(formatDateKey(cursor))
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
export function getHourKeysInRange(
|
||||
timeRange: AnalyticsTimeRange,
|
||||
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||
): string[] {
|
||||
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||
|
||||
if (!startDate || !endDate || startDate.getTime() !== endDate.getTime()) {
|
||||
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 13))
|
||||
}
|
||||
|
||||
const dateKey = formatDateKey(startDate)
|
||||
const isToday = dateKey === resolveTodayDateKey()
|
||||
const maxHour = isToday ? new Date().getHours() : 23
|
||||
|
||||
return Array.from({ length: maxHour + 1 }, (_, hour) => formatHourKey(dateKey, hour))
|
||||
}
|
||||
|
||||
export function getWeekKeysInRange(
|
||||
timeRange: AnalyticsTimeRange,
|
||||
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||
): string[] {
|
||||
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||
|
||||
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||
}
|
||||
|
||||
const keys: string[] = []
|
||||
let cursorKey = getStartOfWeekDateKey(startDate)
|
||||
const endKey = getStartOfWeekDateKey(endDate)
|
||||
|
||||
while (cursorKey <= endKey) {
|
||||
keys.push(cursorKey)
|
||||
cursorKey = getNextWeekKey(cursorKey)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export function getMonthKeysInRange(
|
||||
timeRange: AnalyticsTimeRange,
|
||||
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
|
||||
): string[] {
|
||||
const sortedBuckets = sortBuckets(fallbackBuckets)
|
||||
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
|
||||
|
||||
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
|
||||
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
|
||||
}
|
||||
|
||||
const keys: string[] = []
|
||||
let cursorKey = getStartOfMonthDateKey(startDate)
|
||||
const endKey = getStartOfMonthDateKey(endDate)
|
||||
|
||||
while (cursorKey <= endKey) {
|
||||
keys.push(cursorKey)
|
||||
cursorKey = getNextMonthKey(cursorKey)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export function createZeroDailyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||
return {
|
||||
...ZERO_ANALYTICS_SUMMARY,
|
||||
bucket_start: `${dateKey}T00:00:00`,
|
||||
bucket_end: `${getNextDateKey(dateKey)}T00:00:00`,
|
||||
}
|
||||
}
|
||||
|
||||
export function createZeroHourlyBucket(hourKey: string): AnalyticsTimeseriesBucket {
|
||||
const dateKey = hourKey.slice(0, 10)
|
||||
const hour = Number(hourKey.slice(11, 13))
|
||||
const nextDate = new Date(Date.UTC(
|
||||
Number(dateKey.slice(0, 4)),
|
||||
Number(dateKey.slice(5, 7)) - 1,
|
||||
Number(dateKey.slice(8, 10)),
|
||||
hour,
|
||||
))
|
||||
nextDate.setUTCHours(nextDate.getUTCHours() + 1)
|
||||
const nextDateKey = formatDateKey(nextDate)
|
||||
const nextHour = String(nextDate.getUTCHours()).padStart(2, '0')
|
||||
|
||||
return {
|
||||
...ZERO_ANALYTICS_SUMMARY,
|
||||
bucket_start: `${hourKey}:00:00`,
|
||||
bucket_end: `${nextDateKey}T${nextHour}:00:00`,
|
||||
}
|
||||
}
|
||||
|
||||
export function createZeroWeeklyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||
return {
|
||||
...ZERO_ANALYTICS_SUMMARY,
|
||||
bucket_start: `${dateKey}T00:00:00`,
|
||||
bucket_end: `${getNextWeekKey(dateKey)}T00:00:00`,
|
||||
}
|
||||
}
|
||||
|
||||
export function createZeroMonthlyBucket(dateKey: string): AnalyticsTimeseriesBucket {
|
||||
return {
|
||||
...ZERO_ANALYTICS_SUMMARY,
|
||||
bucket_start: `${dateKey}T00:00:00`,
|
||||
bucket_end: `${getNextMonthKey(dateKey)}T00:00:00`,
|
||||
}
|
||||
}
|
||||
|
||||
export function fillMissingDailyTimeseriesBuckets(
|
||||
buckets: AnalyticsTimeseriesBucket[],
|
||||
timeRange: AnalyticsTimeRange,
|
||||
): AnalyticsTimeseriesBucket[] {
|
||||
const sortedBuckets = sortBuckets(buckets)
|
||||
const dateKeys = getDateKeysInRange(timeRange, sortedBuckets)
|
||||
|
||||
if (!dateKeys.length) {
|
||||
return sortedBuckets
|
||||
}
|
||||
|
||||
const bucketByDate = new Map(
|
||||
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||
)
|
||||
|
||||
return dateKeys.map(dateKey => bucketByDate.get(dateKey) ?? createZeroDailyBucket(dateKey))
|
||||
}
|
||||
|
||||
export function fillMissingWeeklyTimeseriesBuckets(
|
||||
buckets: AnalyticsTimeseriesBucket[],
|
||||
timeRange: AnalyticsTimeRange,
|
||||
): AnalyticsTimeseriesBucket[] {
|
||||
const sortedBuckets = sortBuckets(buckets)
|
||||
const weekKeys = getWeekKeysInRange(timeRange, sortedBuckets)
|
||||
|
||||
if (!weekKeys.length) {
|
||||
return sortedBuckets
|
||||
}
|
||||
|
||||
const bucketByWeek = new Map(
|
||||
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||
)
|
||||
|
||||
return weekKeys.map(weekKey => bucketByWeek.get(weekKey) ?? createZeroWeeklyBucket(weekKey))
|
||||
}
|
||||
|
||||
export function fillMissingMonthlyTimeseriesBuckets(
|
||||
buckets: AnalyticsTimeseriesBucket[],
|
||||
timeRange: AnalyticsTimeRange,
|
||||
): AnalyticsTimeseriesBucket[] {
|
||||
const sortedBuckets = sortBuckets(buckets)
|
||||
const monthKeys = getMonthKeysInRange(timeRange, sortedBuckets)
|
||||
|
||||
if (!monthKeys.length) {
|
||||
return sortedBuckets
|
||||
}
|
||||
|
||||
const bucketByMonth = new Map(
|
||||
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
|
||||
)
|
||||
|
||||
return monthKeys.map(monthKey => bucketByMonth.get(monthKey) ?? createZeroMonthlyBucket(monthKey))
|
||||
}
|
||||
|
||||
export function fillMissingTimeseriesBuckets(
|
||||
buckets: AnalyticsTimeseriesBucket[],
|
||||
timeRange: AnalyticsTimeRange,
|
||||
): AnalyticsTimeseriesBucket[] {
|
||||
const granularity = normalizeGranularity(timeRange.granularity)
|
||||
const sortedBuckets = sortBuckets(buckets)
|
||||
|
||||
if (granularity === 'week') {
|
||||
return fillMissingWeeklyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||
}
|
||||
|
||||
if (granularity === 'month') {
|
||||
return fillMissingMonthlyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||
}
|
||||
|
||||
if (granularity !== 'hour') {
|
||||
return fillMissingDailyTimeseriesBuckets(sortedBuckets, timeRange)
|
||||
}
|
||||
|
||||
const hourKeys = getHourKeysInRange(timeRange, sortedBuckets)
|
||||
|
||||
if (!hourKeys.length) {
|
||||
return sortedBuckets
|
||||
}
|
||||
|
||||
const bucketByHour = new Map(
|
||||
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 13), bucket]),
|
||||
)
|
||||
|
||||
return hourKeys.map(hourKey => bucketByHour.get(hourKey) ?? createZeroHourlyBucket(hourKey))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
const COLOR_KEYS = new Set([
|
||||
'backgroundColor',
|
||||
'borderColor',
|
||||
'color',
|
||||
'pointBackgroundColor',
|
||||
'pointBorderColor',
|
||||
'pointHoverBackgroundColor',
|
||||
'pointHoverBorderColor',
|
||||
'hoverBackgroundColor',
|
||||
'hoverBorderColor',
|
||||
'tickColor',
|
||||
])
|
||||
|
||||
const CSS_COLOR_PATTERN = /(var\(|oklch\(|hsl\(|hsla\(|rgb\(|rgba\()/i
|
||||
const HSL_VAR_PATTERN = /^hsl\(var\((--[^)]+)\)(?:\s*\/\s*([^)]+))?\)$/i
|
||||
|
||||
let resolverElement: HTMLSpanElement | null = null
|
||||
|
||||
function getResolverElement(): HTMLSpanElement | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
if (resolverElement && resolverElement.isConnected) return resolverElement
|
||||
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute('aria-hidden', 'true')
|
||||
element.style.position = 'fixed'
|
||||
element.style.pointerEvents = 'none'
|
||||
element.style.opacity = '0'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.inset = '-9999px'
|
||||
document.body.appendChild(element)
|
||||
resolverElement = element
|
||||
return resolverElement
|
||||
}
|
||||
|
||||
export function resolveCssColor(value: string): string {
|
||||
if (!CSS_COLOR_PATTERN.test(value)) return value
|
||||
|
||||
const hslVarMatch = value.trim().match(HSL_VAR_PATTERN)
|
||||
if (hslVarMatch) {
|
||||
const [, variableName, alphaValue] = hslVarMatch
|
||||
const baseColor = resolveCssColor(`var(${variableName})`)
|
||||
if (!alphaValue) return baseColor
|
||||
return applyAlpha(baseColor, alphaValue)
|
||||
}
|
||||
|
||||
const element = getResolverElement()
|
||||
if (!element) return value
|
||||
|
||||
element.style.color = ''
|
||||
element.style.color = value
|
||||
if (!element.style.color) {
|
||||
return value
|
||||
}
|
||||
const resolved = window.getComputedStyle(element).color.trim()
|
||||
|
||||
return resolved || value
|
||||
}
|
||||
|
||||
function applyAlpha(color: string, alphaValue: string): string {
|
||||
const alpha = parseAlpha(alphaValue)
|
||||
if (alpha == null) return color
|
||||
|
||||
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/i)
|
||||
if (!rgbMatch) return color
|
||||
|
||||
const parts = rgbMatch[1].split(',').map(part => part.trim())
|
||||
if (parts.length < 3) return color
|
||||
|
||||
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`
|
||||
}
|
||||
|
||||
function parseAlpha(value: string): number | null {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.endsWith('%')) {
|
||||
const percentage = Number.parseFloat(trimmed.slice(0, -1))
|
||||
return Number.isFinite(percentage) ? percentage / 100 : null
|
||||
}
|
||||
|
||||
const numeric = Number.parseFloat(trimmed)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isColorKey(key: string | undefined): boolean {
|
||||
if (!key) return false
|
||||
return COLOR_KEYS.has(key) || /color$/i.test(key)
|
||||
}
|
||||
|
||||
function resolveValue(value: unknown, key?: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return isColorKey(key) ? resolveCssColor(value) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => resolveValue(item, key))
|
||||
}
|
||||
|
||||
if (!isObject(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {}
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
output[childKey] = resolveValue(childValue, childKey)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function resolveChartTheme<T>(value: T): T {
|
||||
return resolveValue(value) as T
|
||||
}
|
||||
|
||||
export function observeChartThemeChanges(callback: () => void): () => void {
|
||||
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => callback())
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'theme-mode'],
|
||||
})
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 客户端 CSV 导出工具
|
||||
* 将数据转为 CSV 并触发浏览器下载
|
||||
*/
|
||||
|
||||
function escapeCsvField(value: string | number | null | undefined): string {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 CSV 内容并触发下载
|
||||
* @param filename 文件名(不含扩展名)
|
||||
* @param headers 列标题
|
||||
* @param rows 数据行
|
||||
*/
|
||||
export function downloadCsv(
|
||||
filename: string,
|
||||
headers: string[],
|
||||
rows: (string | number | null | undefined)[][],
|
||||
): void {
|
||||
const bom = '\uFEFF' // UTF-8 BOM,确保 Excel 正确识别中文
|
||||
const headerLine = headers.map(escapeCsvField).join(',')
|
||||
const dataLines = rows.map(row => row.map(escapeCsvField).join(','))
|
||||
const csv = bom + [headerLine, ...dataLines].join('\n')
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${filename}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象数组生成 CSV 下载
|
||||
* @param filename 文件名
|
||||
* @param data 对象数组
|
||||
* @param columns 列定义 [{ key, label }]
|
||||
*/
|
||||
export function downloadCsvFromObjects<T extends Record<string, unknown>>(
|
||||
filename: string,
|
||||
data: T[],
|
||||
columns: { key: string; label: string }[],
|
||||
): void {
|
||||
const headers = columns.map(c => c.label)
|
||||
const rows = data.map(item =>
|
||||
columns.map(c => item[c.key] as string | number | null),
|
||||
)
|
||||
downloadCsv(filename, headers, rows)
|
||||
}
|
||||
@@ -133,6 +133,30 @@ function formatValidationError(error: ValidationError): string {
|
||||
return `${fieldName}: ${error.msg}`
|
||||
}
|
||||
|
||||
function normalizeKnownApiErrorMessage(message: string): string {
|
||||
const text = message.trim()
|
||||
if (!text) return text
|
||||
|
||||
const lowered = text.toLowerCase()
|
||||
if (
|
||||
lowered.includes('refresh_token_reused')
|
||||
|| lowered.includes('already been used to generate a new access token')
|
||||
) {
|
||||
return 'Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权'
|
||||
}
|
||||
|
||||
if (
|
||||
lowered.includes('token refresh 失败:')
|
||||
|| lowered.includes('token refresh failed:')
|
||||
) {
|
||||
return text
|
||||
.replace(/^token refresh 失败:\s*/i, 'Token 刷新失败:')
|
||||
.replace(/^token refresh failed:\s*/i, 'Token 刷新失败:')
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 API 错误响应
|
||||
* @param err 错误对象
|
||||
@@ -145,7 +169,7 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
||||
// 处理网络错误
|
||||
if (!isApiError(err) || !err.response) {
|
||||
if (err instanceof Error) {
|
||||
return err.message || defaultMessage
|
||||
return normalizeKnownApiErrorMessage(err.message || defaultMessage)
|
||||
}
|
||||
return '无法连接到服务器,请检查网络连接'
|
||||
}
|
||||
@@ -154,14 +178,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
||||
|
||||
// 1. 处理 {error: {type, message}} 格式(ProxyException 返回格式)
|
||||
if (data?.error?.message) {
|
||||
return data.error.message
|
||||
return normalizeKnownApiErrorMessage(data.error.message)
|
||||
}
|
||||
|
||||
const detail = data?.detail
|
||||
|
||||
// 如果没有 detail 字段
|
||||
if (!detail) {
|
||||
return data?.message || err.message || defaultMessage
|
||||
return normalizeKnownApiErrorMessage(data?.message || err.message || defaultMessage)
|
||||
}
|
||||
|
||||
// 1. 处理 Pydantic 验证错误(数组格式)
|
||||
@@ -174,14 +198,14 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
|
||||
|
||||
// 2. 处理字符串错误
|
||||
if (typeof detail === 'string') {
|
||||
return detail
|
||||
return normalizeKnownApiErrorMessage(detail)
|
||||
}
|
||||
|
||||
// 3. 处理对象错误
|
||||
if (typeof detail === 'object') {
|
||||
// 可能是自定义错误对象
|
||||
if ((detail as Record<string, unknown>).message) {
|
||||
return String((detail as Record<string, unknown>).message)
|
||||
return normalizeKnownApiErrorMessage(String((detail as Record<string, unknown>).message))
|
||||
}
|
||||
// 尝试 JSON 序列化
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
function toFiniteNumber(value: number | string | undefined | null): number | null {
|
||||
if (value === undefined || value === null) return null
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[$,\s]/g, '')
|
||||
|
||||
if (!normalized) return null
|
||||
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
// Token formatting - intelligent display based on value size
|
||||
export function formatTokens(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null || num === 0) {
|
||||
export function formatTokens(num: number | string | undefined | null): string {
|
||||
const normalized = toFiniteNumber(num)
|
||||
if (normalized === null || normalized === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
// For very small values (< 1000), show as is without unit
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
if (normalized < 1000) {
|
||||
return normalized.toString()
|
||||
}
|
||||
|
||||
// For values 1K-999K, show in thousands
|
||||
if (num < 1000000) {
|
||||
const thousands = num / 1000
|
||||
if (normalized < 1000000) {
|
||||
const thousands = normalized / 1000
|
||||
if (thousands >= 100) {
|
||||
return `${Math.round(thousands) }K`
|
||||
} else if (thousands >= 10) {
|
||||
@@ -22,7 +39,7 @@ export function formatTokens(num: number | undefined | null): string {
|
||||
}
|
||||
|
||||
// For values >= 1M, show in millions
|
||||
const millions = num / 1000000
|
||||
const millions = normalized / 1000000
|
||||
if (millions >= 100) {
|
||||
return `${Math.round(millions) }M`
|
||||
} else if (millions >= 10) {
|
||||
@@ -33,53 +50,54 @@ export function formatTokens(num: number | undefined | null): string {
|
||||
}
|
||||
|
||||
// Currency formatting with high precision for small values
|
||||
export function formatCurrency(amount: number | undefined | null): string {
|
||||
if (amount === undefined || amount === null || amount === 0) {
|
||||
export function formatCurrency(amount: number | string | undefined | null): string {
|
||||
const normalized = toFiniteNumber(amount)
|
||||
if (normalized === null || normalized === 0) {
|
||||
return '$0.00'
|
||||
}
|
||||
|
||||
// For very small amounts (< $0.00001), show up to 8 decimal places
|
||||
if (amount > 0 && amount < 0.00001) {
|
||||
const formatted = amount.toFixed(8)
|
||||
if (normalized > 0 && normalized < 0.00001) {
|
||||
const formatted = normalized.toFixed(8)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return `$${ trimmed}`
|
||||
}
|
||||
|
||||
// For small amounts (< $0.0001), show up to 6 decimal places
|
||||
if (amount < 0.0001) {
|
||||
const formatted = amount.toFixed(6)
|
||||
if (normalized < 0.0001) {
|
||||
const formatted = normalized.toFixed(6)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return `$${ trimmed}`
|
||||
}
|
||||
|
||||
// For small amounts (< $0.01), show up to 5 decimal places
|
||||
if (amount < 0.01) {
|
||||
const formatted = amount.toFixed(5)
|
||||
if (normalized < 0.01) {
|
||||
const formatted = normalized.toFixed(5)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return `$${ trimmed}`
|
||||
}
|
||||
|
||||
// For amounts less than $1, show 4 decimal places
|
||||
if (amount < 1) {
|
||||
const formatted = amount.toFixed(4)
|
||||
if (normalized < 1) {
|
||||
const formatted = normalized.toFixed(4)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return `$${ trimmed}`
|
||||
}
|
||||
|
||||
// For amounts $1-$100, show 2-3 decimal places
|
||||
if (amount < 100) {
|
||||
const formatted = amount.toFixed(3)
|
||||
if (normalized < 100) {
|
||||
const formatted = normalized.toFixed(3)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return `$${ trimmed}`
|
||||
}
|
||||
|
||||
// For larger amounts, show 2 decimal places
|
||||
return `$${ amount.toFixed(2)}`
|
||||
return `$${ normalized.toFixed(2)}`
|
||||
}
|
||||
|
||||
// Number formatting with locale support
|
||||
@@ -157,7 +175,8 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
|
||||
// Cache hit rate formatting
|
||||
export function formatHitRate(rate: number | undefined): string {
|
||||
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
|
||||
return `${rate.toFixed(2)}%`
|
||||
const normalized = rate <= 1 ? rate * 100 : rate
|
||||
return `${normalized.toFixed(2)}%`
|
||||
}
|
||||
|
||||
// Rate limit formatting (supports "inherit" semantics: null = inherit system default)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
type ProviderKeyStatusCarrier,
|
||||
} from './providerKeyStatus'
|
||||
|
||||
export interface OAuthAccountBlockDisplay {
|
||||
label: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface OAuthRefreshFeedbackInput {
|
||||
accountStateRecheckAttempted?: boolean | null
|
||||
accountStateRecheckError?: string | null
|
||||
snapshot?: ProviderKeyStatusCarrier | null
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const text = value.trim()
|
||||
return text || null
|
||||
}
|
||||
|
||||
export function resolveOAuthAccountBlockDisplay(
|
||||
snapshot: ProviderKeyStatusCarrier,
|
||||
): OAuthAccountBlockDisplay {
|
||||
const account = getAccountStatusDisplay(snapshot)
|
||||
if (!account.blocked || !account.label) {
|
||||
return { label: null, reason: null }
|
||||
}
|
||||
return {
|
||||
label: account.label,
|
||||
reason: account.reason,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOAuthRefreshFeedback(
|
||||
input: OAuthRefreshFeedbackInput,
|
||||
): { tone: 'success' | 'warning'; message: string } {
|
||||
const blockedLabel = normalizeText(
|
||||
input.snapshot ? getAccountStatusDisplay(input.snapshot).label : null,
|
||||
)
|
||||
const recheckError = normalizeText(input.accountStateRecheckError)
|
||||
|
||||
if (input.accountStateRecheckAttempted) {
|
||||
if (recheckError) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: 'Token 刷新成功,但额度/账号状态复检失败',
|
||||
}
|
||||
}
|
||||
if (blockedLabel) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `Token 刷新成功,已重新检查额度/账号状态;当前状态仍是${blockedLabel}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
tone: 'success',
|
||||
message: 'Token 刷新成功,已重新检查额度/账号状态',
|
||||
}
|
||||
}
|
||||
|
||||
if (blockedLabel) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `Token 刷新成功,但当前状态仍是${blockedLabel}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'success',
|
||||
message: 'Token 刷新成功',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ProviderKeyStatusSnapshot } from '@/api/endpoints/types/statusSnapshot'
|
||||
import { getOAuthExpiresCountdown, type OAuthStatusInfo } from '@/composables/useCountdownTimer'
|
||||
import {
|
||||
classifyAccountBlockLabel,
|
||||
cleanAccountBlockReason,
|
||||
isAccountLevelBlockReason,
|
||||
isRefreshFailedReason,
|
||||
} from './accountBlock'
|
||||
|
||||
export interface ProviderKeyStatusCarrier {
|
||||
auth_type?: string | null
|
||||
oauth_expires_at?: number | null
|
||||
oauth_invalid_at?: number | null // compatibility only
|
||||
oauth_invalid_reason?: string | null // compatibility only
|
||||
account_status_label?: string | null // compatibility only
|
||||
account_status_reason?: string | null // compatibility only
|
||||
account_status_blocked?: boolean | null // compatibility only
|
||||
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||
}
|
||||
|
||||
export interface AccountStatusDisplay {
|
||||
code: string
|
||||
label: string | null
|
||||
reason: string | null
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const text = value.trim()
|
||||
return text || null
|
||||
}
|
||||
|
||||
function buildLegacyAccountStatus(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
|
||||
const explicitLabel = normalizeText(input.account_status_label)
|
||||
if (input.account_status_blocked && explicitLabel) {
|
||||
return {
|
||||
code: 'legacy',
|
||||
label: explicitLabel,
|
||||
reason: normalizeText(input.account_status_reason),
|
||||
blocked: true,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidReason = normalizeText(input.oauth_invalid_reason)
|
||||
if (!invalidReason || !isAccountLevelBlockReason(invalidReason)) {
|
||||
return { code: 'ok', label: null, reason: null, blocked: false }
|
||||
}
|
||||
|
||||
const cleaned = cleanAccountBlockReason(invalidReason) || invalidReason
|
||||
return {
|
||||
code: 'legacy',
|
||||
label: classifyAccountBlockLabel(cleaned || invalidReason),
|
||||
reason: normalizeText(cleaned),
|
||||
blocked: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountStatusDisplay(input: ProviderKeyStatusCarrier): AccountStatusDisplay {
|
||||
const snapshot = input.status_snapshot?.account
|
||||
if (snapshot) {
|
||||
return {
|
||||
code: normalizeText(snapshot.code) || 'ok',
|
||||
label: normalizeText(snapshot.label),
|
||||
reason: normalizeText(snapshot.reason),
|
||||
blocked: Boolean(snapshot.blocked),
|
||||
}
|
||||
}
|
||||
return buildLegacyAccountStatus(input)
|
||||
}
|
||||
|
||||
function getSnapshotOAuthState(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): OAuthStatusInfo | null {
|
||||
const oauth = input.status_snapshot?.oauth
|
||||
if (!oauth) return null
|
||||
|
||||
const code = normalizeText(oauth.code) || 'none'
|
||||
const expiresAt = oauth.expires_at ?? input.oauth_expires_at ?? null
|
||||
const reason = normalizeText(oauth.reason)
|
||||
|
||||
if (code === 'invalid') {
|
||||
return {
|
||||
text: '已失效',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: true,
|
||||
invalidReason: reason || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 'expired') {
|
||||
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
|
||||
}
|
||||
|
||||
if (code === 'check_failed') {
|
||||
if (expiresAt == null) return null
|
||||
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
|
||||
}
|
||||
|
||||
if (expiresAt == null) return null
|
||||
return getOAuthExpiresCountdown(expiresAt, tick, null, null)
|
||||
}
|
||||
|
||||
function getLegacyOAuthState(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): OAuthStatusInfo | null {
|
||||
if (normalizeText(input.auth_type) !== 'oauth') return null
|
||||
if (!input.oauth_expires_at && !input.oauth_invalid_at && !input.oauth_invalid_reason) return null
|
||||
|
||||
const rawReason = normalizeText(input.oauth_invalid_reason)
|
||||
if (rawReason && isAccountLevelBlockReason(rawReason) && !isRefreshFailedReason(rawReason)) {
|
||||
if (!input.oauth_expires_at) return null
|
||||
return getOAuthExpiresCountdown(input.oauth_expires_at, tick, null, null)
|
||||
}
|
||||
|
||||
return getOAuthExpiresCountdown(
|
||||
input.oauth_expires_at,
|
||||
tick,
|
||||
input.oauth_invalid_at,
|
||||
input.oauth_invalid_reason,
|
||||
)
|
||||
}
|
||||
|
||||
export function getOAuthStatusDisplay(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): OAuthStatusInfo | null {
|
||||
return getSnapshotOAuthState(input, tick) ?? getLegacyOAuthState(input, tick)
|
||||
}
|
||||
|
||||
export function getOAuthStatusTitle(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): string {
|
||||
const status = getOAuthStatusDisplay(input, tick)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
const reason = normalizeText(status.invalidReason)
|
||||
return reason ? `Token 已失效: ${reason}` : 'Token 已失效'
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
}
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
}
|
||||
|
||||
export function getOAuthRefreshButtonTitle(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): string {
|
||||
const status = getOAuthStatusDisplay(input, tick)
|
||||
if (status?.isInvalid || status?.isExpired) {
|
||||
return '重新授权'
|
||||
}
|
||||
return '刷新 Token'
|
||||
}
|
||||
|
||||
export function getAccountStatusTitle(input: ProviderKeyStatusCarrier): string {
|
||||
const account = getAccountStatusDisplay(input)
|
||||
if (!account.blocked || !account.label) return ''
|
||||
return account.reason ? `${account.label}: ${account.reason}` : account.label
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface DailyUsageBreakdown {
|
||||
date: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheCreationCost: number
|
||||
cacheReadCost: number
|
||||
cacheHitRate: number | null
|
||||
totalCacheCost: number
|
||||
totalCacheTokens: number
|
||||
totalTrackedTokens: number
|
||||
baseTokens: number
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold">
|
||||
数据分析
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用量报表、模型报表与全局性能洞察
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<Tabs v-model="filters.activeTab.value">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TabsList class="tabs-button-list">
|
||||
<TabsTrigger value="detail">
|
||||
用量报表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
模型报表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="leaderboard">
|
||||
排行榜
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="performance">
|
||||
性能
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<TimeRangePicker
|
||||
v-model="filters.timeRange.value"
|
||||
:show-granularity="false"
|
||||
:include-auto-granularity="true"
|
||||
:compact="true"
|
||||
/>
|
||||
<template v-if="filters.activeTab.value === 'leaderboard'">
|
||||
<Select v-model="leaderboardDimension">
|
||||
<SelectTrigger class="h-8 w-full text-xs sm:w-32">
|
||||
<SelectValue placeholder="维度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="users">
|
||||
用户
|
||||
</SelectItem>
|
||||
<SelectItem value="api_keys">
|
||||
API Key
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
<template v-else-if="showEntityFilters">
|
||||
<Select v-model="selectedUserValue">
|
||||
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
|
||||
<SelectValue placeholder="全部用户">
|
||||
<span
|
||||
class="block min-w-0 truncate"
|
||||
:title="selectedUserLabel"
|
||||
>
|
||||
{{ selectedUserLabel }}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
|
||||
<SelectItem value="__all__">
|
||||
全部用户
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="option in userOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:text-value="option.label"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="selectedApiKeyValue">
|
||||
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
|
||||
<SelectValue placeholder="全部 Key">
|
||||
<span
|
||||
class="block min-w-0 truncate"
|
||||
:title="selectedApiKeyLabel"
|
||||
>
|
||||
{{ selectedApiKeyLabel }}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
|
||||
<SelectItem value="__all__">
|
||||
全部 Key
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="option in apiKeyOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:text-value="option.label"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<ReportsTab
|
||||
v-if="isReportsTab"
|
||||
:mode="reportMode"
|
||||
:selected-user-label="selectedUserExportLabel"
|
||||
:selected-api-key-label="selectedApiKeyExportLabel"
|
||||
/>
|
||||
<LeaderboardTab
|
||||
v-else-if="filters.activeTab.value === 'leaderboard'"
|
||||
:dimension="leaderboardDimension"
|
||||
/>
|
||||
<PerformanceTab v-else-if="filters.activeTab.value === 'performance'" />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref, watch, onMounted } from 'vue'
|
||||
import { Tabs, TabsList, TabsTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
|
||||
import { useAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||
import type { LeaderboardDimension } from '@/composables/analytics/useLeaderboardData'
|
||||
import ReportsTab from './components/analytics/ReportsTab.vue'
|
||||
import LeaderboardTab from './components/analytics/LeaderboardTab.vue'
|
||||
import PerformanceTab from './components/analytics/PerformanceTab.vue'
|
||||
|
||||
const filters = useAnalyticsFilters()
|
||||
provide('analyticsFilters', filters)
|
||||
|
||||
const userOptions = ref<AnalyticsFilterOption[]>([])
|
||||
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||
const leaderboardDimension = ref<LeaderboardDimension>('users')
|
||||
const supportedTabs = new Set(['detail', 'models', 'leaderboard', 'performance'])
|
||||
const isReportsTab = computed(() => filters.activeTab.value === 'detail' || filters.activeTab.value === 'models')
|
||||
const showEntityFilters = computed(() => isReportsTab.value)
|
||||
const reportMode = computed<'detail' | 'models'>(() => (
|
||||
filters.activeTab.value === 'models' ? 'models' : 'detail'
|
||||
))
|
||||
let suppressNextUserFilterLoad = false
|
||||
let suppressNextApiKeyFilterLoad = false
|
||||
const selectedUserValue = computed({
|
||||
get: () => filters.userFilter.value[0] ?? '__all__',
|
||||
set: (value: string) => {
|
||||
filters.userFilter.value = value && value !== '__all__' ? [value] : []
|
||||
},
|
||||
})
|
||||
const selectedUserLabel = computed(() => (
|
||||
selectedUserValue.value === '__all__'
|
||||
? '全部用户'
|
||||
: userOptions.value.find(option => option.value === selectedUserValue.value)?.label || '全部用户'
|
||||
))
|
||||
const selectedApiKeyValue = computed({
|
||||
get: () => filters.apiKeyFilter.value[0] ?? '__all__',
|
||||
set: (value: string) => {
|
||||
filters.apiKeyFilter.value = value && value !== '__all__' ? [value] : []
|
||||
},
|
||||
})
|
||||
const selectedApiKeyLabel = computed(() => (
|
||||
selectedApiKeyValue.value === '__all__'
|
||||
? '全部 Key'
|
||||
: apiKeyOptions.value.find(option => option.value === selectedApiKeyValue.value)?.label || '全部 Key'
|
||||
))
|
||||
const selectedUserExportLabel = computed(() => (
|
||||
selectedUserValue.value === '__all__' ? '' : selectedUserLabel.value
|
||||
))
|
||||
const selectedApiKeyExportLabel = computed(() => (
|
||||
selectedApiKeyValue.value === '__all__' ? '' : selectedApiKeyLabel.value
|
||||
))
|
||||
|
||||
function hasOption(options: AnalyticsFilterOption[], value: string): boolean {
|
||||
return options.some(option => option.value === value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filters.activeTab.value,
|
||||
(tab) => {
|
||||
if (!supportedTabs.has(tab)) {
|
||||
filters.activeTab.value = 'detail'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFilterOptions() {
|
||||
if (!showEntityFilters.value) return
|
||||
const response = await analyticsApi.getFilterOptions({
|
||||
scope: { kind: 'global' },
|
||||
time_range: filters.getTimeRangeParams(),
|
||||
filters: {
|
||||
user_ids: filters.userFilter.value,
|
||||
api_key_ids: filters.apiKeyFilter.value,
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
if (!response) return
|
||||
userOptions.value = response.users ?? []
|
||||
apiKeyOptions.value = response.api_keys ?? []
|
||||
|
||||
if (filters.userFilter.value.length > 0 && !hasOption(userOptions.value, filters.userFilter.value[0])) {
|
||||
suppressNextUserFilterLoad = true
|
||||
filters.userFilter.value = []
|
||||
}
|
||||
if (filters.apiKeyFilter.value.length > 0 && !hasOption(apiKeyOptions.value, filters.apiKeyFilter.value[0])) {
|
||||
suppressNextApiKeyFilterLoad = true
|
||||
filters.apiKeyFilter.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
showEntityFilters.value,
|
||||
filters.getTimeRangeParams(),
|
||||
filters.userFilter.value.slice(),
|
||||
filters.apiKeyFilter.value.slice(),
|
||||
],
|
||||
([shouldShow]) => {
|
||||
if (!shouldShow) return
|
||||
if (suppressNextUserFilterLoad) {
|
||||
suppressNextUserFilterLoad = false
|
||||
return
|
||||
}
|
||||
if (suppressNextApiKeyFilterLoad) {
|
||||
suppressNextApiKeyFilterLoad = false
|
||||
return
|
||||
}
|
||||
void loadFilterOptions()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (showEntityFilters.value) {
|
||||
void loadFilterOptions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user