feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)

错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab)
对齐用量明细的交互与信息密度。Squash of:

- feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列)
- refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort
- feat(usage): /admin/usage 错误请求新增分类过滤
- fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in
- fix(ops): 错误列表用户列回退显示已删除 KEY 所有者
- fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
This commit is contained in:
DaydreamCoding
2026-07-03 23:02:27 +08:00
parent 2649573b99
commit ebbdc70311
25 changed files with 1108 additions and 459 deletions
+41 -8
View File
@@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
}
// GetErrorLogs lists ops error logs.
// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
// Column whitelist and order normalization live in the repository; unknown
// values degrade to the default (created_at DESC), mirroring the usage list.
func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
}
// GET /api/v1/admin/ops/errors
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
if h.opsService == nil {
@@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
}
filter.View = "all"
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
filter.ClientRequestID = clientRequestID
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.View = parseOpsViewParam(c)
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -322,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) {
filter.ErrorTypesAny = types
}
// 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -85,10 +85,21 @@ func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) {
t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where)
}
// phase=upstream skips the status guard entirely — exemption is irrelevant there.
// phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard:
// request-error list endpoints filter by phase=upstream as a plain condition.
whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"})
if strings.Contains(whereUpstream, "status_code") {
t.Fatalf("upstream phase filter must not add any status_code clause\nfull: %s", whereUpstream)
if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") {
t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream)
}
if !strings.Contains(whereUpstream, "e.error_phase = $") {
t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream)
}
// phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard,
// exposing recovered (<400) upstream rows.
whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true})
if strings.Contains(whereRecovered, "status_code") {
t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered)
}
}
+54 -6
View File
@@ -177,6 +177,37 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any {
}
}
// opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring
// usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is
// always appended as tiebreaker for stable pagination.
func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string {
sortBy := ""
sortOrder := ""
if filter != nil {
sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy))
sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder))
}
var column string
switch sortBy {
case "model":
column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)"
case "status_code":
// 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0),
// status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行
//status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。
column = "COALESCE(e.upstream_status_code, e.status_code, 0)"
default:
column = "e.created_at"
}
dir := "DESC"
if sortOrder == "asc" {
dir = "ASC"
}
return fmt.Sprintf("%s %s, e.id %s", column, dir, dir)
}
func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("nil ops repository")
@@ -233,25 +264,29 @@ SELECT
COALESCE(a.name, ''),
e.group_id,
COALESCE(g.name, ''),
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
COALESCE(e.request_path, ''),
e.stream,
COALESCE(e.inbound_endpoint, ''),
COALESCE(e.upstream_endpoint, ''),
COALESCE(e.requested_model, ''),
COALESCE(e.upstream_model, ''),
COALESCE(e.user_agent, ''),
e.request_type,
COALESCE(ak.name, ''),
ak.deleted_at,
COALESCE(e.deleted_key_name, '')
COALESCE(e.deleted_key_name, ''),
e.deleted_key_owner_user_id,
COALESCE(du.email, '')
FROM ops_error_logs e
LEFT JOIN accounts a ON e.account_id = a.id
LEFT JOIN groups g ON e.group_id = g.id
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
` + where + `
ORDER BY e.created_at DESC
ORDER BY ` + opsErrorLogsOrderBy(filter) + `
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...)
@@ -279,6 +314,8 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
var apiKeyName string
var apiKeyDeletedAt sql.NullTime
var deletedKeyName string
var deletedKeyOwnerID sql.NullInt64
var deletedKeyOwnerEmail string
if err := rows.Scan(
&item.ID,
&item.CreatedAt,
@@ -311,10 +348,13 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
&item.UpstreamEndpoint,
&item.RequestedModel,
&item.UpstreamModel,
&item.UserAgent,
&requestType,
&apiKeyName,
&apiKeyDeletedAt,
&deletedKeyName,
&deletedKeyOwnerID,
&deletedKeyOwnerEmail,
); err != nil {
return nil, err
}
@@ -364,6 +404,12 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
}
// 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。
item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "")
// 已删除 KEY 所有者快照:认证失败行 user_id 为空,列表用户列以此回退。
if deletedKeyOwnerID.Valid {
v := deletedKeyOwnerID.Int64
item.DeletedKeyOwnerUserID = &v
item.DeletedKeyOwnerEmail = deletedKeyOwnerEmail
}
out = append(out, &item)
}
if err := rows.Err(); err != nil {
@@ -417,7 +463,7 @@ SELECT
COALESCE(a.name, ''),
e.group_id,
COALESCE(g.name, ''),
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
COALESCE(e.request_path, ''),
e.stream,
COALESCE(e.inbound_endpoint, ''),
@@ -927,12 +973,14 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
if filter != nil {
resolvedFilter = filter.Resolved
}
// Keep list endpoints scoped to client errors unless explicitly filtering upstream phase.
// Keep list endpoints scoped to client errors unless the caller explicitly opts
// into recovered upstream rows (Phase=="upstream" + IncludeRecoveredUpstream,
// ops 专用上游列表)。请求错误语义的端点即便过滤 phase=upstream 也保留该守卫。
// cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with
// status 200 (the SSE stream opened successfully before upstream returned response.failed),
// but they are always client-visible blocked requests that belong in admin + user error
// lists. Without the exemption the entire streaming-path cyber sink would be invisible.
if phaseFilter != "upstream" {
if phaseFilter != "upstream" || filter == nil || !filter.IncludeRecoveredUpstream {
clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')")
}
+34 -8
View File
@@ -1,6 +1,9 @@
package service
import "time"
import (
"strings"
"time"
)
type OpsSystemLog struct {
ID int64 `json:"id"`
@@ -65,17 +68,22 @@ type OpsErrorLog struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
RequestType *int16 `json:"request_type"`
UserAgent string `json:"user_agent"`
// 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。
APIKeyName string `json:"api_key_name,omitempty"`
APIKeyDeleted bool `json:"api_key_deleted,omitempty"`
// 已删除 KEY 所有者(INVALID_API_KEY 且该 key 曾存在时的归因快照)。
// 认证失败行 user_id 为空,列表用户列以此回退显示所有者。
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
}
type OpsErrorLogDetail struct {
OpsErrorLog
ErrorBody string `json:"error_body"`
UserAgent string `json:"user_agent"`
// Upstream context (optional)
UpstreamStatusCode *int `json:"upstream_status_code,omitempty"`
@@ -93,11 +101,10 @@ type OpsErrorLogDetail struct {
// vNext metric semantics
IsBusinessLimited bool `json:"is_business_limited"`
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted)
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
DeletedKeyName string `json:"deleted_key_name,omitempty"`
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted).
// OwnerUserID/OwnerEmail 已上移到 OpsErrorLog(列表用户列回退需要)。
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
DeletedKeyName string `json:"deleted_key_name,omitempty"`
// Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix.
APIKeyPrefix string `json:"api_key_prefix,omitempty"`
@@ -142,8 +149,14 @@ type OpsErrorLogFilter struct {
// ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true).
ExcludeCountTokens bool
// IncludeRecoveredUpstream 显式豁免 status>=400 守卫(仅在 Phase=="upstream" 时生效):
// ops 专用上游错误列表需要看到 status<400 的 recovered upstream 行。
// 请求错误语义的端点不设此开关,phase=upstream 过滤照常生效且守卫保留。
IncludeRecoveredUpstream bool
// ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the
// special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause).
// special-cased single `Phase` field (only Phase=="upstream" with
// IncludeRecoveredUpstream bypasses the status>=400 clause).
// NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream'
// but status_code<400 (recovered upstream errors) remain excluded.
// Used to map user-facing coarse categories to backend conditions.
@@ -158,6 +171,19 @@ type OpsErrorLogFilter struct {
Page int
PageSize int
// SortBy/SortOrder: server-side sorting aligned with the usage-log list.
// Repo whitelists columns (created_at/model/status_code); anything else
// falls back to created_at. SortOrder is "asc"/"desc" (default desc).
SortBy string
SortOrder string
}
// SetSort normalizes raw sort_by/sort_order query values into the filter.
// Shared by the admin and user-facing error list handlers.
func (f *OpsErrorLogFilter) SetSort(sortBy, sortOrder string) {
f.SortBy = strings.TrimSpace(sortBy)
f.SortOrder = strings.TrimSpace(sortOrder)
}
type OpsErrorLogList struct {
+5 -3
View File
@@ -359,10 +359,12 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi
filter.UserQuery = ""
filter.Owner = ""
filter.Source = ""
// 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。
// 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句),
// 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
// 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny
//纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要
// IncludeRecoveredUpstream(用户端永不设置),recovered upstream
//error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
filter.Phase = ""
filter.IncludeRecoveredUpstream = false
list, err := s.opsRepo.ListErrorLogs(ctx, filter)
if err != nil {
@@ -184,16 +184,16 @@ func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) {
mk := func() *OpsErrorLogDetail {
return &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
DeletedKeyOwnerUserID: &ownerUID,
},
DeletedKeyOwnerUserID: &ownerUID,
}
}
+19 -2
View File
@@ -3,9 +3,12 @@ package service
import "time"
// UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。
// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint /
// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name
// 严禁包含 account / api_key_prefix / upstream_endpoint / user_email 等
// 敏感或内部字段。注:message(网关标准化错误描述)与 key_name
// (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放;
// client_ip / user_agent / group_name / request_type / stream 均为该用户
// 自己请求的属性,经产品决策(2026-07-03)开放,
// 与用量明细已向用户展示自身 ip_address/user_agent/分组/类型 的口径对齐;
// error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。
type UserErrorRequest struct {
ID int64 `json:"id"`
@@ -18,6 +21,11 @@ type UserErrorRequest struct {
Message string `json:"message"`
KeyName string `json:"key_name"`
KeyDeleted bool `json:"key_deleted"`
ClientIP string `json:"client_ip,omitempty"`
GroupName string `json:"group_name,omitempty"`
RequestType *int16 `json:"request_type,omitempty"`
Stream bool `json:"stream"`
UserAgent string `json:"user_agent,omitempty"`
}
// UserErrorRequestList 是用户错误请求分页结果。
@@ -90,6 +98,10 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
if model == "" {
model = e.Model
}
clientIP := ""
if e.ClientIP != nil {
clientIP = *e.ClientIP
}
return &UserErrorRequest{
ID: e.ID,
CreatedAt: e.CreatedAt,
@@ -101,6 +113,11 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
Message: e.Message,
KeyName: e.APIKeyName,
KeyDeleted: e.APIKeyDeleted,
ClientIP: clientIP,
GroupName: e.GroupName,
RequestType: e.RequestType,
Stream: e.Stream,
UserAgent: e.UserAgent,
}
}
@@ -122,9 +122,11 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
UserEmail: "secret@example.com",
ClientIP: func() *string { s := "1.2.3.4"; return &s }(),
UpstreamEndpoint: "https://api.openai.com/v1/chat/completions",
UserAgent: "codex_cli_rs/0.125.0",
GroupName: "grp-a",
Stream: true,
},
ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`,
UserAgent: "Mozilla/5.0 secret-agent",
UpstreamStatusCode: &upstreamStatus,
}
@@ -147,13 +149,27 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
t.Errorf("UpstreamStatusCode mismatch")
}
// client_ip / user_agent / group_name / stream 经产品决策开放(与用量明细口径对齐)
if out.ClientIP != "1.2.3.4" {
t.Errorf("want client_ip=1.2.3.4, got %q", out.ClientIP)
}
if out.UserAgent != "codex_cli_rs/0.125.0" {
t.Errorf("want user_agent=codex_cli_rs/0.125.0, got %q", out.UserAgent)
}
if out.GroupName != "grp-a" {
t.Errorf("want group_name=grp-a, got %q", out.GroupName)
}
if !out.Stream {
t.Errorf("want stream=true")
}
// 序列化后不含敏感字段
b, err := json.Marshal(out)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
raw := string(b)
for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} {
for _, forbidden := range []string{"user_email", "upstream_endpoint"} {
if strings.Contains(raw, forbidden) {
t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw)
}
+14
View File
@@ -57,6 +57,20 @@ if (typeof globalThis.cancelIdleCallback === 'undefined') {
}) as unknown as typeof cancelIdleCallback
}
// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支)
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
window.matchMedia = ((query: string) => ({
matches: true, // 测试默认按桌面视口渲染表格
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
}
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn()
+14 -4
View File
@@ -930,11 +930,16 @@ export interface OpsErrorLog {
requested_model?: string
upstream_model?: string
request_type?: number | null
user_agent?: string
// 已删除 KEY 所有者(INVALID_API_KEY 归因快照):认证失败行 user_id 为空,
// 用户列以此回退显示所有者
deleted_key_owner_user_id?: number | null
deleted_key_owner_email?: string | null
}
export interface OpsErrorDetail extends OpsErrorLog {
error_body: string
user_agent: string
// Upstream context (optional; enriched by gateway services)
upstream_status_code?: number | null
@@ -950,10 +955,9 @@ export interface OpsErrorDetail extends OpsErrorLog {
is_business_limited: boolean
// Deleted key owner info (INVALID_API_KEY attribution)
// Deleted key owner info (INVALID_API_KEY attribution);
// owner user_id/email 已上移到 OpsErrorLog(列表用户列回退)
attempted_key_prefix?: string | null
deleted_key_owner_user_id?: number | null
deleted_key_owner_email?: string | null
deleted_key_name?: string | null
// Bound (non-deleted) key prefix, snapshotted at error time
@@ -1098,6 +1102,8 @@ export type OpsErrorListQueryParams = {
model?: string
phase?: string
// 分类(用户侧粗分类码,如 auth/rate_limit/upstream),后端反查为 phase/type ANY 条件
category?: string
error_owner?: string
error_source?: string
resolved?: string
@@ -1106,6 +1112,10 @@ export type OpsErrorListQueryParams = {
q?: string
status_codes?: string
status_codes_other?: string
// 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// Legacy unified endpoints
+4
View File
@@ -86,6 +86,10 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
billing_mode?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
error_phase?: string | null
error_category?: string | null
status_code?: number | null
}
// ==================== API Functions ====================
@@ -121,24 +121,42 @@
</div>
</div>
<!-- Request Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<!-- Request Type Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<!-- Billing Type Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Billing Mode Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<!-- Billing Mode Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<!-- Error Phase Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
<Select v-model="filters.error_phase" :options="errorPhaseOptions" @change="emitChange" />
</div>
<!-- Error Category Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.errors.category') }}</label>
<Select v-model="filters.error_category" :options="errorCategoryOptions" @change="emitChange" />
</div>
<!-- Status Code Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.status') }}</label>
<Select v-model="filters.status_code" :options="statusCodeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
@@ -156,12 +174,14 @@
{{ t('common.reset') }}
</button>
<slot name="after-reset" />
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
<template v-if="mode !== 'errors'">
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</template>
</div>
</div>
</div>
@@ -172,6 +192,7 @@ import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
type ModelValue = Record<string, any>
@@ -183,10 +204,13 @@ interface Props {
endDate: string
showActions?: boolean
modelOptions?: string[]
/** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */
mode?: 'usage' | 'errors'
}
const props = withDefaults(defineProps<Props>(), {
showActions: true
showActions: true,
mode: 'usage'
})
const emit = defineEmits([
'update:modelValue',
@@ -243,6 +267,29 @@ const billingTypeOptions = ref<SelectOption[]>([
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
])
// 错误类型对应后端 phase 参数(与错误表"类型"徽章同语义)
const errorPhaseOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('admin.usage.allTypes') },
{ value: 'upstream', label: t('admin.ops.errorLog.typeUpstream') },
{ value: 'request', label: t('admin.ops.errorLog.typeRequest') },
{ value: 'auth', label: t('admin.ops.errorLog.typeAuth') },
{ value: 'routing', label: t('admin.ops.errorLog.typeRouting') },
{ value: 'internal', label: t('admin.ops.errorLog.typeInternal') },
])
// 分类码同用户端 /usage 错误筛选;"other" 无法反查为过滤条件,刻意不列
const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
const errorCategoryOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allCategories') },
...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
])
const statusCodeOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allStatuses') },
...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
])
const billingModeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingModes') },
{ value: 'token', label: t('admin.usage.billingModeToken') },
@@ -36,6 +36,8 @@
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
:class="{ 'cursor-pointer': clickableRows }"
@click="clickableRows && emit('rowClick', row)"
>
<div class="space-y-3">
<div
@@ -166,6 +168,8 @@
:data-index="virtualRow.index"
:ref="measureElement"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
:class="{ 'cursor-pointer': clickableRows }"
@click="clickableRows && emit('rowClick', sortedData[virtualRow.index])"
>
<td
v-for="(column, colIndex) in columns"
@@ -214,6 +218,7 @@ const isDesktopViewport = ref(
const emit = defineEmits<{
sort: [key: string, order: 'asc' | 'desc']
rowClick: [row: any]
}>()
// 表格容器引用
@@ -381,6 +386,8 @@ interface Props {
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort?: boolean
/** Emit 'rowClick' on row/card click and show pointer cursor (interactive cells should @click.stop) */
clickableRows?: boolean
/** Estimated row height in px for the virtualizer (default 56) */
estimateRowHeight?: number
/** Number of rows to render beyond the visible area (default 5) */
@@ -0,0 +1,59 @@
<template>
<div
v-if="uniqueIps.length > 0"
class="flex flex-shrink-0 items-center justify-end gap-2 border-b border-gray-200 px-4 py-2 dark:border-dark-700"
>
<span v-if="pendingCount > 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('usage.ipGeo.pending', { count: pendingCount }) }}
</span>
<button
type="button"
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:bg-primary-900/30"
:disabled="loading || pendingCount === 0"
@click="run"
>
{{ loading ? t('usage.ipGeo.batchFetching') : t('usage.ipGeo.batchFetch') }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchBatch, getEntry } from '@/utils/ipGeoLookup'
// 当前页 IP 批量地理查询工具条:传入原始 IP 列表(可含空值),内部去重;
// 无 IP 时自身不渲染。批量失败 emit failed,由使用方弹提示。
const props = defineProps<{
ips: Array<string | null | undefined>
}>()
const emit = defineEmits<{
(e: 'failed'): void
}>()
const { t } = useI18n()
const uniqueIps = computed(() =>
Array.from(new Set(props.ips.filter((ip): ip is string => Boolean(ip))))
)
const pendingCount = computed(() =>
uniqueIps.value.filter((ip) => {
const status = getEntry(ip).status
return status === 'idle' || status === 'error'
}).length
)
const loading = ref(false)
const run = async () => {
loading.value = true
try {
const ok = await fetchBatch(uniqueIps.value)
if (!ok) emit('failed')
} finally {
loading.value = false
}
}
</script>
@@ -1,91 +1,119 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div class="px-6 py-4 flex-shrink-0">
<div class="flex flex-wrap items-end gap-4">
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.errors.model') }}</label>
<Select
v-model="localModel"
:options="modelOptions"
searchable
creatable
clearable
:placeholder="t('usage.errors.modelPlaceholder')"
@change="apply"
/>
</div>
<div class="min-w-[160px]">
<label class="input-label">{{ t('usage.errors.keyName') }}</label>
<Select
v-model="localApiKeyId"
:options="keyOptions"
:placeholder="t('usage.errors.allKeys')"
@change="apply"
/>
</div>
<div class="min-w-[140px]">
<label class="input-label">{{ t('usage.errors.category') }}</label>
<Select
v-model="localCategory"
:options="categoryOptions"
:placeholder="t('usage.errors.allCategories')"
@change="apply"
/>
</div>
<button class="btn btn-primary" @click="apply">
<Icon name="search" size="sm" />
{{ t('common.search') }}
</button>
</div>
</div>
<div class="card flex min-h-0 flex-1 flex-col overflow-hidden">
<IpGeoBatchToolbar :ips="rows.map((r) => r.client_ip)" @failed="emit('ipGeoBatchFailed')" />
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full text-sm">
<thead>
<tr>
<th class="px-4 py-2 text-left">{{ t('usage.errors.model') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.keyName') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.endpoint') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.status') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.category') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.message') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.platform') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.time') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in rows"
:key="i"
class="border-t border-gray-100 dark:border-dark-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-800"
@click="openDetail(row.id)"
>
<td class="px-4 py-2">{{ row.model || '-' }}</td>
<td class="px-4 py-2">
<span>{{ row.key_name || '-' }}</span>
<span
v-if="row.key_deleted"
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400"
>{{ t('usage.errors.keyDeleted') }}</span>
</td>
<td class="px-4 py-2">{{ row.inbound_endpoint || '-' }}</td>
<td class="px-4 py-2"><span class="badge" :class="statusClass(row.status_code)">{{ row.status_code || '-' }}</span></td>
<td class="px-4 py-2">{{ t('usage.errors.categories.' + row.category) }}</td>
<td class="px-4 py-2 max-w-[280px] truncate" :title="row.message">{{ row.message || '-' }}</td>
<td class="px-4 py-2">{{ row.platform || '-' }}</td>
<td class="px-4 py-2">{{ formatDateTime(row.created_at) }}</td>
</tr>
<tr v-if="!loading && rows.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">{{ t('usage.errors.empty') }}</td>
</tr>
</tbody>
</table>
<DataTable
:columns="columns"
:data="rows"
:loading="loading"
clickable-rows
server-side-sort
default-sort-key="created_at"
default-sort-order="desc"
@sort="onSort"
@rowClick="(row) => openDetail(row.id)"
>
<template #cell-model="{ row }">
<span v-if="row.model" class="text-sm font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-key_name="{ row }">
<div class="text-sm">
<span class="text-gray-900 dark:text-white">{{ row.key_name || '-' }}</span>
<span
v-if="row.key_deleted"
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30"
>{{ t('usage.errors.keyDeleted') }}</span>
</div>
</template>
<template #cell-endpoint="{ row }">
<div class="max-w-[320px] text-xs">
<div class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
</div>
</template>
<template #cell-status="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="statusClass(row.status_code)">
{{ row.status_code || '-' }}
</span>
</template>
<template #cell-category="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ t('usage.errors.categories.' + row.category) }}</span>
</template>
<template #cell-message="{ row }">
<span
v-if="row.message"
class="block max-w-[280px] truncate text-sm text-gray-600 dark:text-gray-400"
:title="row.message"
>{{ row.message }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-group="{ row }">
<span
v-if="row.group_name"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>{{ row.group_name }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-type="{ row }">
<span
v-if="requestTypeBadge(row)"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class="requestTypeBadge(row)!.className"
>{{ requestTypeBadge(row)!.label }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-platform="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.platform || '-' }}</span>
</template>
<template #cell-client_ip="{ row }">
<div @click.stop>
<div v-if="row.client_ip">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.client_ip }}</span>
<IpGeoCell :ip="row.client_ip" />
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</div>
</template>
<template #cell-created_at="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(row.created_at) }}</span>
</template>
<template #cell-user_agent="{ row }">
<span
v-if="row.user_agent"
class="block max-w-[320px] truncate text-sm text-gray-600 dark:text-gray-400"
:title="row.user_agent"
>{{ row.user_agent }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.errors.empty')" /></template>
</DataTable>
</div>
<div class="flex-shrink-0">
<Pagination :page="page" :page-size="pageSize" :total="total"
<Pagination
v-if="total > 0"
:page="page"
:page-size="pageSize"
:total="total"
@update:page="$emit('update:page', $event)"
@update:pageSize="$emit('update:pageSize', $event)" />
@update:pageSize="$emit('update:pageSize', $event)"
/>
</div>
<UserErrorDetailModal v-model:show="showDetail" :error-id="selectedId" />
@@ -95,12 +123,22 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import UserErrorDetailModal from '@/components/user/UserErrorDetailModal.vue'
import IpGeoCell from '@/components/common/IpGeoCell.vue'
import IpGeoBatchToolbar from '@/components/common/IpGeoBatchToolbar.vue'
import { formatDateTime } from '@/utils/format'
import type { UserErrorRequest, ApiKey } from '@/types'
import {
mapErrorSortKey,
numericRequestTypeKind,
requestTypeBadgeClass,
requestTypeLabelKey,
statusCodeBadgeClass,
} from '@/utils/errorBadges'
import type { UserErrorRequest } from '@/types'
import type { Column } from '@/components/common/types'
const props = defineProps<{
rows: UserErrorRequest[]
@@ -108,46 +146,51 @@ const props = defineProps<{
loading: boolean
page: number
pageSize: number
apiKeys?: ApiKey[]
/** 列设置:仅显示这些 key 的列;不传则全显(key 须与 allColumns 一致) */
visibleColumnKeys?: string[]
}>()
const emit = defineEmits<{
(e: 'update:page', v: number): void
(e: 'update:pageSize', v: number): void
(e: 'filter', v: { model: string; category: string; api_key_id: number | null }): void
(e: 'ipGeoBatchFailed'): void
(e: 'sort', sortBy: string, sortOrder: 'asc' | 'desc'): void
}>()
function onSort(key: string, order: 'asc' | 'desc') {
emit('sort', mapErrorSortKey(key), order)
}
const { t } = useI18n()
// string | null:clearable 清空时 Select 回传 null,apply 中归一为空串
const localModel = ref<string | null>('')
const localCategory = ref<string>('')
const localApiKeyId = ref<number | null>(null)
const categoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
const categoryOptions = computed(() => [
{ value: '', label: t('usage.errors.allCategories') },
...categoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
// 列序对齐用户端用量明细:Key → 模型 → 端点 → IP → 分组 → 类型 → 平台 → 分类
// → 结果(状态→消息)→ 时间 → UA(用量明细 UA 同在时间之后的尾部)
const allColumns = computed<Column[]>(() => [
{ key: 'key_name', label: t('usage.errors.keyName') },
{ key: 'model', label: t('usage.errors.model'), sortable: true },
{ key: 'endpoint', label: t('usage.errors.endpoint') },
{ key: 'client_ip', label: 'IP' },
{ key: 'group', label: t('admin.usage.group') },
{ key: 'type', label: t('usage.type') },
{ key: 'platform', label: t('usage.errors.platform') },
{ key: 'category', label: t('usage.errors.category') },
{ key: 'status', label: t('usage.errors.status'), sortable: true },
{ key: 'message', label: t('usage.errors.message') },
{ key: 'created_at', label: t('usage.errors.time'), sortable: true },
{ key: 'user_agent', label: t('usage.userAgent') },
])
// 首项 value: null 表示不按 key 过滤;其余项取自父组件传入的 apiKeys 候选列表。
const keyOptions = computed(() => [
{ value: null, label: t('usage.errors.allKeys') },
...(props.apiKeys ?? []).map((k) => ({ value: k.id, label: k.name })),
])
const columns = computed<Column[]>(() =>
props.visibleColumnKeys
? allColumns.value.filter((c) => props.visibleColumnKeys!.includes(c.key))
: allColumns.value
)
// 模型候选取自当前已加载错误中出现过的模型;creatable 允许输入任意片段做后端模糊。
const modelOptions = computed(() => {
const seen = new Set<string>()
const opts: { value: string; label: string }[] = []
for (const r of props.rows) {
if (r.model && !seen.has(r.model)) {
seen.add(r.model)
opts.push({ value: r.model, label: r.model })
}
}
return opts
})
function requestTypeBadge(row: UserErrorRequest): { label: string; className: string } | null {
const kind = numericRequestTypeKind(row.request_type, row.stream)
if (!kind) return null
return { label: t(requestTypeLabelKey(kind)), className: requestTypeBadgeClass(kind) }
}
const showDetail = ref(false)
const selectedId = ref<number | null>(null)
@@ -157,17 +200,5 @@ function openDetail(id: number) {
showDetail.value = true
}
function apply() {
emit('filter', {
model: (localModel.value ?? '').trim(),
category: localCategory.value || '',
api_key_id: localApiKeyId.value,
})
}
function statusClass(code: number) {
if (code >= 500) return 'badge-danger'
if (code === 429) return 'badge-warning'
return 'badge-gray'
}
const statusClass = statusCodeBadgeClass
</script>
+2 -1
View File
@@ -1017,7 +1017,7 @@ export default {
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
category: 'Category', platform: 'Platform', message: 'Message',
keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys',
modelPlaceholder: 'Search model', allCategories: 'All categories',
modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes',
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
categories: {
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
@@ -5088,6 +5088,7 @@ export default {
accountId: 'Account ID',
status: 'Status',
message: 'Message',
ip: 'IP',
latency: 'Request Duration',
action: 'Action',
noErrors: 'No errors in this window.',
+2 -1
View File
@@ -1021,7 +1021,7 @@ export default {
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
category: '分类', platform: '平台', message: '错误信息',
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
modelPlaceholder: '搜索模型', allCategories: '全部分类',
modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码',
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
categories: {
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
@@ -5246,6 +5246,7 @@ export default {
accountId: '账号 ID',
status: '状态码',
message: '响应内容',
ip: 'IP',
latency: '请求时长',
action: '操作',
noErrors: '该窗口内暂无错误。',
+8
View File
@@ -1688,6 +1688,11 @@ export interface UserErrorRequest {
message: string
key_name: string
key_deleted: boolean
client_ip?: string
group_name?: string
request_type?: number
stream?: boolean
user_agent?: string
}
export interface UserErrorRequestDetail extends UserErrorRequest {
@@ -1705,6 +1710,9 @@ export interface UserErrorListParams {
status_code?: number
category?: string
api_key_id?: number
// 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
sort_by?: string
sort_order?: 'asc' | 'desc'
}
export interface UsageQueryParams {
+61
View File
@@ -0,0 +1,61 @@
/**
* 错误请求/用量明细共享的徽章配色与列映射。
* 配色统一 bg-X-100/text-X-800 体系,与 UsageTable 一致。
*/
import type { UsageRequestType } from '@/types'
export type UsageRequestKind = UsageRequestType
/** 状态码徽章:≥500 红、429 紫、≥400 琥珀、其余灰 */
export function statusCodeBadgeClass(code: number): string {
if (code >= 500) return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
if (code === 429) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
if (code >= 400) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
}
/** 请求类型徽章配色(cyber 红、ws 紫、stream 蓝、sync 灰、未知琥珀) */
export function requestTypeBadgeClass(kind: UsageRequestKind): string {
if (kind === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
if (kind === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
if (kind === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
if (kind === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
/** 请求类型 i18n 键(展示方自行 t()) */
export function requestTypeLabelKey(kind: UsageRequestKind): string {
if (kind === 'cyber') return 'usage.cyber'
if (kind === 'ws_v2') return 'usage.ws'
if (kind === 'stream') return 'usage.stream'
if (kind === 'sync') return 'usage.sync'
return 'usage.unknown'
}
/**
* 数字 request_type(1 同步/2 流式/3 WS)→ kind;
* 缺失时按 stream 布尔回退,两者都缺返回 null(展示为 -)。
*/
export function numericRequestTypeKind(
requestType?: number | null,
stream?: boolean | null
): UsageRequestKind | null {
const rt = requestType ?? (stream == null ? 0 : stream ? 2 : 1)
if (rt === 3) return 'ws_v2'
if (rt === 2) return 'stream'
if (rt === 1) return 'sync'
return null
}
/** 错误表列 key → 后端 sort_by(status 列实际按 status_code 排序) */
export function mapErrorSortKey(key: string): string {
return key === 'status' ? 'status_code' : key
}
/**
* 错误请求筛选的常用状态码固定候选(管理端 + 用户端共用)。
* 用固定列表而非「当前页出现过的码」派生,避免目标状态码只在后续页/筛选外时无法选中
* ——后端 status_code 过滤对全量数据生效,选项不应被当前页数据限制。
*/
export const COMMON_ERROR_STATUS_CODES = [400, 401, 403, 404, 408, 413, 429, 499, 500, 502, 503, 504, 529]
+31
View File
@@ -0,0 +1,31 @@
/**
* 错误请求"分类"虚拟维度:phase + error type → 用户侧粗分类码。
* 镜像后端 service.MapUserErrorCategory(backend/internal/service/ops_user_error.go),
* 两处修改须同步。返回稳定分类码,展示文案走 i18n `usage.errors.categories.*`。
*/
export function mapErrorCategory(phase?: string | null, errType?: string | null): string {
switch ((phase || '').toLowerCase()) {
case 'auth':
return 'auth'
case 'routing':
return 'service_unavailable'
case 'upstream':
case 'network':
return 'upstream'
case 'internal':
return 'internal'
case 'request':
switch ((errType || '').toLowerCase()) {
case 'rate_limit_error':
return 'rate_limit'
case 'billing_error':
case 'subscription_error':
return 'quota'
case 'invalid_request_error':
return 'invalid_request'
case 'cyber_policy':
return 'cyber'
}
}
return 'other'
}
+92 -5
View File
@@ -64,7 +64,7 @@
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
</div>
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<UsageFilters v-model="filters" :mode="activeTab === 'errors' ? 'errors' : 'usage'" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<template #after-reset>
<div class="relative" ref="columnDropdownRef">
<button
@@ -82,14 +82,14 @@
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
v-for="col in currentToggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
@click="toggleCurrentColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
v-if="isCurrentColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
@@ -126,9 +126,14 @@
<OpsErrorLogTable
:rows="errRows" :total="errTotal" :loading="errLoading"
:page="errPage" :page-size="errPageSize"
:visible-column-keys="errVisibleColumnKeys"
user-clickable
@userClick="handleUserClick"
@openErrorDetail="openError"
@sort="onErrSort"
@update:page="onErrPage"
@update:pageSize="onErrPageSize" />
@update:pageSize="onErrPageSize"
@ipGeoBatchFailed="handleIpGeoBatchFailed" />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
</div>
</div>
@@ -609,6 +614,74 @@ const toggleColumn = (key: string) => {
}
}
// ---- 错误请求 tab 列设置(与用量明细同机制,独立存储) ----
const ERR_ALWAYS_VISIBLE = ['user', 'status', 'created_at', 'actions']
const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
const ERR_HIDDEN_COLUMNS_KEY = 'usage-error-hidden-columns'
// key 集合须与 OpsErrorLogTable 内部 allColumns 一致
const errAllColumns = computed(() => [
{ key: 'user', label: t('admin.ops.errorLog.user') },
{ key: 'api_key', label: t('admin.ops.errorLog.apiKey') },
{ key: 'account', label: t('admin.ops.errorLog.account') },
{ key: 'platform', label: t('admin.ops.errorLog.platform') },
{ key: 'model', label: t('admin.ops.errorLog.model') },
{ key: 'endpoint', label: t('admin.ops.errorLog.endpoint') },
{ key: 'group', label: t('admin.ops.errorLog.group') },
{ key: 'type', label: t('admin.ops.errorLog.type') },
{ key: 'category', label: t('usage.errors.category') },
{ key: 'status', label: t('admin.ops.errorLog.status') },
{ key: 'message', label: t('admin.ops.errorLog.message') },
{ key: 'created_at', label: t('admin.ops.errorLog.time') },
{ key: 'user_agent', label: t('usage.userAgent') },
{ key: 'client_ip', label: t('admin.ops.errorLog.ip') },
{ key: 'actions', label: t('admin.ops.errorLog.action') },
])
const errHiddenColumns = reactive<Set<string>>(new Set())
const errToggleableColumns = computed(() =>
errAllColumns.value.filter(col => !ERR_ALWAYS_VISIBLE.includes(col.key))
)
const errVisibleColumnKeys = computed(() =>
errAllColumns.value
.filter(col => ERR_ALWAYS_VISIBLE.includes(col.key) || !errHiddenColumns.has(col.key))
.map(col => col.key)
)
const toggleErrColumn = (key: string) => {
if (errHiddenColumns.has(key)) {
errHiddenColumns.delete(key)
} else {
errHiddenColumns.add(key)
}
try {
localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
} catch (e) {
console.error('Failed to save error columns:', e)
}
}
const loadSavedErrColumns = () => {
try {
const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
const keys = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
keys.forEach((key) => errHiddenColumns.add(key))
} catch {
ERR_DEFAULT_HIDDEN_COLUMNS.forEach((key) => errHiddenColumns.add(key))
}
}
// 列设置下拉按当前 tab 分发
const currentToggleableColumns = computed(() =>
activeTab.value === 'errors' ? errToggleableColumns.value : toggleableColumns.value
)
const isCurrentColumnVisible = (key: string) =>
activeTab.value === 'errors' ? !errHiddenColumns.has(key) : isColumnVisible(key)
const toggleCurrentColumn = (key: string) =>
activeTab.value === 'errors' ? toggleErrColumn(key) : toggleColumn(key)
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
@@ -635,6 +708,8 @@ const errLoading = ref(false)
const errPage = ref(1)
const errPageSize = ref(20)
const errTotal = ref(0)
const errSortBy = ref('created_at')
const errSortOrder = ref<'asc' | 'desc'>('desc')
const showErrorModal = ref(false)
const selectedErrorId = ref<number | null>(null)
@@ -656,6 +731,11 @@ const loadAdminErrors = async () => {
account_id: filters.value.account_id ?? undefined,
group_id: filters.value.group_id ?? undefined,
model: filters.value.model || undefined,
phase: filters.value.error_phase || undefined,
category: filters.value.error_category || undefined,
status_codes: filters.value.status_code != null ? String(filters.value.status_code) : undefined,
sort_by: errSortBy.value,
sort_order: errSortOrder.value,
})
errRows.value = resp.items
errTotal.value = resp.total
@@ -667,6 +747,12 @@ const loadAdminErrors = async () => {
}
}
const onErrSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
errSortBy.value = sortBy
errSortOrder.value = sortOrder
errPage.value = 1
loadAdminErrors()
}
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
@@ -690,6 +776,7 @@ onMounted(() => {
void loadChartData()
}, 120)
loadSavedColumns()
loadSavedErrColumns()
document.addEventListener('click', handleColumnClickOutside)
})
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
@@ -84,6 +84,16 @@ function close() {
emit('update:show', false)
}
const sortBy = ref('created_at')
const sortOrder = ref<'asc' | 'desc'>('desc')
function onSort(nextSortBy: string, nextSortOrder: 'asc' | 'desc') {
sortBy.value = nextSortBy
sortOrder.value = nextSortOrder
page.value = 1
void fetchErrorLogs()
}
async function fetchErrorLogs() {
if (!props.show) return
@@ -93,7 +103,9 @@ async function fetchErrorLogs() {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange,
view: viewMode.value
view: viewMode.value,
sort_by: sortBy.value,
sort_order: sortOrder.value
}
const platform = String(props.platform || '').trim()
@@ -253,6 +265,7 @@ watch(
:page="page"
:page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)"
@sort="onSort"
@update:page="page = $event"
@update:pageSize="pageSize = $event"
@@ -1,272 +1,251 @@
<template>
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-dark-900">
<!-- Loading State -->
<div v-if="loading" class="flex flex-1 items-center justify-center py-10">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
<div class="flex h-full min-h-0 flex-col">
<div class="card flex min-h-0 flex-1 flex-col overflow-hidden">
<IpGeoBatchToolbar :ips="rows.map((r) => r.client_ip)" @failed="emit('ipGeoBatchFailed')" />
<DataTable
:columns="columns"
:data="rows"
:loading="loading"
clickable-rows
server-side-sort
default-sort-key="created_at"
default-sort-order="desc"
@sort="onSort"
@rowClick="(row) => emit('openErrorDetail', row.id)"
>
<template #cell-created_at="{ row }">
<span
class="text-sm text-gray-600 dark:text-gray-400"
:title="row.request_id || row.client_request_id"
>{{ formatDateTime(row.created_at) }}</span>
</template>
<template #cell-type="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getTypeBadge(row).className">
{{ getTypeBadge(row).label }}
</span>
</template>
<template #cell-endpoint="{ row }">
<div class="max-w-[320px] space-y-1 text-xs">
<div class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
<div v-if="row.upstream_endpoint" class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
</div>
</div>
</template>
<template #cell-platform="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.platform || '-' }}</span>
</template>
<template #cell-model="{ row }">
<div v-if="hasModelMapping(row)" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">{{ row.requested_model }}</div>
<div class="break-all text-gray-500 dark:text-gray-400"><span class="mr-0.5"></span>{{ row.upstream_model }}</div>
</div>
<span v-else-if="displayModel(row)" class="text-sm font-medium text-gray-900 dark:text-white">{{ displayModel(row) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-group="{ row }">
<span
v-if="row.group_id"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
:title="t('admin.ops.errorLog.id') + ' ' + row.group_id"
>
{{ row.group_name || '#' + row.group_id }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-user="{ row }">
<div v-if="row.user_id" class="text-sm">
<button
v-if="userClickable && row.user_email"
class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
:title="t('admin.usage.clickToViewBalance')"
@click.stop="emit('userClick', row.user_id, row.user_email)"
>
{{ row.user_email }}
</button>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.user_email || '-' }}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
</div>
<!-- 认证失败行 user_id 为空:回退显示已删除 KEY 所有者(归因快照,与详情弹窗一致) -->
<div v-else-if="row.deleted_key_owner_user_id" class="text-sm">
<button
v-if="userClickable && row.deleted_key_owner_email"
class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
:title="t('admin.usage.clickToViewBalance')"
@click.stop="emit('userClick', row.deleted_key_owner_user_id, row.deleted_key_owner_email ?? undefined)"
>
{{ row.deleted_key_owner_email }}
</button>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.deleted_key_owner_email || '-' }}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.deleted_key_owner_user_id }}</span>
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-api_key="{ row }">
<div v-if="row.api_key_id || row.api_key_name" class="text-sm">
<span class="text-gray-900 dark:text-white">{{ row.api_key_name || '#' + row.api_key_id }}</span>
<span
v-if="row.api_key_deleted"
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30"
>{{ t('admin.ops.errorLog.keyDeletedBadge') }}</span>
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-account="{ row }">
<span
v-if="row.account_id"
class="text-sm text-gray-900 dark:text-white"
:title="t('admin.ops.errorLog.accountId') + ' ' + row.account_id"
>{{ row.account_name || '#' + row.account_id }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-category="{ row }">
<span class="text-sm text-gray-900 dark:text-white">
{{ t('usage.errors.categories.' + mapErrorCategory(row.phase, row.type)) }}
</span>
</template>
<template #cell-status="{ row }">
<div class="flex items-center gap-1.5">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getStatusClass(row.status_code)">
{{ row.status_code }}
</span>
<span
v-if="row.severity"
:class="['rounded px-1.5 py-0.5 text-[10px] font-medium', getSeverityClass(row.severity)]"
>{{ row.severity }}</span>
<span
v-if="row.request_type != null && row.request_type > 0"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200"
>{{ formatRequestType(row.request_type) }}</span>
</div>
</template>
<template #cell-message="{ row }">
<span
v-if="row.message"
class="block max-w-[280px] truncate text-sm text-gray-600 dark:text-gray-400"
:title="row.message"
>{{ formatSmartMessage(row.message) || '-' }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-user_agent="{ row }">
<span
v-if="row.user_agent"
class="block max-w-[320px] truncate text-sm text-gray-600 dark:text-gray-400"
:title="row.user_agent"
>{{ row.user_agent }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-client_ip="{ row }">
<div @click.stop>
<div v-if="row.client_ip">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.client_ip }}</span>
<IpGeoCell :ip="row.client_ip" />
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</div>
</template>
<template #cell-actions="{ row }">
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-600 dark:hover:text-primary-400"
:title="t('admin.ops.errorLog.details')"
@click.stop="emit('openErrorDetail', row.id)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</button>
</template>
<template #empty><EmptyState :message="t('admin.ops.errorLog.noErrors')" /></template>
</DataTable>
</div>
<!-- Table Container -->
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-auto border-b border-gray-200 dark:border-dark-700">
<table class="w-full border-separate border-spacing-0">
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-800">
<tr>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.time') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.type') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.endpoint') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.platform') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.model') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.group') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.user') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.apiKey') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.account') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.status') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.message') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.action') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0">
<td colspan="12" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }}
</td>
</tr>
<tr
v-for="log in rows"
:key="log.id"
class="group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
@click="emit('openErrorDetail', log.id)"
>
<!-- Time -->
<td class="whitespace-nowrap px-4 py-2">
<el-tooltip :content="log.request_id || log.client_request_id" placement="top" :show-after="500">
<span class="font-mono text-xs font-medium text-gray-900 dark:text-gray-200">
{{ formatDateTime(log.created_at).split(' ')[1] }}
</span>
</el-tooltip>
</td>
<!-- Type -->
<td class="whitespace-nowrap px-4 py-2">
<span
:class="[
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getTypeBadge(log).className
]"
>
{{ getTypeBadge(log).label }}
</span>
</td>
<!-- Endpoint -->
<td class="px-4 py-2">
<div class="max-w-[160px]">
<el-tooltip v-if="log.inbound_endpoint" :content="formatEndpointTooltip(log)" placement="top" :show-after="500">
<span class="truncate font-mono text-[11px] text-gray-700 dark:text-gray-300">
{{ log.inbound_endpoint }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</div>
</td>
<!-- Platform -->
<td class="whitespace-nowrap px-4 py-2">
<span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300">
{{ log.platform || '-' }}
</span>
</td>
<!-- Model -->
<td class="px-4 py-2">
<div class="max-w-[160px]">
<template v-if="hasModelMapping(log)">
<el-tooltip :content="modelMappingTooltip(log)" placement="top" :show-after="500">
<span class="flex items-center gap-1 truncate font-mono text-[11px] text-gray-700 dark:text-gray-300">
<span class="truncate">{{ log.requested_model }}</span>
<span class="flex-shrink-0 text-gray-400"></span>
<span class="truncate text-primary-600 dark:text-primary-400">{{ log.upstream_model }}</span>
</span>
</el-tooltip>
</template>
<template v-else>
<span v-if="displayModel(log)" class="truncate font-mono text-[11px] text-gray-700 dark:text-gray-300" :title="displayModel(log)">
{{ displayModel(log) }}
</span>
<span v-else class="text-xs text-gray-400">-</span>
</template>
</div>
</td>
<!-- Group -->
<td class="px-4 py-2">
<el-tooltip v-if="log.group_id" :content="t('admin.ops.errorLog.id') + ' ' + log.group_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.group_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- User -->
<td class="px-4 py-2">
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="block max-w-[140px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.user_email || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- API Key -->
<td class="px-4 py-2">
<div v-if="log.api_key_id || log.api_key_name" class="flex max-w-[140px] items-center gap-1">
<span class="truncate text-xs font-medium text-gray-900 dark:text-gray-200" :title="log.api_key_name || ('#' + log.api_key_id)">
{{ log.api_key_name || ('#' + log.api_key_id) }}
</span>
<span
v-if="log.api_key_deleted"
class="flex-shrink-0 rounded px-1 py-0.5 text-[9px] font-bold ring-1 ring-inset bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30"
>
{{ t('admin.ops.errorLog.keyDeletedBadge') }}
</span>
</div>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- Account -->
<td class="px-4 py-2">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="block max-w-[120px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.account_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- Status -->
<td class="whitespace-nowrap px-4 py-2">
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getStatusClass(log.status_code)
]"
>
{{ log.status_code }}
</span>
<span
v-if="log.severity"
:class="['rounded px-1.5 py-0.5 text-[10px] font-bold', getSeverityClass(log.severity)]"
>
{{ log.severity }}
</span>
<span
v-if="log.request_type != null && log.request_type > 0"
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ formatRequestType(log.request_type) }}
</span>
</div>
</td>
<!-- Message (Response Content) -->
<td class="px-4 py-2">
<div class="max-w-[200px]">
<p class="truncate text-[11px] font-medium text-gray-600 dark:text-gray-400" :title="log.message">
{{ formatSmartMessage(log.message) || '-' }}
</p>
</div>
</td>
<!-- Actions -->
<td class="whitespace-nowrap px-4 py-2 text-right" @click.stop>
<div class="flex items-center justify-end gap-3">
<button type="button" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold" @click="emit('openErrorDetail', log.id)">
{{ t('admin.ops.errorLog.details') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="bg-gray-50/50 dark:bg-dark-800/50">
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
<div class="flex-shrink-0">
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Pagination from '@/components/common/Pagination.vue'
import IpGeoCell from '@/components/common/IpGeoCell.vue'
import IpGeoBatchToolbar from '@/components/common/IpGeoBatchToolbar.vue'
import type { OpsErrorLog } from '@/api/admin/ops'
import type { Column } from '@/components/common/types'
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
import { mapErrorCategory } from '@/utils/errorCategory'
import { mapErrorSortKey, statusCodeBadgeClass } from '@/utils/errorBadges'
const { t } = useI18n()
// 列序对齐管理端用量明细:身份(用户→Key→账号)→ 请求形态(平台→模型→端点→分组→类型)
// → 结果(状态→消息)→ 时间→UA→IP→操作
const allColumns = computed<Column[]>(() => [
{ key: 'user', label: t('admin.ops.errorLog.user') },
{ key: 'api_key', label: t('admin.ops.errorLog.apiKey') },
{ key: 'account', label: t('admin.ops.errorLog.account') },
{ key: 'platform', label: t('admin.ops.errorLog.platform') },
{ key: 'model', label: t('admin.ops.errorLog.model'), sortable: true },
{ key: 'endpoint', label: t('admin.ops.errorLog.endpoint') },
{ key: 'group', label: t('admin.ops.errorLog.group') },
{ key: 'type', label: t('admin.ops.errorLog.type') },
{ key: 'category', label: t('usage.errors.category') },
{ key: 'status', label: t('admin.ops.errorLog.status'), sortable: true },
{ key: 'message', label: t('admin.ops.errorLog.message') },
{ key: 'created_at', label: t('admin.ops.errorLog.time'), sortable: true },
{ key: 'user_agent', label: t('usage.userAgent') },
{ key: 'client_ip', label: t('admin.ops.errorLog.ip') },
{ key: 'actions', label: t('admin.ops.errorLog.action') },
])
// 传入 visibleColumnKeys 时按其过滤(列设置);未传则全量(Ops 弹窗等使用方)
const columns = computed<Column[]>(() =>
props.visibleColumnKeys
? allColumns.value.filter((c) => props.visibleColumnKeys!.includes(c.key))
: allColumns.value
)
function isUpstreamRow(log: OpsErrorLog): boolean {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
return phase === 'upstream' && owner === 'provider'
}
function formatEndpointTooltip(log: OpsErrorLog): string {
const parts: string[] = []
if (log.inbound_endpoint) parts.push(`Inbound: ${log.inbound_endpoint}`)
if (log.upstream_endpoint) parts.push(`Upstream: ${log.upstream_endpoint}`)
return parts.join('\n') || ''
}
function hasModelMapping(log: OpsErrorLog): boolean {
const requested = String(log.requested_model || '').trim()
const upstream = String(log.upstream_model || '').trim()
return !!requested && !!upstream && requested !== upstream
}
function modelMappingTooltip(log: OpsErrorLog): string {
const requested = String(log.requested_model || '').trim()
const upstream = String(log.upstream_model || '').trim()
if (!requested && !upstream) return ''
if (requested && upstream) return `${requested}${upstream}`
return upstream || requested
}
function displayModel(log: OpsErrorLog): string {
const upstream = String(log.upstream_model || '').trim()
if (upstream) return upstream
@@ -284,28 +263,29 @@ function formatRequestType(type: number | null | undefined): string {
}
}
// 徽章配色对齐用量明细(UsageTable)的 bg-X-100/text-X-800 体系
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
if (isUpstreamRow(log)) {
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30' }
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
}
if (phase === 'request' && owner === 'client') {
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30' }
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' }
}
if (phase === 'auth' && owner === 'client') {
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-500/30' }
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }
}
if (phase === 'routing' && owner === 'platform') {
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30' }
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' }
}
if (phase === 'internal' && owner === 'platform') {
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200' }
}
const fallback = phase || owner || t('common.unknown')
return { label: fallback, className: 'bg-gray-50 text-gray-700 ring-gray-600/10 dark:bg-dark-900 dark:text-gray-300 dark:ring-dark-700' }
const fallback = phase || owner || t('common.unknown')
return { label: fallback, className: 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200' }
}
interface Props {
@@ -314,24 +294,30 @@ interface Props {
loading: boolean
page: number
pageSize: number
/** 用户邮箱可点击(emit userClick),仅在有弹窗承接的使用方开启 */
userClickable?: boolean
/** 列设置:仅显示这些 key 的列;不传则全量 */
visibleColumnKeys?: string[]
}
interface Emits {
(e: 'openErrorDetail', id: number): void
(e: 'update:page', value: number): void
(e: 'update:pageSize', value: number): void
(e: 'ipGeoBatchFailed'): void
(e: 'sort', sortBy: string, sortOrder: 'asc' | 'desc'): void
(e: 'userClick', userId: number, email?: string): void
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
function getStatusClass(code: number): string {
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
function onSort(key: string, order: 'asc' | 'desc') {
emit('sort', mapErrorSortKey(key), order)
}
const getStatusClass = statusCodeBadgeClass
function formatSmartMessage(msg: string): string {
if (!msg) return ''
@@ -352,6 +338,5 @@ function formatSmartMessage(msg: string): string {
if (msg.toLowerCase().includes('rate limit')) return t('admin.ops.errorLog.commonErrors.rateLimit')
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
}
</script>
+146 -12
View File
@@ -68,7 +68,33 @@
<div class="card p-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div class="flex flex-1 flex-wrap items-end gap-4">
<div v-if="activeTab === 'errors'" class="flex flex-1 flex-wrap items-end gap-4">
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.errors.keyName') }}</label>
<Select v-model="errorFilter.api_key_id" :options="errorKeyOptions" @change="applyErrorFilters" />
</div>
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.errors.model') }}</label>
<Select
v-model="errorFilter.model"
:options="errorModelOptions"
searchable
creatable
clearable
:placeholder="t('usage.errors.modelPlaceholder')"
@change="applyErrorFilters"
/>
</div>
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('usage.errors.category') }}</label>
<Select v-model="errorFilter.category" :options="errorCategoryOptions" @change="applyErrorFilters" />
</div>
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.errors.status') }}</label>
<Select v-model="errorFilter.status_code" :options="errorStatusOptions" @change="applyErrorFilters" />
</div>
</div>
<div v-else class="flex flex-1 flex-wrap items-end gap-4">
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<Select v-model="filters.api_key_id" :options="apiKeyOptions" @change="applyFilters" />
@@ -96,7 +122,7 @@
</div>
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" @click="refreshData" :disabled="loading" class="btn btn-secondary">
<button type="button" @click="refreshData" :disabled="activeTab === 'errors' ? errorLoading : loading" class="btn btn-secondary">
{{ t('common.refresh') }}
</button>
<button type="button" @click="resetFilters" class="btn btn-secondary">
@@ -117,18 +143,18 @@
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
v-for="col in currentToggleableColumns"
:key="col.key"
type="button"
@click="toggleColumn(col.key)"
@click="toggleCurrentColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
<Icon v-if="isCurrentColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
<button type="button" @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
<button v-if="activeTab !== 'errors'" type="button" @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
</button>
</div>
@@ -175,10 +201,11 @@
:loading="errorLoading"
:page="errorPage"
:page-size="errorPageSize"
:api-keys="apiKeys"
@filter="onErrorFilter"
:visible-column-keys="errVisibleColumnKeys"
@sort="onErrorSort"
@update:page="onErrorPage"
@update:pageSize="onErrorPageSize"
@ipGeoBatchFailed="handleIpGeoBatchFailed"
/>
</div>
</AppLayout>
@@ -219,6 +246,7 @@ import type {
UserErrorRequest,
} from '@/types'
import type { Column } from '@/components/common/types'
import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
const { t } = useI18n()
const appStore = useAppStore()
@@ -244,13 +272,53 @@ const errorRows = ref<UserErrorRequest[]>([])
const errorLoading = ref(false)
const errorPage = ref(1)
const errorPageSize = ref(20)
const errorSortBy = ref('created_at')
const errorSortOrder = ref<'asc' | 'desc'>('desc')
const errorTotal = ref(0)
const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({
const errorFilter = ref<{ model: string | null; category: string; api_key_id: number | null; status_code: number | null }>({
model: '',
category: '',
api_key_id: null,
status_code: null,
})
const errorKeyOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allKeys') },
...apiKeys.value.map((k) => ({ value: k.id, label: k.name })),
])
// 模型候选取自当前已加载错误中出现过的模型;creatable 允许输入任意片段做后端模糊。
const errorModelOptions = computed<SelectOption[]>(() => {
const seen = new Set<string>()
const opts: SelectOption[] = []
for (const r of errorRows.value) {
if (r.model && !seen.has(r.model)) {
seen.add(r.model)
opts.push({ value: r.model, label: r.model })
}
}
return opts
})
const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
const errorCategoryOptions = computed<SelectOption[]>(() => [
{ value: '', label: t('usage.errors.allCategories') },
...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
])
// 状态码候选用固定常用列表(与管理端 UsageFilters 共用常量),不受当前页数据限制:
// 后端 status_code 过滤对全量生效,若只列当前页出现过的码,用户就选不到仅在后续页的码。
const errorStatusOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allStatuses') },
...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
])
const applyErrorFilters = () => {
errorPage.value = 1
void loadErrors()
}
let abortController: AbortController | null = null
let chartReqSeq = 0
let statsReqSeq = 0
@@ -486,6 +554,10 @@ const resetFilters = () => {
}
granularity.value = getGranularityForRange(range.start, range.end)
applyFilters()
if (activeTab.value === 'errors') {
errorFilter.value = { model: '', category: '', api_key_id: null, status_code: null }
applyErrorFilters()
}
}
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
@@ -663,6 +735,63 @@ const loadSavedColumns = () => {
}
}
// 错误请求 tab 独立列设置(机制同用量列设置,存储互不影响)
const ERR_ALWAYS_VISIBLE = ['status', 'created_at']
const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
const ERR_HIDDEN_COLUMNS_KEY = 'user-usage-error-hidden-columns'
// key 须与 UserErrorRequestsTable 的 allColumns 一致
const errAllColumns = computed<Column[]>(() => [
{ key: 'key_name', label: t('usage.errors.keyName') },
{ key: 'model', label: t('usage.errors.model') },
{ key: 'endpoint', label: t('usage.errors.endpoint') },
{ key: 'client_ip', label: 'IP' },
{ key: 'group', label: t('admin.usage.group') },
{ key: 'type', label: t('usage.type') },
{ key: 'platform', label: t('usage.errors.platform') },
{ key: 'category', label: t('usage.errors.category') },
{ key: 'status', label: t('usage.errors.status') },
{ key: 'message', label: t('usage.errors.message') },
{ key: 'created_at', label: t('usage.errors.time') },
{ key: 'user_agent', label: t('usage.userAgent') },
])
const errHiddenColumns = reactive<Set<string>>(new Set())
const errToggleableColumns = computed(() =>
errAllColumns.value.filter((col) => !ERR_ALWAYS_VISIBLE.includes(col.key))
)
const errVisibleColumnKeys = computed(() =>
errAllColumns.value
.filter((col) => ERR_ALWAYS_VISIBLE.includes(col.key) || !errHiddenColumns.has(col.key))
.map((col) => col.key)
)
const isErrColumnVisible = (key: string) => !errHiddenColumns.has(key)
const toggleErrColumn = (key: string) => {
if (errHiddenColumns.has(key)) errHiddenColumns.delete(key)
else errHiddenColumns.add(key)
localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
}
const loadSavedErrColumns = () => {
try {
const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
const values = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
values.forEach((key) => errHiddenColumns.add(key))
} catch {
ERR_DEFAULT_HIDDEN_COLUMNS.forEach((key) => errHiddenColumns.add(key))
}
}
// 列设置下拉按当前 tab 分发
const currentToggleableColumns = computed(() =>
activeTab.value === 'errors' ? errToggleableColumns.value : toggleableColumns.value
)
const isCurrentColumnVisible = (key: string) =>
activeTab.value === 'errors' ? isErrColumnVisible(key) : isColumnVisible(key)
const toggleCurrentColumn = (key: string) => {
if (activeTab.value === 'errors') toggleErrColumn(key)
else toggleColumn(key)
}
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)
const handleColumnClickOutside = (event: MouseEvent) => {
@@ -702,9 +831,12 @@ const loadErrors = async () => {
page_size: errorPageSize.value,
start_date: startDate.value,
end_date: endDate.value,
model: errorFilter.value.model || undefined,
model: (errorFilter.value.model ?? '').trim() || undefined,
category: errorFilter.value.category || undefined,
api_key_id: errorFilter.value.api_key_id ?? undefined,
status_code: errorFilter.value.status_code ?? undefined,
sort_by: errorSortBy.value,
sort_order: errorSortOrder.value,
})
errorRows.value = resp.items
errorTotal.value = resp.total
@@ -716,8 +848,9 @@ const loadErrors = async () => {
}
}
const onErrorFilter = (filter: { model: string; category: string; api_key_id: number | null }) => {
errorFilter.value = filter
const onErrorSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
errorSortBy.value = sortBy
errorSortOrder.value = sortOrder
errorPage.value = 1
void loadErrors()
}
@@ -740,6 +873,7 @@ const switchToErrors = () => {
onMounted(() => {
loadSavedColumns()
loadSavedErrColumns()
document.addEventListener('click', handleColumnClickOutside)
void loadFilterOptions()
refreshData()