mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '该窗口内暂无错误。',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user