Merge branch 'main' into dev

# Conflicts:
#	web/admin/src/views/ai/ChannelDetailPage.vue
This commit is contained in:
qian committed 2026-08-01 23:03:23 +08:00
commit 4bf835394d
15 files changed
+286 -75

No files matched your search

+3 -3
View File
@@ -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 + `<script setup>` 语法
- **管理后台**:使用 **Naive UI** 组件库,快速构建数据密集型页面(表格、表单、图表等),可参考 [Soybean Admin](https://github.com/soybeanjs/soybean-admin) 的架构设计(路由、权限、布局)
- **管理后台**:使用 **Arco Design Vue** 组件库(`@arco-design/web-vue`),快速构建数据密集型页面(表格、表单、图表等),可参考 [Soybean Admin](https://github.com/soybeanjs/soybean-admin) 的架构设计(路由、权限、布局)
- **租户控制台**:TailwindCSS 原子类 + 自建组件,面向客户,注重品牌调性和视觉差异化
- TailwindCSS 作为基础样式层,两个控制台共用(Naive UI 的 CSS-in-JS 方案与 TailwindCSS 无冲突)
- TailwindCSS 作为基础样式层,两个控制台共用(注意与 Arco 组件内置样式共存,避免全局 reset 冲突)
- 状态管理用 Pinia
- HTTP 请求封装 Axios,JWT 自动刷新,统一错误处理
- 按钮级权限控制:无权限的按钮不渲染(`v-if="hasPermission('xxx')"`)
+1
View File
@@ -73,6 +73,7 @@ type ChannelUpdateReq struct {
g.Meta `path:"/channels/{id}" method:"put" mime:"json" tags:"管理后台-渠道" summary:"更新渠道"`
ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"`
Name string `json:"name" dc:"渠道名称"`
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:"优先级"`
+22 -6
View File
@@ -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 查询审计表分页数据,自动计数、分页、转换并批量填充关联名称。
+36
View File
@@ -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)
}
}
+3
View File
@@ -309,6 +309,9 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
if req.Name != "" {
data.Name = req.Name
}
if req.Type != nil {
data.Type = *req.Type
}
if req.BaseURL != "" {
data.BaseUrl = req.BaseURL
}
-17
View File
@@ -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
}
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const error = ref<unknown>(null)
onErrorCaptured((caughtError, _instance, info) => {
error.value = caughtError
console.error('[RouteErrorBoundary]', info, caughtError)
return false
})
function reloadPage() {
window.location.reload()
}
async function goToDashboard() {
error.value = null
await router.push({ name: 'AdminDashboard' })
}
</script>
<template>
<slot v-if="!error" />
<div v-else class="route-error">
<AResult status="error" title="页面加载失败" subtitle="页面数据异常,请重试或返回仪表盘">
<template #extra>
<ASpace>
<AButton type="primary" @click="reloadPage">重新加载</AButton>
<AButton @click="goToDashboard">返回仪表盘</AButton>
</ASpace>
</template>
</AResult>
</div>
</template>
<style scoped>
.route-error {
display: flex;
min-height: min(520px, calc(100vh - 160px));
align-items: center;
justify-content: center;
}
</style>
+3
View File
@@ -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)
+4 -4
View File
@@ -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<boolean> {
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
+54 -19
View File
@@ -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<string> {
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<void>((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
}
+5 -2
View File
@@ -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 {
@@ -612,9 +613,11 @@ onUnmounted(() => {
<!-- Content -->
<main class="admin-content page-bg">
<router-view v-slot="{ Component }">
<router-view v-slot="{ Component, route: currentRoute }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" />
<RouteErrorBoundary :key="currentRoute.fullPath">
<component :is="Component" />
</RouteErrorBoundary>
</Transition>
</router-view>
</main>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
const router = useRouter()
const error = ref<unknown>(null)
onErrorCaptured((caughtError, _instance, info) => {
error.value = caughtError
console.error('[RouteErrorBoundary]', info, caughtError)
return false
})
function reloadPage() {
window.location.reload()
}
async function goToDashboard() {
error.value = null
await router.push({ name: 'TenantDashboard' })
}
</script>
<template>
<slot v-if="!error" />
<div v-else class="flex min-h-[min(520px,calc(100vh-10rem))] items-center justify-center px-4">
<div class="w-full max-w-lg text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-500">
<Icon name="exclamationCircle" size="xl" />
</div>
<h2 class="mt-5 text-lg font-semibold text-gray-900">页面加载失败</h2>
<p class="mt-2 text-sm text-gray-500">页面数据异常,请重试或返回仪表盘</p>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<button type="button" class="btn btn-primary" @click="reloadPage">
<Icon name="refresh" size="sm" />
重新加载
</button>
<button type="button" class="btn btn-secondary" @click="goToDashboard">
<Icon name="chart" size="sm" />
返回仪表盘
</button>
</div>
</div>
</div>
</template>
+3
View File
@@ -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)
+55 -23
View File
@@ -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<string> {
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<void>((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
}
+6 -1
View File
@@ -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 }"
>
<router-view />
<router-view v-slot="{ Component, route: currentRoute }">
<RouteErrorBoundary :key="currentRoute.fullPath">
<component :is="Component" />
</RouteErrorBoundary>
</router-view>
</main>
</div>
</div>