From 245cedad4780ceb9474715a12d800d0fd9f49e63 Mon Sep 17 00:00:00 2001 From: chengqian Date: Wed, 29 Jul 2026 16:57:11 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(wallet):=20=E4=BF=AE=E5=A4=8D=E9=A2=84?= =?UTF-8?q?=E8=AD=A6=E9=98=88=E5=80=BC=20nil=20=E6=8C=87=E9=92=88=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20/api/tenant/wallet=20=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning_threshold 列可空,注册(Register)/后台建租户(CreateTenant)两条建钱包路径曾漏设该字段,导致这些租户的钱包阈值为 NULL。Wallet() 直接解引用 *w.WarningThreshold,owner/admin 访问 /api/tenant/wallet 时 panic recovered 500。 统一语义:warning_threshold = 0 表示关闭预警(也是新建默认值),正数 = 预警线,与告警引擎 alert.go「阈值 <= 0 视为未启用」一致。 - Wallet() 增加 nil 守卫,NULL 行按 0 返回,消除解引用崩溃 - 所有建钱包路径(懒初始化/Register/CreateTenant/EnsureWallet)显式写入 0,不再产生 NULL - 管理后台阈值显示:>0 显示金额,否则显示「关闭」 --- internal/logic/admin/tenant.go | 11 +++++++---- internal/logic/billing/wallet.go | 2 +- internal/logic/tenant/auth.go | 11 +++++++---- internal/logic/tenant/billing.go | 15 +++++++++++---- web/admin/src/views/tenant/TenantDetailPage.vue | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 8d09662..693328a 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -13,6 +13,7 @@ import ( v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" "github.com/qianfree/team-api/internal/model/entity" @@ -167,11 +168,13 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 return gerror.Wrapf(err, "set tenant owner") } + warningThreshold := billing.Zero // 预警阈值默认 0 = 关闭,用户可自行开启 _, err = dao.BilWallets.Ctx(ctx).Data(do.BilWallets{ - TenantId: tenantID, - Balance: 0, - FrozenBalance: 0, - Currency: "USD", + TenantId: tenantID, + Balance: 0, + FrozenBalance: 0, + WarningThreshold: &warningThreshold, + Currency: "USD", }).Insert() if err != nil { return gerror.Wrapf(err, "create wallet") diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index ecaaa25..0fc4dfe 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -99,7 +99,7 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { func EnsureWallet(ctx context.Context, tenantID int64) error { _, err := g.DB().Ctx(ctx).Exec(ctx, `INSERT INTO bil_wallets (tenant_id, balance, frozen_balance, warning_threshold, currency) - VALUES ($1, 0, 0, 1.00, 'USD') + VALUES ($1, 0, 0, 0, 'USD') ON CONFLICT (tenant_id) DO NOTHING`, tenantID) if err != nil { diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 1dfafcc..507ed0f 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -15,6 +15,7 @@ import ( v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" do "github.com/qianfree/team-api/internal/model/do" @@ -179,11 +180,13 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. } // Create wallet for tenant + warningThreshold := billing.Zero // 预警阈值默认 0 = 关闭,用户可自行开启 _, err = dao.BilWallets.Ctx(ctx).Data(do.BilWallets{ - TenantId: tenantID, - Balance: 0, - FrozenBalance: 0, - Currency: "USD", + TenantId: tenantID, + Balance: 0, + FrozenBalance: 0, + WarningThreshold: &warningThreshold, + Currency: "USD", }).Insert() if err != nil { return gerror.Wrapf(err, "create wallet") diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 1c912b9..31a7f7b 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -35,8 +35,8 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena return nil, err } if w == nil { - // 钱包不存在,初始化 - threshold := billing.NewFromFloat(1.00) + // 钱包不存在,初始化(预警阈值默认 0 = 关闭,用户可自行开启) + threshold := billing.Zero _, err = dao.BilWallets.Ctx(ctx).Insert(do.BilWallets{ TenantId: tenantID, Balance: billing.Zero, @@ -51,16 +51,23 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena Balance: 0, FrozenBalance: 0, AvailableBalance: 0, - WarningThreshold: 1.00, + WarningThreshold: 0, Currency: "USD", }, nil } + // 语义:warning_threshold = 0 表示关闭预警。该列当前可空,存量/建钱包路径漏设时会为 NULL, + // 这里对 NULL 行按 0(关闭)返回,避免解引用 nil 指针 + warningThreshold := billing.Zero + if w.WarningThreshold != nil { + warningThreshold = *w.WarningThreshold + } + return &v1.TenantWalletRes{ Balance: billing.InexactFloat64(w.Balance), FrozenBalance: billing.InexactFloat64(w.FrozenBalance), AvailableBalance: billing.InexactFloat64(billing.SubtractMoney(w.Balance, w.FrozenBalance)), - WarningThreshold: billing.InexactFloat64(*w.WarningThreshold), + WarningThreshold: billing.InexactFloat64(warningThreshold), Currency: w.Currency, }, nil } diff --git a/web/admin/src/views/tenant/TenantDetailPage.vue b/web/admin/src/views/tenant/TenantDetailPage.vue index f00df4e..beb9920 100644 --- a/web/admin/src/views/tenant/TenantDetailPage.vue +++ b/web/admin/src/views/tenant/TenantDetailPage.vue @@ -739,7 +739,7 @@ onMounted(() => { ${{ parseFloat(walletInfo.frozen_balance).toFixed(2) }} - {{ walletInfo.warning_threshold != null ? `$${parseFloat(walletInfo.warning_threshold).toFixed(2)}` : '未设置' }} + {{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(2)}` : '关闭' }} From 25c9de0465c7b36d12c4902a40ee20612ee9553c Mon Sep 17 00:00:00 2001 From: chengqian Date: Wed, 29 Jul 2026 17:21:58 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(tenant):=20=E9=92=B1=E5=8C=85=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BD=99=E9=A2=9D=E9=A2=84=E8=AD=A6=E9=98=88?= =?UTF-8?q?=E5=80=BC=E8=AE=BE=E7=BD=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 租户前端原本没有修改预警阈值的入口(后端 PUT /wallet/warning-threshold 已存在但无调用方)。owner/admin 只能找管理员代设,无法自助开关告警。 - 余额卡新增「余额预警」行:启用(>0)显示 $X.XX,关闭(=0)显示「已关闭」,点击打开编辑 - 编辑 modal:金额输入(校验 ≥0) + 关闭/$1/$5/$10 快捷项,保存调 PUT /tenant/wallet/warning-threshold - 成功就地更新显示并关弹窗,失败由 request 拦截器统一报错 - 语义与后端统一的「0 = 关闭预警」对齐;钱包页本身要求 owner/admin,入口天然仅 owner/admin 可用 --- web/tenant/src/views/finance/WalletPage.vue | 158 ++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/web/tenant/src/views/finance/WalletPage.vue b/web/tenant/src/views/finance/WalletPage.vue index 07207e2..67b982e 100644 --- a/web/tenant/src/views/finance/WalletPage.vue +++ b/web/tenant/src/views/finance/WalletPage.vue @@ -37,6 +37,11 @@ const redeemHistoryLoading = ref(false) const redeemTypeLabels: Record = { quota: '额度', plan: '套餐', duration: '时长' } const redeemTypeBadgeClasses: Record = { quota: 'badge-success', plan: 'badge-primary', duration: 'badge-warning' } +// Warning threshold +const showThresholdModal = ref(false) +const thresholdInput = ref('') +const thresholdSaving = ref(false) + // Computed: preset amounts from payment settings const presetAmounts = computed(() => { if (paymentInfo.value?.amount_options?.length) { @@ -66,6 +71,15 @@ const payMethods = computed(() => { const minimumAmount = computed(() => Number(paymentInfo.value?.min_topup) || 1) +const thresholdActive = computed(() => Number(wallet.value?.warning_threshold) > 0) + +const thresholdValidationError = computed(() => { + const val = Number(thresholdInput.value) + if (thresholdInput.value === '' || isNaN(val)) return '请输入有效金额' + if (val < 0) return '阈值不能为负数' + return '' +}) + const selectedDiscount = computed(() => { if (!rechargeAmount.value) return 1 return Number(paymentInfo.value?.amount_discount?.[Math.trunc(rechargeAmount.value)]) || 1 @@ -225,6 +239,32 @@ async function fetchRedeemHistory() { } } +// Warning threshold +function openThresholdModal() { + thresholdInput.value = wallet.value?.warning_threshold ? String(wallet.value.warning_threshold) : '0' + showThresholdModal.value = true +} + +function closeThresholdModal() { + showThresholdModal.value = false +} + +async function saveThreshold() { + if (thresholdValidationError.value) return + const threshold = Number(thresholdInput.value) + thresholdSaving.value = true + try { + await request.put('/tenant/wallet/warning-threshold', { threshold }) + // 拦截器在 code !== 0 时已 reject 并提示错误,走到这里即成功;更新本地展示 + if (wallet.value) wallet.value.warning_threshold = threshold + showThresholdModal.value = false + } catch { + // 拦截器已提示错误 + } finally { + thresholdSaving.value = false + } +} + function formatTime(unix: number): string { if (!unix) return '-' return new Date(unix * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) @@ -358,6 +398,18 @@ onBeforeUnmount(() => { + + @@ -641,6 +693,62 @@ onBeforeUnmount(() => { + + + + + + + + + @@ -754,6 +862,56 @@ onBeforeUnmount(() => { background: #fffbeb; } +.threshold-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + background: #f8fafc; + padding: 0.875rem 1rem; + transition: border-color 180ms ease, background-color 180ms ease; +} +.threshold-row:hover { + border-color: #99f6e4; + background: #f0fdfa; +} + +.threshold-note { + display: flex; + align-items: flex-start; + gap: 0.5rem; + border-radius: 0.75rem; + background: #f0fdfa; + padding: 0.65rem 0.75rem; + color: #0f766e; +} +.threshold-note p { + font-size: 0.75rem; + line-height: 1.5; +} + +.threshold-quick { + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.72); + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; + font-weight: 600; + color: #475569; + transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease; +} +.threshold-quick:hover { + border-color: #99f6e4; + color: #0d9488; +} +.threshold-quick-active { + border-color: #5eead4; + background: #f0fdfa; + color: #0d9488; +} + .amount-pill { display: flex; min-height: 3.75rem; From 49eb8d07ccc54efb91ed5443cc339f96ce43ad4d Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 30 Jul 2026 14:10:04 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(web):=20=E7=BB=9F=E4=B8=80=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E5=92=8C=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=98=9F=E5=88=97=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/router/index.ts | 8 ++-- web/admin/src/utils/request.ts | 73 ++++++++++++++++++++++-------- web/tenant/src/utils/request.ts | 78 +++++++++++++++++++++++---------- 3 files changed, 113 insertions(+), 46 deletions(-) diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 08a104f..e0f3b09 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,10 +2,9 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' -import { shouldRefresh, getRefreshToken } from '@/utils/request' +import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { useTopProgress } from '@/composables/useTopProgress' -import axios from 'axios' const routes: RouteRecordRaw[] = [ { @@ -35,10 +34,11 @@ export function markSystemInitialized(): void { async function checkSetupStatus(): Promise { if (setupChecked) return systemInitialized === true try { - const res = await axios.get('/api/setup/status', { timeout: 5000 }) + const res = await request.get('/setup/status', { timeout: 5000 }) systemInitialized = res.data?.data?.initialized === true } catch { - systemInitialized = true + // Allow the current navigation, but retry the setup check next time. + return true } setupChecked = true return systemInitialized === true diff --git a/web/admin/src/utils/request.ts b/web/admin/src/utils/request.ts index 52d6919..112a7ba 100644 --- a/web/admin/src/utils/request.ts +++ b/web/admin/src/utils/request.ts @@ -82,7 +82,12 @@ export function shouldRefresh(): boolean { } let isRefreshing = false -let pendingRequests: Array<(token: string) => void> = [] +interface PendingRequest { + resolve: (token: string) => void + reject: (error: unknown) => void +} + +let pendingRequests: PendingRequest[] = [] let tokenRefreshedCallback: ((tokens: TokenPair) => void) | null = null export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { @@ -90,14 +95,21 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { } function replayPending(token: string): void { - pendingRequests.forEach((cb) => cb(token)) + pendingRequests.forEach(({ resolve }) => resolve(token)) pendingRequests = [] } -function clearPending(): void { +function rejectPending(error: unknown): void { + pendingRequests.forEach(({ reject }) => reject(error)) pendingRequests = [] } +function waitForRefresh(): Promise { + return new Promise((resolve, reject) => { + pendingRequests.push({ resolve, reject }) + }) +} + const request: AxiosInstance = axios.create({ baseURL: '/api', timeout: 30_000, @@ -132,18 +144,18 @@ request.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { if (shouldRefresh() && !config.url?.includes('/auth/refresh')) { if (isRefreshing) { - await new Promise((resolve) => { - pendingRequests.push(() => resolve()) - }) + await waitForRefresh() } else { isRefreshing = true try { const newTokens = await doRefresh() replayPending(newTokens.accessToken) - } catch { - clearPending() + } catch (error) { + rejectPending(error) clearTokens() + showRequestError(error, config) window.location.hash = '#/admin/login' + return Promise.reject(error) } finally { isRefreshing = false } @@ -168,6 +180,26 @@ function showErrorToast(message: string, config?: InternalAxiosRequestConfig) { Message.error(message) } +function showRequestError(error: unknown, config?: InternalAxiosRequestConfig) { + if (config?._suppressErrorMsg || !axios.isAxiosError(error)) return + if ((error as any)._errorToastShown) return + ;(error as any)._errorToastShown = true + const responseData = error.response?.data + if (responseData && typeof responseData === 'object' && 'message' in responseData) { + Message.error(String(responseData.message || '请求失败')) + return + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + Message.error('请求超时,请稍后重试') + return + } + if (!error.response) { + Message.error('网络连接异常,请检查后端服务是否正常') + return + } + Message.error(`服务器请求失败(${error.response.status})`) +} + request.interceptors.response.use( (response) => { const data = response.data @@ -204,18 +236,20 @@ request.interceptors.response.use( ;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE return Promise.reject(err) } + showRequestError(error, originalRequest) return Promise.reject(error) } if (isRefreshing) { - return new Promise((resolve) => { - pendingRequests.push((newToken: string) => { - originalRequest.headers = originalRequest.headers ?? {} - originalRequest.headers.Authorization = `Bearer ${newToken}` - originalRequest._retry = true - resolve(request(originalRequest)) - }) - }) + try { + const newToken = await waitForRefresh() + originalRequest.headers = originalRequest.headers ?? {} + originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest._retry = true + return request(originalRequest) + } catch (refreshError) { + return Promise.reject(refreshError) + } } originalRequest._retry = true @@ -228,11 +262,12 @@ request.interceptors.response.use( originalRequest.headers = originalRequest.headers ?? {} originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}` return request(originalRequest) - } catch { - clearPending() + } catch (refreshError) { + rejectPending(refreshError) clearTokens() + showRequestError(refreshError, originalRequest) window.location.hash = '#/admin/login' - return Promise.reject(error) + return Promise.reject(refreshError) } finally { isRefreshing = false } diff --git a/web/tenant/src/utils/request.ts b/web/tenant/src/utils/request.ts index d56b413..1d65c96 100644 --- a/web/tenant/src/utils/request.ts +++ b/web/tenant/src/utils/request.ts @@ -82,7 +82,12 @@ export function shouldRefresh(): boolean { } let isRefreshing = false -let pendingRequests: Array<(token: string) => void> = [] +interface PendingRequest { + resolve: (token: string) => void + reject: (error: unknown) => void +} + +let pendingRequests: PendingRequest[] = [] let tokenRefreshedCallback: ((tokens: TokenPair) => void) | null = null export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { @@ -90,14 +95,21 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { } function replayPending(token: string): void { - pendingRequests.forEach((cb) => cb(token)) + pendingRequests.forEach(({ resolve }) => resolve(token)) pendingRequests = [] } -function clearPending(): void { +function rejectPending(error: unknown): void { + pendingRequests.forEach(({ reject }) => reject(error)) pendingRequests = [] } +function waitForRefresh(): Promise { + return new Promise((resolve, reject) => { + pendingRequests.push({ resolve, reject }) + }) +} + const request: AxiosInstance = axios.create({ baseURL: '/api', timeout: 30_000, @@ -149,18 +161,18 @@ request.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { if (!isPublicPath(config.url) && shouldRefresh() && !config.url?.includes('/auth/refresh')) { if (isRefreshing) { - await new Promise((resolve) => { - pendingRequests.push(() => resolve()) - }) + await waitForRefresh() } else { isRefreshing = true try { const newTokens = await doRefresh() replayPending(newTokens.accessToken) - } catch { - clearPending() + } catch (error) { + rejectPending(error) clearTokens() + showRequestError(error, config) window.location.hash = '#/tenant/login' + return Promise.reject(error) } finally { isRefreshing = false } @@ -185,6 +197,27 @@ function showErrorToast(message: string, config?: InternalAxiosRequestConfig | A toast.error(message) } +function showRequestError(error: unknown, config?: InternalAxiosRequestConfig | AxiosRequestConfig) { + if (config && '_suppressErrorMsg' in config && (config as any)._suppressErrorMsg) return + if (!axios.isAxiosError(error)) return + if ((error as any)._errorToastShown) return + ;(error as any)._errorToastShown = true + const responseData = error.response?.data + if (responseData && typeof responseData === 'object' && 'message' in responseData) { + toast.error(String(responseData.message || '请求失败')) + return + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + toast.error('请求超时,请稍后重试') + return + } + if (!error.response) { + toast.error('网络连接异常,请检查后端服务是否正常') + return + } + toast.error(`服务器请求失败(${error.response.status})`) +} + request.interceptors.response.use( (response) => { const data = response.data @@ -222,22 +255,20 @@ request.interceptors.response.use( ;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE return Promise.reject(err) } - // Network or other unexpected errors - if (!error.response) { - showErrorToast('网络连接异常,请稍后重试', originalRequest) - } + showRequestError(error, originalRequest) return Promise.reject(error) } if (isRefreshing) { - return new Promise((resolve) => { - pendingRequests.push((newToken: string) => { - originalRequest.headers = originalRequest.headers ?? {} - originalRequest.headers.Authorization = `Bearer ${newToken}` - originalRequest._retry = true - resolve(request(originalRequest)) - }) - }) + try { + const newToken = await waitForRefresh() + originalRequest.headers = originalRequest.headers ?? {} + originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest._retry = true + return request(originalRequest) + } catch (refreshError) { + return Promise.reject(refreshError) + } } originalRequest._retry = true @@ -250,11 +281,12 @@ request.interceptors.response.use( originalRequest.headers = originalRequest.headers ?? {} originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}` return request(originalRequest) - } catch { - clearPending() + } catch (refreshError) { + rejectPending(refreshError) clearTokens() + showRequestError(refreshError, originalRequest) window.location.hash = '#/tenant/login' - return Promise.reject(error) + return Promise.reject(refreshError) } finally { isRefreshing = false } From 8a8a96a0127311fcf05041189e1cc809997a874c Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 30 Jul 2026 14:26:34 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=97=A5=E5=BF=97=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=A9=BA=E5=85=83=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/admin/audit.go | 28 ++++++++++++++++----- internal/logic/admin/audit_test.go | 36 +++++++++++++++++++++++++++ internal/logic/admin/channel_error.go | 17 ------------- 3 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 internal/logic/admin/audit_test.go diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index 66b8c20..6a77405 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -95,6 +95,15 @@ func queryPage(ctx context.Context, table, fields, where, orderBy string, page, if where != "" { m = m.Where(where, args...) } + + total, err := m.Count() + if err != nil { + return nil, 0, err + } + if total == 0 { + return []map[string]any{}, 0, nil + } + if fields != "" { m = m.Fields(fields) } @@ -102,16 +111,23 @@ func queryPage(ctx context.Context, table, fields, where, orderBy string, page, m = m.Order(orderBy) } - var items []map[string]any - var total int - err := m.Page(page, pageSize).ScanAndCount(&items, &total, false) + result, err := m.Page(page, pageSize).All() if err != nil { return nil, 0, err } - if items == nil { - items = []map[string]any{} + return toMapList(result), total, nil +} + +// toMapList converts database records to JSON-safe maps. +func toMapList(result gdb.Result) []map[string]any { + if len(result) == 0 { + return []map[string]any{} } - return items, total, nil + list := make([]map[string]any, 0, len(result)) + for _, row := range result { + list = append(list, row.Map()) + } + return list } // queryAuditPage 查询审计表分页数据,自动计数、分页、转换并批量填充关联名称。 diff --git a/internal/logic/admin/audit_test.go b/internal/logic/admin/audit_test.go new file mode 100644 index 0000000..d9b1248 --- /dev/null +++ b/internal/logic/admin/audit_test.go @@ -0,0 +1,36 @@ +package admin + +import ( + "encoding/json" + "testing" + + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" +) + +func TestToMapListProducesJSONObjects(t *testing.T) { + t.Parallel() + + result := gdb.Result{ + gdb.Record{ + "id": gvar.New(9), + "error_message": gvar.New("request failed"), + }, + } + + list := toMapList(result) + if len(list) != 1 { + t.Fatalf("toMapList() length = %d, want 1", len(list)) + } + if list[0] == nil { + t.Fatal("toMapList() returned a nil map") + } + + data, err := json.Marshal(list) + if err != nil { + t.Fatalf("marshal list: %v", err) + } + if got, want := string(data), `[{"error_message":"request failed","id":9}]`; got != want { + t.Fatalf("marshal list = %s, want %s", got, want) + } +} diff --git a/internal/logic/admin/channel_error.go b/internal/logic/admin/channel_error.go index c00c62f..b1fb755 100644 --- a/internal/logic/admin/channel_error.go +++ b/internal/logic/admin/channel_error.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -196,19 +195,3 @@ func (s *sAdmin) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErro }, }, nil } - -// toMapList 将 Result 转为 []map[string]any -func toMapList(result gdb.Result) []map[string]any { - if result == nil || len(result) == 0 { - return []map[string]any{} - } - list := make([]map[string]any, len(result)) - for i, row := range result { - data := make(map[string]any, len(row)) - for k, v := range row { - data[k] = v.Val() - } - list[i] = data - } - return list -} From 6ff5c6e949edfe7b236ffac2ef04d0a675b4bf5c Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 30 Jul 2026 14:40:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(web):=20=E9=98=B2=E6=AD=A2=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=BC=82=E5=B8=B8=E9=98=BB=E5=A1=9E=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/RouteErrorBoundary.vue | 45 ++++++++++++++++++ web/admin/src/main.ts | 3 ++ web/admin/src/views/AdminLayout.vue | 7 ++- .../components/common/RouteErrorBoundary.vue | 46 +++++++++++++++++++ web/tenant/src/main.ts | 3 ++ web/tenant/src/views/Layout.vue | 7 ++- 6 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 web/admin/src/components/RouteErrorBoundary.vue create mode 100644 web/tenant/src/components/common/RouteErrorBoundary.vue diff --git a/web/admin/src/components/RouteErrorBoundary.vue b/web/admin/src/components/RouteErrorBoundary.vue new file mode 100644 index 0000000..7935347 --- /dev/null +++ b/web/admin/src/components/RouteErrorBoundary.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 661008c..4ba0800 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -27,6 +27,9 @@ console.log( ) const app = createApp(App) +app.config.errorHandler = (error, _instance, info) => { + console.error('[VueError]', info, error) +} app.use(createPinia()) app.use(ArcoVue) app.use(router) diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 9b3c96b..70bbc4f 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -5,6 +5,7 @@ import { Message } from '@arco-design/web-vue' import axios from 'axios' import { marked } from 'marked' import request from '@/utils/request' +import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue' import { useWatermark } from '@/composables/useWatermark' import { useSiteName } from '@/composables/useSiteName' import { @@ -610,9 +611,11 @@ onUnmounted(() => {
- + - + + +
diff --git a/web/tenant/src/components/common/RouteErrorBoundary.vue b/web/tenant/src/components/common/RouteErrorBoundary.vue new file mode 100644 index 0000000..98ff179 --- /dev/null +++ b/web/tenant/src/components/common/RouteErrorBoundary.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/tenant/src/main.ts b/web/tenant/src/main.ts index 425faee..b7926af 100644 --- a/web/tenant/src/main.ts +++ b/web/tenant/src/main.ts @@ -29,6 +29,9 @@ console.log( ) const app = createApp(App) +app.config.errorHandler = (error, _instance, info) => { + console.error('[VueError]', info, error) +} app.use(head) app.use(createPinia()) app.use(router) diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index a928885..21e3ccb 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -10,6 +10,7 @@ import { toast } from '@/utils/toast' import Icon from '@/components/common/Icon.vue' import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue' import AnnouncementBanner from '@/components/common/AnnouncementBanner.vue' +import RouteErrorBoundary from '@/components/common/RouteErrorBoundary.vue' import { marked } from 'marked' import request from '@/utils/request' @@ -510,7 +511,11 @@ onBeforeUnmount(() => { class="tenant-main px-3 pb-5 pt-4 md:px-5 md:pb-6 lg:pt-5" :class="{ 'tenant-table-main': route.meta.viewportTable }" > - + + + + + From f4debdfab2f204439322993bbfd9a9e0593fce41 Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 31 Jul 2026 15:19:01 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=97=A0=E6=B3=95=E4=BF=AE=E6=94=B9=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codegraph/.gitignore | 5 +++ CLAUDE.md | 6 ++-- api/admin/v1/channel.go | 1 + internal/logic/admin/channel.go | 3 ++ web/admin/src/views/ai/ChannelDetailPage.vue | 32 ++++++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/CLAUDE.md b/CLAUDE.md index d239ad4..3c26385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ - **后端**:Go + GoFrame (v2) + goose(数据库迁移) - **数据库**:PostgreSQL(唯一,不用 MySQL/SQLite) - **缓存**:Redis + 内存缓存(双层缓存) -- **前端**:Vue 3 + Vite + TailwindCSS + Naive UI(管理后台) +- **前端**:Vue 3 + Vite + TailwindCSS + Arco Design Vue(管理后台) - **包管理**:bun - **对象存储**:S3 / OSS / COS / MinIO(禁止纯本地磁盘作为生产存储) @@ -383,9 +383,9 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额 ### 前端 - Vue 3 Composition API + `