feat(ui): 补齐权重、分组启停与日志路由入口 (#572)

* feat(ui): 补齐权重、分组启停与日志路由入口

订阅账号卡片支持调度权重(#535):徽章行只在手动权重时露出 chip,
点击一次完成展开并进入编辑;权重面板落在展开区代理面板之上,两块上下堆叠。
自动权重等同代理的继承态,不在折叠态制造视觉噪音。

代理模式改用分段控件,三个模式直接可见;表单不再折行,地址栏独自吸收
剩余宽度。分段控件新增 xs 尺寸,与同排输入框、按钮统一 26px,
顺带修掉密钥列表权重编辑器原本 30px 与 26px 不齐的问题。

分组列表支持快速启停(#554):开关并入状态列,与徽章相邻以区分
「我关的」和「凭据不可用」。集合项新增 enabled 字段——status 还叠加了
weight_manual 为 0 的历史数据,拿它当开关会出现点击后状态不变的假故障。
追加凭据改为纯图标,省下的宽度抵掉状态列扩宽,总表宽反而更窄。

模型列表展示归属分组(#555):上游行挂分组 chip,最多两枚、其余折叠为 +N,
数据本就随列表返回,此前未被消费。

请求日志路由拆成访问密钥与路由两列(#555):路由列首行渠道图标加分组名、
次行凭据编号,分组名可点击就地筛选。按各列实际内容重算宽度,
压出的空间正好装下新列,总宽与拆列前持平。

* fix(ui): 修正开关反馈延迟、按钮内边距与列举分隔符

分组开关此前绑查询数据,而 AppSwitch 是纯受控组件,点击后要等请求与
refetch 走完才翻转,看起来是个卡住的开关。改为本地乐观置位,请求结束
(成功交还 refetch 结果、失败回滚原值)后再交还查询数据。

代理指示器的按钮形态补上 padding/font 复位:容器是固定 22×22 的
border-box,UA 默认内边距会把 13px 图标挤变形。

模型分组折叠提示原先硬编码中文顿号,会漏进英文与日文界面,改用
Intl.ListFormat 按当前语言格式化。实测须用 conjunction + narrow:
narrow 去掉「和 / and」只留分隔符,而 unit 类型在中日文下根本不插
分隔符,会把名字连成一串。

* fix(monitor): 修正日志骨架屏列数

路由列拆成访问密钥与路由两列后,非访问密钥视图的列头与 grid 都是 9 列,
骨架屏仍按 8 列渲染,加载时与表头错位。两处 SkeletonSurface 一并修正。

* refactor(ui): 撤除分组集合 enabled 字段并精简注释

开关改由 status 派生:weight_manual 为 0 虽然也判定 disabled,但该值经
接口限定在 1~100,只有直接改库才能触发,为此新增响应字段与两个测试
超出了本次 UI 补齐的范围。后端与测试恢复原状,本 PR 回到纯前端改动。

同时精简全部新增注释,只保留解释非显然决策的部分,去掉代码本身已经
说清楚的描述。

* feat(monitor): 日志显示凭据掩码并补齐交互细节

日志响应新增 credential_name:按 ID 从凭据注册表取密文解密后算掩码,
订阅账号给邮箱掩码、API 密钥给密钥掩码。密文常驻内存,整个过程不读
数据库,一页内重复出现的凭据只解密一次;注册表里没有的凭据留空,
前端据此显示「已删除 · #id」,与访问密钥、分组共用同一套回退。

分组开关改走设置页同一条缓存失效链路,切换后进入详情页不再需要刷新。

日志中可点击就地筛选的值扩展到访问密钥、分组、凭据、模型四处,样式
提取为全局 filterable-value,此前两个组件各维护一套雷同规则。

订阅卡片的权重徽标与代理徽标统一改用 AppTooltip 并提示可点击编辑;
模型页的分组 chip 直达该分组的模型 tab;日志详情的凭据后增加复制按钮,
与 request_id 的复制控件同尺寸同图标。

* fix(models): 详情抽屉的分组链接直达模型 tab

与模型列表的分组 chip 保持一致,从模型上下文点进分组不再落到凭据 tab。

* fix: 修复样式

* fix: 访问密钥模型页面修复
This commit is contained in:
tbphp authored and GitHub committed 2026-09-05 21:41:41 +08:00
1 parent 7eb1e3726d
commit 2b23d8a62a
38 files changed
+1177 -215

No files matched your search

+9 -6
View File
@@ -44,7 +44,7 @@ type healthProblemCredentialResponse struct {
CredentialID uint `json:"credential_id"`
GroupID uint `json:"group_id"`
GroupName string `json:"group_name"`
Mask string `json:"mask"`
Identity string `json:"identity"`
LastFailureCategory string `json:"last_failure_category"`
LastStatusCode *int `json:"last_status_code"`
CooldownUntilMS *int64 `json:"cooldown_until_ms"`
@@ -193,7 +193,10 @@ func optionalHealthStatusCode(value int) *int {
return &cloned
}
func (service *Service) healthProblemMask(
// healthProblemCredentialIdentity 返回健康问题列表里凭据的展示身份:
// API 密钥仍是掩码,订阅账号给完整邮箱,与凭据卡片、日志的既有约定一致;
// 拿不到邮箱时回退到 "Subscription #ID"。access/refresh token 绝不进入返回值。
func (service *Service) healthProblemCredentialIdentity(
ciphertexts map[uint]string,
credentialID uint,
channelID channel.ID,
@@ -233,8 +236,8 @@ func (service *Service) healthProblemMask(
if parseErr != nil {
return "", fmt.Errorf("map runtime health subscription credential %d: %w", credentialID, app_errors.ErrInternalServer)
}
if mask := maskEmail(credential.Account().Email); mask != "" {
return mask, nil
if email := strings.TrimSpace(credential.Account().Email); email != "" {
return email, nil
}
return fmt.Sprintf("Subscription #%d", credentialID), nil
}
@@ -331,7 +334,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) {
continue
}
groupView := observation.snapshot.Groups[key.GroupID]
mask, err := service.healthProblemMask(
identity, err := service.healthProblemCredentialIdentity(
observation.problemCiphertexts,
key.ID,
groupView.ChannelID,
@@ -351,7 +354,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) {
CredentialID: key.ID,
GroupID: key.GroupID,
GroupName: group.Name,
Mask: mask,
Identity: identity,
LastFailureCategory: lastFailureCategory.String(),
LastStatusCode: optionalHealthStatusCode(lastStatusCode),
FailureCount: key.FailureCount,
+17 -15
View File
@@ -147,7 +147,7 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) {
t.Fatalf("disabled/zero/empty group counts = %#v", got.Groups)
}
if len(got.CooldownCredentials) != 1 || got.CooldownCredentials[0].CredentialID != 12 ||
got.CooldownCredentials[0].Mask != "rate****safe" ||
got.CooldownCredentials[0].Identity != "rate****safe" ||
got.CooldownCredentials[0].LastFailureCategory != "rate_limited" ||
got.CooldownCredentials[0].LastStatusCode == nil ||
*got.CooldownCredentials[0].LastStatusCode != 429 ||
@@ -158,7 +158,7 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) {
t.Fatalf("cooldown details = %#v", got.CooldownCredentials)
}
if len(got.BlacklistedCredentials) != 1 || got.BlacklistedCredentials[0].CredentialID != 13 ||
got.BlacklistedCredentials[0].Mask != "inva****lock" ||
got.BlacklistedCredentials[0].Identity != "inva****lock" ||
got.BlacklistedCredentials[0].LastFailureCategory != "invalid_key" ||
got.BlacklistedCredentials[0].LastStatusCode == nil ||
*got.BlacklistedCredentials[0].LastStatusCode != 401 ||
@@ -435,7 +435,7 @@ func TestRuntimeHealthJSONOmitsScoresCredentialsAndZeroTimes(t *testing.T) {
t.Fatalf("RuntimeHealth() error = %v", err)
}
if len(result.BlacklistedCredentials) != 1 ||
result.BlacklistedCredentials[0].Mask != "prov****tail" ||
result.BlacklistedCredentials[0].Identity != "prov****tail" ||
result.BlacklistedCredentials[0].LastFailureCategory != "ambiguous" ||
result.BlacklistedCredentials[0].LastStatusCode != nil {
t.Fatalf("blacklisted details = %#v", result.BlacklistedCredentials)
@@ -519,18 +519,18 @@ func TestRuntimeHealthFailsClosedWhenProblemKeyCannotBeDecrypted(t *testing.T) {
}
}
func TestHealthProblemMaskFailsClosedWhenCiphertextIsMissing(t *testing.T) {
func TestHealthProblemCredentialIdentityFailsClosedWhenCiphertextIsMissing(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
if _, err := fixture.service.healthProblemMask(nil, 1, "", "api_key"); !errors.Is(
if _, err := fixture.service.healthProblemCredentialIdentity(nil, 1, "", "api_key"); !errors.Is(
err,
app_errors.ErrInternalServer,
) {
t.Fatalf("healthProblemMask() error = %v, want INTERNAL_SERVER_ERROR", err)
t.Fatalf("healthProblemCredentialIdentity() error = %v, want INTERNAL_SERVER_ERROR", err)
}
}
func TestHealthProblemMaskExtractsTypedCredentialSecret(t *testing.T) {
func TestHealthProblemCredentialIdentityMasksAPIKeySecret(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
ciphertext := encryptHealthKey(
@@ -538,31 +538,33 @@ func TestHealthProblemMaskExtractsTypedCredentialSecret(t *testing.T) {
fixture,
`{"api_key":"provider-secret-credential-tail"}`,
)
mask, err := fixture.service.healthProblemMask(
identity, err := fixture.service.healthProblemCredentialIdentity(
map[uint]string{1: ciphertext},
1,
channel.OpenAI,
"api_key",
)
if err != nil {
t.Fatalf("healthProblemMask() error = %v", err)
t.Fatalf("healthProblemCredentialIdentity() error = %v", err)
}
if mask != "prov****tail" || strings.Contains(mask, "api_key") || strings.Contains(mask, "{") {
t.Fatalf("healthProblemMask() = %q, want api_key-only mask", mask)
if identity != "prov****tail" || strings.Contains(identity, "api_key") || strings.Contains(identity, "{") {
t.Fatalf("healthProblemCredentialIdentity() = %q, want api_key mask", identity)
}
}
func TestHealthProblemMaskUsesSafeSubscriptionAccountIdentity(t *testing.T) {
// 订阅账号在管理面其余位置(凭据卡片、日志)一律展示完整邮箱,这里不再是例外;
// 泄露边界仍然成立——展示的只是邮箱,refresh/access token 绝不出现在返回值里。
func TestHealthProblemCredentialIdentityShowsFullSubscriptionEmail(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
ciphertext := encryptHealthKey(t, fixture,
`{"type":"codex","access_token":"access-secret","refresh_token":"refresh-secret","account_id":"account-one","email":"owner@example.com"}`)
mask, err := fixture.service.healthProblemMask(map[uint]string{1: ciphertext}, 1, channel.Codex, "subscription")
identity, err := fixture.service.healthProblemCredentialIdentity(map[uint]string{1: ciphertext}, 1, channel.Codex, "subscription")
if err != nil {
t.Fatal(err)
}
if mask != "o***r@example.com" || strings.Contains(mask, "secret") {
t.Fatalf("mask = %q", mask)
if identity != "owner@example.com" || strings.Contains(identity, "secret") {
t.Fatalf("identity = %q", identity)
}
}
+12 -2
View File
@@ -300,10 +300,20 @@ func (s *Service) ListProjectModels(ctx context.Context, query ProjectModelListQ
continue
}
sortProjectModelGroups(upstream.routeGroups)
// 分组信息只对管理员可见;访问密钥范围整条不发,而不是发一条
// 身份被抹空的占位记录——id=0/channel_id="" 这类占位值会违反
// 前端 projectSafeInteger/projectChannelID 的强校验,把整个
// 模型页判定为无效响应,比不发这条数据本身还糟。
routeGroups := []ProjectModelGroupDTO{}
affectedGroups := []ProjectModelGroupDTO{}
if query.AccessKeyID == nil {
routeGroups = append(routeGroups, upstream.routeGroups...)
affectedGroups = append(affectedGroups, upstream.affectedGroups...)
}
upstreamDTO := ProjectUpstreamModelDTO{
ModelID: upstream.modelID, AliasApplied: upstream.aliasApplied,
Price: upstream.price, RouteGroups: append([]ProjectModelGroupDTO{}, upstream.routeGroups...),
AffectedGroups: append([]ProjectModelGroupDTO{}, upstream.affectedGroups...),
Price: upstream.price, RouteGroups: routeGroups,
AffectedGroups: affectedGroups,
CatalogReference: upstream.catalogReference,
}
dto.UpstreamModels = append(dto.UpstreamModels, upstreamDTO)
@@ -182,17 +182,15 @@ func TestProjectModelsHTTPScopesAccessKeyFiltersAndRelationships(t *testing.T) {
t.Fatalf("AccessKey shared model = %#v", shared)
}
for _, upstream := range shared.UpstreamModels {
if len(upstream.RouteGroups) != 1 || len(upstream.AffectedGroups) != 1 {
t.Fatalf("channel-scoped shared upstream = %#v", upstream)
// 分组信息只对管理员可见。占位对象(id=0/channel_id="")会触发前端
// projectSafeInteger/projectChannelID 的强校验直接判无效响应,把整个
// 模型页读崩——必须整条不发,而不是发一条身份被抹空的记录。
if len(upstream.RouteGroups) != 0 || len(upstream.AffectedGroups) != 0 {
t.Fatalf("AccessKey upstream must omit group identity entirely = %#v", upstream)
}
if upstream.Price.ChannelIcon == "" || upstream.Price.ChannelMark == "" {
t.Fatalf("AccessKey upstream misses channel visual identity: %#v", upstream.Price)
}
for _, group := range append(upstream.RouteGroups, upstream.AffectedGroups...) {
if got := string(group.Params); got != `{}` {
t.Fatalf("AccessKey route Group params = %s, want empty object", got)
}
}
}
for _, privateValue := range []string{
"base_url", "https://openai.example/v1",
+117 -8
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -67,6 +68,7 @@ type requestLogAttemptResponse struct {
GroupName string `json:"group_name"`
ChannelID *channel.ID `json:"channel_id"`
CredentialID *uint `json:"credential_id"`
CredentialName string `json:"credential_name"`
Operation *execution.Operation `json:"operation"`
RouteMode *channel.RouteMode `json:"route_mode"`
UpstreamModel *string `json:"upstream_model"`
@@ -147,6 +149,7 @@ type requestLogItemResponse struct {
GroupID *uint `json:"group_id"`
ChannelID *channel.ID `json:"channel_id"`
CredentialID *uint `json:"credential_id"`
CredentialName string `json:"credential_name"`
RouteMode *channel.RouteMode `json:"route_mode"`
UsageState usage.State `json:"usage_state"`
CostState pricing.CostState `json:"cost_state"`
@@ -249,7 +252,10 @@ func (s *Server) handleListRequestLogs(c *gin.Context) {
page.Items[index] = sanitizeAccessKeyRequestLog(page.Items[index])
}
}
result, err := mapRequestLogListResponse(page)
result, err := mapRequestLogListResponse(
page,
s.service.CredentialLabels(requestLogCredentialIDs(page.Items)),
)
if err != nil {
writeServiceError(c, "list_request_logs", err)
return
@@ -278,7 +284,10 @@ func (s *Server) handleGetRequestLog(c *gin.Context) {
writeServiceError(c, "get_request_log", err)
return
}
result, err := mapRequestLogDetailResponse(record)
result, err := mapRequestLogDetailResponse(
record,
s.service.CredentialLabels(requestLogCredentialIDs([]requestlog.Record{record})),
)
if err != nil {
writeServiceError(c, "get_request_log", err)
return
@@ -805,7 +814,29 @@ func encodeRequestLogCursor(cursor requestlog.Cursor) (string, error) {
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, error) {
func credentialLabelFor(labels map[uint]string, credentialID *uint) string {
if labels == nil || credentialID == nil {
return ""
}
return labels[*credentialID]
}
// 一页日志里的凭据数远小于条数:同一个号会被反复使用,去重后通常只剩几个。
func requestLogCredentialIDs(records []requestlog.Record) []uint {
ids := make([]uint, 0, len(records))
for _, record := range records {
ids = append(ids, record.CredentialID)
for _, attempt := range record.Attempts {
ids = append(ids, attempt.CredentialID)
}
}
return ids
}
func mapRequestLogListResponse(
page requestlog.Page,
credentialLabels map[uint]string,
) (requestLogListResponse, error) {
result := requestLogListResponse{
Items: make([]requestLogItemResponse, 0, len(page.Items)),
}
@@ -820,7 +851,7 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er
if err != nil {
return requestLogListResponse{}, err
}
item, err := mapRequestLogItemResponse(record, usageCost)
item, err := mapRequestLogItemResponse(record, usageCost, credentialLabels)
if err != nil {
return requestLogListResponse{}, err
}
@@ -839,6 +870,7 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er
func mapRequestLogItemResponse(
record requestlog.Record,
usageCost requestLogUsageCostResponse,
credentialLabels map[uint]string,
) (requestLogItemResponse, error) {
inputTokens, ok := checkedRequestLogInputTokens(record)
if !ok {
@@ -893,6 +925,7 @@ func mapRequestLogItemResponse(
GroupID: usageCost.groupID,
ChannelID: usageCost.channelID,
CredentialID: usageCost.credentialID,
CredentialName: credentialLabelFor(credentialLabels, usageCost.credentialID),
RouteMode: routeMode,
UsageState: record.UsageState,
CostState: record.CostState,
@@ -979,18 +1012,21 @@ func checkedRequestLogInputTokens(record requestlog.Record) (int64, bool) {
return total, true
}
func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResponse, error) {
func mapRequestLogDetailResponse(
record requestlog.Record,
credentialLabels map[uint]string,
) (requestLogDetailResponse, error) {
usageCost, err := mapRequestLogUsageCost(record)
if err != nil {
return requestLogDetailResponse{}, err
}
item, err := mapRequestLogItemResponse(record, usageCost)
item, err := mapRequestLogItemResponse(record, usageCost, credentialLabels)
if err != nil {
return requestLogDetailResponse{}, err
}
attempts := make([]requestLogAttemptResponse, 0, len(record.Attempts))
for _, attempt := range record.Attempts {
mapped, err := mapRequestLogAttempt(attempt)
mapped, err := mapRequestLogAttempt(attempt, credentialLabels)
if err != nil {
return requestLogDetailResponse{}, err
}
@@ -1002,7 +1038,10 @@ func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResp
}, nil
}
func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse, error) {
func mapRequestLogAttempt(
attempt requestlog.Attempt,
credentialLabels map[uint]string,
) (requestLogAttemptResponse, error) {
channelID, err := nullableRequestLogChannelID(attempt.ChannelID)
if err != nil {
return requestLogAttemptResponse{}, fmt.Errorf("map request log attempt: %w", err)
@@ -1053,6 +1092,7 @@ func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse
GroupName: attempt.GroupName,
ChannelID: channelID,
CredentialID: credentialID,
CredentialName: credentialLabelFor(credentialLabels, credentialID),
Operation: operation,
RouteMode: routeMode,
UpstreamModel: nullableRequestLogModel(attempt.UpstreamModel),
@@ -1323,3 +1363,72 @@ func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostRespon
}
return result, nil
}
// CredentialLabels 把凭据 ID 翻译成可读标识:API 密钥给掩码,订阅账号给邮箱掩码。
// 密文常驻凭据注册表,整个过程不读数据库;注册表里没有的凭据(已删除)不出现在
// 结果里,由调用方决定如何呈现。入参允许重复,每个 ID 至多解密一次。
func (s *Service) CredentialLabels(credentialIDs []uint) map[uint]string {
if s == nil || s.registry == nil || s.encryption == nil || len(credentialIDs) == 0 {
return nil
}
s.writeMu.RLock()
snapshot := s.manager.Current()
s.writeMu.RUnlock()
if snapshot == nil {
return nil
}
labels := make(map[uint]string, len(credentialIDs))
seen := make(map[uint]struct{}, len(credentialIDs))
for _, credentialID := range credentialIDs {
if credentialID == 0 {
continue
}
if _, done := seen[credentialID]; done {
continue
}
seen[credentialID] = struct{}{}
ref, known := s.registry.CredentialRef(credentialID)
if !known || ref.EncryptedValue == "" {
continue
}
group, exists := snapshot.Groups[ref.GroupID]
if !exists {
continue
}
label, err := s.credentialLabel(ref.EncryptedValue, group.ChannelID, group.ConnectionType)
if err != nil || label == "" {
continue
}
labels[credentialID] = label
}
return labels
}
// 与凭据列表一致:订阅账号给完整邮箱(本就在管理面明示),API 密钥给掩码。
func (s *Service) credentialLabel(
ciphertext string,
channelID channel.ID,
connectionType string,
) (string, error) {
plaintext, err := s.encryption.Decrypt(ciphertext)
if err != nil {
return "", err
}
expected, known := s.channelRegistry.ConnectionType(channelID)
if !known || expected != strings.TrimSpace(connectionType) {
return "", fmt.Errorf("channel connection mismatch")
}
if driver, bound := s.subscriptions.Driver(channelID); bound {
credential, parseErr := driver.Parse([]byte(plaintext))
plaintext = ""
if parseErr != nil {
return "", parseErr
}
return strings.TrimSpace(credential.Account().Email), nil
}
validated, err := normalizeStoredCredential(s.channelRegistry, channelID, plaintext)
if err != nil {
return "", err
}
return maskCanonicalCredential(validated.CanonicalJSON())
}
+114 -5
View File
@@ -17,14 +17,17 @@ import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/platform/config"
"gpt-load/internal/platform/utils"
"gpt-load/internal/pricing"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
"gpt-load/internal/requestlog"
"gpt-load/internal/storage/models"
"gpt-load/internal/telemetry"
"gpt-load/internal/usage"
)
@@ -842,7 +845,7 @@ func TestRequestLogResponseUsesNullModelsForProtocolOnlyResponsesResources(t *te
CostState: pricing.CostStateNotApplicable,
PricingCompleteness: pricing.CompletenessNotApplicable,
}},
})
}, nil)
if err != nil {
t.Fatalf("mapRequestLogListResponse() error = %v", err)
}
@@ -886,7 +889,7 @@ func TestRequestLogDetailProjectsEmptyAttemptReasoningAsNull(t *testing.T) {
FailureCategory: telemetry.FailureCategoryClientError,
Action: telemetry.ActionTerminate,
}},
})
}, nil)
if err != nil {
t.Fatalf("mapRequestLogDetailResponse() error = %v", err)
}
@@ -957,7 +960,7 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
record := base
test.mutate(&record)
if _, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}); err == nil {
if _, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, nil); err == nil {
t.Fatal("mapRequestLogListResponse() error = nil, want rejection")
}
})
@@ -974,7 +977,7 @@ func TestRequestLogUsageCostProjectionAcceptsMaximumSafeFinalGroupID(t *testing.
GroupID: uint(safeGroupID), UsageState: usage.StateComplete,
CostState: pricing.CostStatePriced, PricingCompleteness: pricing.CompletenessComplete,
}
result, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}})
result, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, nil)
if err != nil {
t.Fatalf("mapRequestLogListResponse() error = %v", err)
}
@@ -1311,7 +1314,7 @@ func TestRequestLogAttemptActionUsesCredentialTerms(t *testing.T) {
telemetry.Action("fail_credential"): "fail_credential",
telemetry.ActionTerminate: string(telemetry.ActionTerminate),
} {
mapped, err := mapRequestLogAttempt(requestlog.Attempt{Action: input})
mapped, err := mapRequestLogAttempt(requestlog.Attempt{Action: input}, nil)
if err != nil {
t.Fatalf("mapRequestLogAttempt(%q) error = %v", input, err)
}
@@ -1334,3 +1337,109 @@ func Example_requestLogOpaqueCursor() {
fmt.Println(encodeTestCursorPayload(payload))
// Output: eyJ2IjoyLCJjb21wbGV0ZWRfYXRfbXMiOjE3ODQ4OTQ0MDAwMDAsInJlcXVlc3RfaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ
}
// 日志里的凭据要显示成人话:密文常驻凭据注册表,按 ID 取出解密即可,
// 不额外读库;注册表里没有的(已删除)留空,交由前端显示“已删除 · #id”。
func TestCredentialLabelsMasksFromRegistryWithoutDatabaseReads(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
Name: stringPointer("log labels"), ChannelID: channel.OpenAI,
ConnectionType: models.ConnectionTypeAPIKey,
Models: optionalGroupModels{Set: true},
Credentials: "sk-log-label-abcdefghijklmnop",
})
if err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
var credential models.Credential
if err := fixture.db.Where("group_id = ?", created.GroupID).Take(&credential).Error; err != nil {
t.Fatal(err)
}
queries := 0
const callbackName = "test:credential_labels_no_db"
if err := fixture.db.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) {
queries++
}); err != nil {
t.Fatalf("register query observer: %v", err)
}
t.Cleanup(func() { _ = fixture.db.Callback().Query().Remove(callbackName) })
labels := fixture.service.CredentialLabels([]uint{credential.ID, credential.ID, 9_999})
if queries != 0 {
t.Fatalf("database queries = %d, want 0", queries)
}
label, exists := labels[credential.ID]
if !exists || label == "" {
t.Fatalf("label for %d = %q, exists = %v", credential.ID, label, exists)
}
if strings.Contains(label, "log-label-abcdefghijklmnop") {
t.Fatalf("label %q leaks the credential", label)
}
if want := utils.MaskAPIKey("sk-log-label-abcdefghijklmnop"); label != want {
t.Fatalf("label = %q, want %q", label, want)
}
if _, exists := labels[9_999]; exists {
t.Fatalf("unknown credential should be absent, got %q", labels[9_999])
}
}
func TestRequestLogResponsesCarryCredentialLabels(t *testing.T) {
t.Parallel()
record := requestlog.Record{
RequestID: "11111111-1111-4111-8111-111111111111",
CompletedAtMS: 1_700_000_000_000,
GroupID: 3,
CredentialID: 41,
UsageState: usage.StateComplete,
CostState: pricing.CostStatePriced,
PricingCompleteness: pricing.CompletenessComplete,
Attempts: []requestlog.Attempt{
{Sequence: 1, GroupID: 3, CredentialID: 41},
{Sequence: 2, GroupID: 3, CredentialID: 42},
},
}
labels := map[uint]string{41: "m***t@example.com"}
detail, err := mapRequestLogDetailResponse(record, labels)
if err != nil {
t.Fatalf("mapRequestLogDetailResponse() error = %v", err)
}
if detail.CredentialName != "m***t@example.com" {
t.Fatalf("item credential_name = %q", detail.CredentialName)
}
if detail.Attempts[0].CredentialName != "m***t@example.com" {
t.Fatalf("attempt 1 credential_name = %q", detail.Attempts[0].CredentialName)
}
// 注册表里没有的凭据留空,前端据此显示“已删除”。
if detail.Attempts[1].CredentialName != "" {
t.Fatalf("attempt 2 credential_name = %q, want empty", detail.Attempts[1].CredentialName)
}
list, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, labels)
if err != nil {
t.Fatalf("mapRequestLogListResponse() error = %v", err)
}
if list.Items[0].CredentialName != "m***t@example.com" {
t.Fatalf("list credential_name = %q", list.Items[0].CredentialName)
}
}
// 同一个凭据在一页里反复出现,收集时按出现顺序全量给出,去重交给 CredentialLabels。
func TestRequestLogCredentialIDsCollectsItemAndAttempts(t *testing.T) {
t.Parallel()
ids := requestLogCredentialIDs([]requestlog.Record{
{CredentialID: 41, Attempts: []requestlog.Attempt{{CredentialID: 41}, {CredentialID: 42}}},
{CredentialID: 41},
})
want := []uint{41, 41, 42, 41}
if len(ids) != len(want) {
t.Fatalf("ids = %v, want %v", ids, want)
}
for index := range want {
if ids[index] != want[index] {
t.Fatalf("ids = %v, want %v", ids, want)
}
}
}
+2 -1
View File
@@ -425,7 +425,8 @@ export interface HealthProblemCredentialDto {
weight_manual: number | null
weight_auto: number
recovery: HealthRecoveryDto
mask: string
/** API 密钥仍是掩码,订阅账号给完整邮箱,与凭据卡片、日志的展示约定一致。 */
identity: string
last_failure_category: Exclude<FailureCategory, 'ok'>
last_status_code: number | null
}
+2 -2
View File
@@ -90,7 +90,7 @@ const problemCredentialFields = [
'weight_manual',
'weight_auto',
'recovery',
'mask',
'identity',
'last_failure_category',
'last_status_code',
] as const
@@ -218,7 +218,7 @@ function projectProblemCredential(value: unknown): HealthProblemCredentialDto {
: projectSafeInteger(record.weight_manual, { minimum: 0, maximum: 100 }),
weight_auto: projectSafeInteger(record.weight_auto, { minimum: 0, maximum: 100 }),
recovery,
mask: projectString(record.mask),
identity: projectString(record.identity),
last_failure_category: projectEnum(record.last_failure_category, problemFailureCategories),
last_status_code:
record.last_status_code === null
+22 -11
View File
@@ -322,6 +322,7 @@ function projectCatalogReference(
function projectUpstreamPrice(
record: Record<string, unknown>,
upstreamModelID: string,
isAccessKey: boolean,
): Pick<ModelUpstreamDto, 'price' | 'route_groups' | 'affected_groups' | 'catalog_reference'> {
const price = projectModelPrice(record.price)
const routeGroups = projectArray(record.route_groups, projectRouteGroup)
@@ -329,14 +330,16 @@ function projectUpstreamPrice(
const affectedGroupIDs = new Set(affectedGroups.map(({ id }) => id))
if (
price.model_id !== upstreamModelID ||
routeGroups.length === 0 ||
affectedGroups.length === 0 ||
(isAccessKey
? routeGroups.length !== 0 || affectedGroups.length !== 0
: routeGroups.length === 0 ||
affectedGroups.length === 0 ||
price.reference_group_count !== affectedGroups.length) ||
routeGroups.some(({ channel_id }) => channel_id !== price.channel_id) ||
affectedGroups.some(({ channel_id }) => channel_id !== price.channel_id) ||
new Set(routeGroups.map(({ id }) => id)).size !== routeGroups.length ||
affectedGroupIDs.size !== affectedGroups.length ||
routeGroups.some(({ id }) => !affectedGroupIDs.has(id)) ||
price.reference_group_count !== affectedGroups.length
routeGroups.some(({ id }) => !affectedGroupIDs.has(id))
) {
invalidResponse()
}
@@ -351,7 +354,11 @@ function projectUpstreamPrice(
}
}
function projectUpstreamModel(value: unknown, clientModel: string): ModelUpstreamDto {
function projectUpstreamModel(
value: unknown,
clientModel: string,
isAccessKey: boolean,
): ModelUpstreamDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, upstreamModelFields)
const modelID = projectIdentityString(record.model_id)
@@ -360,7 +367,7 @@ function projectUpstreamModel(value: unknown, clientModel: string): ModelUpstrea
return {
model_id: modelID,
alias_applied: aliasApplied,
...projectUpstreamPrice(record, modelID),
...projectUpstreamPrice(record, modelID, isAccessKey),
}
}
@@ -404,12 +411,12 @@ export function projectUpstreamModelDetail(value: unknown): UpstreamModelDetailD
}
}
function projectClientModel(value: unknown): ClientModelDto {
function projectClientModel(value: unknown, isAccessKey: boolean): ClientModelDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, clientModelFields)
const clientModel = projectIdentityString(record.client_model)
const upstreamModels = projectArray(record.upstream_models, (upstream) =>
projectUpstreamModel(upstream, clientModel),
projectUpstreamModel(upstream, clientModel, isAccessKey),
)
if (
upstreamModels.length === 0 ||
@@ -475,12 +482,12 @@ function expectedPageItems(pagination: ModelCollectionPaginationDto): number {
return remainder === 0 ? pagination.page_size : remainder
}
export function projectModelCollection(value: unknown): ModelCollectionDto {
export function projectModelCollection(value: unknown, isAccessKey = false): ModelCollectionDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, collectionFields)
const summary = projectSummary(record.summary)
const catalog = projectCatalog(record.catalog)
const items = projectArray(record.items, projectClientModel)
const items = projectArray(record.items, (item) => projectClientModel(item, isAccessKey))
const pagination = projectPagination(record.pagination)
const expectedTotalPages =
pagination.total_items === 0 ? 0 : Math.ceil(pagination.total_items / pagination.page_size)
@@ -513,6 +520,7 @@ export async function listModels(
client: ApiClient,
filters: ModelCollectionFilters,
signal?: AbortSignal,
isAccessKey = false,
): Promise<ModelCollectionDto> {
const normalized = normalizeModelCollectionFilters(filters)
const params = new URLSearchParams({
@@ -524,6 +532,7 @@ export async function listModels(
if (normalized.q !== undefined) params.set('q', normalized.q)
const result = projectModelCollection(
await client.request(`/api/models?${params.toString()}`, { method: 'GET', signal }),
isAccessKey,
)
if (
result.pagination.page !== normalized.page ||
@@ -547,12 +556,14 @@ export async function getUpstreamModelDetail(
export function modelCollectionQueryOptions(
client: ApiClient,
filters: MaybeRefOrGetter<ModelCollectionFilters>,
isAccessKey: MaybeRefOrGetter<boolean> = false,
) {
return queryOptions({
queryKey: computed(() =>
controlQueryKeys.models.collection(normalizeModelCollectionFilters(toValue(filters))),
),
queryFn: ({ queryKey, signal }) => listModels(client, queryKey[3], signal),
queryFn: ({ queryKey, signal }) =>
listModels(client, queryKey[3], signal, toValue(isAccessKey)),
placeholderData: keepPreviousData,
})
}
+8
View File
@@ -126,6 +126,8 @@ export interface RequestLogAttemptDto {
group_name: string
channel_id: string | null
credential_id: number | null
/** 凭据的可读标识(掩码)。凭据已删除时为空。 */
credential_name: string
operation: RequestLogOperation | null
route_mode: RequestLogRouteMode | null
upstream_model: string | null
@@ -180,6 +182,8 @@ export interface RequestLogItemDto {
group_id: number | null
channel_id: string | null
credential_id: number | null
/** 凭据的可读标识(掩码)。凭据已删除时为空。 */
credential_name: string
route_mode: RequestLogRouteMode | null
usage_state: RequestLogUsageState
cost_state: RequestLogCostState
@@ -281,6 +285,7 @@ const itemFields = [
'group_id',
'channel_id',
'credential_id',
'credential_name',
'route_mode',
'usage_state',
'cost_state',
@@ -423,6 +428,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto {
'group_name',
'channel_id',
'credential_id',
'credential_name',
'operation',
'route_mode',
'upstream_model',
@@ -455,6 +461,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto {
record.credential_id === null
? null
: projectSafeInteger(record.credential_id, { minimum: 1 }),
credential_name: projectString(record.credential_name, { allowEmpty: true }),
operation: record.operation === null ? null : projectEnum(record.operation, operations),
route_mode: record.route_mode === null ? null : projectEnum(record.route_mode, routeModes),
upstream_model: projectNullableModel(record.upstream_model),
@@ -602,6 +609,7 @@ function projectItemRecord(record: Record<string, unknown>): RequestLogItemDto {
record.credential_id === null
? null
: projectSafeInteger(record.credential_id, { minimum: 1 }),
credential_name: projectString(record.credential_name, { allowEmpty: true }),
route_mode: record.route_mode === null ? null : projectEnum(record.route_mode, routeModes),
pricing_mode: record.pricing_mode === null ? null : projectPricingMode(record.pricing_mode),
context_threshold_tokens:
+14 -12
View File
@@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n'
import type { ProxyConfiguredMode, ProxyMutation, ProxyViewDto } from '@/api/control/types'
import { proxyDraftState, proxyPlaceholderURL } from '@/app/resources/proxy'
import AppButton from '@/components/ui/AppButton.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import AppTextInput from '@/components/ui/AppTextInput.vue'
import CompactFieldError from '@/components/ui/CompactFieldError.vue'
import IconButton from '@/components/ui/IconButton.vue'
@@ -35,6 +35,10 @@ const modeOptions = computed(() => [
{ value: 'direct', label: t('common.proxy.mode.direct') },
{ value: 'custom', label: t('common.proxy.mode.custom') },
])
// 分段控件没有整体 disabled,逐项禁用。
const segmentedModeOptions = computed(() =>
modeOptions.value.map((option) => ({ ...option, disabled: props.disabled || pending.value })),
)
const draft = computed(() => proxyDraftState(props.view, mode.value, endpoint.value))
const endpointError = computed(() =>
mode.value === 'custom' && touched.value && draft.value.invalid
@@ -78,6 +82,9 @@ function updateEndpoint(value: string): void {
saveFailed.value = false
}
// 供卡片徽章行的指示器一次完成“展开 + 进入编辑”。
defineExpose({ beginEdit })
async function save(): Promise<void> {
touched.value = true
saveFailed.value = false
@@ -126,13 +133,12 @@ async function save(): Promise<void> {
</template>
<form v-else class="setting-panel__form" @submit.prevent="save">
<AppSelect
<SegmentedControl
class="proxy-config-editor__mode"
:model-value="mode"
:label="t('common.proxy.modeLabel')"
:options="modeOptions"
size="compact"
:disabled="disabled || pending"
:options="segmentedModeOptions"
size="xs"
@update:model-value="updateMode"
/>
<CompactFieldError
@@ -209,13 +215,8 @@ async function save(): Promise<void> {
font-size: var(--text-label-xs);
}
/* 下拉固定宽度,不随所选模式伸缩。 */
.proxy-config-editor__mode :deep(.app-select__trigger) {
width: 92px;
min-height: 26px;
.proxy-config-editor__mode {
flex: none;
padding-inline: 7px;
font-size: var(--text-label-xs);
}
.proxy-config-editor__endpoint {
@@ -223,7 +224,8 @@ async function save(): Promise<void> {
--compact-field-error-indicator-size: 22px;
--compact-field-error-indicator-right: 2px;
--compact-field-error-input-gap: 4px;
flex: 1 1 120px;
/* basis 归零,地址栏独吞剩余宽度,控件行不折行。 */
flex: 1 1 0;
min-width: 0;
}
@@ -6,22 +6,35 @@ import { useI18n } from 'vue-i18n'
import type { ProxyViewDto } from '@/api/control/types'
import AppTooltip from '@/components/ui/AppTooltip.vue'
const props = defineProps<{ view: ProxyViewDto }>()
const props = withDefaults(defineProps<{ view: ProxyViewDto; clickable?: boolean }>(), {
clickable: false,
})
const emit = defineEmits<{ activate: [] }>()
const { t } = useI18n()
// 只在凭据自己配置了代理(未沿用上一级)时提示,继承态不加视觉噪音。
const own = computed(() => props.view.configured_mode !== 'inherit')
// 只报类型,具体地址留给展开后的代理面板,避免在列表层面泄露/堆叠细节。
const tooltip = computed(() =>
t('common.proxy.ownTooltip', {
type: t(`common.proxy.mode.${props.view.configured_mode}`),
}),
)
const tooltip = computed(() => {
const type = t(`common.proxy.mode.${props.view.configured_mode}`)
return props.clickable
? t('common.proxy.ownTooltipClickable', { type })
: t('common.proxy.ownTooltip', { type })
})
</script>
<template>
<AppTooltip v-if="own" :content="tooltip">
<span class="proxy-scope-indicator" tabindex="0" :aria-label="tooltip">
<button
v-if="clickable"
class="proxy-scope-indicator proxy-scope-indicator--clickable"
type="button"
:aria-label="tooltip"
@click="emit('activate')"
>
<Route :size="13" aria-hidden="true" />
</button>
<span v-else class="proxy-scope-indicator" tabindex="0" :aria-label="tooltip">
<Route :size="13" aria-hidden="true" />
</span>
</AppTooltip>
@@ -40,6 +53,14 @@ const tooltip = computed(() =>
cursor: help;
}
/* 容器是固定 22×22 的 border-box,UA 默认内边距会挤变形图标。 */
.proxy-scope-indicator--clickable {
border: 0;
padding: 0;
font: inherit;
cursor: pointer;
}
.proxy-scope-indicator:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
+8 -1
View File
@@ -16,7 +16,7 @@ withDefaults(
idPrefix?: string
scrollable?: boolean
appearance?: 'joined' | 'pills' | 'drawer'
size?: 'sm' | 'compact' | 'touch'
size?: 'xs' | 'sm' | 'compact' | 'touch'
}>(),
{
controlsId: undefined,
@@ -118,6 +118,13 @@ function handleSegmentKeydown(event: KeyboardEvent): void {
min-height: var(--control-compact);
padding: 4px 10px;
}
/* 与设置面板内同排的输入框、按钮统一 26px。 */
.segmented-control__list--xs .segmented-control__trigger {
min-height: 26px;
padding: 3px 9px;
font-size: var(--text-label-xs);
font-weight: 620;
}
.segmented-control__list--sm .segmented-control__trigger {
min-width: 78px;
min-height: var(--control-sm);
+65 -22
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ArrowRight, KeyRound, Layers3, Plus, Search, TriangleAlert, UserRound } from '@lucide/vue'
import { useQuery } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -10,15 +10,22 @@ import type {
ConnectionType,
CredentialCounts,
GroupCollectionFilters,
GroupCollectionItemDto,
GroupCollectionSort,
GroupCollectionStatus,
} from '@/api/control/types'
import { channelsQueryOptions, type ChannelDto } from '@/app/resources/channels'
import { groupCollectionQueryOptions } from '@/app/resources/groups'
import {
cacheGroupSettings,
groupCollectionQueryOptions,
invalidateGroupSettingsDependents,
updateGroupSettings,
} from '@/app/resources/groups'
import { groupDetailLocation, groupsLocation, importLocation } from '@/app/route-locations'
import { useCollectionLoading } from '@/app/loading-state'
import { useDebouncedAction } from '@/app/use-debounced-action'
import { useVisibleRefetch } from '@/app/use-visible-refetch'
import { useToast } from '@/app/toast'
import ChannelIcon from '@/components/brand/ChannelIcon.vue'
import CollectionFilterBar from '@/components/collection/CollectionFilterBar.vue'
import CollectionStatusSummary from '@/components/collection/CollectionStatusSummary.vue'
@@ -26,6 +33,7 @@ import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
import PageFrame from '@/components/layout/PageFrame.vue'
import AppButton from '@/components/ui/AppButton.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue'
import AsyncRefreshIndicator from '@/components/ui/AsyncRefreshIndicator.vue'
import AppSearchInput from '@/components/ui/AppSearchInput.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
@@ -71,6 +79,42 @@ const channelsByID = computed<Record<string, ChannelDto>>(() =>
const searchDebounce = useDebouncedAction(250)
const data = computed(() => groupsQuery.data.value)
const toast = useToast()
const queryClient = useQueryClient()
const togglingGroupIDs = ref(new Set<number>())
const optimisticEnabled = ref(new Map<number, boolean>())
// weight_manual 为 0 也判定 disabled,但接口限定 1~100,故 disabled 即已停用。
// AppSwitch 纯受控,等请求走完才翻转会像卡住,故先本地置位。
function groupEnabled(group: GroupCollectionItemDto): boolean {
return optimisticEnabled.value.get(group.id) ?? group.status !== 'disabled'
}
async function toggleGroupEnabled(group: GroupCollectionItemDto, next: boolean): Promise<void> {
if (togglingGroupIDs.value.has(group.id)) return
optimisticEnabled.value = new Map(optimisticEnabled.value).set(group.id, next)
togglingGroupIDs.value = new Set(togglingGroupIDs.value).add(group.id)
try {
const settings = await updateGroupSettings(client, group.id, { enabled: next })
cacheGroupSettings(queryClient, group.id, settings)
await invalidateGroupSettingsDependents(queryClient, group.id)
toast.show({
message: t(next ? 'groups.collection.enabledOn' : 'groups.collection.enabledOff', {
name: group.name,
}),
tone: next ? 'success' : 'warning',
})
} catch {
toast.show({ message: t('groups.collection.toggleFailed'), tone: 'danger' })
} finally {
const optimistic = new Map(optimisticEnabled.value)
optimistic.delete(group.id)
optimisticEnabled.value = optimistic
const pending = new Set(togglingGroupIDs.value)
pending.delete(group.id)
togglingGroupIDs.value = pending
}
}
const hasFilterCriteria = computed(
() =>
filters.value.q !== undefined ||
@@ -453,6 +497,12 @@ function connectionTypeBadgeClass(type: ConnectionType): string {
</div>
<div class="ledger-record-list__cell group-status" role="cell">
<AppSwitch
:model-value="groupEnabled(group)"
:disabled="togglingGroupIDs.has(group.id)"
:label="t('groups.collection.toggleEnabled', { name: group.name })"
@update:model-value="toggleGroupEnabled(group, $event)"
/>
<StatusBadge :status="group.status">
{{ t(`groups.collection.status.${group.status}`) }}
</StatusBadge>
@@ -517,19 +567,15 @@ function connectionTypeBadgeClass(type: ConnectionType): string {
:to="importLocation({ mode: 'existing', group_id: group.id })"
custom
>
<AppButton
class="append-credential"
<IconButton
role="link"
variant="secondary"
variant="surface"
size="compact"
:aria-label="t('groups.collection.appendCredentialFor', { name: group.name })"
:label="t('groups.collection.appendCredentialFor', { name: group.name })"
@click="navigate"
>
<Plus :size="15" aria-hidden="true" />
<span class="append-credential__label">{{
t('groups.collection.appendCredential')
}}</span>
</AppButton>
</IconButton>
</RouterLink>
<RouterLink v-slot="{ navigate }" :to="groupDetailLocation(group.id)" custom>
<IconButton
@@ -571,7 +617,14 @@ function connectionTypeBadgeClass(type: ConnectionType): string {
}
.groups-record-grid {
--ledger-record-list-grid: minmax(0, 1fr) 96px minmax(0, 1.55fr) 92px minmax(0, 1.25fr) 164px;
/* 状态列扩宽容纳开关,操作列去掉文案后收窄,总宽比改前更小。 */
--ledger-record-list-grid: minmax(0, 1fr) 140px minmax(0, 1.55fr) 92px minmax(0, 1.25fr) 96px;
}
.group-status {
display: flex;
align-items: center;
gap: 9px;
}
.group-name {
@@ -710,19 +763,9 @@ function connectionTypeBadgeClass(type: ConnectionType): string {
@media (max-width: 1040px) {
.groups-record-grid {
--ledger-record-list-grid: minmax(0, 1fr) 84px minmax(0, 1.25fr) 76px minmax(0, 1.15fr) 72px;
--ledger-record-list-grid: minmax(0, 1fr) 124px minmax(0, 1.25fr) 76px minmax(0, 1.15fr) 72px;
--ledger-record-list-column-gap: 12px;
}
.append-credential {
width: 34px;
min-width: 34px;
padding: 0;
}
.append-credential__label {
display: none;
}
}
@media (max-width: 860px) {
@@ -308,9 +308,10 @@ function runMenuAction(action: 'test' | 'toggle' | 'restore' | 'remove'): void {
>
<SegmentedControl
v-model="draftWeightMode"
class="group-credential-record__weight-mode"
:label="t('group.credentials.weightEditor.mode')"
:options="weightModeOptions"
size="compact"
size="xs"
/>
<label class="sr-only" :for="weightInputId">
{{ t('group.credentials.weightEditor.value') }}
@@ -531,6 +532,10 @@ function runMenuAction(action: 'test' | 'toggle' | 'restore' | 'remove'): void {
gap: 13px 16px;
}
.group-credential-record__weight-mode {
flex: none;
}
.group-credential-record__weight-form > input {
width: 64px;
min-height: 26px;
@@ -1718,6 +1718,7 @@ async function runBatch(
@update:selected="setSelected(item.credential_id, $event)"
@toggle="mutateItem($event, 'toggle')"
@restore="mutateItem($event, 'restore')"
@weight="mutateItem($event.item, 'weight', $event.value)"
@refresh="refreshObservation"
@load-details="loadCredentialUsage"
@reset="openResetCreditDialog"
@@ -8,11 +8,12 @@ import {
Gauge,
KeyRound,
LoaderCircle,
PencilLine,
RefreshCw,
RotateCcw,
Trash2,
} from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
@@ -31,6 +32,7 @@ import AppRelativeTime from '@/components/ui/AppRelativeTime.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import IconButton from '@/components/ui/IconButton.vue'
import OverflowTooltip from '@/components/ui/OverflowTooltip.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import SkeletonBlock from '@/components/ui/SkeletonBlock.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatEstimatedCost, formatLocalInstant, formatTokens } from '@/lib/format'
@@ -68,10 +70,73 @@ const emit = defineEmits<{
download: [item: CredentialItemDto]
'refresh-credential': [item: CredentialItemDto]
remove: [item: CredentialItemDto]
weight: [payload: { item: CredentialItemDto; value: string }]
}>()
const { locale, n, t, te } = useI18n()
const menuOpen = ref(false)
const detailsExpanded = ref(false)
const proxyEditor = ref<{ beginEdit: () => void } | null>(null)
const weightEditing = ref(false)
const draftWeightMode = ref<'auto' | 'manual'>('auto')
const draftWeight = ref('50')
const weightInputId = computed(() => `subscription-account-weight-${props.item.credential_id}`)
// 自动权重等同代理的“继承”态,折叠时不加视觉噪音。
const showWeightChip = computed(
() => props.item.weight_mode === 'manual' && props.item.weight !== null,
)
const weightChipTooltip = computed(() =>
t('group.credentials.weightChipTooltip', { weight: n(props.item.weight ?? 0) }),
)
const weightModeOptions = computed(() => [
{ value: 'auto', label: t('group.credentials.weightEditor.auto'), disabled: props.busy },
{ value: 'manual', label: t('group.credentials.weightEditor.manual'), disabled: props.busy },
])
const manualWeightValid = computed(() => {
if (draftWeightMode.value === 'auto') return true
const value = Number(draftWeight.value)
return Number.isInteger(value) && value >= 1 && value <= 100
})
function resetWeightDraft(): void {
draftWeightMode.value = props.item.weight_mode
draftWeight.value = String(props.item.weight ?? 50)
}
// 一次完成“展开 + 进入编辑”,与密钥列表点权重值一致。
function editWeight(): void {
if (props.busy) return
resetWeightDraft()
detailsExpanded.value = true
weightEditing.value = true
}
function editProxy(): void {
if (props.busy) return
detailsExpanded.value = true
void nextTick(() => proxyEditor.value?.beginEdit())
}
function saveWeight(): void {
if (props.busy || !manualWeightValid.value) return
emit('weight', {
item: props.item,
value: draftWeightMode.value === 'auto' ? 'auto' : String(Number(draftWeight.value)),
})
weightEditing.value = false
}
// 收起卡片时退出编辑,避免下次展开停在旧草稿。
watch(
() => [props.item.weight_mode, props.item.weight] as const,
() => {
if (!weightEditing.value) resetWeightDraft()
},
{ immediate: true },
)
watch(detailsExpanded, (expanded) => {
if (!expanded) weightEditing.value = false
})
const nowMs = ref(Date.now())
let clockTimer: number | undefined
@@ -717,7 +782,24 @@ function runMenuAction(
>
{{ statusLabel }}
</StatusBadge>
<ProxyScopeIndicator v-if="capabilities.outbound_proxy" :view="item.proxy" />
<AppTooltip v-if="showWeightChip" :content="weightChipTooltip">
<button
class="subscription-account__weight-chip"
type="button"
:disabled="busy"
:aria-label="weightChipTooltip"
@click="editWeight"
>
<Gauge :size="12" aria-hidden="true" />
<b>{{ n(item.weight as number) }}</b>
</button>
</AppTooltip>
<ProxyScopeIndicator
v-if="capabilities.outbound_proxy"
:view="item.proxy"
clickable
@activate="editProxy"
/>
</div>
<div class="subscription-account__actions">
<span
@@ -1241,13 +1323,82 @@ function runMenuAction(
</div>
</section>
</div>
<ProxyConfigEditor
class="subscription-account__proxy"
:view="item.proxy"
:save-proxy="saveProxy"
:supported="capabilities.outbound_proxy"
:disabled="busy"
/>
<div class="subscription-account__panels">
<div class="setting-panel">
<span class="setting-panel__title">{{ t('group.credentials.columns.weight') }}</span>
<div class="setting-panel__body">
<template v-if="!weightEditing">
<span class="setting-panel__tag">
{{ t(`group.credentials.weightEditor.${item.weight_mode}`) }}
</span>
<span class="setting-panel__value">
{{ item.weight === null ? t('group.credentials.none') : n(item.weight) }}
</span>
<IconButton
class="setting-panel__edit"
variant="ghost"
tone="action"
size="xs"
:label="t('group.credentials.editWeight')"
:disabled="busy || displayDisabled"
@click="editWeight"
>
<PencilLine :size="12" aria-hidden="true" />
</IconButton>
</template>
<form v-else class="setting-panel__form" @submit.prevent="saveWeight">
<SegmentedControl
v-model="draftWeightMode"
class="subscription-account__weight-mode"
:label="t('group.credentials.weightEditor.mode')"
:options="weightModeOptions"
size="xs"
/>
<label class="sr-only" :for="weightInputId">
{{ t('group.credentials.weightEditor.value') }}
</label>
<input
:id="weightInputId"
v-model="draftWeight"
class="subscription-account__weight-input"
:class="{ 'is-concealed': draftWeightMode === 'auto' }"
type="number"
min="1"
max="100"
step="1"
inputmode="numeric"
:disabled="busy || draftWeightMode === 'auto'"
:tabindex="draftWeightMode === 'auto' ? -1 : undefined"
:aria-hidden="draftWeightMode === 'auto' ? 'true' : undefined"
:aria-invalid="!manualWeightValid || undefined"
/>
<div class="setting-panel__actions">
<AppButton variant="ghost" size="compact" @click="weightEditing = false">
{{ t('group.credentials.weightEditor.cancel') }}
</AppButton>
<AppButton type="submit" size="compact" :disabled="busy || !manualWeightValid">
{{ t('group.credentials.weightEditor.save') }}
</AppButton>
</div>
<p
v-if="draftWeightMode === 'manual' && !manualWeightValid"
class="setting-panel__error"
role="alert"
>
{{ t('group.credentials.weightEditor.invalid') }}
</p>
</form>
</div>
</div>
<ProxyConfigEditor
ref="proxyEditor"
:view="item.proxy"
:save-proxy="saveProxy"
:supported="capabilities.outbound_proxy"
:disabled="busy"
/>
</div>
</section>
</article>
</template>
@@ -1842,9 +1993,59 @@ function runMenuAction(
display: grid;
gap: 13px;
}
.subscription-account__proxy {
.subscription-account__panels {
display: grid;
gap: 13px;
margin-top: 13px;
}
.subscription-account__weight-chip {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 4px;
border: 0;
border-radius: var(--radius-tag);
background: var(--color-info-bg);
color: var(--color-info);
padding: 3px 7px 3px 6px;
font: inherit;
font-size: var(--text-sm);
font-weight: 650;
white-space: nowrap;
cursor: pointer;
}
.subscription-account__weight-chip:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.subscription-account__weight-chip:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.subscription-account__weight-chip > b {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.subscription-account__weight-mode {
flex: none;
}
.subscription-account__weight-input {
width: 64px;
min-height: 26px;
flex: none;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font-family: var(--font-mono);
font-size: var(--text-label-xs);
font-variant-numeric: tabular-nums;
}
.subscription-account__weight-input.is-concealed {
visibility: hidden;
}
.subscription-account__skeleton-section {
display: grid;
gap: 6px;
+103 -2
View File
@@ -2,10 +2,12 @@
import { ChevronRight, CircleHelp, Zap } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { enabledDataProtocols } from '@/api/control/protocols'
import { groupDetailLocation } from '@/app/route-locations'
import type { GroupProtocol } from '@/api/control/types'
import type { ClientModelDto, ModelUpstreamDto } from '@/app/resources/models'
import type { ClientModelDto, ModelRouteGroupDto, ModelUpstreamDto } from '@/app/resources/models'
import ChannelIcon from '@/components/brand/ChannelIcon.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import CopyChip from '@/components/ui/CopyChip.vue'
@@ -20,7 +22,35 @@ const props = defineProps<{
readOnly?: boolean
}>()
const emit = defineEmits<{ open: [upstream: ModelUpstreamDto] }>()
const { t } = useI18n()
// 一行里的分组必定同渠道,两枚足够点出归属,其余在抽屉里看。
const visibleRouteGroupCount = 2
function visibleRouteGroups(upstream: ModelUpstreamDto): ModelRouteGroupDto[] {
return upstream.route_groups.slice(0, visibleRouteGroupCount)
}
function hiddenRouteGroups(upstream: ModelUpstreamDto): ModelRouteGroupDto[] {
return upstream.route_groups.slice(visibleRouteGroupCount)
}
// narrow 去掉“和 / and”只留分隔符;unit 类型在中日文下不插分隔符,不能用。
function formatGroupNames(names: string[]): string {
try {
return new Intl.ListFormat(locale.value, { style: 'narrow', type: 'conjunction' }).format(names)
} catch {
return names.join(', ')
}
}
function hiddenRouteGroupsTooltip(upstream: ModelUpstreamDto): string {
const hidden = hiddenRouteGroups(upstream)
return t('models.tree.routeGroupMore', {
count: hidden.length,
names: formatGroupNames(hidden.map(({ name }) => name)),
})
}
const { locale, t } = useI18n()
const rows = computed<ClientModelRow[]>(() => props.items.map(presentClientModel))
@@ -168,6 +198,34 @@ function pricingIdentityTooltip(upstream: ModelUpstreamDto): string {
<span v-if="entry.tierCount > 0" class="model-tree__tag">
{{ t('models.tree.tierCount', { count: entry.tierCount }) }}
</span>
<span
v-if="!readOnly && entry.upstream.route_groups.length > 0"
class="model-tree__groups"
:aria-label="t('models.tree.routeGroups')"
>
<RouterLink
v-for="group in visibleRouteGroups(entry.upstream)"
:key="group.id"
class="model-tree__group"
:class="{ 'model-tree__group--disabled': !group.enabled }"
:to="groupDetailLocation(group.id, { tab: 'models' })"
:title="
group.enabled
? t('models.tree.routeGroupLink', { name: group.name })
: t('models.tree.routeGroupDisabled', { name: group.name })
"
>
{{ group.name }}
</RouterLink>
<AppTooltip
v-if="hiddenRouteGroups(entry.upstream).length > 0"
:content="hiddenRouteGroupsTooltip(entry.upstream)"
>
<span class="model-tree__group model-tree__group--more" tabindex="0">
+{{ hiddenRouteGroups(entry.upstream).length }}
</span>
</AppTooltip>
</span>
</div>
<div
@@ -477,6 +535,49 @@ function pricingIdentityTooltip(upstream: ModelUpstreamDto): string {
text-align: left;
}
.model-tree__groups {
display: inline-flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.model-tree__group {
max-width: 168px;
overflow: hidden;
border-radius: var(--radius-tag);
background: var(--color-action-soft);
color: var(--color-action);
padding: 1px 7px;
font-size: var(--text-label-xs);
font-weight: 620;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tree__group:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.model-tree__group:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.model-tree__group--disabled {
background: var(--color-neutral-bg);
color: var(--color-text-faint);
}
.model-tree__group--more {
background: var(--color-surface-sunken);
color: var(--color-text-muted);
cursor: help;
}
.model-tree__upstream-name {
color: var(--color-text-muted);
font-family: var(--font-mono);
@@ -265,7 +265,7 @@ defineExpose({ requestClose, confirmDiscardSwitch, discardChanges, hasUnsavedCha
</span>
<span class="upstream-drawer__group">
<span class="upstream-drawer__eyebrow">{{ t('models.drawer.group') }}</span>
<RouterLink :to="groupDetailLocation(association.group.id)">
<RouterLink :to="groupDetailLocation(association.group.id, { tab: 'models' })">
{{ association.group.name }}
</RouterLink>
<span v-if="!association.group.enabled" class="upstream-drawer__tag">
+1 -1
View File
@@ -65,7 +65,7 @@ const drawerOpen = computed(() => routeState.value.selectedPriceID !== undefined
const activePriceID = computed(() => routeState.value.selectedPriceID ?? null)
const drawer = ref<InstanceType<typeof ModelUpstreamDrawer>>()
const modelsQuery = useQuery(modelCollectionQueryOptions(client, filters))
const modelsQuery = useQuery(modelCollectionQueryOptions(client, filters, isAccessKey))
const data = computed(() => modelsQuery.data.value)
const collectionBusy = computed(() => data.value !== undefined && modelsQuery.isFetching.value)
const {
@@ -122,11 +122,11 @@ function credentialMeta(credential: HealthProblemCredentialDto): string {
<div class="ledger-record-list__cell problem-health-record__identity" role="cell">
<OverflowTooltip
:as="RouterLink"
class="problem-health-record__mask"
:content="item.credential.mask"
class="problem-health-record__identity"
:content="item.credential.identity"
:to="groupDetailLocation(item.credential.group_id, { tab: 'credentials' })"
>
{{ item.credential.mask }}
{{ item.credential.identity }}
</OverflowTooltip>
<OverflowTooltip as="small" :content="credentialMeta(item.credential)">
{{ credentialMeta(item.credential) }}
@@ -266,7 +266,9 @@ function credentialMeta(credential: HealthProblemCredentialDto): string {
role="link"
variant="ghost"
size="compact"
:label="t('monitor.health.problems.viewLogs', { credential: item.credential.mask })"
:label="
t('monitor.health.problems.viewLogs', { credential: item.credential.identity })
"
@click="navigate"
>
<ScrollText :size="15" aria-hidden="true" />
@@ -335,7 +337,7 @@ function credentialMeta(credential: HealthProblemCredentialDto): string {
gap: var(--space-1);
}
.problem-health-record__mask {
.problem-health-record__identity {
max-width: 100%;
color: var(--color-text);
font-family: var(--font-mono);
@@ -345,7 +347,7 @@ function credentialMeta(credential: HealthProblemCredentialDto): string {
white-space: nowrap;
}
.problem-health-record__mask:hover {
.problem-health-record__identity:hover {
color: var(--color-action);
}
+63 -3
View File
@@ -5,8 +5,10 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useApiClient } from '@/api/client-context'
import { useAbortControllerPool } from '@/app/use-abort-controller-pool'
import { useStableLoading } from '@/app/loading-state'
import type { ChannelDto } from '@/app/resources/channels'
import { revealCredential } from '@/app/resources/credentials'
import {
requestLogDetailQueryOptions,
type RequestLogAttemptDto,
@@ -16,6 +18,7 @@ import AppButton from '@/components/ui/AppButton.vue'
import AppDateTime from '@/components/ui/AppDateTime.vue'
import AppDrawer from '@/components/ui/AppDrawer.vue'
import CopyButton from '@/components/ui/CopyButton.vue'
import CopyChip from '@/components/ui/CopyChip.vue'
import OverflowTooltip from '@/components/ui/OverflowTooltip.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import SkeletonSurface from '@/components/ui/SkeletonSurface.vue'
@@ -152,7 +155,10 @@ watch(
watch(
() => props.open,
(open) => {
if (!open) return
if (!open) {
copyControllers.abortAll()
return
}
errorMessageExpanded.value = false
expandedAttemptErrorMessages.value = new Set()
},
@@ -227,7 +233,7 @@ function formatFormulaLine(line: RequestLogPricingLineDto): string {
function accessKeyLabel(): string {
const key = log.value?.access_key
if (!key) return '—'
if (key.deleted) return t('monitor.logs.accessKey.deleted', { id: key.id })
if (key.deleted) return t('monitor.logs.deletedRef', { id: key.id })
return key.name ? `${key.name} · #${key.id}` : `#${key.id}`
}
@@ -242,6 +248,32 @@ function finalGroupName(): string | null {
)
}
const copyControllers = useAbortControllerPool()
// 订阅账号展示的就是完整邮箱,直接复制即可;密钥展示的是掩码,需取真值。
const revealsCredential = computed(
() => channelDefinition(log.value?.channel_id)?.connection.type === 'api_key',
)
// 密钥的 reveal 被订阅渠道拒绝,故仅密钥类走这条取值路径。
async function resolveCredentialCopyValue(): Promise<string> {
const record = log.value
if (!record || record.group_id === null || record.credential_id === null) return ''
const controller = copyControllers.create()
try {
const result = await revealCredential(
client,
record.group_id,
record.credential_id,
controller.signal,
)
const values = Object.values(result.credential)
return values.length === 1 ? values[0] : JSON.stringify(result.credential)
} finally {
copyControllers.release(controller)
}
}
function channelDefinition(channelID: string | null | undefined): ChannelDto | null {
if (!channelID) return null
return props.channels?.[channelID] ?? null
@@ -433,15 +465,26 @@ function toggleAttemptErrorMessage(sequence: number): void {
<dl class="log-detail__grid">
<div class="log-detail__wide">
<dt>{{ t('monitor.logs.drawer.routeIdentity') }}</dt>
<dd>
<dd class="log-detail__route">
<LogRouteIdentity
:group-id="log.group_id"
:group-name="finalGroupName()"
:channel-id="log.channel_id"
:channel="finalChannel()"
:credential-id="log.credential_id"
:credential-name="log.credential_name"
:credential-deleted="log.credential_id !== null && log.credential_name === ''"
appearance="plain"
/>
<CopyChip
v-if="log.credential_name"
layout="icon"
:value="log.credential_name"
:label="t('monitor.logs.drawer.copyCredential')"
:success-label="t('common.copied')"
:failure-label="t('common.copyFailed')"
:resolve-value="revealsCredential ? resolveCredentialCopyValue : undefined"
/>
</dd>
</div>
<div>
@@ -595,6 +638,10 @@ function toggleAttemptErrorMessage(sequence: number): void {
:channel-id="attempt.channel_id"
:channel="channelDefinition(attempt.channel_id)"
:credential-id="attempt.credential_id"
:credential-name="attempt.credential_name"
:credential-deleted="
attempt.credential_id !== null && attempt.credential_name === ''
"
appearance="plain"
/>
</dd>
@@ -754,6 +801,19 @@ function toggleAttemptErrorMessage(sequence: number): void {
white-space: nowrap;
}
.log-detail__route {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.log-detail__route :deep(.copy-chip) {
min-height: 22px;
padding: 0;
}
.log-detail__request-id :deep(.copy-control button) {
width: 28px;
height: 28px;
+178 -53
View File
@@ -6,6 +6,8 @@ import type { ChannelDto } from '@/app/resources/channels'
import ChannelIcon from '@/components/brand/ChannelIcon.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import { formatRouteEntity } from './log-format'
const props = withDefaults(
defineProps<{
groupId: number | null
@@ -13,109 +15,232 @@ const props = withDefaults(
channelId: string | null
channel?: Pick<ChannelDto, 'name' | 'icon' | 'mark'> | null
credentialId: number | null
credentialName?: string | null
/** 名称来源已就绪却查不到,即该实体已被删除。 */
groupDeleted?: boolean
credentialDeleted?: boolean
appearance?: 'compact' | 'plain'
/** 列表里点分组、凭据就地筛选;详情抽屉没有筛选上下文,保持纯展示。 */
filterable?: boolean
}>(),
{
groupName: undefined,
credentialName: undefined,
channel: undefined,
groupDeleted: false,
credentialDeleted: false,
appearance: 'compact',
filterable: false,
},
)
const emit = defineEmits<{
'filter-group': [groupId: number]
'filter-credential': [credentialId: number]
}>()
const { t } = useI18n()
// Keep the compact route layout icon-first. When icon metadata is unavailable,
// show the channel name instead; an ID remains the diagnostic fallback while
// the channel catalog is loading or unavailable.
// 图标优先表达渠道;图标资源缺失时退回渠道名,ID 是最后的诊断兜底。
const showsIcon = computed(() => Boolean(props.channel?.mark))
const channelLabel = computed(() => props.channel?.name.trim() || props.channelId || '—')
const compactLabel = computed(() => {
const parts: string[] = []
if (props.groupId !== null) parts.push(`G${props.groupId}`)
if (props.credentialId !== null) parts.push(`K${props.credentialId}`)
if (!showsIcon.value && props.channelId !== null) parts.push(channelLabel.value)
return parts.join('·') || '—'
})
// 取不到名称(已删除)才退回编号,凭据与分组同一套规则。
const groupLabel = computed(() =>
formatRouteEntity({
id: props.groupId,
name: props.groupName,
deleted: props.groupDeleted,
prefix: 'G',
deletedText: (id) => t('monitor.logs.deletedRef', { id }),
}),
)
const credentialLabel = computed(() =>
props.credentialId === null
? ''
: formatRouteEntity({
id: props.credentialId,
name: props.credentialName,
deleted: props.credentialDeleted,
prefix: 'K',
deletedText: (id) => t('monitor.logs.deletedRef', { id }),
}),
)
const hasGroupName = computed(() => Boolean(props.groupName?.trim()) || props.groupDeleted)
const hasCredentialName = computed(
() => Boolean(props.credentialName?.trim()) || props.credentialDeleted,
)
const canFilterGroup = computed(() => props.filterable && props.groupId !== null)
const canFilterCredential = computed(() => props.filterable && props.credentialId !== null)
const tooltip = computed(() => {
// 三个字段共用一条提示:列里放不下的名称在这里给全,省得逐个悬停。
const routeTooltip = computed(() => {
const lines: string[] = []
if (props.groupId !== null) {
lines.push(
t('monitor.logs.routeIdentity.group', {
name: props.groupName?.trim() || '—',
id: props.groupId,
}),
)
}
if (props.channelId !== null) {
lines.push(
t('monitor.logs.routeIdentity.channel', {
name: channelLabel.value,
}),
)
lines.push(t('monitor.logs.routeIdentity.channel', { name: channelLabel.value }))
}
if (props.credentialId !== null) {
lines.push(t('monitor.logs.routeIdentity.credential', { id: props.credentialId }))
if (props.groupId !== null) {
lines.push(t('monitor.logs.routeIdentity.group', { name: groupLabel.value }))
}
if (credentialLabel.value) {
lines.push(t('monitor.logs.routeIdentity.credential', { name: credentialLabel.value }))
}
return lines.join('\n')
})
const groupAction = computed(() =>
t('monitor.logs.routeIdentity.filterGroup', { name: groupLabel.value }),
)
const credentialAction = computed(() =>
t('monitor.logs.routeIdentity.filterCredential', { name: credentialLabel.value }),
)
</script>
<template>
<AppTooltip :content="tooltip" :disabled="tooltip.length === 0" side="top" align="start">
<span
class="log-route-identity"
:class="{ 'log-route-identity--plain': props.appearance === 'plain' }"
:tabindex="tooltip ? 0 : undefined"
:aria-label="tooltip || undefined"
>
<ChannelIcon
v-if="showsIcon"
class="log-route-identity__icon"
:icon="channel?.icon ?? ''"
:mark="channel?.mark ?? ''"
/>
<code class="log-route-identity__label">{{ compactLabel }}</code>
<!-- 抽屉里字段已完整展开,无需再提示。 -->
<AppTooltip
:content="routeTooltip"
:disabled="appearance === 'plain' || routeTooltip.length === 0"
side="top"
align="start"
>
<span class="log-route-identity" :class="`log-route-identity--${appearance}`">
<span class="log-route-identity__line">
<span v-if="showsIcon" class="log-route-identity__icon">
<ChannelIcon :icon="channel?.icon ?? ''" :mark="channel?.mark ?? ''" />
</span>
<span v-else-if="channelId !== null" class="log-route-identity__channel">
{{ channelLabel }}
</span>
<button
v-if="canFilterGroup"
class="log-route-identity__group filterable-value"
:class="{ 'log-route-identity__group--code': !hasGroupName }"
type="button"
:aria-label="groupAction"
@click="emit('filter-group', groupId as number)"
>
{{ groupLabel }}
</button>
<span
v-else
class="log-route-identity__group"
:class="{ 'log-route-identity__group--code': !hasGroupName }"
>
{{ groupLabel }}
</span>
</span>
<template v-if="credentialLabel">
<button
v-if="canFilterCredential"
class="log-route-identity__credential filterable-value"
:class="{ 'log-route-identity__credential--code': !hasCredentialName }"
type="button"
:aria-label="credentialAction"
@click="emit('filter-credential', credentialId as number)"
>
{{ credentialLabel }}
</button>
<span
v-else
class="log-route-identity__credential"
:class="{ 'log-route-identity__credential--code': !hasCredentialName }"
>
{{ credentialLabel }}
</span>
</template>
</span>
</AppTooltip>
</template>
<style scoped>
.log-route-identity {
display: grid;
min-width: 0;
gap: 2px;
}
.log-route-identity__line {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
cursor: help;
}
.log-route-identity:focus-visible {
border-radius: var(--radius-tag);
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.log-route-identity__icon {
display: inline-flex;
flex: none;
font-size: var(--text-label-xs);
}
.log-route-identity__label {
overflow: hidden;
.log-route-identity__channel {
flex: none;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
/* 名称可任意长,一律省略;凭据独占一行,两者不挤压彼此。 */
.log-route-identity__group,
.log-route-identity__credential {
display: block;
min-width: 0;
overflow: hidden;
border: 0;
background: none;
padding: 0;
font: inherit;
/* 行高须高于字号,否则悬停下划线被 overflow 裁掉;须排在 font 简写之后。 */
line-height: 1.5;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-route-identity__group {
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 560;
}
.log-route-identity__credential {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.log-route-identity__group--code,
.log-route-identity__credential--code {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.log-route-identity__group--code {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
/* 下方两条规则设了字色,与全局悬停色特异性相同却后加载,这里叠类名压过它们。 */
.log-route-identity__group.filterable-value:hover,
.log-route-identity__credential.filterable-value:hover {
color: var(--color-action);
}
/* 抽屉空间充裕,排成一行并跟随所在字段字号。 */
.log-route-identity--plain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}
.log-route-identity--plain .log-route-identity__icon,
.log-route-identity--plain .log-route-identity__label {
.log-route-identity--plain .log-route-identity__group,
.log-route-identity--plain .log-route-identity__credential,
.log-route-identity--plain .log-route-identity__channel {
font-size: inherit;
}
.log-route-identity--plain .log-route-identity__label {
color: inherit;
font-family: inherit;
.log-route-identity--plain .log-route-identity__credential {
color: var(--color-text-muted);
}
</style>
+72 -15
View File
@@ -58,6 +58,7 @@ import LogProtocolConversion from './LogProtocolConversion.vue'
import LogRouteIdentity from './LogRouteIdentity.vue'
import LogsFilterForm from './LogsFilterForm.vue'
import PricingModeIndicator from './PricingModeIndicator.vue'
import { formatRouteEntity } from './log-format'
import {
logsMonitorQuery,
parseLogsMonitorState,
@@ -355,6 +356,23 @@ async function commitFilters(filters: RequestLogFilters): Promise<void> {
await router.push(monitorLocation(logsMonitorQuery(filters)))
}
// 就地收窄而非跳转:排查时要看的是同一维度的其他请求,且目标可能已删。
async function filterByGroup(groupID: number): Promise<void> {
await commitFilters({ ...appliedFilters.value, group_id: groupID })
}
async function filterByCredential(credentialID: number): Promise<void> {
await commitFilters({ ...appliedFilters.value, credential_id: credentialID })
}
async function filterByAccessKey(accessKeyID: number): Promise<void> {
await commitFilters({ ...appliedFilters.value, access_key_id: accessKeyID })
}
async function filterByClientModel(clientModel: string): Promise<void> {
await commitFilters({ ...appliedFilters.value, client_model: clientModel })
}
async function applyFilters(): Promise<void> {
const errors = validateLogFilterDraft(draft.value)
filterErrors.value = errors
@@ -453,8 +471,18 @@ async function setDetailOpen(requestID: string | undefined, open: boolean): Prom
}
function accessKeyLabel(log: RequestLogItemDto): string {
if (log.access_key.deleted) return t('monitor.logs.accessKey.deleted', { id: log.access_key.id })
return log.access_key.name ?? `#${log.access_key.id}`
return formatRouteEntity({
id: log.access_key.id,
name: log.access_key.name,
deleted: log.access_key.deleted,
prefix: '#',
deletedText: (id) => t('monitor.logs.deletedRef', { id }),
})
}
// 分组名靠 options 反查:查询就绪后仍找不到,才能断定分组已被删除。
function groupDeleted(log: RequestLogItemDto): boolean {
return log.group_id !== null && groupsQuery.isSuccess.value && groupName(log) === null
}
function groupName(log: RequestLogItemDto): string | null {
@@ -608,7 +636,7 @@ function costLabel(log: RequestLogItemDto): string {
v-if="logsQuery.isPending.value || initialLoading"
variant="collection"
:rows="appliedFilters.limit ?? 20"
:columns="isAccessKey ? 7 : 8"
:columns="isAccessKey ? 7 : 9"
row-height="72px"
mobile-row-height="176px"
:concealed="!initialLoading"
@@ -633,7 +661,7 @@ function costLabel(log: RequestLogItemDto): string {
v-if="collectionTransition"
variant="collection"
:rows="skeletonRows"
:columns="isAccessKey ? 7 : 8"
:columns="isAccessKey ? 7 : 9"
row-height="72px"
mobile-row-height="176px"
:label="t('monitor.logs.loading')"
@@ -647,6 +675,9 @@ function costLabel(log: RequestLogItemDto): string {
>
<template #header>
<span role="columnheader">{{ t('monitor.logs.columns.time') }}</span>
<span v-if="!isAccessKey" role="columnheader">
{{ t('monitor.logs.columns.accessKey') }}
</span>
<span v-if="!isAccessKey" role="columnheader">{{ t('monitor.logs.columns.route') }}</span>
<span role="columnheader">{{ t('monitor.logs.columns.modelProtocol') }}</span>
<span role="columnheader">{{ t('monitor.logs.columns.response') }}</span>
@@ -678,17 +709,37 @@ function costLabel(log: RequestLogItemDto): string {
v-if="!isAccessKey"
class="ledger-record-list__cell logs-list__cell"
role="cell"
:data-label="t('monitor.logs.columns.route')"
:data-label="t('monitor.logs.columns.accessKey')"
>
<OverflowTooltip as="span" :content="accessKeyLabel(log)">
<OverflowTooltip
as="button"
type="button"
class="filterable-value"
:content="accessKeyLabel(log)"
:aria-label="t('monitor.logs.filterAccessKey', { name: accessKeyLabel(log) })"
@click="filterByAccessKey(log.access_key.id)"
>
{{ accessKeyLabel(log) }}
</OverflowTooltip>
</div>
<div
v-if="!isAccessKey"
class="ledger-record-list__cell logs-list__cell"
role="cell"
:data-label="t('monitor.logs.columns.route')"
>
<LogRouteIdentity
:group-id="log.group_id"
:group-name="groupName(log)"
:channel-id="log.channel_id"
:channel="channelDefinition(log)"
:credential-id="log.credential_id"
:credential-name="log.credential_name"
:group-deleted="groupDeleted(log)"
:credential-deleted="log.credential_id !== null && log.credential_name === ''"
filterable
@filter-group="filterByGroup"
@filter-credential="filterByCredential"
/>
</div>
<div
@@ -699,9 +750,12 @@ function costLabel(log: RequestLogItemDto): string {
<span class="logs-list__inline">
<OverflowTooltip
v-if="log.client_model"
as="code"
class="logs-list__model"
as="button"
type="button"
class="logs-list__model filterable-value"
:content="log.client_model"
:aria-label="t('monitor.logs.filterModel', { name: log.client_model })"
@click="filterByClientModel(log.client_model)"
>
{{ log.client_model }}
</OverflowTooltip>
@@ -945,16 +999,17 @@ function costLabel(log: RequestLogItemDto): string {
}
.logs-list {
--ledger-record-list-grid: 116px minmax(132px, 0.95fr) minmax(180px, 1.2fr) 112px
minmax(88px, 0.58fr) minmax(142px, 0.9fr) 126px 34px;
/* 时间定长、Token/耗时/成本按实际内容重算,压出的宽度装下新增的密钥列。 */
--ledger-record-list-grid: 96px minmax(96px, 0.62fr) minmax(132px, 0.86fr) minmax(180px, 1.2fr)
96px minmax(76px, 0.42fr) minmax(104px, 0.6fr) 100px 34px;
--ledger-record-list-column-gap: 16px;
--ledger-record-list-record-min-height: 72px;
--ledger-record-list-record-padding: 10px 0;
}
.logs-list--scoped {
--ledger-record-list-grid: 116px minmax(180px, 1.2fr) 112px minmax(88px, 0.58fr)
minmax(142px, 0.9fr) 126px 34px;
--ledger-record-list-grid: 96px minmax(180px, 1.2fr) 96px minmax(76px, 0.42fr)
minmax(104px, 0.6fr) 100px 34px;
}
.logs-list__cell {
@@ -967,7 +1022,8 @@ function costLabel(log: RequestLogItemDto): string {
}
.logs-list__cell > span,
.logs-list__cell code {
.logs-list__cell code,
.logs-list__cell .filterable-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1040,6 +1096,7 @@ function costLabel(log: RequestLogItemDto): string {
.logs-list__model {
flex: 0 1 auto;
font-family: var(--font-mono);
}
.logs-list__reasoning {
@@ -1133,8 +1190,8 @@ function costLabel(log: RequestLogItemDto): string {
@media (max-width: 1080px) {
.logs-list {
--ledger-record-list-column-gap: 10px;
--ledger-record-list-grid: 108px minmax(118px, 0.9fr) minmax(160px, 1.15fr) 104px
minmax(84px, 0.58fr) minmax(124px, 0.85fr) 116px 32px;
--ledger-record-list-grid: 92px minmax(88px, 0.6fr) minmax(118px, 0.82fr) minmax(160px, 1.15fr)
92px minmax(72px, 0.42fr) minmax(96px, 0.58fr) 96px 32px;
}
}
+21
View File
@@ -119,3 +119,24 @@ export function hasRequestLogCache(log: RequestLogItemDto): boolean {
log.cache_write_unknown_tokens,
].some((value) => value !== '0')
}
/**
* 路由链路上的实体(访问密钥 / 分组 / 凭据)在日志里的显示名。
*
* 日志是历史记录,实体随时可能已被删除,三者因此共用同一套回退:
* 有名称就用名称,确认删除就标已删除,两者都不成立时只能给编号
* (名称来源尚未加载,或该实体压根没有名称数据源)。
*/
export function formatRouteEntity(options: {
id: number | null
name: string | null | undefined
deleted: boolean
prefix: string
deletedText: (id: number) => string
}): string {
if (options.id === null) return '—'
const name = options.name?.trim()
if (name) return name
if (options.deleted) return options.deletedText(options.id)
return `${options.prefix}${options.id}`
}
+1
View File
@@ -38,6 +38,7 @@ export default {
invalid: 'Enter a valid HTTP or SOCKS5 proxy URL.',
effective: 'Effective: {mode}',
ownTooltip: 'Proxy: {type}',
ownTooltipClickable: 'Proxy: {type}. Click to edit.',
inherit: {
global: 'Inherit startup environment',
group: 'Inherit global',
+5
View File
@@ -63,6 +63,10 @@ export default {
copyFailure: 'Copy failed',
credentialHealthLabel:
'{total} credentials total: {available} available, {cooldown} cooling down, {blacklisted} blacklisted, {disabled} disabled',
toggleEnabled: 'Enable {name}',
enabledOn: 'Enabled {name}',
enabledOff: 'Disabled {name}. It no longer takes new requests.',
toggleFailed: 'Could not change the switch. Try again.',
appendCredential: 'Add credential',
appendCredentialFor: 'Add a credential to {name}',
},
@@ -425,6 +429,7 @@ export default {
diagnostics: 'Diagnostics',
moreActions: 'More actions',
editWeightHint: 'Click to adjust weight',
weightChipTooltip: 'Manual weight {weight}. Click to edit.',
expand: 'Expand credential details',
collapse: 'Collapse credential details',
restore: 'Restore now',
+4
View File
@@ -66,6 +66,10 @@ export default {
fastPrice: 'Fast mode price',
fastPriceValue: 'Fast {field}: {price}',
copy: 'Copy client model {model}',
routeGroups: 'Groups',
routeGroupMore: '{count} more: {names}',
routeGroupLink: 'Open group {name}',
routeGroupDisabled: '{name} (disabled)',
copyUpstream: 'Copy upstream model {model}',
copySucceeded: 'Copied',
copyFailed: 'Copy failed',
+10 -8
View File
@@ -63,7 +63,7 @@ export default {
available: 'Available',
tableLabel: 'Group health list',
columns: {
group: 'Group',
group: 'Group: {name}',
status: 'Scheduling',
credentialHealth: 'Credential composition',
exceptions: 'Current exceptions',
@@ -233,7 +233,7 @@ export default {
anyGroup: 'All Groups',
channel: 'Channel',
anyChannel: 'All channels',
credential: 'Credential ID',
credential: 'Credential: {name}',
model: 'Upstream model',
modelPlaceholder: 'Exact model name',
modelHelp: 'Optional exact upstream model after routing.',
@@ -716,10 +716,7 @@ export default {
text: 'Text cannot contain surrounding whitespace or control characters.',
usd: 'Enter a non-negative USD amount with up to 9 decimal places.',
},
accessKey: {
named: '{name} · #{id}',
deleted: 'Deleted · #{id}',
},
deletedRef: 'Deleted · #{id}',
none: 'None',
yes: 'Yes',
no: 'No',
@@ -732,10 +729,14 @@ export default {
notRecorded: 'Not recorded',
},
routeIdentity: {
group: 'Group: {name} (G{id})',
group: 'Group: {name}',
channel: 'Channel: {name}',
credential: 'Credential: K{id}',
credential: 'Credential: {name}',
filterGroup: 'Show only logs from group {name}',
filterCredential: 'Show only logs from credential {name}',
},
filterAccessKey: 'Show only logs from access key {name}',
filterModel: 'Show only logs for model {name}',
details: 'View details',
failureCategory: {
ok: 'OK',
@@ -821,6 +822,7 @@ export default {
summary: 'Request outcome',
requestId: 'Request ID',
copyRequestId: 'Copy request ID',
copyCredential: 'Copy credential',
completedAt: 'Completed',
status: 'Status',
protocol: 'Protocol',
+1
View File
@@ -38,6 +38,7 @@ export default {
invalid: '有効な HTTP または SOCKS5 プロキシ URL を入力してください。',
effective: '現在有効:{mode}',
ownTooltip: 'プロキシ:{type}',
ownTooltipClickable: 'プロキシ:{type}。クリックで編集',
inherit: {
global: '起動環境を継承',
group: 'グローバル設定を継承',
+5
View File
@@ -63,6 +63,10 @@ export default {
copyFailure: 'コピーできませんでした',
credentialHealthLabel:
'合計 {total} 件の認証情報、正常 {available}、クールダウン中 {cooldown}、ブラックリスト {blacklisted}、無効 {disabled}',
toggleEnabled: '{name} を有効化',
enabledOn: '{name} を有効にしました',
enabledOff: '{name} を停止しました。新しいリクエストは受け付けません。',
toggleFailed: '切り替えに失敗しました。再試行してください。',
appendCredential: '認証情報を追加',
appendCredentialFor: '{name} に認証情報を追加',
},
@@ -424,6 +428,7 @@ export default {
diagnostics: '実行診断',
moreActions: 'その他の操作',
editWeightHint: 'クリックして重みを調整',
weightChipTooltip: '手動ウェイト {weight}。クリックで編集',
expand: '認証情報の詳細を展開',
collapse: '認証情報の詳細を折りたたむ',
restore: 'すぐに回復',
+4
View File
@@ -67,6 +67,10 @@ export default {
fastPrice: 'Fast モード料金',
fastPriceValue: 'Fast {field}:{price}',
copy: 'クライアントモデル {model} をコピー',
routeGroups: '所属グループ',
routeGroupMore: '他 {count} 件:{names}',
routeGroupLink: 'グループ {name} を開く',
routeGroupDisabled: '{name}(停止中)',
copyUpstream: '上流モデル {model} をコピー',
copySucceeded: 'コピーしました',
copyFailed: 'コピーできません',
+10 -8
View File
@@ -63,7 +63,7 @@ export default {
available: '利用可能',
tableLabel: 'グループヘルス一覧',
columns: {
group: 'グループ',
group: 'グループ:{name}',
status: 'スケジュール',
credentialHealth: '認証情報の構成',
exceptions: '現在の例外',
@@ -233,7 +233,7 @@ export default {
anyGroup: 'すべてのグループ',
channel: 'チャネル',
anyChannel: 'すべてのチャネル',
credential: 'クレデンシャル ID',
credential: '認証情報:{name}',
model: 'アップストリームモデル',
modelPlaceholder: '完全一致のモデル名',
modelHelp: 'ルーティング後のアップストリームモデルを完全一致で絞り込みます。',
@@ -715,10 +715,7 @@ export default {
text: '前後の空白や制御文字は使用できません。',
usd: '小数点以下 9 桁までの 0 以上の USD 金額を入力してください。',
},
accessKey: {
named: '{name} · #{id}',
deleted: '削除済み · #{id}',
},
deletedRef: '削除済み · #{id}',
none: 'なし',
yes: 'はい',
no: 'いいえ',
@@ -731,10 +728,14 @@ export default {
notRecorded: '未記録',
},
routeIdentity: {
group: 'グループ:{name}(G{id})',
group: 'グループ:{name}',
channel: 'チャネル:{name}',
credential: 'クレデンシャル:K{id}',
credential: '認証情報:{name}',
filterGroup: 'グループ {name} のログのみ表示',
filterCredential: '認証情報 {name} のログのみ表示',
},
filterAccessKey: 'アクセスキー {name} のログのみ表示',
filterModel: 'モデル {name} のログのみ表示',
details: '詳細を表示',
failureCategory: {
ok: '正常',
@@ -820,6 +821,7 @@ export default {
summary: 'リクエスト結果',
requestId: 'リクエスト ID',
copyRequestId: 'リクエスト ID をコピー',
copyCredential: '認証情報をコピー',
completedAt: '完了時刻',
status: '状態',
protocol: 'プロトコル',
+1
View File
@@ -38,6 +38,7 @@ export default {
invalid: '请输入有效的 HTTP 或 SOCKS5 代理地址。',
effective: '当前生效:{mode}',
ownTooltip: '代理:{type}',
ownTooltipClickable: '代理:{type},点击编辑',
inherit: {
global: '继承启动环境',
group: '继承全局',
+5
View File
@@ -63,6 +63,10 @@ export default {
copyFailure: '复制失败',
credentialHealthLabel:
'共 {total} 个凭据,正常 {available},冷却中 {cooldown},已拉黑 {blacklisted},已停用 {disabled}',
toggleEnabled: '启用 {name}',
enabledOn: '已启用 {name}',
enabledOff: '已停用 {name},该分组不再接受新请求',
toggleFailed: '切换失败,请重试',
appendCredential: '追加凭据',
appendCredentialFor: '向 {name} 追加凭据',
},
@@ -410,6 +414,7 @@ export default {
diagnostics: '运行诊断',
moreActions: '更多操作',
editWeightHint: '点击调整权重',
weightChipTooltip: '手动权重 {weight},点击编辑',
expand: '展开凭据详情',
collapse: '收起凭据详情',
restore: '立即恢复',
+4
View File
@@ -66,6 +66,10 @@ export default {
fastPrice: 'Fast 模式价格',
fastPriceValue: 'Fast {field}:{price}',
copy: '复制客户端模型 {model}',
routeGroups: '归属分组',
routeGroupMore: '另有 {count} 个分组:{names}',
routeGroupLink: '查看分组 {name}',
routeGroupDisabled: '{name}(已停用)',
copyUpstream: '复制上游模型 {model}',
copySucceeded: '已复制',
copyFailed: '复制失败',
+10 -8
View File
@@ -62,7 +62,7 @@ export default {
available: '可用',
tableLabel: '分组健康列表',
columns: {
group: '分组',
group: '分组:{name}',
status: '调度状态',
credentialHealth: '凭据构成',
exceptions: '当前异常',
@@ -225,7 +225,7 @@ export default {
anyGroup: '全部分组',
channel: '渠道',
anyChannel: '全部渠道',
credential: '凭据 ID',
credential: '凭据:{name}',
model: '上游模型',
modelPlaceholder: '精确模型名',
modelHelp: '可选,精确限定路由后的上游模型。',
@@ -697,10 +697,7 @@ export default {
text: '内容不能包含首尾空白或控制字符。',
usd: '请输入最多 9 位小数的非负 USD 金额。',
},
accessKey: {
named: '{name} · #{id}',
deleted: '已删除 · #{id}',
},
deletedRef: '已删除 · #{id}',
none: '无',
yes: '是',
no: '否',
@@ -713,10 +710,14 @@ export default {
notRecorded: '未记录',
},
routeIdentity: {
group: '分组:{name}(G{id})',
group: '分组:{name}',
channel: '渠道:{name}',
credential: '凭据:K{id}',
credential: '凭据:{name}',
filterGroup: '只看分组 {name} 的日志',
filterCredential: '只看凭据 {name} 的日志',
},
filterAccessKey: '只看访问密钥 {name} 的日志',
filterModel: '只看模型 {name} 的日志',
details: '查看详情',
failureCategory: {
ok: '正常',
@@ -802,6 +803,7 @@ export default {
summary: '请求结果',
requestId: '请求 ID',
copyRequestId: '复制请求 ID',
copyCredential: '复制凭据标识',
completedAt: '完成时间',
status: '状态',
protocol: '协议',
+29
View File
@@ -441,3 +441,32 @@
color: var(--color-danger);
font-size: var(--text-label-xs);
}
/*
* 日志里可点击就地筛选的值:访问密钥、分组、凭据、模型共用同一套外观。
* 平时跟随所在列的字色字号,只在悬停时点出可交互。
*/
.filterable-value {
min-width: 0;
border: 0;
background: none;
color: inherit;
padding: 0;
font: inherit;
/* 行高须高于字号,否则悬停下划线被 overflow 裁掉;须排在 font 简写之后。 */
line-height: 1.5;
text-align: left;
cursor: pointer;
}
.filterable-value:hover {
color: var(--color-action);
text-decoration: underline;
text-underline-offset: 2px;
}
.filterable-value:focus-visible {
border-radius: 3px;
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}