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:
1 parent
7eb1e3726d
commit
2b23d8a62a
38 files changed
+1177
-215
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -38,6 +38,7 @@ export default {
|
||||
invalid: '有効な HTTP または SOCKS5 プロキシ URL を入力してください。',
|
||||
effective: '現在有効:{mode}',
|
||||
ownTooltip: 'プロキシ:{type}',
|
||||
ownTooltipClickable: 'プロキシ:{type}。クリックで編集',
|
||||
inherit: {
|
||||
global: '起動環境を継承',
|
||||
group: 'グローバル設定を継承',
|
||||
|
||||
@@ -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: 'すぐに回復',
|
||||
|
||||
@@ -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: 'コピーできません',
|
||||
|
||||
@@ -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: 'プロトコル',
|
||||
|
||||
@@ -38,6 +38,7 @@ export default {
|
||||
invalid: '请输入有效的 HTTP 或 SOCKS5 代理地址。',
|
||||
effective: '当前生效:{mode}',
|
||||
ownTooltip: '代理:{type}',
|
||||
ownTooltipClickable: '代理:{type},点击编辑',
|
||||
inherit: {
|
||||
global: '继承启动环境',
|
||||
group: '继承全局',
|
||||
|
||||
@@ -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: '立即恢复',
|
||||
|
||||
@@ -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: '复制失败',
|
||||
|
||||
@@ -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: '协议',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user