From 85ccf083d2d6d0232caa0ba4c3117d79f1f0672a Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 14 Aug 2026 18:27:25 +0800 Subject: [PATCH 01/24] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=9D=83=E9=87=8D=E8=A2=AB=E8=AE=BE=E7=BD=AE=E4=B8=BA?= =?UTF-8?q?0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/channel.go | 4 ++-- docs/reference/goframe-conventions.md | 20 ++++++++++++++++++++ internal/logic/admin/channel.go | 10 ++++++++-- web/admin/src/views/ai/ChannelDetailPage.vue | 2 +- web/admin/src/views/ai/ChannelsPage.vue | 9 +-------- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 8205d27..0559c2a 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -83,8 +83,8 @@ type ChannelUpdateReq struct { Type *int `json:"type" v:"min:1#请选择供应商类型" dc:"供应商类型(留空不修改)"` BaseURL string `json:"base_url" dc:"API 基础地址"` ApiKey *string `json:"api_key" dc:"更新 API Key(留空不更新)"` - Priority int `json:"priority" dc:"优先级"` - Weight int `json:"weight" dc:"权重"` + Priority *int `json:"priority" dc:"优先级(留空不更新)"` + Weight *int `json:"weight" dc:"权重(留空不更新)"` MaxConcurrency *int `json:"max_concurrency" v:"min:0" dc:"最大并发请求数(0=按上游 429 水位自动估算,留空不更新)"` TestModel string `json:"test_model" dc:"测试模型名"` Remark string `json:"remark" dc:"备注"` diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 4bc5d68..bd95dcb 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -699,3 +699,23 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { | 长流水线编排(relay handler) | 入口与各昂贵阶段前 `ctx.Err()` fail-fast;下游报错先判 `errors.Is(err, context.Canceled)`,勿映射为业务错误("无可用渠道"/"余额不足"等) | 排查信号:同一 request_id 在日志里留下一串不同模块、不同语义的报错,尾部都是 `context canceled`——说明缺 fail-fast 入口闸门,且各模块把取消误当成了自己领域的故障。 + +### 2026-08-14(五):更新接口非指针 int 字段被零值无条件覆写(渠道 weight/priority 被洗成 0) + +**问题**:渠道创建后数据库 `chn_channels.weight` 是 0,但创建表单明明提交了 100。前端"仅切换状态"或"仅更新 API Key"的 PUT 请求会把已有渠道的 weight/priority 洗成 0。 + +**原因**:`ChannelUpdateReq.Weight/Priority` 是非指针 `int`,JSON 里不带该字段时解析为 Go 零值 0,无法区分"未提交"与"显式提交 0";而 `UpdateChannel` 里 `data.Weight = req.Weight` 无条件写入,`do` 结构体的非 nil 字段(含 0)都会进 UPDATE SET 列表,于是 0 覆盖了库里的 100。前端曾在多处用"提交时带上当前 weight"打补丁,但详情页的状态切换/Key 更新漏带,且编辑表单 `weight || 100` 的 falsy 兜底把数据损坏掩盖成"显示正常"。 + +**修复**: + +1. `ChannelUpdateReq.Weight/Priority` 改为 `*int`(与同结构体 `MaxConcurrency`、`Type` 等"留空不更新"字段风格一致),Logic 里仅在非 nil 时赋值; +2. 删除前端"必须携带当前 priority/weight"的 workaround(ChannelsPage 的状态切换/行内权重/行内层级三处),只提交变更字段; +3. 详情页编辑表单去掉 `detail.weight || 100` 兜底,显示真实库值,避免再次掩盖数据异常。 + +**正确做法(通用规则)**: + +- **更新接口的"可选更新"字段一律用指针类型**(`*int`/`*string`/`*bool`),`nil` = 不更新,非 nil(含零值)= 显式更新;非指针字段意味着"调用方必须每次全量携带",要在 API 注释里写明并让所有前端调用点遵守。 +- **do 结构体字段是 `interface{}`**:nil 才会被 ORM 过滤,非 nil 的 0/""/false 都会写入——"零值是否合法的业务值"必须在 Req 层用指针区分,不能指望 ORM 帮你跳过。 +- **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。 + +排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。 diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index b36ca6f..8d573c5 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -347,8 +347,14 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* if req.BaseURL != "" { data.BaseUrl = req.BaseURL } - data.Priority = req.Priority - data.Weight = req.Weight + // priority/weight 为指针字段,仅在显式传入时更新,避免不含这两个字段的 + // 局部更新(如仅切换状态、仅换 Key)把已有值洗成 0。 + if req.Priority != nil { + data.Priority = *req.Priority + } + if req.Weight != nil { + data.Weight = *req.Weight + } if req.MaxConcurrency != nil { data.MaxConcurrency = *req.MaxConcurrency } diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index f1a528d..f0e37fb 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -80,7 +80,7 @@ function openEditModal() { name: detail.value.name || '', base_url: detail.value.base_url || '', priority: detail.value.priority || 0, - weight: detail.value.weight || 100, + weight: detail.value.weight, max_concurrency: detail.value.max_concurrency ?? 100, tier: detail.value.tier || 'primary', strict_capacity: detail.value.strict_capacity || false, diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 73404aa..36c1f6d 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -290,7 +290,6 @@ async function resetHealth(row: any) { } // 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。 -// 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。 // status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。 async function toggleStatus(record: any) { if (record._toggling) return @@ -302,8 +301,6 @@ async function toggleStatus(record: any) { try { await request.put(`/admin/channels/${record.id}`, { status: nextStatus, - priority: record.priority, - weight: record.weight, }) message.success(nextStatus === 'active' ? '已启用' : '已禁用') } catch { @@ -314,8 +311,7 @@ async function toggleStatus(record: any) { } // === 行内编辑(权重 / 层级) === -// 行内编辑复用 PUT /admin/channels/{id}。后端 UpdateChannel 会无条件写入 priority/weight, -// 故每次提交必须携带当前 priority 与 weight,避免被重置为 0。 +// 行内编辑复用 PUT /admin/channels/{id},仅提交变更字段(其余字段留空不更新)。 const weightTimers = new Map() function onWeightChange(record: any, val: number | undefined) { @@ -337,7 +333,6 @@ async function saveWeight(record: any, val: number) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, weight: val, }) record._savedWeight = val @@ -355,8 +350,6 @@ async function saveTier(record: any, val: string) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, - weight: record.weight, tier: val, }) record._tierSaved = val From 9bab883779a878fe465cb2d50fe6c648b7df3e99 Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 14 Aug 2026 18:27:25 +0800 Subject: [PATCH 02/24] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=9D=83=E9=87=8D=E8=A2=AB=E8=AE=BE=E7=BD=AE=E4=B8=BA?= =?UTF-8?q?0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/channel.go | 4 ++-- docs/reference/goframe-conventions.md | 20 ++++++++++++++++++++ internal/logic/admin/channel.go | 10 ++++++++-- web/admin/src/views/ai/ChannelDetailPage.vue | 2 +- web/admin/src/views/ai/ChannelsPage.vue | 9 +-------- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 8205d27..0559c2a 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -83,8 +83,8 @@ type ChannelUpdateReq struct { Type *int `json:"type" v:"min:1#请选择供应商类型" dc:"供应商类型(留空不修改)"` BaseURL string `json:"base_url" dc:"API 基础地址"` ApiKey *string `json:"api_key" dc:"更新 API Key(留空不更新)"` - Priority int `json:"priority" dc:"优先级"` - Weight int `json:"weight" dc:"权重"` + Priority *int `json:"priority" dc:"优先级(留空不更新)"` + Weight *int `json:"weight" dc:"权重(留空不更新)"` MaxConcurrency *int `json:"max_concurrency" v:"min:0" dc:"最大并发请求数(0=按上游 429 水位自动估算,留空不更新)"` TestModel string `json:"test_model" dc:"测试模型名"` Remark string `json:"remark" dc:"备注"` diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 4bc5d68..bd95dcb 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -699,3 +699,23 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { | 长流水线编排(relay handler) | 入口与各昂贵阶段前 `ctx.Err()` fail-fast;下游报错先判 `errors.Is(err, context.Canceled)`,勿映射为业务错误("无可用渠道"/"余额不足"等) | 排查信号:同一 request_id 在日志里留下一串不同模块、不同语义的报错,尾部都是 `context canceled`——说明缺 fail-fast 入口闸门,且各模块把取消误当成了自己领域的故障。 + +### 2026-08-14(五):更新接口非指针 int 字段被零值无条件覆写(渠道 weight/priority 被洗成 0) + +**问题**:渠道创建后数据库 `chn_channels.weight` 是 0,但创建表单明明提交了 100。前端"仅切换状态"或"仅更新 API Key"的 PUT 请求会把已有渠道的 weight/priority 洗成 0。 + +**原因**:`ChannelUpdateReq.Weight/Priority` 是非指针 `int`,JSON 里不带该字段时解析为 Go 零值 0,无法区分"未提交"与"显式提交 0";而 `UpdateChannel` 里 `data.Weight = req.Weight` 无条件写入,`do` 结构体的非 nil 字段(含 0)都会进 UPDATE SET 列表,于是 0 覆盖了库里的 100。前端曾在多处用"提交时带上当前 weight"打补丁,但详情页的状态切换/Key 更新漏带,且编辑表单 `weight || 100` 的 falsy 兜底把数据损坏掩盖成"显示正常"。 + +**修复**: + +1. `ChannelUpdateReq.Weight/Priority` 改为 `*int`(与同结构体 `MaxConcurrency`、`Type` 等"留空不更新"字段风格一致),Logic 里仅在非 nil 时赋值; +2. 删除前端"必须携带当前 priority/weight"的 workaround(ChannelsPage 的状态切换/行内权重/行内层级三处),只提交变更字段; +3. 详情页编辑表单去掉 `detail.weight || 100` 兜底,显示真实库值,避免再次掩盖数据异常。 + +**正确做法(通用规则)**: + +- **更新接口的"可选更新"字段一律用指针类型**(`*int`/`*string`/`*bool`),`nil` = 不更新,非 nil(含零值)= 显式更新;非指针字段意味着"调用方必须每次全量携带",要在 API 注释里写明并让所有前端调用点遵守。 +- **do 结构体字段是 `interface{}`**:nil 才会被 ORM 过滤,非 nil 的 0/""/false 都会写入——"零值是否合法的业务值"必须在 Req 层用指针区分,不能指望 ORM 帮你跳过。 +- **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。 + +排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。 diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index b36ca6f..8d573c5 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -347,8 +347,14 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* if req.BaseURL != "" { data.BaseUrl = req.BaseURL } - data.Priority = req.Priority - data.Weight = req.Weight + // priority/weight 为指针字段,仅在显式传入时更新,避免不含这两个字段的 + // 局部更新(如仅切换状态、仅换 Key)把已有值洗成 0。 + if req.Priority != nil { + data.Priority = *req.Priority + } + if req.Weight != nil { + data.Weight = *req.Weight + } if req.MaxConcurrency != nil { data.MaxConcurrency = *req.MaxConcurrency } diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index f1a528d..f0e37fb 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -80,7 +80,7 @@ function openEditModal() { name: detail.value.name || '', base_url: detail.value.base_url || '', priority: detail.value.priority || 0, - weight: detail.value.weight || 100, + weight: detail.value.weight, max_concurrency: detail.value.max_concurrency ?? 100, tier: detail.value.tier || 'primary', strict_capacity: detail.value.strict_capacity || false, diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 73404aa..36c1f6d 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -290,7 +290,6 @@ async function resetHealth(row: any) { } // 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。 -// 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。 // status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。 async function toggleStatus(record: any) { if (record._toggling) return @@ -302,8 +301,6 @@ async function toggleStatus(record: any) { try { await request.put(`/admin/channels/${record.id}`, { status: nextStatus, - priority: record.priority, - weight: record.weight, }) message.success(nextStatus === 'active' ? '已启用' : '已禁用') } catch { @@ -314,8 +311,7 @@ async function toggleStatus(record: any) { } // === 行内编辑(权重 / 层级) === -// 行内编辑复用 PUT /admin/channels/{id}。后端 UpdateChannel 会无条件写入 priority/weight, -// 故每次提交必须携带当前 priority 与 weight,避免被重置为 0。 +// 行内编辑复用 PUT /admin/channels/{id},仅提交变更字段(其余字段留空不更新)。 const weightTimers = new Map() function onWeightChange(record: any, val: number | undefined) { @@ -337,7 +333,6 @@ async function saveWeight(record: any, val: number) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, weight: val, }) record._savedWeight = val @@ -355,8 +350,6 @@ async function saveTier(record: any, val: string) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, - weight: record.weight, tier: val, }) record._tierSaved = val From 0e44e8a9d38625189fc5824d8d9c0c6f18d0edcc Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 22:15:56 +0800 Subject: [PATCH 03/24] =?UTF-8?q?fix(ui):=E4=BF=AE=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E6=A8=A1=E5=BC=8F=E4=B8=8B=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=8F=E8=83=8C=E6=99=AF=E8=BF=87=E4=BA=8E=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/tenant/src/components/layout/AuthLayout.vue | 1 - web/tenant/src/styles/main.css | 9 --------- web/tenant/src/views/Layout.vue | 1 - web/tenant/src/views/auth/LoginPage.vue | 1 - web/tenant/src/views/landing/LandingPage.vue | 8 ++++---- .../src/views/marketplace/MarketplacePage.vue | 7 ------- web/tenant/src/views/service/UsageLogsPage.vue | 14 -------------- 7 files changed, 4 insertions(+), 37 deletions(-) diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index 9666867..0951233 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -86,7 +86,6 @@ onBeforeUnmount(() => { background: rgba(255, 255, 255, 0.66); box-shadow: var(--shadow-glass-sm), inset 0 1px 0 rgba(255, 255, 255, 0.94); backdrop-filter: blur(20px) saturate(1.16); - -webkit-backdrop-filter: blur(20px) saturate(1.16); } .auth-card { diff --git a/web/tenant/src/styles/main.css b/web/tenant/src/styles/main.css index 0eddc32..dd42ecc 100644 --- a/web/tenant/src/styles/main.css +++ b/web/tenant/src/styles/main.css @@ -262,13 +262,11 @@ body.modal-open { .glass { background: var(--glass-bg-subtle); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); } /* 液态玻璃卡片:弱透明底 + 模糊 + 高光描边 + 冷投影 + 内高光 */ .glass-card { background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); border-radius: var(--radius-panel); box-shadow: var(--shadow-glass), var(--glass-highlight); @@ -286,7 +284,6 @@ body.modal-open { border-radius: var(--radius-card); background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-card); } @@ -305,7 +302,6 @@ body.modal-open { .card-glass { background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-glass); } @@ -343,7 +339,6 @@ body.modal-open { border-radius: var(--radius-card); background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-card); } @@ -389,7 +384,6 @@ body.modal-open { 0 10px 30px rgba(75, 91, 137, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.92); backdrop-filter: blur(22px) saturate(1.2); - -webkit-backdrop-filter: blur(22px) saturate(1.2); scrollbar-width: thin; scrollbar-color: rgba(20, 184, 166, 0.28) transparent; } @@ -661,7 +655,6 @@ body.modal-open { @apply origin-top-right animate-scale-in; background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-glass), var(--glass-highlight); } @@ -747,7 +740,6 @@ body.modal-open { linear-gradient(115deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.035) 42%, rgba(255, 255, 255, 0.08)), rgba(238, 246, 252, 0.2); backdrop-filter: blur(20px) saturate(1.38) brightness(1.04); - -webkit-backdrop-filter: blur(20px) saturate(1.38) brightness(1.04); border: 1px solid rgba(255, 255, 255, 0.38); box-shadow: 0 16px 38px rgba(35, 48, 68, 0.12), @@ -868,7 +860,6 @@ body.modal-open { .sidebar { background: rgba(247, 249, 253, 0.94); backdrop-filter: none; - -webkit-backdrop-filter: none; } } @media (prefers-contrast: more) { diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index d23a15c..f1570c7 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -600,7 +600,6 @@ onBeforeUnmount(() => { .user-dropdown { background: #fff; backdrop-filter: none; - -webkit-backdrop-filter: none; } .fade-enter-active, diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 56046f5..ac0fe5d 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -544,7 +544,6 @@ async function handleOAuthLogin(provider: string) { inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 6px 16px rgba(76, 91, 142, 0.06); backdrop-filter: blur(18px) saturate(1.14); - -webkit-backdrop-filter: blur(18px) saturate(1.14); } .login-mode-indicator { diff --git a/web/tenant/src/views/landing/LandingPage.vue b/web/tenant/src/views/landing/LandingPage.vue index 589d1d4..ae3bf53 100644 --- a/web/tenant/src/views/landing/LandingPage.vue +++ b/web/tenant/src/views/landing/LandingPage.vue @@ -590,7 +590,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 justify-content: center; width: fit-content; margin: 0 auto 1.5rem; border-radius: 14px; background: rgba(241, 245, 249, 0.8); - backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.6); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); } @@ -634,7 +634,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 height: 440px; border-radius: 1.5rem; padding: 2rem 2.25rem; background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18); + backdrop-filter: blur(28px) saturate(1.18); border: 1px solid rgba(255, 255, 255, 0.82); box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.9); } @@ -835,7 +835,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 .announce-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); - backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); } /* Card */ @@ -843,7 +843,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 position: relative; width: 100%; max-width: 860px; border-radius: 1.5rem; overflow: hidden; background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18); + backdrop-filter: blur(28px) saturate(1.18); border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 32px 80px -16px rgba(15, 23, 42, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.95); animation: announceEnter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; diff --git a/web/tenant/src/views/marketplace/MarketplacePage.vue b/web/tenant/src/views/marketplace/MarketplacePage.vue index 23044d5..38da627 100644 --- a/web/tenant/src/views/marketplace/MarketplacePage.vue +++ b/web/tenant/src/views/marketplace/MarketplacePage.vue @@ -623,7 +623,6 @@ useSeo({ border-bottom: 1px solid rgba(220, 229, 225, 0.82); background: rgba(247, 250, 248, 0.88); backdrop-filter: blur(18px) saturate(1.2); - -webkit-backdrop-filter: blur(18px) saturate(1.2); } .header-inner, @@ -2101,7 +2100,6 @@ useSeo({ border-bottom: 0; background: transparent; backdrop-filter: none; - -webkit-backdrop-filter: none; } .header-inner { @@ -2146,7 +2144,6 @@ useSeo({ background: rgba(241, 245, 249, 0.58); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); } .header-nav a { @@ -2211,7 +2208,6 @@ useSeo({ box-shadow: 0 5px 16px rgba(76, 91, 142, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.9); color: #475569; backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); } .secondary-button:hover { @@ -2263,7 +2259,6 @@ useSeo({ background: rgba(255, 255, 255, 0.64); box-shadow: 0 18px 55px rgba(76, 91, 142, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.9); backdrop-filter: blur(28px) saturate(1.16); - -webkit-backdrop-filter: blur(28px) saturate(1.16); } .category-panel { @@ -2422,7 +2417,6 @@ useSeo({ box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.92); color: #111827; backdrop-filter: blur(28px) saturate(1.18); - -webkit-backdrop-filter: blur(28px) saturate(1.18); } .cta-icon { @@ -2471,7 +2465,6 @@ useSeo({ background: rgba(255, 255, 255, 0.9); box-shadow: 0 24px 80px rgba(76, 91, 142, 0.24), inset 0 1px 0 #fff; backdrop-filter: blur(28px) saturate(1.16); - -webkit-backdrop-filter: blur(28px) saturate(1.16); } .detail-hero { diff --git a/web/tenant/src/views/service/UsageLogsPage.vue b/web/tenant/src/views/service/UsageLogsPage.vue index a9ff4f1..cc59f04 100644 --- a/web/tenant/src/views/service/UsageLogsPage.vue +++ b/web/tenant/src/views/service/UsageLogsPage.vue @@ -11,10 +11,6 @@ import request from '@/utils/request' import { tableScrollX } from '@/utils/renderUtils' import { useExport } from '@/composables/useExport' -// 日期辅助(native,避免引入 dayjs 依赖) -function pad2(n: number): string { - return String(n).padStart(2, '0') -} // timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss function tsToStr(ts: number): string { const d = new Date(ts) @@ -210,16 +206,6 @@ function viewAuditLog(requestId: string, taskId?: string) { router.push({ name: 'TenantRequestAuditLogs', query }) } -function hasCacheTokens(log: any): boolean { - return (log.cache_creation_tokens > 0) || (log.cache_read_tokens > 0) || - (log.cache_creation_5m_tokens > 0) || (log.cache_creation_1h_tokens > 0) -} - -function hasExtraTokens(log: any): boolean { - return (log.reasoning_tokens > 0) || (log.audio_input_tokens > 0) || - (log.audio_output_tokens > 0) || (log.image_output_tokens > 0) -} - function hasUpstreamModel(log: any): boolean { return log.upstream_model && log.upstream_model !== log.model_name && log.upstream_model !== '' } From a1f15ab5b156688ad89a21526afff532795a7cc6 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 22:26:12 +0800 Subject: [PATCH 04/24] =?UTF-8?q?feat(ui):=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BC=93=E5=AD=98=E4=BB=B7=E6=A0=BC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/tenant/model.go | 68 ++++++++++++--------- web/tenant/src/views/service/ModelsPage.vue | 4 ++ 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 6c7e82f..b59f29e 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -45,11 +45,13 @@ type tenantModelPriceRow struct { // groupPriceRow 分组模型的 base 价格查询结果 type groupPriceRow struct { - ModelID int64 `json:"model_id"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - BasePerRequestPrice *float64 `json:"base_per_request_price"` + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + BaseCacheReadPrice float64 `json:"base_cache_read_price"` + BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` + BasePerRequestPrice *float64 `json:"base_per_request_price"` } // baseTierRow 阶梯定价查询结果 @@ -89,10 +91,12 @@ type priceInfo struct { // groupPriceInfo 分组模型的价格信息 type groupPriceInfo struct { - BaseBillingMode string - BaseInputPrice float64 - BaseOutputPrice float64 - BasePerRequestPrice *float64 + BaseBillingMode string + BaseInputPrice float64 + BaseOutputPrice float64 + BaseCacheReadPrice float64 + BaseCacheCreationPrice float64 + BasePerRequestPrice *float64 } // ListAvailableModels 获取租户可用的模型列表 @@ -167,7 +171,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", groupDBIDs). Where("min_tokens", 0). - Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, per_request_price AS base_per_request_price"). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price"). Scan(&groupPrices) if err != nil { return nil, err @@ -175,10 +179,12 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab for _, gp := range groupPrices { groupPriceMap[gp.ModelID] = &groupPriceInfo{ - BaseBillingMode: gp.BaseBillingMode, - BaseInputPrice: gp.BaseInputPrice, - BaseOutputPrice: gp.BaseOutputPrice, - BasePerRequestPrice: gp.BasePerRequestPrice, + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + BaseCacheReadPrice: gp.BaseCacheReadPrice, + BaseCacheCreationPrice: gp.BaseCacheCreationPrice, + BasePerRequestPrice: gp.BasePerRequestPrice, } } } @@ -272,9 +278,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab list = append(list, item) } else { - // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 阶梯明细) + // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 缓存价 / 阶梯明细) billingMode := "token" - var inputPrice, outputPrice, perRequestPrice *float64 + var inputPrice, outputPrice, cacheReadPrice, cacheCreationPrice, perRequestPrice *float64 var baseInputPrice, baseOutputPrice float64 gp, ok := groupPriceMap[m.ModelDBID] if ok { @@ -286,23 +292,27 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab baseOutputPrice = gp.BaseOutputPrice inputPrice = effectivePrice(nil, baseInputPrice) outputPrice = effectivePrice(nil, baseOutputPrice) + cacheReadPrice = effectivePrice(nil, gp.BaseCacheReadPrice) + cacheCreationPrice = effectivePrice(nil, gp.BaseCacheCreationPrice) perRequestPrice = gp.BasePerRequestPrice } item := v1.TenantAvailableModelItem{ - ID: m.ModelDBID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, - MaxContext: m.MaxContextTokens, - MaxOutput: m.MaxOutputTokens, - Description: m.Description, - Tags: m.Tags, - Capabilities: m.Capabilities, - BillingMode: &billingMode, - PerRequestPrice: perRequestPrice, - InputPrice: inputPrice, - OutputPrice: outputPrice, + ID: m.ModelDBID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + PerRequestPrice: perRequestPrice, + InputPrice: inputPrice, + OutputPrice: outputPrice, + CacheReadPrice: cacheReadPrice, + CacheCreationPrice: cacheCreationPrice, } if billingMode == "tiered" && ok { item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID]) diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index 62fe5bf..c00469d 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -355,6 +355,10 @@ onMounted(fetchModels) 输出 {{ formatPrice(m.output_price) }} + + 缓存读取 + {{ formatPrice(m.cache_read_price) }} + /1M tokens From 68fd94cd2cae6eb4f7f1b4b385af7ec1405c9673 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 22:15:56 +0800 Subject: [PATCH 05/24] =?UTF-8?q?fix(ui):=E4=BF=AE=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E6=A8=A1=E5=BC=8F=E4=B8=8B=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=8F=E8=83=8C=E6=99=AF=E8=BF=87=E4=BA=8E=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/tenant/src/components/layout/AuthLayout.vue | 1 - web/tenant/src/styles/main.css | 9 --------- web/tenant/src/views/Layout.vue | 1 - web/tenant/src/views/auth/LoginPage.vue | 1 - web/tenant/src/views/landing/LandingPage.vue | 8 ++++---- .../src/views/marketplace/MarketplacePage.vue | 7 ------- web/tenant/src/views/service/UsageLogsPage.vue | 14 -------------- 7 files changed, 4 insertions(+), 37 deletions(-) diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index 9666867..0951233 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -86,7 +86,6 @@ onBeforeUnmount(() => { background: rgba(255, 255, 255, 0.66); box-shadow: var(--shadow-glass-sm), inset 0 1px 0 rgba(255, 255, 255, 0.94); backdrop-filter: blur(20px) saturate(1.16); - -webkit-backdrop-filter: blur(20px) saturate(1.16); } .auth-card { diff --git a/web/tenant/src/styles/main.css b/web/tenant/src/styles/main.css index 0eddc32..dd42ecc 100644 --- a/web/tenant/src/styles/main.css +++ b/web/tenant/src/styles/main.css @@ -262,13 +262,11 @@ body.modal-open { .glass { background: var(--glass-bg-subtle); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); } /* 液态玻璃卡片:弱透明底 + 模糊 + 高光描边 + 冷投影 + 内高光 */ .glass-card { background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); border-radius: var(--radius-panel); box-shadow: var(--shadow-glass), var(--glass-highlight); @@ -286,7 +284,6 @@ body.modal-open { border-radius: var(--radius-card); background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-card); } @@ -305,7 +302,6 @@ body.modal-open { .card-glass { background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-glass); } @@ -343,7 +339,6 @@ body.modal-open { border-radius: var(--radius-card); background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-card); } @@ -389,7 +384,6 @@ body.modal-open { 0 10px 30px rgba(75, 91, 137, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.92); backdrop-filter: blur(22px) saturate(1.2); - -webkit-backdrop-filter: blur(22px) saturate(1.2); scrollbar-width: thin; scrollbar-color: rgba(20, 184, 166, 0.28) transparent; } @@ -661,7 +655,6 @@ body.modal-open { @apply origin-top-right animate-scale-in; background: var(--glass-bg-strong); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid var(--glass-border); box-shadow: var(--shadow-glass), var(--glass-highlight); } @@ -747,7 +740,6 @@ body.modal-open { linear-gradient(115deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.035) 42%, rgba(255, 255, 255, 0.08)), rgba(238, 246, 252, 0.2); backdrop-filter: blur(20px) saturate(1.38) brightness(1.04); - -webkit-backdrop-filter: blur(20px) saturate(1.38) brightness(1.04); border: 1px solid rgba(255, 255, 255, 0.38); box-shadow: 0 16px 38px rgba(35, 48, 68, 0.12), @@ -868,7 +860,6 @@ body.modal-open { .sidebar { background: rgba(247, 249, 253, 0.94); backdrop-filter: none; - -webkit-backdrop-filter: none; } } @media (prefers-contrast: more) { diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index d23a15c..f1570c7 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -600,7 +600,6 @@ onBeforeUnmount(() => { .user-dropdown { background: #fff; backdrop-filter: none; - -webkit-backdrop-filter: none; } .fade-enter-active, diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 56046f5..ac0fe5d 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -544,7 +544,6 @@ async function handleOAuthLogin(provider: string) { inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 6px 16px rgba(76, 91, 142, 0.06); backdrop-filter: blur(18px) saturate(1.14); - -webkit-backdrop-filter: blur(18px) saturate(1.14); } .login-mode-indicator { diff --git a/web/tenant/src/views/landing/LandingPage.vue b/web/tenant/src/views/landing/LandingPage.vue index 589d1d4..ae3bf53 100644 --- a/web/tenant/src/views/landing/LandingPage.vue +++ b/web/tenant/src/views/landing/LandingPage.vue @@ -590,7 +590,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 justify-content: center; width: fit-content; margin: 0 auto 1.5rem; border-radius: 14px; background: rgba(241, 245, 249, 0.8); - backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.6); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); } @@ -634,7 +634,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 height: 440px; border-radius: 1.5rem; padding: 2rem 2.25rem; background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18); + backdrop-filter: blur(28px) saturate(1.18); border: 1px solid rgba(255, 255, 255, 0.82); box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.9); } @@ -835,7 +835,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 .announce-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); - backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); } /* Card */ @@ -843,7 +843,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78 position: relative; width: 100%; max-width: 860px; border-radius: 1.5rem; overflow: hidden; background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18); + backdrop-filter: blur(28px) saturate(1.18); border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 32px 80px -16px rgba(15, 23, 42, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.95); animation: announceEnter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; diff --git a/web/tenant/src/views/marketplace/MarketplacePage.vue b/web/tenant/src/views/marketplace/MarketplacePage.vue index 23044d5..38da627 100644 --- a/web/tenant/src/views/marketplace/MarketplacePage.vue +++ b/web/tenant/src/views/marketplace/MarketplacePage.vue @@ -623,7 +623,6 @@ useSeo({ border-bottom: 1px solid rgba(220, 229, 225, 0.82); background: rgba(247, 250, 248, 0.88); backdrop-filter: blur(18px) saturate(1.2); - -webkit-backdrop-filter: blur(18px) saturate(1.2); } .header-inner, @@ -2101,7 +2100,6 @@ useSeo({ border-bottom: 0; background: transparent; backdrop-filter: none; - -webkit-backdrop-filter: none; } .header-inner { @@ -2146,7 +2144,6 @@ useSeo({ background: rgba(241, 245, 249, 0.58); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); } .header-nav a { @@ -2211,7 +2208,6 @@ useSeo({ box-shadow: 0 5px 16px rgba(76, 91, 142, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.9); color: #475569; backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); } .secondary-button:hover { @@ -2263,7 +2259,6 @@ useSeo({ background: rgba(255, 255, 255, 0.64); box-shadow: 0 18px 55px rgba(76, 91, 142, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.9); backdrop-filter: blur(28px) saturate(1.16); - -webkit-backdrop-filter: blur(28px) saturate(1.16); } .category-panel { @@ -2422,7 +2417,6 @@ useSeo({ box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.92); color: #111827; backdrop-filter: blur(28px) saturate(1.18); - -webkit-backdrop-filter: blur(28px) saturate(1.18); } .cta-icon { @@ -2471,7 +2465,6 @@ useSeo({ background: rgba(255, 255, 255, 0.9); box-shadow: 0 24px 80px rgba(76, 91, 142, 0.24), inset 0 1px 0 #fff; backdrop-filter: blur(28px) saturate(1.16); - -webkit-backdrop-filter: blur(28px) saturate(1.16); } .detail-hero { diff --git a/web/tenant/src/views/service/UsageLogsPage.vue b/web/tenant/src/views/service/UsageLogsPage.vue index a9ff4f1..cc59f04 100644 --- a/web/tenant/src/views/service/UsageLogsPage.vue +++ b/web/tenant/src/views/service/UsageLogsPage.vue @@ -11,10 +11,6 @@ import request from '@/utils/request' import { tableScrollX } from '@/utils/renderUtils' import { useExport } from '@/composables/useExport' -// 日期辅助(native,避免引入 dayjs 依赖) -function pad2(n: number): string { - return String(n).padStart(2, '0') -} // timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss function tsToStr(ts: number): string { const d = new Date(ts) @@ -210,16 +206,6 @@ function viewAuditLog(requestId: string, taskId?: string) { router.push({ name: 'TenantRequestAuditLogs', query }) } -function hasCacheTokens(log: any): boolean { - return (log.cache_creation_tokens > 0) || (log.cache_read_tokens > 0) || - (log.cache_creation_5m_tokens > 0) || (log.cache_creation_1h_tokens > 0) -} - -function hasExtraTokens(log: any): boolean { - return (log.reasoning_tokens > 0) || (log.audio_input_tokens > 0) || - (log.audio_output_tokens > 0) || (log.image_output_tokens > 0) -} - function hasUpstreamModel(log: any): boolean { return log.upstream_model && log.upstream_model !== log.model_name && log.upstream_model !== '' } From 0819d3edae2dd92d2b034cf5a5470edfd6557ad8 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 22:26:12 +0800 Subject: [PATCH 06/24] =?UTF-8?q?feat(ui):=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BC=93=E5=AD=98=E4=BB=B7=E6=A0=BC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/tenant/model.go | 68 ++++++++++++--------- web/tenant/src/views/service/ModelsPage.vue | 4 ++ 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 6c7e82f..b59f29e 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -45,11 +45,13 @@ type tenantModelPriceRow struct { // groupPriceRow 分组模型的 base 价格查询结果 type groupPriceRow struct { - ModelID int64 `json:"model_id"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - BasePerRequestPrice *float64 `json:"base_per_request_price"` + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + BaseCacheReadPrice float64 `json:"base_cache_read_price"` + BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` + BasePerRequestPrice *float64 `json:"base_per_request_price"` } // baseTierRow 阶梯定价查询结果 @@ -89,10 +91,12 @@ type priceInfo struct { // groupPriceInfo 分组模型的价格信息 type groupPriceInfo struct { - BaseBillingMode string - BaseInputPrice float64 - BaseOutputPrice float64 - BasePerRequestPrice *float64 + BaseBillingMode string + BaseInputPrice float64 + BaseOutputPrice float64 + BaseCacheReadPrice float64 + BaseCacheCreationPrice float64 + BasePerRequestPrice *float64 } // ListAvailableModels 获取租户可用的模型列表 @@ -167,7 +171,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", groupDBIDs). Where("min_tokens", 0). - Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, per_request_price AS base_per_request_price"). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price"). Scan(&groupPrices) if err != nil { return nil, err @@ -175,10 +179,12 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab for _, gp := range groupPrices { groupPriceMap[gp.ModelID] = &groupPriceInfo{ - BaseBillingMode: gp.BaseBillingMode, - BaseInputPrice: gp.BaseInputPrice, - BaseOutputPrice: gp.BaseOutputPrice, - BasePerRequestPrice: gp.BasePerRequestPrice, + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + BaseCacheReadPrice: gp.BaseCacheReadPrice, + BaseCacheCreationPrice: gp.BaseCacheCreationPrice, + BasePerRequestPrice: gp.BasePerRequestPrice, } } } @@ -272,9 +278,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab list = append(list, item) } else { - // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 阶梯明细) + // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 缓存价 / 阶梯明细) billingMode := "token" - var inputPrice, outputPrice, perRequestPrice *float64 + var inputPrice, outputPrice, cacheReadPrice, cacheCreationPrice, perRequestPrice *float64 var baseInputPrice, baseOutputPrice float64 gp, ok := groupPriceMap[m.ModelDBID] if ok { @@ -286,23 +292,27 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab baseOutputPrice = gp.BaseOutputPrice inputPrice = effectivePrice(nil, baseInputPrice) outputPrice = effectivePrice(nil, baseOutputPrice) + cacheReadPrice = effectivePrice(nil, gp.BaseCacheReadPrice) + cacheCreationPrice = effectivePrice(nil, gp.BaseCacheCreationPrice) perRequestPrice = gp.BasePerRequestPrice } item := v1.TenantAvailableModelItem{ - ID: m.ModelDBID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, - MaxContext: m.MaxContextTokens, - MaxOutput: m.MaxOutputTokens, - Description: m.Description, - Tags: m.Tags, - Capabilities: m.Capabilities, - BillingMode: &billingMode, - PerRequestPrice: perRequestPrice, - InputPrice: inputPrice, - OutputPrice: outputPrice, + ID: m.ModelDBID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + PerRequestPrice: perRequestPrice, + InputPrice: inputPrice, + OutputPrice: outputPrice, + CacheReadPrice: cacheReadPrice, + CacheCreationPrice: cacheCreationPrice, } if billingMode == "tiered" && ok { item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID]) diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index 62fe5bf..c00469d 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -355,6 +355,10 @@ onMounted(fetchModels) 输出 {{ formatPrice(m.output_price) }} + + 缓存读取 + {{ formatPrice(m.cache_read_price) }} + /1M tokens From 95da2088a041e2b96ac9edfb4160182d2b4b08e9 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 22:50:13 +0800 Subject: [PATCH 07/24] =?UTF-8?q?fix(sys):=E4=BF=AE=E5=A4=8D=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A3=80=E6=9F=A5=E4=B8=8D=E8=83=BD=E5=8F=8A=E6=97=B6?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/update/checker.go | 19 ++++++++++++------- web/admin/src/views/AdminLayout.vue | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/internal/logic/update/checker.go b/internal/logic/update/checker.go index 75c9a8b..25a4e11 100644 --- a/internal/logic/update/checker.go +++ b/internal/logic/update/checker.go @@ -35,8 +35,8 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { } } - // Call GitHub API - release, etag, err := fetchLatestRelease(ctx) + // Call GitHub API(force 时不带 ETag 条件请求,见 fetchLatestRelease 注释) + release, etag, err := fetchLatestRelease(ctx, !force) if err != nil { return nil, fmt.Errorf("failed to fetch latest release: %w", err) } @@ -120,8 +120,11 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { return result, nil } -// fetchLatestRelease calls the GitHub Releases API with conditional request support -func fetchLatestRelease(ctx context.Context) (*GitHubRelease, string, error) { +// fetchLatestRelease calls the GitHub Releases API. +// conditional 为 true 时携带 If-None-Match ETag 做条件请求(省配额); +// 强制检测(force)必须传 false:GitHub /releases/latest 有 CDN 缓存,刚发布新版本后 +// 条件请求可能返回过期的 304 Not Modified,导致强制检测仍拿不到最新发行版。 +func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, string, error) { url := githubAPIBase + "/releases/latest" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -133,9 +136,11 @@ func fetchLatestRelease(ctx context.Context) (*GitHubRelease, string, error) { req.Header.Set("X-GitHub-Api-Version", "2022-11-28") // Add ETag for conditional request - if etagVal := manager.lastETag.Load(); etagVal != nil { - if etag, ok := etagVal.(string); ok && etag != "" { - req.Header.Set("If-None-Match", etag) + if conditional { + if etagVal := manager.lastETag.Load(); etagVal != nil { + if etag, ok := etagVal.(string); ok && etag != "" { + req.Header.Set("If-None-Match", etag) + } } } diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6d93f7f..8b79d79 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -58,6 +58,7 @@ const updateModalVisible = ref(false) const updateLoading = ref(false) const executing = ref(false) const rollingBack = ref(false) +const checking = ref(false) const updateStatus = ref(null) const releaseNotes = ref('') const releaseUrl = ref('') @@ -129,6 +130,16 @@ function openUpdateModal() { checkUpdate() // 打开弹窗时刷新版本检查,确保当前版本/最新版本为最新 } +// 立即检测:force=true 跳过后端检查缓存,强制向上游拉取最新版本信息 +async function manualCheck() { + checking.value = true + try { + await Promise.all([fetchUpdateStatus(), checkUpdate(true)]) + } finally { + checking.value = false + } +} + function closeUpdateModal() { updateModalVisible.value = false stopPolling() @@ -717,6 +728,9 @@ onUnmounted(() => { + From a073d6b2720ff136ad4b47d405697e557aebab07 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 23:02:03 +0800 Subject: [PATCH 08/24] =?UTF-8?q?fix(sys):=E4=BF=AE=E5=A4=8D=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E6=9B=B4=E6=96=B0=E8=B7=A8=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=96=87=E4=BB=B6=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/update/replacer.go | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/internal/logic/update/replacer.go b/internal/logic/update/replacer.go index 023ff93..34449ad 100644 --- a/internal/logic/update/replacer.go +++ b/internal/logic/update/replacer.go @@ -84,9 +84,24 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL return } - // Step 3: Extract binary from tarball + // Step 3: 定位当前二进制路径 + // 新二进制必须提取到与当前二进制相同的目录(同一文件系统), + // 否则后续 os.Rename 会因跨设备(EXDEV)失败,如 /tmp 与部署目录不在同一分区 + currentExe, err := os.Executable() + if err != nil { + manager.setProgressError("获取当前程序路径失败", err.Error()) + _ = os.Remove(dlResult.FilePath) + return + } + // Resolve symlinks + currentExe, err = filepath.EvalSymlinks(currentExe) + if err != nil { + g.Log().Warningf(ctx, "Failed to resolve symlink: %v", err) + } + + // Step 4: Extract binary from tarball(提取到当前二进制同目录) manager.setProgress(PhaseBackingUp, "正在提取二进制文件...", 10) - newBinaryPath, err := extractBinary(ctx, dlResult.FilePath) + newBinaryPath, err := extractBinary(ctx, dlResult.FilePath, currentExe+".new") if err != nil { manager.setProgressError("提取文件失败", err.Error()) g.Log().Errorf(ctx, "Extract failed: %v", err) @@ -95,19 +110,8 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL } _ = os.Remove(dlResult.FilePath) // clean up tarball - // Step 4: Backup current binary + // Step 5: Backup current binary manager.setProgress(PhaseBackingUp, "正在备份当前版本...", 30) - currentExe, err := os.Executable() - if err != nil { - manager.setProgressError("获取当前程序路径失败", err.Error()) - _ = os.Remove(newBinaryPath) - return - } - // Resolve symlinks - currentExe, err = filepath.EvalSymlinks(currentExe) - if err != nil { - g.Log().Warningf(ctx, "Failed to resolve symlink: %v", err) - } backupPath := fmt.Sprintf("%s.backup.%s", currentExe, gtime.Now().Format("YmdHis")) if err := copyFile(currentExe, backupPath); err != nil { @@ -128,7 +132,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL g.Log().Warningf(ctx, "Failed to save rollback info: %v", err) } - // Step 5: Replace binary + // Step 6: Replace binary manager.setProgress(PhaseReplacing, "正在替换程序文件...", 60) if err := replaceBinary(ctx, currentExe, newBinaryPath); err != nil { manager.setProgressError("替换程序文件失败", err.Error()) @@ -137,7 +141,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL return } - // Step 6: Write pending verification marker + // Step 7: Write pending verification marker pendingData, _ := json.Marshal(struct { Version string `json:"version"` OldBinary string `json:"old_binary"` @@ -147,7 +151,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL }) _ = os.WriteFile(filepath.Join(updateDir, pendingVerificationFile), pendingData, 0644) - // Step 7: Restart + // Step 8: Restart manager.setProgress(PhaseRestarting, "正在重启服务...", 90) g.Log().Info(ctx, "Update complete, exiting for restart...") time.Sleep(500 * time.Millisecond) // give status API a moment to respond @@ -155,8 +159,10 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL os.Exit(0) } -// extractBinary extracts the team-api binary from a tar.gz archive -func extractBinary(ctx context.Context, tarballPath string) (string, error) { +// extractBinary extracts the team-api binary from a tar.gz archive to outputPath. +// outputPath 必须与被替换的二进制位于同一文件系统(通常为同目录), +// 否则 replaceBinary 中的 os.Rename 会因跨设备(EXDEV)失败 +func extractBinary(ctx context.Context, tarballPath, outputPath string) (string, error) { f, err := os.Open(tarballPath) if err != nil { return "", err @@ -185,8 +191,6 @@ func extractBinary(ctx context.Context, tarballPath string) (string, error) { // Look for the binary file (not directory) baseName := filepath.Base(hdr.Name) if baseName == "team-api" && hdr.Typeflag == tar.TypeReg { - // Extract to temp file - outputPath := filepath.Join(updateDir, "team-api.new") outf, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) if err != nil { return "", err From 0e3cecf1448fe7930b43c0cbe82d93a0ff7ae1d3 Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 14 Aug 2026 23:59:05 +0800 Subject: [PATCH 09/24] =?UTF-8?q?fix(member):=E4=BF=AE=E5=A4=8D=E6=88=90?= =?UTF-8?q?=E5=91=98=E5=88=97=E8=A1=A8=E5=AF=BC=E5=87=BA=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=8E=E8=A1=A8=E6=A0=BC=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/tenant/member.go | 125 ++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 14 deletions(-) diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 1db6cea..4851b61 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strconv" "strings" "time" @@ -1065,7 +1066,35 @@ func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApi }, nil } +// 成员导出显示文案,与前端成员列表页保持一致 +var memberRoleLabels = map[string]string{ + "owner": "所有者", + "admin": "管理员", + "member": "成员", +} + +var memberStatusLabels = map[string]string{ + "active": "已激活", + "invited": "已邀请", + "disabled": "已禁用", +} + +var memberPeriodLabels = map[string]string{ + "day": "按天", + "week": "按周", + "month": "按月", +} + +// formatMemberUSD 格式化金额为 $ 前缀、最多两位小数(去掉末尾 0),与前端 formatMoney(precision: 2) 输出一致 +func formatMemberUSD(v float64) string { + s := strconv.FormatFloat(v, 'f', 2, 64) + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + return "$" + s +} + // ExportMembers exports the tenant member list as CSV or Excel. +// 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。 func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error) { role := middleware.GetUserRole(ctx) if role != "owner" && role != "admin" { @@ -1077,13 +1106,16 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR tenantID := middleware.GetTenantID(ctx) columns := []export.Column{ - {Field: "id", Header: "ID"}, {Field: "username", Header: "用户名"}, - {Field: "email", Header: "邮箱"}, {Field: "display_name", Header: "显示名称"}, + {Field: "email", Header: "邮箱"}, {Field: "role", Header: "角色"}, {Field: "status", Header: "状态"}, - {Field: "created_at", Header: "创建时间"}, + {Field: "quota", Header: "额度限制"}, + {Field: "model_count", Header: "可用模型"}, + {Field: "month_cost", Header: "本月消费(USD)"}, + {Field: "created_at", Header: "加入时间"}, + {Field: "updated_at", Header: "最后更新"}, } config := export.Config{ @@ -1106,27 +1138,92 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR } var users []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + Id int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + QuotaType string `json:"quota_type"` + QuotaLimit float64 `json:"quota_limit"` + QuotaPeriod string `json:"quota_period"` + UpdatedAt string `json:"updated_at"` } err := model.OrderDesc("id").Limit(1000).Offset(offset).Scan(&users) if err = common.IgnoreScanNoRows(err); err != nil { return } + + // 与列表页口径一致:批量聚合模型范围与本月消费,避免逐行 N+1 查询 + userIds := make([]int64, len(users)) + for i, u := range users { + userIds[i] = u.Id + } + modelCountMap := make(map[int64]int) + hasScopeMap := make(map[int64]bool) + monthCostMap := make(map[int64]float64) + if len(userIds) > 0 { + var scopeRows []struct { + UserId int64 `json:"user_id"` + ModelId int64 `json:"model_id"` + } + if err = dao.TntMemberModelScopes.Ctx(ctx). + Where("tenant_id", tenantID). + Where("user_id", userIds). + Scan(&scopeRows); err == nil { + for _, r := range scopeRows { + hasScopeMap[r.UserId] = true + if r.ModelId > 0 { + modelCountMap[r.UserId]++ + } + } + } + + monthStart := time.Now().Format("2006-01") + "-01 00:00:00" + var costRows []struct { + UserId int64 `json:"user_id"` + Cost float64 `json:"cost"` + } + if err = dao.BilUsageLogs.Ctx(ctx). + Fields("user_id, COALESCE(SUM(total_cost), 0) as cost"). + Where("tenant_id", tenantID). + Where("user_id", userIds). + Where("created_at >= ?", monthStart). + Group("user_id"). + Scan(&costRows); err == nil { + for _, c := range costRows { + monthCostMap[c.UserId] = c.Cost + } + } + } + for _, u := range users { + // 额度限制:不限制 / 金额(周期性追加周期文案),与列表页渲染一致 + quota := "不限制" + if u.QuotaType != "" && u.QuotaType != "none" { + quota = formatMemberUSD(u.QuotaLimit) + if u.QuotaType == "periodic" && u.QuotaPeriod != "" { + quota += "(" + memberPeriodLabels[u.QuotaPeriod] + ")" + } + } + // 可用模型:无任何范围记录 = 不限,否则输出授权数量 + modelCount := "不限" + if hasScopeMap[u.Id] { + modelCount = strconv.Itoa(modelCountMap[u.Id]) + } + if !yield(map[string]any{ - "id": u.Id, "username": u.Username, - "email": u.Email, "display_name": u.DisplayName, - "role": u.Role, - "status": u.Status, + "email": u.Email, + "role": memberRoleLabels[u.Role], + "status": memberStatusLabels[u.Status], + "quota": quota, + "model_count": modelCount, + "month_cost": formatMemberUSD(monthCostMap[u.Id]), "created_at": u.CreatedAt, + "updated_at": u.UpdatedAt, }) { return } From ec0f815a536f2cbb6344e14e0be3ec4b574f23e0 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 15 Aug 2026 00:07:18 +0800 Subject: [PATCH 10/24] =?UTF-8?q?fix(ui):=E4=BC=98=E5=8C=96=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=A1=86=E8=A2=AB=E5=85=B6=E4=BB=96=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E9=81=AE=E6=8C=A1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/common/TableFilterForm.vue | 88 ++++++------------- web/tenant/src/views/finance/OrdersPage.vue | 28 +++--- .../src/views/finance/TransactionsPage.vue | 24 ++--- .../views/notification/NotificationsPage.vue | 27 +++--- web/tenant/src/views/service/ApiKeysPage.vue | 28 +++--- web/tenant/src/views/service/TaskLogsPage.vue | 25 +++--- .../src/views/service/UsageLogsPage.vue | 24 ++--- web/tenant/src/views/support/TicketsPage.vue | 28 +++--- web/tenant/src/views/team/MembersPage.vue | 1 - 9 files changed, 138 insertions(+), 135 deletions(-) diff --git a/web/tenant/src/components/common/TableFilterForm.vue b/web/tenant/src/components/common/TableFilterForm.vue index adf58c8..cdae757 100644 --- a/web/tenant/src/components/common/TableFilterForm.vue +++ b/web/tenant/src/components/common/TableFilterForm.vue @@ -1,6 +1,6 @@