Merge upstream/main
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
|
||||
@@ -84,6 +85,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationProgress />
|
||||
<RouterView />
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
478
frontend/src/__tests__/integration/navigation.spec.ts
Normal file
478
frontend/src/__tests__/integration/navigation.spec.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* 导航集成测试
|
||||
* 测试完整的页面导航流程、预加载和错误恢复机制
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createWebHistory, type Router } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
|
||||
// Mock 视图组件
|
||||
const MockDashboard = defineComponent({
|
||||
name: 'MockDashboard',
|
||||
render() {
|
||||
return h('div', { class: 'dashboard' }, 'Dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
const MockKeys = defineComponent({
|
||||
name: 'MockKeys',
|
||||
render() {
|
||||
return h('div', { class: 'keys' }, 'Keys')
|
||||
}
|
||||
})
|
||||
|
||||
const MockUsage = defineComponent({
|
||||
name: 'MockUsage',
|
||||
render() {
|
||||
return h('div', { class: 'usage' }, 'Usage')
|
||||
}
|
||||
})
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
checkAuth: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
siteName: 'Test Site'
|
||||
})
|
||||
}))
|
||||
|
||||
// 创建测试路由
|
||||
function createTestRouter(): Router {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
meta: { requiresAuth: true, title: 'Keys' }
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: MockUsage,
|
||||
meta: { requiresAuth: true, title: 'Usage' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 测试用 App 组件
|
||||
const TestApp = defineComponent({
|
||||
name: 'TestApp',
|
||||
setup() {
|
||||
return () => h('div', { id: 'app' }, [h('router-view')])
|
||||
}
|
||||
})
|
||||
|
||||
describe('Navigation Integration Tests', () => {
|
||||
let router: Router
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
|
||||
beforeEach(() => {
|
||||
// 设置 Pinia
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// 重置导航加载状态
|
||||
_resetNavigationLoadingInstance()
|
||||
|
||||
// 创建新的路由实例
|
||||
router = createTestRouter()
|
||||
|
||||
// Mock requestIdleCallback
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('完整页面导航流程', () => {
|
||||
it('导航时应该触发加载状态变化', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 初始状态
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
// 挂载应用
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
// 等待路由初始化
|
||||
await router.isReady()
|
||||
await flushPromises()
|
||||
|
||||
// 导航到 /dashboard
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 导航结束后状态应该重置
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航到新页面应该正确渲染组件', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 检查当前路由
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('连续快速导航应该正确处理路由状态', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
|
||||
// 快速连续导航
|
||||
router.push('/keys')
|
||||
router.push('/usage')
|
||||
router.push('/dashboard')
|
||||
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 应该最终停在 /dashboard
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由预加载', () => {
|
||||
it('导航后应该触发相关路由预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch')
|
||||
|
||||
// 设置 afterEach 守卫
|
||||
router.afterEach((to) => {
|
||||
routePrefetch.triggerPrefetch(to)
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 应该触发预加载
|
||||
expect(triggerSpy).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已预加载的路由不应重复预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 手动触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const prefetchedCount = routePrefetch.prefetchedRoutes.value.size
|
||||
|
||||
// 再次触发相同路由预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 预加载数量不应增加
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('路由变化时应取消之前的预加载任务', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
|
||||
// 触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
|
||||
// 立即导航到新路由(这会在内部调用 cancelPendingPrefetch)
|
||||
routePrefetch.triggerPrefetch({ path: '/keys' } as any)
|
||||
|
||||
// 由于 triggerPrefetch 内部调用 cancelPendingPrefetch,检查是否有预加载被正确管理
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chunk 加载错误恢复', () => {
|
||||
it('chunk 加载失败应该被正确捕获', async () => {
|
||||
const errorHandler = vi.fn()
|
||||
|
||||
// 创建带错误处理的路由
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/error-page',
|
||||
name: 'ErrorPage',
|
||||
// 模拟加载失败的组件
|
||||
component: () => Promise.reject(new Error('Failed to fetch dynamically imported module'))
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError(errorHandler)
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
await errorRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到会失败的页面
|
||||
try {
|
||||
await errorRouter.push('/error-page')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// 错误处理器应该被调用
|
||||
expect(errorHandler).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('chunk 加载错误应该包含正确的错误信息', async () => {
|
||||
let capturedError: Error | null = null
|
||||
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/chunk-error',
|
||||
name: 'ChunkError',
|
||||
component: () => {
|
||||
const error = new Error('Loading chunk failed')
|
||||
error.name = 'ChunkLoadError'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError((error) => {
|
||||
capturedError = error
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
|
||||
try {
|
||||
await errorRouter.push('/chunk-error')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(capturedError).not.toBeNull()
|
||||
expect(capturedError!.name).toBe('ChunkLoadError')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('导航状态管理', () => {
|
||||
it('导航开始时 isLoading 应该变为 true', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 创建一个延迟加载的组件来模拟真实场景
|
||||
const DelayedComponent = defineComponent({
|
||||
name: 'DelayedComponent',
|
||||
async setup() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return () => h('div', 'Delayed')
|
||||
}
|
||||
})
|
||||
|
||||
const delayRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/delayed',
|
||||
name: 'Delayed',
|
||||
component: DelayedComponent
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 设置导航守卫
|
||||
delayRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
delayRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [delayRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await delayRouter.isReady()
|
||||
await delayRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 导航结束后 isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航取消时应该正确重置状态', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
const testRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
beforeEnter: (_to, _from, next) => {
|
||||
// 模拟导航取消
|
||||
next(false)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
testRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
testRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [testRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await testRouter.isReady()
|
||||
await testRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到被取消的路由
|
||||
await testRouter.push('/keys').catch(() => {})
|
||||
await flushPromises()
|
||||
|
||||
// 导航被取消后,状态应该被重置
|
||||
// 注意:由于 afterEach 仍然会被调用,isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
45
frontend/src/__tests__/setup.ts
Normal file
45
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Vitest 测试环境设置
|
||||
* 提供全局 mock 和测试工具
|
||||
*/
|
||||
import { config } from '@vue/test-utils'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock requestIdleCallback (Safari < 15 不支持)
|
||||
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
||||
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
||||
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
|
||||
}) as unknown as typeof requestIdleCallback
|
||||
}
|
||||
|
||||
if (typeof globalThis.cancelIdleCallback === 'undefined') {
|
||||
globalThis.cancelIdleCallback = ((id: number) => {
|
||||
window.clearTimeout(id)
|
||||
}) as unknown as typeof cancelIdleCallback
|
||||
}
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Vue Test Utils 全局配置
|
||||
config.global.stubs = {
|
||||
// 可以在这里添加全局 stub
|
||||
}
|
||||
|
||||
// 设置全局测试超时
|
||||
vi.setConfig({ testTimeout: 10000 })
|
||||
@@ -275,11 +275,15 @@ export async function bulkUpdate(
|
||||
): Promise<{
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}> {
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}>('/admin/accounts/bulk-update', {
|
||||
account_ids: accountIds,
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface TrendParams {
|
||||
granularity?: 'day' | 'hour'
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
@@ -70,6 +74,10 @@ export interface ModelStatsParams {
|
||||
end_date?: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
|
||||
@@ -9,6 +9,7 @@ import groupsAPI from './groups'
|
||||
import accountsAPI from './accounts'
|
||||
import proxiesAPI from './proxies'
|
||||
import redeemAPI from './redeem'
|
||||
import promoAPI from './promo'
|
||||
import settingsAPI from './settings'
|
||||
import systemAPI from './system'
|
||||
import subscriptionsAPI from './subscriptions'
|
||||
@@ -16,6 +17,7 @@ import usageAPI from './usage'
|
||||
import geminiAPI from './gemini'
|
||||
import antigravityAPI from './antigravity'
|
||||
import userAttributesAPI from './userAttributes'
|
||||
import opsAPI from './ops'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -27,13 +29,15 @@ export const adminAPI = {
|
||||
accounts: accountsAPI,
|
||||
proxies: proxiesAPI,
|
||||
redeem: redeemAPI,
|
||||
promo: promoAPI,
|
||||
settings: settingsAPI,
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
usage: usageAPI,
|
||||
gemini: geminiAPI,
|
||||
antigravity: antigravityAPI,
|
||||
userAttributes: userAttributesAPI
|
||||
userAttributes: userAttributesAPI,
|
||||
ops: opsAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -43,13 +47,15 @@ export {
|
||||
accountsAPI,
|
||||
proxiesAPI,
|
||||
redeemAPI,
|
||||
promoAPI,
|
||||
settingsAPI,
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
usageAPI,
|
||||
geminiAPI,
|
||||
antigravityAPI,
|
||||
userAttributesAPI
|
||||
userAttributesAPI,
|
||||
opsAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
1210
frontend/src/api/admin/ops.ts
Normal file
1210
frontend/src/api/admin/ops.ts
Normal file
File diff suppressed because it is too large
Load Diff
69
frontend/src/api/admin/promo.ts
Normal file
69
frontend/src/api/admin/promo.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Admin Promo Codes API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
PromoCode,
|
||||
PromoCodeUsage,
|
||||
CreatePromoCodeRequest,
|
||||
UpdatePromoCodeRequest,
|
||||
BasePaginationResponse
|
||||
} from '@/types'
|
||||
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
}
|
||||
): Promise<BasePaginationResponse<PromoCode>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<PromoCode> {
|
||||
const { data } = await apiClient.get<PromoCode>(`/admin/promo-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(request: CreatePromoCodeRequest): Promise<PromoCode> {
|
||||
const { data } = await apiClient.post<PromoCode>('/admin/promo-codes', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, request: UpdatePromoCodeRequest): Promise<PromoCode> {
|
||||
const { data } = await apiClient.put<PromoCode>(`/admin/promo-codes/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/promo-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUsages(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<BasePaginationResponse<PromoCodeUsage>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCodeUsage>>(
|
||||
`/admin/promo-codes/${id}/usages`,
|
||||
{ params: { page, page_size: pageSize } }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
const promoAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteCode,
|
||||
getUsages
|
||||
}
|
||||
|
||||
export default promoAPI
|
||||
@@ -4,7 +4,13 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types'
|
||||
import type {
|
||||
Proxy,
|
||||
ProxyAccountSummary,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all proxies with pagination
|
||||
@@ -120,6 +126,7 @@ export async function testProxy(id: number): Promise<{
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean
|
||||
@@ -129,6 +136,7 @@ export async function testProxy(id: number): Promise<{
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
}>(`/admin/proxies/${id}/test`)
|
||||
return data
|
||||
}
|
||||
@@ -160,8 +168,8 @@ export async function getStats(id: number): Promise<{
|
||||
* @param id - Proxy ID
|
||||
* @returns List of accounts using the proxy
|
||||
*/
|
||||
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`)
|
||||
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
|
||||
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -189,6 +197,17 @@ export async function batchCreate(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted_ids: number[]
|
||||
skipped: Array<{ id: number; reason: string }>
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted_ids: number[]
|
||||
skipped: Array<{ id: number; reason: string }>
|
||||
}>('/admin/proxies/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -201,7 +220,8 @@ export const proxiesAPI = {
|
||||
testProxy,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate
|
||||
batchCreate,
|
||||
batchDelete
|
||||
}
|
||||
|
||||
export default proxiesAPI
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SystemSettings {
|
||||
api_base_url: string
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -34,14 +35,29 @@ export interface SystemSettings {
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
turnstile_secret_key_configured: boolean
|
||||
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||
|
||||
// LinuxDo Connect OAuth settings
|
||||
linuxdo_connect_enabled: boolean
|
||||
linuxdo_connect_client_id: string
|
||||
linuxdo_connect_client_secret_configured: boolean
|
||||
linuxdo_connect_redirect_url: string
|
||||
|
||||
// Model fallback configuration
|
||||
enable_model_fallback: boolean
|
||||
fallback_model_anthropic: string
|
||||
fallback_model_openai: string
|
||||
fallback_model_gemini: string
|
||||
fallback_model_antigravity: string
|
||||
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
enable_identity_patch: boolean
|
||||
identity_patch_prompt: string
|
||||
|
||||
// Ops Monitoring (vNext)
|
||||
ops_monitoring_enabled: boolean
|
||||
ops_realtime_monitoring_enabled: boolean
|
||||
ops_query_mode_default: 'auto' | 'raw' | 'preagg' | string
|
||||
ops_metrics_interval_seconds: number
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -55,6 +71,7 @@ export interface UpdateSettingsRequest {
|
||||
api_base_url?: string
|
||||
contact_info?: string
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
@@ -69,8 +86,17 @@ export interface UpdateSettingsRequest {
|
||||
linuxdo_connect_client_id?: string
|
||||
linuxdo_connect_client_secret?: string
|
||||
linuxdo_connect_redirect_url?: string
|
||||
enable_model_fallback?: boolean
|
||||
fallback_model_anthropic?: string
|
||||
fallback_model_openai?: string
|
||||
fallback_model_gemini?: string
|
||||
fallback_model_antigravity?: string
|
||||
enable_identity_patch?: boolean
|
||||
identity_patch_prompt?: string
|
||||
ops_monitoring_enabled?: boolean
|
||||
ops_realtime_monitoring_enabled?: boolean
|
||||
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string
|
||||
ops_metrics_interval_seconds?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +201,41 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream timeout settings interface
|
||||
*/
|
||||
export interface StreamTimeoutSettings {
|
||||
enabled: boolean
|
||||
action: 'temp_unsched' | 'error' | 'none'
|
||||
temp_unsched_minutes: number
|
||||
threshold_count: number
|
||||
threshold_window_minutes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream timeout settings
|
||||
* @returns Stream timeout settings
|
||||
*/
|
||||
export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> {
|
||||
const { data } = await apiClient.get<StreamTimeoutSettings>('/admin/settings/stream-timeout')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stream timeout settings
|
||||
* @param settings - Stream timeout settings to update
|
||||
* @returns Updated settings
|
||||
*/
|
||||
export async function updateStreamTimeoutSettings(
|
||||
settings: StreamTimeoutSettings
|
||||
): Promise<StreamTimeoutSettings> {
|
||||
const { data } = await apiClient.put<StreamTimeoutSettings>(
|
||||
'/admin/settings/stream-timeout',
|
||||
settings
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const settingsAPI = {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
@@ -182,7 +243,9 @@ export const settingsAPI = {
|
||||
sendTestEmail,
|
||||
getAdminApiKey,
|
||||
regenerateAdminApiKey,
|
||||
deleteAdminApiKey
|
||||
deleteAdminApiKey,
|
||||
getStreamTimeoutSettings,
|
||||
updateStreamTimeoutSettings
|
||||
}
|
||||
|
||||
export default settingsAPI
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
total_account_cost?: number
|
||||
average_duration_ms: number
|
||||
}
|
||||
|
||||
@@ -64,7 +65,6 @@ export async function getStats(params: {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
|
||||
@@ -113,6 +113,26 @@ export async function sendVerifyCode(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate promo code response
|
||||
*/
|
||||
export interface ValidatePromoCodeResponse {
|
||||
valid: boolean
|
||||
bonus_amount?: number
|
||||
error_code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate promo code (public endpoint, no auth required)
|
||||
* @param code - Promo code to validate
|
||||
* @returns Validation result with bonus amount if valid
|
||||
*/
|
||||
export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> {
|
||||
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
|
||||
return data
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login,
|
||||
register,
|
||||
@@ -123,7 +143,8 @@ export const authAPI = {
|
||||
getAuthToken,
|
||||
clearAuthToken,
|
||||
getPublicSettings,
|
||||
sendVerifyCode
|
||||
sendVerifyCode,
|
||||
validatePromoCode
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -80,9 +80,45 @@ apiClient.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||
// Request cancellation: keep the original axios cancellation error so callers can ignore it.
|
||||
// Otherwise we'd misclassify it as a generic "network error".
|
||||
if (error.code === 'ERR_CANCELED' || axios.isCancel(error)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Handle common errors
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const url = String(error.config?.url || '')
|
||||
|
||||
// Validate `data` shape to avoid HTML error pages breaking our error handling.
|
||||
const apiData = (typeof data === 'object' && data !== null ? data : {}) as Record<string, any>
|
||||
|
||||
// Ops monitoring disabled: treat as feature-flagged 404, and proactively redirect away
|
||||
// from ops pages to avoid broken UI states.
|
||||
if (status === 404 && apiData.message === 'Ops monitoring is disabled') {
|
||||
try {
|
||||
localStorage.setItem('ops_monitoring_enabled_cached', 'false')
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ops-monitoring-disabled'))
|
||||
} catch {
|
||||
// ignore event failures
|
||||
}
|
||||
|
||||
if (window.location.pathname.startsWith('/admin/ops')) {
|
||||
window.location.href = '/admin/settings'
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: 'OPS_DISABLED',
|
||||
message: apiData.message || error.message,
|
||||
url
|
||||
})
|
||||
}
|
||||
|
||||
// 401: Unauthorized - clear token and redirect to login
|
||||
if (status === 401) {
|
||||
@@ -113,8 +149,8 @@ apiClient.interceptors.response.use(
|
||||
// Return structured error
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: data?.code,
|
||||
message: data?.message || error.message
|
||||
code: apiData.code,
|
||||
message: apiData.message || apiData.detail || error.message
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,16 @@ export async function getById(id: number): Promise<ApiKey> {
|
||||
* @param name - Key name
|
||||
* @param groupId - Optional group ID
|
||||
* @param customKey - Optional custom key value
|
||||
* @param ipWhitelist - Optional IP whitelist
|
||||
* @param ipBlacklist - Optional IP blacklist
|
||||
* @returns Created API key
|
||||
*/
|
||||
export async function create(
|
||||
name: string,
|
||||
groupId?: number | null,
|
||||
customKey?: string
|
||||
customKey?: string,
|
||||
ipWhitelist?: string[],
|
||||
ipBlacklist?: string[]
|
||||
): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name }
|
||||
if (groupId !== undefined) {
|
||||
@@ -56,6 +60,12 @@ export async function create(
|
||||
if (customKey) {
|
||||
payload.custom_key = customKey
|
||||
}
|
||||
if (ipWhitelist && ipWhitelist.length > 0) {
|
||||
payload.ip_whitelist = ipWhitelist
|
||||
}
|
||||
if (ipBlacklist && ipBlacklist.length > 0) {
|
||||
payload.ip_blacklist = ipBlacklist
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload)
|
||||
return data
|
||||
|
||||
199
frontend/src/components/account/AccountCapacityCell.vue
Normal file
199
frontend/src/components/account/AccountCapacityCell.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<!-- 并发槽位 -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
concurrencyClass
|
||||
]"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ currentConcurrency }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ account.concurrency }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
|
||||
<div v-if="showWindowCost" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
windowCostClass
|
||||
]"
|
||||
:title="windowCostTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">${{ formatCost(currentWindowCost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">${{ formatCost(account.window_cost_limit) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
|
||||
<div v-if="showSessionLimit" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
sessionLimitClass
|
||||
]"
|
||||
:title="sessionLimitTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ activeSessions }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ account.max_sessions }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前并发数
|
||||
const currentConcurrency = computed(() => props.account.current_concurrency || 0)
|
||||
|
||||
// 是否为 Anthropic OAuth/SetupToken 账号
|
||||
const isAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
props.account.platform === 'anthropic' &&
|
||||
(props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
)
|
||||
})
|
||||
|
||||
// 是否显示窗口费用限制
|
||||
const showWindowCost = computed(() => {
|
||||
return (
|
||||
isAnthropicOAuthOrSetupToken.value &&
|
||||
props.account.window_cost_limit !== undefined &&
|
||||
props.account.window_cost_limit !== null &&
|
||||
props.account.window_cost_limit > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 当前窗口费用
|
||||
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
|
||||
|
||||
// 是否显示会话限制
|
||||
const showSessionLimit = computed(() => {
|
||||
return (
|
||||
isAnthropicOAuthOrSetupToken.value &&
|
||||
props.account.max_sessions !== undefined &&
|
||||
props.account.max_sessions !== null &&
|
||||
props.account.max_sessions > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 当前活跃会话数
|
||||
const activeSessions = computed(() => props.account.active_sessions ?? 0)
|
||||
|
||||
// 并发状态样式
|
||||
const concurrencyClass = computed(() => {
|
||||
const current = currentConcurrency.value
|
||||
const max = props.account.concurrency
|
||||
|
||||
if (current >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (current > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
})
|
||||
|
||||
// 窗口费用状态样式
|
||||
const windowCostClass = computed(() => {
|
||||
if (!showWindowCost.value) return ''
|
||||
|
||||
const current = currentWindowCost.value
|
||||
const limit = props.account.window_cost_limit || 0
|
||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||
|
||||
// >= 阈值+预留: 完全不可调度 (红色)
|
||||
if (current >= limit + reserve) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 阈值: 仅粘性会话 (橙色)
|
||||
if (current >= limit) {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
// >= 80% 阈值: 警告 (黄色)
|
||||
if (current >= limit * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
// 窗口费用提示文字
|
||||
const windowCostTooltip = computed(() => {
|
||||
if (!showWindowCost.value) return ''
|
||||
|
||||
const current = currentWindowCost.value
|
||||
const limit = props.account.window_cost_limit || 0
|
||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||
|
||||
if (current >= limit + reserve) {
|
||||
return t('admin.accounts.capacity.windowCost.blocked')
|
||||
}
|
||||
if (current >= limit) {
|
||||
return t('admin.accounts.capacity.windowCost.stickyOnly')
|
||||
}
|
||||
return t('admin.accounts.capacity.windowCost.normal')
|
||||
})
|
||||
|
||||
// 会话限制状态样式
|
||||
const sessionLimitClass = computed(() => {
|
||||
if (!showSessionLimit.value) return ''
|
||||
|
||||
const current = activeSessions.value
|
||||
const max = props.account.max_sessions || 0
|
||||
|
||||
// >= 最大: 完全占满 (红色)
|
||||
if (current >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 80%: 警告 (黄色)
|
||||
if (current >= max * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
// 会话限制提示文字
|
||||
const sessionLimitTooltip = computed(() => {
|
||||
if (!showSessionLimit.value) return ''
|
||||
|
||||
const current = activeSessions.value
|
||||
const max = props.account.max_sessions || 0
|
||||
const idle = props.account.session_idle_timeout_minutes || 5
|
||||
|
||||
if (current >= max) {
|
||||
return t('admin.accounts.capacity.sessions.full', { idle })
|
||||
}
|
||||
return t('admin.accounts.capacity.sessions.normal', { idle })
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
const formatCost = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
158
frontend/src/components/account/AccountGroupsCell.vue
Normal file
158
frontend/src/components/account/AccountGroupsCell.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div v-if="groups && groups.length > 0" class="relative max-w-56">
|
||||
<!-- 分组容器:固定最大宽度,最多显示2行 -->
|
||||
<div class="flex flex-wrap gap-1 max-h-14 overflow-hidden">
|
||||
<GroupBadge
|
||||
v-for="group in displayGroups"
|
||||
:key="group.id"
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
:show-rate="false"
|
||||
class="max-w-24"
|
||||
/>
|
||||
<!-- 更多数量徽章 -->
|
||||
<button
|
||||
v-if="hiddenCount > 0"
|
||||
ref="moreButtonRef"
|
||||
@click.stop="showPopover = !showPopover"
|
||||
class="inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span>+{{ hiddenCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Popover 显示完整列表 -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
ref="popoverRef"
|
||||
class="fixed z-50 min-w-48 max-w-96 rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="showPopover = false"
|
||||
class="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 max-h-64 overflow-y-auto">
|
||||
<GroupBadge
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
:show-rate="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 点击外部关闭 popover -->
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="showPopover = false"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import type { Group } from '@/types'
|
||||
|
||||
interface Props {
|
||||
groups: Group[] | null | undefined
|
||||
maxDisplay?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxDisplay: 4
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const moreButtonRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const showPopover = ref(false)
|
||||
|
||||
// 显示的分组(最多显示 maxDisplay 个)
|
||||
const displayGroups = computed(() => {
|
||||
if (!props.groups) return []
|
||||
if (props.groups.length <= props.maxDisplay) {
|
||||
return props.groups
|
||||
}
|
||||
// 留一个位置给 +N 按钮
|
||||
return props.groups.slice(0, props.maxDisplay - 1)
|
||||
})
|
||||
|
||||
// 隐藏的数量
|
||||
const hiddenCount = computed(() => {
|
||||
if (!props.groups) return 0
|
||||
if (props.groups.length <= props.maxDisplay) return 0
|
||||
return props.groups.length - (props.maxDisplay - 1)
|
||||
})
|
||||
|
||||
// Popover 位置样式
|
||||
const popoverStyle = computed(() => {
|
||||
if (!moreButtonRef.value) return {}
|
||||
const rect = moreButtonRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
let top = rect.bottom + 8
|
||||
let left = rect.left
|
||||
|
||||
// 如果下方空间不足,显示在上方
|
||||
if (top + 280 > viewportHeight) {
|
||||
top = Math.max(8, rect.top - 280)
|
||||
}
|
||||
|
||||
// 如果右侧空间不足,向左偏移
|
||||
if (left + 384 > viewportWidth) {
|
||||
left = Math.max(8, viewportWidth - 392)
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
})
|
||||
|
||||
// 关闭 popover 的键盘事件
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
showPopover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
@@ -73,11 +73,12 @@
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
|
||||
{{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
formatCost(stats.summary.total_standard_cost)
|
||||
}})</span
|
||||
>
|
||||
}})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -121,12 +122,15 @@
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.avg_daily_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('admin.accounts.stats.basedOnActualDays', {
|
||||
days: stats.summary.actual_days_used
|
||||
})
|
||||
}}
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -189,13 +193,17 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
@@ -240,13 +248,17 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
@@ -291,13 +303,17 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,13 +413,17 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayCost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
|
||||
labels: stats.value.history.map((h) => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.cost),
|
||||
label: t('usage.accountBilled') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.actual_cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('usage.userBilled') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.user_cost),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map((h) => h.requests),
|
||||
@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
text: t('usage.accountBilled') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11
|
||||
|
||||
@@ -32,15 +32,20 @@
|
||||
formatTokens(stats.tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost -->
|
||||
<!-- Cost (Account) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>{{ t('admin.accounts.stats.cost') }}:</span
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||
formatCurrency(stats.cost)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost (User/API Key) -->
|
||||
<div v-if="stats.user_cost != null" class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatCurrency(stats.user_cost)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data -->
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Concurrency & Priority -->
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-3">
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label
|
||||
@@ -516,6 +516,36 @@
|
||||
aria-labelledby="bulk-edit-priority-label"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label
|
||||
id="bulk-edit-rate-multiplier-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-rate-multiplier-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.billingRateMultiplier') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="enableRateMultiplier"
|
||||
id="bulk-edit-rate-multiplier-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-rate-multiplier"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="rateMultiplier"
|
||||
id="bulk-edit-rate-multiplier"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="!enableRateMultiplier"
|
||||
class="input"
|
||||
:class="!enableRateMultiplier && 'cursor-not-allowed opacity-50'"
|
||||
aria-labelledby="bulk-edit-rate-multiplier-label"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
|
||||
const enableProxy = ref(false)
|
||||
const enableConcurrency = ref(false)
|
||||
const enablePriority = ref(false)
|
||||
const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
|
||||
@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
|
||||
const proxyId = ref<number | null>(null)
|
||||
const concurrency = ref(1)
|
||||
const priority = ref(1)
|
||||
const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
|
||||
@@ -778,6 +810,16 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
if (index === -1) {
|
||||
// Adding code - check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
} else {
|
||||
selectedErrorCodes.value.splice(index, 1)
|
||||
@@ -794,6 +836,16 @@ const addCustomErrorCode = () => {
|
||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||
return
|
||||
}
|
||||
// Check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
customErrorCodeInput.value = null
|
||||
}
|
||||
@@ -843,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
updates.priority = priority.value
|
||||
}
|
||||
|
||||
if (enableRateMultiplier.value) {
|
||||
updates.rate_multiplier = rateMultiplier.value
|
||||
}
|
||||
|
||||
if (enableStatus.value) {
|
||||
updates.status = status.value
|
||||
}
|
||||
@@ -903,6 +959,7 @@ const handleSubmit = async () => {
|
||||
enableProxy.value ||
|
||||
enableConcurrency.value ||
|
||||
enablePriority.value ||
|
||||
enableRateMultiplier.value ||
|
||||
enableStatus.value ||
|
||||
enableGroups.value
|
||||
|
||||
@@ -957,6 +1014,7 @@ watch(
|
||||
enableProxy.value = false
|
||||
enableConcurrency.value = false
|
||||
enablePriority.value = false
|
||||
enableRateMultiplier.value = false
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
|
||||
@@ -971,6 +1029,7 @@ watch(
|
||||
proxyId.value = null
|
||||
concurrency.value = 1
|
||||
priority.value = 1
|
||||
rateMultiplier.value = 1
|
||||
status.value = 'active'
|
||||
groupIds.value = []
|
||||
}
|
||||
|
||||
@@ -1196,7 +1196,7 @@
|
||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
|
||||
@@ -1212,6 +1212,11 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
@@ -1832,6 +1837,7 @@ const form = reactive({
|
||||
proxy_id: null as number | null,
|
||||
concurrency: 10,
|
||||
priority: 1,
|
||||
rate_multiplier: 1,
|
||||
group_ids: [] as number[],
|
||||
expires_at: null as number | null
|
||||
})
|
||||
@@ -1976,6 +1982,16 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
if (index === -1) {
|
||||
// Adding code - check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
} else {
|
||||
selectedErrorCodes.value.splice(index, 1)
|
||||
@@ -1993,6 +2009,16 @@ const addCustomErrorCode = () => {
|
||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||
return
|
||||
}
|
||||
// Check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
customErrorCodeInput.value = null
|
||||
}
|
||||
@@ -2099,6 +2125,7 @@ const resetForm = () => {
|
||||
form.proxy_id = null
|
||||
form.concurrency = 10
|
||||
form.priority = 1
|
||||
form.rate_multiplier = 1
|
||||
form.group_ids = []
|
||||
form.expires_at = null
|
||||
accountCategory.value = 'oauth-based'
|
||||
@@ -2252,6 +2279,7 @@ const createAccountAndFinish = async (
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
@@ -2462,6 +2490,7 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: form.platform,
|
||||
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
||||
credentials,
|
||||
@@ -2469,6 +2498,9 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@
|
||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
|
||||
@@ -564,6 +564,11 @@
|
||||
data-tour="account-form-priority"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
@@ -599,6 +604,136 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Window Cost Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="windowCostEnabled = !windowCostEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostStickyReserve"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||||
<input
|
||||
v-model.number="maxSessions"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="sessionIdleTimeout"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input pr-12"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.status') }}</label>
|
||||
@@ -762,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
|
||||
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||
const windowCostEnabled = ref(false)
|
||||
const windowCostLimit = ref<number | null>(null)
|
||||
const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
const tempUnschedPresets = computed(() => [
|
||||
@@ -807,6 +950,7 @@ const form = reactive({
|
||||
proxy_id: null as number | null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
rate_multiplier: 1,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
group_ids: [] as number[],
|
||||
expires_at: null as number | null
|
||||
@@ -834,6 +978,7 @@ watch(
|
||||
form.proxy_id = newAccount.proxy_id
|
||||
form.concurrency = newAccount.concurrency
|
||||
form.priority = newAccount.priority
|
||||
form.rate_multiplier = newAccount.rate_multiplier ?? 1
|
||||
form.status = newAccount.status as 'active' | 'inactive'
|
||||
form.group_ids = newAccount.group_ids || []
|
||||
form.expires_at = newAccount.expires_at ?? null
|
||||
@@ -847,6 +992,9 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
|
||||
// Load quota control settings (Anthropic OAuth/SetupToken only)
|
||||
loadQuotaControlSettings(newAccount)
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
@@ -936,6 +1084,16 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
if (index === -1) {
|
||||
// Adding code - check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
} else {
|
||||
selectedErrorCodes.value.splice(index, 1)
|
||||
@@ -953,6 +1111,16 @@ const addCustomErrorCode = () => {
|
||||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||
return
|
||||
}
|
||||
// Check for 429/529 warning
|
||||
if (code === 429) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||||
return
|
||||
}
|
||||
} else if (code === 529) {
|
||||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedErrorCodes.value.push(code)
|
||||
customErrorCodeInput.value = null
|
||||
}
|
||||
@@ -1060,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
|
||||
function loadQuotaControlSettings(account: Account) {
|
||||
// Reset all quota control state first
|
||||
windowCostEnabled.value = false
|
||||
windowCostLimit.value = null
|
||||
windowCostStickyReserve.value = null
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load from extra field (via backend DTO fields)
|
||||
if (account.window_cost_limit != null && account.window_cost_limit > 0) {
|
||||
windowCostEnabled.value = true
|
||||
windowCostLimit.value = account.window_cost_limit
|
||||
windowCostStickyReserve.value = account.window_cost_sticky_reserve ?? 10
|
||||
}
|
||||
|
||||
if (account.max_sessions != null && account.max_sessions > 0) {
|
||||
sessionLimitEnabled.value = true
|
||||
maxSessions.value = account.max_sessions
|
||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||||
}
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -1187,6 +1384,32 @@ const handleSubmit = async () => {
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
|
||||
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
|
||||
// Window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
newExtra.window_cost_limit = windowCostLimit.value
|
||||
newExtra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
} else {
|
||||
delete newExtra.window_cost_limit
|
||||
delete newExtra.window_cost_sticky_reserve
|
||||
}
|
||||
|
||||
// Session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
newExtra.max_sessions = maxSessions.value
|
||||
newExtra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
} else {
|
||||
delete newExtra.max_sessions
|
||||
delete newExtra.session_idle_timeout_minutes
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatTokens }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
|
||||
<span
|
||||
v-if="windowStats?.user_cost != null"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||
>
|
||||
U ${{ formatUserCost }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
|
||||
return t.toString()
|
||||
})
|
||||
|
||||
const formatCost = computed(() => {
|
||||
const formatAccountCost = computed(() => {
|
||||
if (!props.windowStats) return '0.00'
|
||||
return props.windowStats.cost.toFixed(2)
|
||||
})
|
||||
|
||||
const formatUserCost = computed(() => {
|
||||
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
|
||||
return props.windowStats.user_cost.toFixed(2)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -61,11 +61,12 @@
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
|
||||
{{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
formatCost(stats.summary.total_standard_cost)
|
||||
}})</span
|
||||
>
|
||||
}})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -108,12 +109,15 @@
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.avg_daily_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('admin.accounts.stats.basedOnActualDays', {
|
||||
days: stats.summary.actual_days_used
|
||||
})
|
||||
}}
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -164,13 +168,17 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
@@ -210,13 +218,17 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
@@ -260,13 +272,17 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
|
||||
labels: stats.value.history.map((h) => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.cost),
|
||||
label: t('usage.accountBilled') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.actual_cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('usage.userBilled') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.user_cost),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map((h) => h.requests),
|
||||
@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
text: t('usage.accountBilled') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11
|
||||
|
||||
@@ -127,12 +127,6 @@
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -227,12 +221,6 @@ const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 1, label: t('usage.subscription') },
|
||||
{ value: 0, label: t('usage.balance') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
|
||||
@@ -27,9 +27,18 @@
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
|
||||
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
|
||||
{{ t('usage.userBilled') }}:
|
||||
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
|
||||
· {{ t('usage.standardCost') }}:
|
||||
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400" v-else>
|
||||
{{ t('usage.standardCost') }}:
|
||||
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,27 +81,26 @@
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<div class="flex items-center gap-1.5 text-sm">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<!-- Cost Detail Tooltip -->
|
||||
<div
|
||||
class="group relative"
|
||||
@mouseenter="showTooltip($event, row)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<!-- Cost Detail Tooltip -->
|
||||
<div
|
||||
class="group relative"
|
||||
@mouseenter="showTooltip($event, row)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
|
||||
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
@@ -120,6 +119,11 @@
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-ip_address="{ row }">
|
||||
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -203,14 +207,24 @@
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">
|
||||
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||
</div>
|
||||
@@ -249,11 +263,11 @@ const cols = computed(() => [
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||
])
|
||||
|
||||
const formatCacheTokens = (tokens: number): string => {
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ user.balance.toFixed(2) }}</p></div>
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ formatBalance(user.balance) }}</p></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
|
||||
<div class="relative"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="0.01" min="0.01" required class="input pl-8" /></div>
|
||||
<div class="relative flex gap-2">
|
||||
<div class="relative flex-1"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="any" min="0" required class="input pl-8" /></div>
|
||||
<button v-if="operation === 'subtract'" type="button" @click="fillAllBalance" class="btn btn-secondary whitespace-nowrap">{{ t('admin.users.withdrawAll') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
|
||||
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ calculateNewBalance().toFixed(2) }}</span></div></div>
|
||||
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -35,13 +38,38 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a
|
||||
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
|
||||
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
|
||||
|
||||
const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0)
|
||||
// 格式化余额:显示完整精度,去除尾部多余的0
|
||||
const formatBalance = (value: number) => {
|
||||
if (value === 0) return '0.00'
|
||||
// 最多保留8位小数,去除尾部的0
|
||||
const formatted = value.toFixed(8).replace(/\.?0+$/, '')
|
||||
// 确保至少有2位小数
|
||||
const parts = formatted.split('.')
|
||||
if (parts.length === 1) return formatted + '.00'
|
||||
if (parts[1].length === 1) return formatted + '0'
|
||||
return formatted
|
||||
}
|
||||
|
||||
// 填入全部余额
|
||||
const fillAllBalance = () => {
|
||||
if (props.user) {
|
||||
form.amount = props.user.balance
|
||||
}
|
||||
}
|
||||
|
||||
const calculateNewBalance = () => {
|
||||
if (!props.user) return 0
|
||||
const result = props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount
|
||||
// 避免浮点数精度问题导致的 -0.00 显示
|
||||
return Math.abs(result) < 1e-10 ? 0 : result
|
||||
}
|
||||
const handleBalanceSubmit = async () => {
|
||||
if (!props.user) return
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
appStore.showError(t('admin.users.amountRequired'))
|
||||
return
|
||||
}
|
||||
// 退款时验证金额不超过实际余额
|
||||
if (props.operation === 'subtract' && form.amount > props.user.balance) {
|
||||
appStore.showError(t('admin.users.insufficientBalance'))
|
||||
return
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
<input v-model.number="form.balance" type="number" step="any" class="input" />
|
||||
|
||||
@@ -1,7 +1,68 @@
|
||||
<template>
|
||||
<div class="md:hidden space-y-3">
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||||
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!data || data.length === 0">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<Icon
|
||||
name="inbox"
|
||||
size="xl"
|
||||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||||
/>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('empty.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="resolveRowKey(row, index)"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
|
||||
{{ column.label }}
|
||||
</span>
|
||||
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||||
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="tableWrapperRef"
|
||||
class="table-wrapper"
|
||||
class="table-wrapper hidden md:block"
|
||||
:class="{
|
||||
'actions-expanded': actionsExpanded,
|
||||
'is-scrollable': isScrollable
|
||||
@@ -22,29 +83,36 @@
|
||||
]"
|
||||
@click="column.sortable && handleSort(column.key)"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<slot
|
||||
:name="`header-${column.key}`"
|
||||
:column="column"
|
||||
:sort-key="sortKey"
|
||||
:sort-order="sortOrder"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -83,7 +151,7 @@
|
||||
<tr
|
||||
v-else
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="index"
|
||||
:key="resolveRowKey(row, index)"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<td
|
||||
@@ -210,6 +278,7 @@ interface Props {
|
||||
stickyActionsColumn?: boolean
|
||||
expandableActions?: boolean
|
||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||
rowKey?: string | ((row: any) => string | number)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -222,6 +291,18 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const actionsExpanded = ref(false)
|
||||
const resolveRowKey = (row: any, index: number) => {
|
||||
if (typeof props.rowKey === 'function') {
|
||||
const key = props.rowKey(row)
|
||||
return key ?? index
|
||||
}
|
||||
if (typeof props.rowKey === 'string' && props.rowKey) {
|
||||
const key = row?.[props.rowKey]
|
||||
return key ?? index
|
||||
}
|
||||
const key = row?.id
|
||||
return key ?? index
|
||||
}
|
||||
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
@@ -264,7 +345,10 @@ const sortedData = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 检查第一列是否为勾选列
|
||||
const hasActionsColumn = computed(() => {
|
||||
return props.columns.some(column => column.key === 'actions')
|
||||
})
|
||||
|
||||
const hasSelectColumn = computed(() => {
|
||||
return props.columns.length > 0 && props.columns[0].key === 'select'
|
||||
})
|
||||
|
||||
44
frontend/src/components/common/HelpTooltip.vue
Normal file
44
frontend/src/components/common/HelpTooltip.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative ml-1 inline-flex items-center align-middle"
|
||||
@mouseenter="show = true"
|
||||
@mouseleave="show = false"
|
||||
>
|
||||
<!-- Trigger Icon -->
|
||||
<slot name="trigger">
|
||||
<svg
|
||||
class="h-4 w-4 cursor-help text-gray-400 transition-colors hover:text-primary-600 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
|
||||
<!-- Popover Content -->
|
||||
<div
|
||||
v-show="show"
|
||||
class="absolute bottom-full left-1/2 z-50 mb-2 w-64 -translate-x-1/2 rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:bg-gray-800"
|
||||
>
|
||||
<slot>{{ content }}</slot>
|
||||
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
109
frontend/src/components/common/NavigationProgress.vue
Normal file
109
frontend/src/components/common/NavigationProgress.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 导航进度条组件
|
||||
* 在页面顶部显示加载进度,提供导航反馈
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
|
||||
const { isLoading } = useNavigationLoadingState()
|
||||
|
||||
// 进度条可见性
|
||||
const isVisible = computed(() => isLoading.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="progress-fade">
|
||||
<div
|
||||
v-show="isVisible"
|
||||
class="navigation-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div class="navigation-progress-bar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navigation-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.navigation-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
theme('colors.primary.400') 20%,
|
||||
theme('colors.primary.500') 50%,
|
||||
theme('colors.primary.400') 80%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: progress-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 暗色模式下的进度条颜色 */
|
||||
:root.dark .navigation-progress-bar {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
theme('colors.primary.500') 20%,
|
||||
theme('colors.primary.400') 50%,
|
||||
theme('colors.primary.500') 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 进度条滑动动画 */
|
||||
@keyframes progress-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 淡入淡出过渡 */
|
||||
.progress-fade-enter-active {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.progress-fade-leave-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.progress-fade-enter-from,
|
||||
.progress-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 减少动画模式 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.navigation-progress-bar {
|
||||
animation: progress-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ A generic data table component with sorting, loading states, and custom cell ren
|
||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||
- `data: any[]` - Array of data objects to display
|
||||
- `loading?: boolean` - Show loading skeleton
|
||||
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
||||
|
||||
**Slots:**
|
||||
|
||||
|
||||
@@ -67,12 +67,13 @@
|
||||
:aria-selected="isSelected(option)"
|
||||
:aria-disabled="isOptionDisabled(option)"
|
||||
@click.stop="!isOptionDisabled(option) && selectOption(option)"
|
||||
@mouseenter="focusedIndex = index"
|
||||
@mouseenter="handleOptionMouseEnter(option, index)"
|
||||
:class="[
|
||||
'select-option',
|
||||
isGroupHeaderOption(option) && 'select-option-group',
|
||||
isSelected(option) && 'select-option-selected',
|
||||
isOptionDisabled(option) && 'select-option-disabled',
|
||||
focusedIndex === index && 'select-option-focused'
|
||||
isOptionDisabled(option) && !isGroupHeaderOption(option) && 'select-option-disabled',
|
||||
focusedIndex === index && !isGroupHeaderOption(option) && 'select-option-focused'
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="isSelected(option)">
|
||||
@@ -201,6 +202,13 @@ const isOptionDisabled = (option: any): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
const isGroupHeaderOption = (option: any): boolean => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option.kind === 'group'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
|
||||
})
|
||||
@@ -225,6 +233,31 @@ const isSelected = (option: any): boolean => {
|
||||
return getOptionValue(option) === props.modelValue
|
||||
}
|
||||
|
||||
const findNextEnabledIndex = (startIndex: number): number => {
|
||||
const opts = filteredOptions.value
|
||||
if (opts.length === 0) return -1
|
||||
for (let offset = 0; offset < opts.length; offset++) {
|
||||
const idx = (startIndex + offset) % opts.length
|
||||
if (!isOptionDisabled(opts[idx])) return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
const findPrevEnabledIndex = (startIndex: number): number => {
|
||||
const opts = filteredOptions.value
|
||||
if (opts.length === 0) return -1
|
||||
for (let offset = 0; offset < opts.length; offset++) {
|
||||
const idx = (startIndex - offset + opts.length) % opts.length
|
||||
if (!isOptionDisabled(opts[idx])) return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
const handleOptionMouseEnter = (option: any, index: number) => {
|
||||
if (isOptionDisabled(option) || isGroupHeaderOption(option)) return
|
||||
focusedIndex.value = index
|
||||
}
|
||||
|
||||
// Update trigger rect periodically while open to follow scroll/resize
|
||||
const updateTriggerRect = () => {
|
||||
if (containerRef.value) {
|
||||
@@ -259,8 +292,15 @@ watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
calculateDropdownPosition()
|
||||
// Reset focused index to current selection or first item
|
||||
const selectedIdx = filteredOptions.value.findIndex(isSelected)
|
||||
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
|
||||
if (filteredOptions.value.length === 0) {
|
||||
focusedIndex.value = -1
|
||||
} else {
|
||||
const selectedIdx = filteredOptions.value.findIndex(isSelected)
|
||||
const initialIdx = selectedIdx >= 0 ? selectedIdx : 0
|
||||
focusedIndex.value = isOptionDisabled(filteredOptions.value[initialIdx])
|
||||
? findNextEnabledIndex(initialIdx + 1)
|
||||
: initialIdx
|
||||
}
|
||||
|
||||
if (props.searchable) {
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
@@ -295,13 +335,13 @@ const onDropdownKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value + 1) % filteredOptions.value.length
|
||||
scrollToFocused()
|
||||
focusedIndex.value = findNextEnabledIndex(focusedIndex.value + 1)
|
||||
if (focusedIndex.value >= 0) scrollToFocused()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
scrollToFocused()
|
||||
focusedIndex.value = findPrevEnabledIndex(focusedIndex.value - 1)
|
||||
if (focusedIndex.value >= 0) scrollToFocused()
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
@@ -441,6 +481,17 @@ onUnmounted(() => {
|
||||
@apply cursor-not-allowed opacity-40;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-group {
|
||||
@apply cursor-default select-none;
|
||||
@apply bg-gray-50 dark:bg-dark-900;
|
||||
@apply text-[11px] font-bold uppercase tracking-wider;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-group:hover {
|
||||
@apply bg-gray-50 dark:bg-dark-900;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-label {
|
||||
@apply flex-1 min-w-0 truncate text-left;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* NavigationProgress 组件单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import NavigationProgress from '../../common/NavigationProgress.vue'
|
||||
|
||||
// Mock useNavigationLoadingState
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/useNavigationLoading', () => ({
|
||||
useNavigationLoadingState: () => ({
|
||||
isLoading: mockIsLoading
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NavigationProgress', () => {
|
||||
beforeEach(() => {
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
it('isLoading=false 时进度条应该隐藏', () => {
|
||||
mockIsLoading.value = false
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
// v-show 会设置 display: none
|
||||
expect(progressBar.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('isLoading=true 时进度条应该可见', async () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
expect(progressBar.exists()).toBe(true)
|
||||
expect(progressBar.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('应该有正确的 ARIA 属性', () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
expect(progressBar.attributes('role')).toBe('progressbar')
|
||||
expect(progressBar.attributes('aria-label')).toBe('Loading')
|
||||
expect(progressBar.attributes('aria-valuemin')).toBe('0')
|
||||
expect(progressBar.attributes('aria-valuemax')).toBe('100')
|
||||
})
|
||||
|
||||
it('进度条应该有动画 class', () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const bar = wrapper.find('.navigation-progress-bar')
|
||||
expect(bar.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('应该正确响应 isLoading 状态变化', async () => {
|
||||
// 测试初始状态为 false
|
||||
mockIsLoading.value = false
|
||||
const wrapper = mount(NavigationProgress)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// 初始状态隐藏
|
||||
expect(wrapper.find('.navigation-progress').isVisible()).toBe(false)
|
||||
|
||||
// 卸载后重新挂载以测试 true 状态
|
||||
wrapper.unmount()
|
||||
|
||||
// 改变为 true 后重新挂载
|
||||
mockIsLoading.value = true
|
||||
const wrapper2 = mount(NavigationProgress)
|
||||
await wrapper2.vm.$nextTick()
|
||||
expect(wrapper2.find('.navigation-progress').isVisible()).toBe(true)
|
||||
|
||||
// 清理
|
||||
wrapper2.unmount()
|
||||
})
|
||||
})
|
||||
@@ -124,7 +124,8 @@ const icons = {
|
||||
chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z',
|
||||
calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z'
|
||||
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z',
|
||||
brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z'
|
||||
} as const
|
||||
|
||||
const iconPath = computed(() => icons[props.name])
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
{{ platformDescription }}
|
||||
</p>
|
||||
|
||||
<!-- Client Tabs (only for Antigravity platform) -->
|
||||
<div v-if="platform === 'antigravity'" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<!-- Client Tabs -->
|
||||
<div v-if="clientTabs.length" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Client">
|
||||
<button
|
||||
v-for="tab in clientTabs"
|
||||
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
|
||||
<!-- OS/Shell Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-dark-700">
|
||||
<div v-if="showShellTabs" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in currentTabs"
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Usage Note -->
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
|
||||
<div v-if="showPlatformNote" class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
|
||||
<Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ platformNote }}
|
||||
@@ -173,17 +173,28 @@ const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
const copiedIndex = ref<number | null>(null)
|
||||
const activeTab = ref<string>('unix')
|
||||
const activeClientTab = ref<string>('claude') // Level 1 tab for antigravity platform
|
||||
const activeClientTab = ref<string>('claude')
|
||||
|
||||
// Reset tabs when platform changes
|
||||
watch(() => props.platform, (newPlatform) => {
|
||||
activeTab.value = 'unix'
|
||||
if (newPlatform === 'antigravity') {
|
||||
activeClientTab.value = 'claude'
|
||||
const defaultClientTab = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return 'codex'
|
||||
case 'gemini':
|
||||
return 'gemini'
|
||||
case 'antigravity':
|
||||
return 'claude'
|
||||
default:
|
||||
return 'claude'
|
||||
}
|
||||
})
|
||||
|
||||
// Reset shell tab when client changes (for antigravity)
|
||||
watch(() => props.platform, () => {
|
||||
activeTab.value = 'unix'
|
||||
activeClientTab.value = defaultClientTab.value
|
||||
}, { immediate: true })
|
||||
|
||||
// Reset shell tab when client changes
|
||||
watch(activeClientTab, () => {
|
||||
activeTab.value = 'unix'
|
||||
})
|
||||
@@ -251,11 +262,32 @@ const SparkleIcon = {
|
||||
}
|
||||
}
|
||||
|
||||
// Client tabs for Antigravity platform (Level 1)
|
||||
const clientTabs = computed((): TabConfig[] => [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.antigravity.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.antigravity.geminiCli'), icon: SparkleIcon }
|
||||
])
|
||||
const clientTabs = computed((): TabConfig[] => {
|
||||
if (!props.platform) return []
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return [
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'gemini':
|
||||
return [
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'antigravity':
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Shell tabs (3 types for environment variable based configs)
|
||||
const shellTabs: TabConfig[] = [
|
||||
@@ -270,11 +302,13 @@ const openaiTabs: TabConfig[] = [
|
||||
{ id: 'windows', label: 'Windows', icon: WindowsIcon }
|
||||
]
|
||||
|
||||
const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const currentTabs = computed(() => {
|
||||
if (!showShellTabs.value) return []
|
||||
if (props.platform === 'openai') {
|
||||
return openaiTabs // 2 tabs: unix, windows
|
||||
return openaiTabs
|
||||
}
|
||||
// All other platforms (anthropic, gemini, antigravity) use shell tabs
|
||||
return shellTabs
|
||||
})
|
||||
|
||||
@@ -308,6 +342,8 @@ const platformNote = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const showPlatformNote = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const escapeHtml = (value: string) => value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -329,6 +365,39 @@ const comment = (value: string) => wrapToken('text-slate-500', value)
|
||||
const currentFiles = computed((): FileConfig[] => {
|
||||
const baseUrl = props.baseUrl || window.location.origin
|
||||
const apiKey = props.apiKey
|
||||
const baseRoot = baseUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
|
||||
const ensureV1 = (value: string) => {
|
||||
const trimmed = value.replace(/\/+$/, '')
|
||||
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
|
||||
}
|
||||
const apiBase = ensureV1(baseRoot)
|
||||
const antigravityBase = ensureV1(`${baseRoot}/antigravity`)
|
||||
const antigravityGeminiBase = (() => {
|
||||
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||
})()
|
||||
const geminiBase = (() => {
|
||||
const trimmed = baseRoot.replace(/\/+$/, '')
|
||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||
})()
|
||||
|
||||
if (activeClientTab.value === 'opencode') {
|
||||
switch (props.platform) {
|
||||
case 'anthropic':
|
||||
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
|
||||
case 'openai':
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
case 'gemini':
|
||||
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
|
||||
case 'antigravity':
|
||||
return [
|
||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
||||
]
|
||||
default:
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
}
|
||||
}
|
||||
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
@@ -336,12 +405,11 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
case 'gemini':
|
||||
return [generateGeminiCliContent(baseUrl, apiKey)]
|
||||
case 'antigravity':
|
||||
// Both Claude Code and Gemini CLI need /antigravity suffix for antigravity platform
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
if (activeClientTab.value === 'gemini') {
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
}
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
default: // anthropic
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
})
|
||||
@@ -456,6 +524,95 @@ requires_openai_auth = true`
|
||||
]
|
||||
}
|
||||
|
||||
function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: string, pathLabel?: string): FileConfig {
|
||||
const provider: Record<string, any> = {
|
||||
[platform]: {
|
||||
options: {
|
||||
baseURL: baseUrl,
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
const openaiModels = {
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
const geminiModels = {
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' }
|
||||
}
|
||||
const claudeModels = {
|
||||
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
|
||||
'claude-sonnet-4-5-thinking': { name: 'Claude Sonnet 4.5 Thinking' },
|
||||
'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' }
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
provider[platform].models = geminiModels
|
||||
} else if (platform === 'anthropic') {
|
||||
provider[platform].npm = '@ai-sdk/anthropic'
|
||||
} else if (platform === 'antigravity-claude') {
|
||||
provider[platform].npm = '@ai-sdk/anthropic'
|
||||
provider[platform].name = 'Antigravity (Claude)'
|
||||
provider[platform].models = claudeModels
|
||||
} else if (platform === 'antigravity-gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
provider[platform].name = 'Antigravity (Gemini)'
|
||||
provider[platform].models = geminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
}
|
||||
|
||||
const agent =
|
||||
platform === 'openai'
|
||||
? {
|
||||
build: {
|
||||
options: {
|
||||
store: false
|
||||
}
|
||||
},
|
||||
plan: {
|
||||
options: {
|
||||
store: false
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
provider,
|
||||
...(agent ? { agent } : {}),
|
||||
$schema: 'https://opencode.ai/config.json'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
return {
|
||||
path: pathLabel ?? 'opencode.json',
|
||||
content,
|
||||
hint: t('keys.useKeyModal.opencode.hint')
|
||||
}
|
||||
}
|
||||
|
||||
const copyContent = async (content: string, index: number) => {
|
||||
const success = await clipboardCopy(content, t('keys.copied'))
|
||||
if (success) {
|
||||
|
||||
@@ -144,10 +144,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -156,6 +156,7 @@ const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
@@ -442,12 +443,16 @@ const personalNavItems = computed(() => {
|
||||
const adminNavItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
...(adminSettingsStore.opsMonitoringEnabled
|
||||
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
|
||||
: []),
|
||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
]
|
||||
|
||||
@@ -510,6 +515,23 @@ if (
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
// Fetch admin settings (for feature-gated nav items like Ops).
|
||||
watch(
|
||||
isAdmin,
|
||||
(v) => {
|
||||
if (v) {
|
||||
adminSettingsStore.fetch()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) {
|
||||
adminSettingsStore.fetch()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* useNavigationLoading 组合式函数单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
useNavigationLoading,
|
||||
_resetNavigationLoadingInstance
|
||||
} from '../useNavigationLoading'
|
||||
|
||||
describe('useNavigationLoading', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
_resetNavigationLoadingInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('startNavigation', () => {
|
||||
it('导航开始时 isNavigating 应变为 true', () => {
|
||||
const { isNavigating, startNavigation } = useNavigationLoading()
|
||||
|
||||
expect(isNavigating.value).toBe(false)
|
||||
|
||||
startNavigation()
|
||||
|
||||
expect(isNavigating.value).toBe(true)
|
||||
})
|
||||
|
||||
it('导航开始后延迟显示加载指示器(防闪烁)', () => {
|
||||
const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 立即检查,不应该显示
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 经过防闪烁延迟后应该显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('endNavigation', () => {
|
||||
it('导航结束时 isLoading 应变为 false', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('导航结束时 isNavigating 应变为 false', () => {
|
||||
const { isNavigating, startNavigation, endNavigation } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('快速导航(< 100ms)防闪烁', () => {
|
||||
it('快速导航不应触发显示加载指示器', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 在防闪烁延迟之前结束导航
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50)
|
||||
endNavigation()
|
||||
|
||||
// 不应该显示加载指示器
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 即使继续等待也不应该显示
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelNavigation', () => {
|
||||
it('导航取消时应正确重置状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2)
|
||||
|
||||
cancelNavigation()
|
||||
|
||||
// 取消后不应该触发显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNavigationDuration', () => {
|
||||
it('应该返回正确的导航持续时间', () => {
|
||||
const { startNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
const duration = getNavigationDuration()
|
||||
expect(duration).toBe(500)
|
||||
})
|
||||
|
||||
it('导航结束后应返回 null', () => {
|
||||
const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
endNavigation()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetState', () => {
|
||||
it('应该重置所有状态', () => {
|
||||
const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
resetState()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续导航场景', () => {
|
||||
it('连续快速导航应正确处理状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
// 第一次导航
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第二次导航(取消第一次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第三次导航(取消第二次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
|
||||
// 这次等待足够长时间
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// 结束导航
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ANTI_FLICKER_DELAY 常量', () => {
|
||||
it('应该为 100ms', () => {
|
||||
const { ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
expect(ANTI_FLICKER_DELAY).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
244
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
244
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* useRoutePrefetch 组合式函数单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router'
|
||||
|
||||
import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch'
|
||||
|
||||
// Mock 路由对象
|
||||
const createMockRoute = (path: string): RouteLocationNormalized => ({
|
||||
path,
|
||||
name: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
hash: '',
|
||||
fullPath: path,
|
||||
matched: [],
|
||||
meta: {},
|
||||
redirectedFrom: undefined
|
||||
})
|
||||
|
||||
// Mock Router
|
||||
const createMockRouter = (): Router => {
|
||||
const mockImportFn = vi.fn().mockResolvedValue({ default: {} })
|
||||
|
||||
const routes: Partial<RouteRecordNormalized>[] = [
|
||||
{ path: '/admin/dashboard', components: { default: mockImportFn } },
|
||||
{ path: '/admin/accounts', components: { default: mockImportFn } },
|
||||
{ path: '/admin/users', components: { default: mockImportFn } },
|
||||
{ path: '/admin/groups', components: { default: mockImportFn } },
|
||||
{ path: '/admin/subscriptions', components: { default: mockImportFn } },
|
||||
{ path: '/admin/redeem', components: { default: mockImportFn } },
|
||||
{ path: '/dashboard', components: { default: mockImportFn } },
|
||||
{ path: '/keys', components: { default: mockImportFn } },
|
||||
{ path: '/usage', components: { default: mockImportFn } },
|
||||
{ path: '/redeem', components: { default: mockImportFn } },
|
||||
{ path: '/profile', components: { default: mockImportFn } }
|
||||
]
|
||||
|
||||
return {
|
||||
getRoutes: () => routes as RouteRecordNormalized[]
|
||||
} as Router
|
||||
}
|
||||
|
||||
describe('useRoutePrefetch', () => {
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
let mockRouter: Router
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouter = createMockRouter()
|
||||
|
||||
// 保存原始函数
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
// Mock requestIdleCallback 立即执行
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// 恢复原始函数
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('_isAdminRoute', () => {
|
||||
it('应该正确识别管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch(mockRouter)
|
||||
expect(_isAdminRoute('/admin/dashboard')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/users')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/accounts')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该正确识别非管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch(mockRouter)
|
||||
expect(_isAdminRoute('/dashboard')).toBe(false)
|
||||
expect(_isAdminRoute('/keys')).toBe(false)
|
||||
expect(_isAdminRoute('/usage')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getPrefetchConfig', () => {
|
||||
it('管理员 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('普通用户 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('未定义的路由应该返回空数组', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/unknown-route')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerPrefetch', () => {
|
||||
it('应该在浏览器空闲时触发预加载', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待 requestIdleCallback 执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/admin/dashboard')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该避免重复预加载同一路由', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 第二次触发
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只应该预加载一次
|
||||
expect(prefetchedRoutes.value.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPendingPrefetch', () => {
|
||||
it('应该取消挂起的预加载任务', () => {
|
||||
const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
cancelPendingPrefetch()
|
||||
|
||||
// 不应该有预加载完成
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由变化时取消之前的预加载', () => {
|
||||
it('应该在路由变化时取消之前的预加载任务', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
|
||||
// 触发第一个路由的预加载
|
||||
triggerPrefetch(createMockRoute('/admin/dashboard'))
|
||||
|
||||
// 立即切换到另一个路由
|
||||
triggerPrefetch(createMockRoute('/admin/users'))
|
||||
|
||||
// 等待执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只有最后一个路由应该被预加载
|
||||
expect(prefetchedRoutes.value.has('/admin/users')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetPrefetchState', () => {
|
||||
it('应该重置所有预加载状态', async () => {
|
||||
const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBeGreaterThan(0)
|
||||
|
||||
resetPrefetchState()
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载映射表', () => {
|
||||
it('管理员预加载映射表应该包含正确的路由', () => {
|
||||
expect(_adminPrefetchMap).toHaveProperty('/admin/dashboard')
|
||||
expect(_adminPrefetchMap['/admin/dashboard']).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('用户预加载映射表应该包含正确的路由', () => {
|
||||
expect(_userPrefetchMap).toHaveProperty('/dashboard')
|
||||
expect(_userPrefetchMap['/dashboard']).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestIdleCallback 超时处理', () => {
|
||||
it('超时后仍能正常执行预加载', async () => {
|
||||
// 模拟超时情况
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback, options?: IdleRequestOptions) => {
|
||||
const timeout = options?.timeout || 2000
|
||||
return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout)
|
||||
})
|
||||
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待超时执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/dashboard')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载失败处理', () => {
|
||||
it('预加载失败时应该静默处理不影响页面功能', async () => {
|
||||
const { triggerPrefetch } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
// 不应该抛出异常
|
||||
expect(() => triggerPrefetch(route)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('无 router 时的行为', () => {
|
||||
it('没有传入 router 时应该正常工作但不执行预加载', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 没有 router,无法获取组件,所以不会预加载
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,17 @@ const openaiModels = [
|
||||
'o1', 'o1-preview', 'o1-mini', 'o1-pro',
|
||||
'o3', 'o3-mini', 'o3-pro',
|
||||
'o4-mini',
|
||||
'gpt-5', 'gpt-5-mini', 'gpt-5-nano',
|
||||
// GPT-5 系列(同步后端定价文件)
|
||||
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
|
||||
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
|
||||
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
|
||||
// GPT-5.1 系列
|
||||
'gpt-5.1', 'gpt-5.1-2025-11-13', 'gpt-5.1-chat-latest',
|
||||
'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
|
||||
// GPT-5.2 系列
|
||||
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||
'chatgpt-4o-latest',
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
||||
]
|
||||
@@ -211,7 +221,10 @@ const openaiPresetMappings = [
|
||||
{ label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
|
||||
]
|
||||
|
||||
const geminiPresetMappings = [
|
||||
|
||||
132
frontend/src/composables/useNavigationLoading.ts
Normal file
132
frontend/src/composables/useNavigationLoading.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 导航加载状态组合式函数
|
||||
* 管理路由切换时的加载状态,支持防闪烁逻辑
|
||||
*/
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 导航加载状态管理
|
||||
*
|
||||
* 功能:
|
||||
* 1. 在路由切换时显示加载状态
|
||||
* 2. 快速导航(< 100ms)不显示加载指示器(防闪烁)
|
||||
* 3. 导航取消时正确重置状态
|
||||
*/
|
||||
export function useNavigationLoading() {
|
||||
// 内部加载状态
|
||||
const _isLoading = ref(false)
|
||||
|
||||
// 导航开始时间(用于防闪烁计算)
|
||||
let navigationStartTime: number | null = null
|
||||
|
||||
// 防闪烁延迟计时器
|
||||
let showLoadingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 是否应该显示加载指示器(考虑防闪烁逻辑)
|
||||
const shouldShowLoading = ref(false)
|
||||
|
||||
// 防闪烁延迟时间(毫秒)
|
||||
const ANTI_FLICKER_DELAY = 100
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
const clearTimer = (): void => {
|
||||
if (showLoadingTimer !== null) {
|
||||
clearTimeout(showLoadingTimer)
|
||||
showLoadingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航开始时调用
|
||||
*/
|
||||
const startNavigation = (): void => {
|
||||
navigationStartTime = Date.now()
|
||||
_isLoading.value = true
|
||||
|
||||
// 延迟显示加载指示器,实现防闪烁
|
||||
clearTimer()
|
||||
showLoadingTimer = setTimeout(() => {
|
||||
if (_isLoading.value) {
|
||||
shouldShowLoading.value = true
|
||||
}
|
||||
}, ANTI_FLICKER_DELAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航结束时调用
|
||||
*/
|
||||
const endNavigation = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航取消时调用(比如快速连续点击不同链接)
|
||||
*/
|
||||
const cancelNavigation = (): void => {
|
||||
clearTimer()
|
||||
// 保持加载状态,因为新的导航会立即开始
|
||||
// 但重置导航开始时间
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有状态(用于测试)
|
||||
*/
|
||||
const resetState = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航持续时间(毫秒)
|
||||
*/
|
||||
const getNavigationDuration = (): number | null => {
|
||||
if (navigationStartTime === null) {
|
||||
return null
|
||||
}
|
||||
return Date.now() - navigationStartTime
|
||||
}
|
||||
|
||||
// 公开的加载状态(只读)
|
||||
const isLoading = computed(() => shouldShowLoading.value)
|
||||
|
||||
// 内部加载状态(用于测试,不考虑防闪烁)
|
||||
const isNavigating = readonly(_isLoading)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isNavigating,
|
||||
startNavigation,
|
||||
endNavigation,
|
||||
cancelNavigation,
|
||||
resetState,
|
||||
getNavigationDuration,
|
||||
// 导出常量用于测试
|
||||
ANTI_FLICKER_DELAY
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例,供全局使用
|
||||
let navigationLoadingInstance: ReturnType<typeof useNavigationLoading> | null = null
|
||||
|
||||
export function useNavigationLoadingState() {
|
||||
if (!navigationLoadingInstance) {
|
||||
navigationLoadingInstance = useNavigationLoading()
|
||||
}
|
||||
return navigationLoadingInstance
|
||||
}
|
||||
|
||||
// 导出重置函数(用于测试)
|
||||
export function _resetNavigationLoadingInstance(): void {
|
||||
if (navigationLoadingInstance) {
|
||||
navigationLoadingInstance.resetState()
|
||||
}
|
||||
navigationLoadingInstance = null
|
||||
}
|
||||
202
frontend/src/composables/useRoutePrefetch.ts
Normal file
202
frontend/src/composables/useRoutePrefetch.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 路由预加载组合式函数
|
||||
* 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验
|
||||
*
|
||||
* 优化说明:
|
||||
* - 不使用静态 import() 映射表,避免增加入口文件大小
|
||||
* - 通过路由配置动态获取组件的 import 函数
|
||||
* - 只在实际需要预加载时才执行
|
||||
*/
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { RouteLocationNormalized, Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 组件导入函数类型
|
||||
*/
|
||||
type ComponentImportFn = () => Promise<unknown>
|
||||
|
||||
/**
|
||||
* 预加载邻接表:定义每个路由应该预加载哪些相邻路由
|
||||
* 只存储路由路径,不存储 import 函数,避免打包问题
|
||||
*/
|
||||
const PREFETCH_ADJACENCY: Record<string, string[]> = {
|
||||
// Admin routes - 预加载最常访问的相邻页面
|
||||
'/admin/dashboard': ['/admin/accounts', '/admin/users'],
|
||||
'/admin/accounts': ['/admin/dashboard', '/admin/users'],
|
||||
'/admin/users': ['/admin/groups', '/admin/dashboard'],
|
||||
'/admin/groups': ['/admin/subscriptions', '/admin/users'],
|
||||
'/admin/subscriptions': ['/admin/groups', '/admin/redeem'],
|
||||
// User routes
|
||||
'/dashboard': ['/keys', '/usage'],
|
||||
'/keys': ['/dashboard', '/usage'],
|
||||
'/usage': ['/keys', '/redeem'],
|
||||
'/redeem': ['/usage', '/profile'],
|
||||
'/profile': ['/dashboard', '/keys']
|
||||
}
|
||||
|
||||
/**
|
||||
* requestIdleCallback 的返回类型
|
||||
*/
|
||||
type IdleCallbackHandle = number | ReturnType<typeof setTimeout>
|
||||
|
||||
/**
|
||||
* requestIdleCallback polyfill (Safari < 15)
|
||||
*/
|
||||
const scheduleIdleCallback = (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
): IdleCallbackHandle => {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
return window.requestIdleCallback(callback, options)
|
||||
}
|
||||
return setTimeout(() => {
|
||||
callback({ didTimeout: false, timeRemaining: () => 50 })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const cancelScheduledCallback = (handle: IdleCallbackHandle): void => {
|
||||
if (typeof window.cancelIdleCallback === 'function' && typeof handle === 'number') {
|
||||
window.cancelIdleCallback(handle)
|
||||
} else {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由预加载组合式函数
|
||||
*
|
||||
* @param router - Vue Router 实例,用于获取路由组件
|
||||
*/
|
||||
export function useRoutePrefetch(router?: Router) {
|
||||
// 当前挂起的预加载任务句柄
|
||||
const pendingPrefetchHandle = ref<IdleCallbackHandle | null>(null)
|
||||
|
||||
// 已预加载的路由集合
|
||||
const prefetchedRoutes = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 从路由配置中获取组件的 import 函数
|
||||
*/
|
||||
const getComponentImporter = (path: string): ComponentImportFn | null => {
|
||||
if (!router) return null
|
||||
|
||||
const routes = router.getRoutes()
|
||||
const route = routes.find((r) => r.path === path)
|
||||
|
||||
if (route && route.components?.default) {
|
||||
const component = route.components.default
|
||||
// 检查是否是懒加载组件(函数形式)
|
||||
if (typeof component === 'function') {
|
||||
return component as ComponentImportFn
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前路由应该预加载的路由路径列表
|
||||
*/
|
||||
const getPrefetchPaths = (route: RouteLocationNormalized): string[] => {
|
||||
return PREFETCH_ADJACENCY[route.path] || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个组件的预加载
|
||||
*/
|
||||
const prefetchComponent = async (importFn: ComponentImportFn): Promise<void> => {
|
||||
try {
|
||||
await importFn()
|
||||
} catch (error) {
|
||||
// 静默处理预加载错误
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Prefetch] Failed to prefetch component:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消挂起的预加载任务
|
||||
*/
|
||||
const cancelPendingPrefetch = (): void => {
|
||||
if (pendingPrefetchHandle.value !== null) {
|
||||
cancelScheduledCallback(pendingPrefetchHandle.value)
|
||||
pendingPrefetchHandle.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发路由预加载
|
||||
*/
|
||||
const triggerPrefetch = (route: RouteLocationNormalized): void => {
|
||||
cancelPendingPrefetch()
|
||||
|
||||
const prefetchPaths = getPrefetchPaths(route)
|
||||
if (prefetchPaths.length === 0) return
|
||||
|
||||
pendingPrefetchHandle.value = scheduleIdleCallback(
|
||||
() => {
|
||||
pendingPrefetchHandle.value = null
|
||||
|
||||
const routePath = route.path
|
||||
if (prefetchedRoutes.value.has(routePath)) return
|
||||
|
||||
// 获取需要预加载的组件 import 函数
|
||||
const importFns: ComponentImportFn[] = []
|
||||
for (const path of prefetchPaths) {
|
||||
const importFn = getComponentImporter(path)
|
||||
if (importFn) {
|
||||
importFns.push(importFn)
|
||||
}
|
||||
}
|
||||
|
||||
if (importFns.length > 0) {
|
||||
Promise.all(importFns.map(prefetchComponent)).then(() => {
|
||||
prefetchedRoutes.value.add(routePath)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置预加载状态
|
||||
*/
|
||||
const resetPrefetchState = (): void => {
|
||||
cancelPendingPrefetch()
|
||||
prefetchedRoutes.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为管理员路由
|
||||
*/
|
||||
const isAdminRoute = (path: string): boolean => {
|
||||
return path.startsWith('/admin')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预加载配置(兼容旧 API)
|
||||
*/
|
||||
const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => {
|
||||
const paths = getPrefetchPaths(route)
|
||||
const importFns: ComponentImportFn[] = []
|
||||
for (const path of paths) {
|
||||
const importFn = getComponentImporter(path)
|
||||
if (importFn) importFns.push(importFn)
|
||||
}
|
||||
return importFns
|
||||
}
|
||||
|
||||
return {
|
||||
prefetchedRoutes: readonly(prefetchedRoutes),
|
||||
triggerPrefetch,
|
||||
cancelPendingPrefetch,
|
||||
resetPrefetchState,
|
||||
_getPrefetchConfig: getPrefetchConfig,
|
||||
_isAdminRoute: isAdminRoute
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧测试的导出
|
||||
export const _adminPrefetchMap = PREFETCH_ADJACENCY
|
||||
export const _userPrefetchMap = PREFETCH_ADJACENCY
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,20 @@ import i18n from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -172,6 +175,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.dashboard.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/ops',
|
||||
name: 'AdminOps',
|
||||
component: () => import('@/views/admin/ops/OpsDashboard.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Ops Monitoring',
|
||||
titleKey: 'admin.ops.title',
|
||||
descriptionKey: 'admin.ops.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
@@ -244,6 +259,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.redeem.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/promo-codes',
|
||||
name: 'AdminPromoCodes',
|
||||
component: () => import('@/views/admin/PromoCodesView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Promo Code Management',
|
||||
titleKey: 'admin.promo.title',
|
||||
descriptionKey: 'admin.promo.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'AdminSettings',
|
||||
@@ -301,7 +328,15 @@ const router = createRouter({
|
||||
*/
|
||||
let authInitialized = false
|
||||
|
||||
// 初始化导航加载状态和预加载
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
// 延迟初始化预加载,传入 router 实例
|
||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 开始导航加载状态
|
||||
navigationLoading.startNavigation()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Restore auth state from localStorage on first navigation (page refresh)
|
||||
@@ -311,10 +346,12 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
// Set page title
|
||||
const appStore = useAppStore()
|
||||
const siteName = appStore.siteName || 'Sub2API'
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - Sub2API`
|
||||
document.title = `${to.meta.title} - ${siteName}`
|
||||
} else {
|
||||
document.title = 'Sub2API'
|
||||
document.title = siteName
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
@@ -371,11 +408,50 @@ router.beforeEach((to, _from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation guard: End loading and trigger prefetch
|
||||
*/
|
||||
router.afterEach((to) => {
|
||||
// 结束导航加载状态
|
||||
navigationLoading.endNavigation()
|
||||
|
||||
// 懒初始化预加载(首次导航时创建,传入 router 实例)
|
||||
if (!routePrefetch) {
|
||||
routePrefetch = useRoutePrefetch(router)
|
||||
}
|
||||
// 触发路由预加载(在浏览器空闲时执行)
|
||||
routePrefetch.triggerPrefetch(to)
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation guard: Error handling
|
||||
* Handles dynamic import failures caused by deployment updates
|
||||
*/
|
||||
router.onError((error) => {
|
||||
console.error('Router error:', error)
|
||||
|
||||
// Check if this is a dynamic import failure (chunk loading error)
|
||||
const isChunkLoadError =
|
||||
error.message?.includes('Failed to fetch dynamically imported module') ||
|
||||
error.message?.includes('Loading chunk') ||
|
||||
error.message?.includes('Loading CSS chunk') ||
|
||||
error.name === 'ChunkLoadError'
|
||||
|
||||
if (isChunkLoadError) {
|
||||
// Avoid infinite reload loop by checking sessionStorage
|
||||
const reloadKey = 'chunk_reload_attempted'
|
||||
const lastReload = sessionStorage.getItem(reloadKey)
|
||||
const now = Date.now()
|
||||
|
||||
// Allow reload if never attempted or more than 10 seconds ago
|
||||
if (!lastReload || now - parseInt(lastReload) > 10000) {
|
||||
sessionStorage.setItem(reloadKey, now.toString())
|
||||
console.warn('Chunk load error detected, reloading page to fetch latest version...')
|
||||
window.location.reload()
|
||||
} else {
|
||||
console.error('Chunk load error persists after reload. Please clear browser cache.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
130
frontend/src/stores/adminSettings.ts
Normal file
130
frontend/src/stores/adminSettings.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { adminAPI } from '@/api'
|
||||
|
||||
export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const readCachedBool = (key: string, defaultValue: boolean): boolean => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const writeCachedBool = (key: string, value: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(key, value ? 'true' : 'false')
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
}
|
||||
|
||||
const readCachedString = (key: string, defaultValue: string): string => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (typeof raw === 'string' && raw.length > 0) return raw
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const writeCachedString = (key: string, value: string) => {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
}
|
||||
|
||||
// Default open, but honor cached value to reduce UI flicker on first paint.
|
||||
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
|
||||
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
|
||||
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
|
||||
|
||||
async function fetch(force = false): Promise<void> {
|
||||
if (loaded.value && !force) return
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings()
|
||||
opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true
|
||||
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
|
||||
|
||||
opsRealtimeMonitoringEnabled.value = settings.ops_realtime_monitoring_enabled ?? true
|
||||
writeCachedBool('ops_realtime_monitoring_enabled_cached', opsRealtimeMonitoringEnabled.value)
|
||||
|
||||
opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto'
|
||||
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
|
||||
|
||||
loaded.value = true
|
||||
} catch (err) {
|
||||
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
|
||||
loaded.value = true
|
||||
console.error('[adminSettings] Failed to fetch settings:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setOpsMonitoringEnabledLocal(value: boolean) {
|
||||
opsMonitoringEnabled.value = value
|
||||
writeCachedBool('ops_monitoring_enabled_cached', value)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setOpsRealtimeMonitoringEnabledLocal(value: boolean) {
|
||||
opsRealtimeMonitoringEnabled.value = value
|
||||
writeCachedBool('ops_realtime_monitoring_enabled_cached', value)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setOpsQueryModeDefaultLocal(value: string) {
|
||||
opsQueryModeDefault.value = value || 'auto'
|
||||
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
// Keep UI consistent if we learn that ops is disabled via feature-gated 404s.
|
||||
// (event is dispatched from the axios interceptor)
|
||||
let eventHandlerCleanup: (() => void) | null = null
|
||||
|
||||
function initializeEventListeners() {
|
||||
if (eventHandlerCleanup) return
|
||||
|
||||
try {
|
||||
const handler = () => {
|
||||
setOpsMonitoringEnabledLocal(false)
|
||||
}
|
||||
window.addEventListener('ops-monitoring-disabled', handler)
|
||||
eventHandlerCleanup = () => {
|
||||
window.removeEventListener('ops-monitoring-disabled', handler)
|
||||
}
|
||||
} catch {
|
||||
// ignore window access failures (SSR)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeEventListeners()
|
||||
}
|
||||
|
||||
return {
|
||||
loaded,
|
||||
loading,
|
||||
opsMonitoringEnabled,
|
||||
opsRealtimeMonitoringEnabled,
|
||||
opsQueryModeDefault,
|
||||
fetch,
|
||||
setOpsMonitoringEnabledLocal,
|
||||
setOpsRealtimeMonitoringEnabledLocal,
|
||||
setOpsQueryModeDefaultLocal
|
||||
}
|
||||
})
|
||||
@@ -279,11 +279,31 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
// ==================== Public Settings Management ====================
|
||||
|
||||
/**
|
||||
* Apply settings to store state (internal helper to avoid code duplication)
|
||||
*/
|
||||
function applySettings(config: PublicSettings): void {
|
||||
cachedPublicSettings.value = config
|
||||
siteName.value = config.site_name || 'Sub2API'
|
||||
siteLogo.value = config.site_logo || ''
|
||||
siteVersion.value = config.version || ''
|
||||
contactInfo.value = config.contact_info || ''
|
||||
apiBaseUrl.value = config.api_base_url || ''
|
||||
docUrl.value = config.doc_url || ''
|
||||
publicSettingsLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public settings (uses cache unless force=true)
|
||||
* @param force - Force refresh from API
|
||||
*/
|
||||
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||
// Check for injected config from server (eliminates flash)
|
||||
if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) {
|
||||
applySettings(window.__APP_CONFIG__)
|
||||
return window.__APP_CONFIG__
|
||||
}
|
||||
|
||||
// Return cached data if available and not forcing refresh
|
||||
if (publicSettingsLoaded.value && !force) {
|
||||
if (cachedPublicSettings.value) {
|
||||
@@ -300,6 +320,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
api_base_url: apiBaseUrl.value,
|
||||
contact_info: contactInfo.value,
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
linuxdo_oauth_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
@@ -313,14 +334,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
publicSettingsLoading.value = true
|
||||
try {
|
||||
const data = await fetchPublicSettingsAPI()
|
||||
cachedPublicSettings.value = data
|
||||
siteName.value = data.site_name || 'Sub2API'
|
||||
siteLogo.value = data.site_logo || ''
|
||||
siteVersion.value = data.version || ''
|
||||
contactInfo.value = data.contact_info || ''
|
||||
apiBaseUrl.value = data.api_base_url || ''
|
||||
docUrl.value = data.doc_url || ''
|
||||
publicSettingsLoaded.value = true
|
||||
applySettings(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch public settings:', error)
|
||||
@@ -338,6 +352,19 @@ export const useAppStore = defineStore('app', () => {
|
||||
cachedPublicSettings.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize settings from injected config (window.__APP_CONFIG__)
|
||||
* This is called synchronously before Vue app mounts to prevent flash
|
||||
* @returns true if config was found and applied, false otherwise
|
||||
*/
|
||||
function initFromInjectedConfig(): boolean {
|
||||
if (window.__APP_CONFIG__) {
|
||||
applySettings(window.__APP_CONFIG__)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ==================== Return Store API ====================
|
||||
|
||||
return {
|
||||
@@ -355,6 +382,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
contactInfo,
|
||||
apiBaseUrl,
|
||||
docUrl,
|
||||
cachedPublicSettings,
|
||||
|
||||
// Version state
|
||||
versionLoaded,
|
||||
@@ -391,6 +419,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
// Public settings actions
|
||||
fetchPublicSettings,
|
||||
clearPublicSettingsCache
|
||||
clearPublicSettingsCache,
|
||||
initFromInjectedConfig
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
export { useAuthStore } from './auth'
|
||||
export { useAppStore } from './app'
|
||||
export { useAdminSettingsStore } from './adminSettings'
|
||||
export { useSubscriptionStore } from './subscriptions'
|
||||
export { useOnboardingStore } from './onboarding'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -11,7 +9,6 @@
|
||||
|
||||
html {
|
||||
@apply scroll-smooth antialiased;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -19,7 +16,22 @@
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*:hover,
|
||||
*:focus-within {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark *:hover,
|
||||
.dark *:focus-within {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-2 w-2;
|
||||
}
|
||||
@@ -29,10 +41,15 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-300 dark:bg-dark-600;
|
||||
@apply rounded-full bg-transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300/50 dark:bg-dark-600/50;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-dark-500;
|
||||
}
|
||||
|
||||
@@ -325,7 +342,7 @@
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
@apply bg-black/50 backdrop-blur-sm;
|
||||
@apply flex items-center justify-center p-4;
|
||||
@apply flex items-center justify-center p-2 sm:p-4;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
9
frontend/src/types/global.d.ts
vendored
Normal file
9
frontend/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { PublicSettings } from '@/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: PublicSettings
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -50,6 +50,7 @@ export interface RegisterRequest {
|
||||
password: string
|
||||
verify_code?: string
|
||||
turnstile_token?: string
|
||||
promo_code?: string
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeRequest {
|
||||
@@ -73,6 +74,7 @@ export interface PublicSettings {
|
||||
api_base_url: string
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -267,6 +269,9 @@ export interface Group {
|
||||
// Claude Code 客户端限制
|
||||
claude_code_only: boolean
|
||||
fallback_group_id: number | null
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
model_routing: Record<string, number[]> | null
|
||||
model_routing_enabled: boolean
|
||||
account_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -279,6 +284,8 @@ export interface ApiKey {
|
||||
name: string
|
||||
group_id: number | null
|
||||
status: 'active' | 'inactive'
|
||||
ip_whitelist: string[]
|
||||
ip_blacklist: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
group?: Group
|
||||
@@ -288,12 +295,16 @@ export interface CreateApiKeyRequest {
|
||||
name: string
|
||||
group_id?: number | null
|
||||
custom_key?: string // Optional custom API Key
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyRequest {
|
||||
name?: string
|
||||
group_id?: number | null
|
||||
status?: 'active' | 'inactive'
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
@@ -356,10 +367,26 @@ export interface Proxy {
|
||||
password?: string | null
|
||||
status: 'active' | 'inactive'
|
||||
account_count?: number // Number of accounts using this proxy
|
||||
latency_ms?: number
|
||||
latency_status?: 'success' | 'failed'
|
||||
latency_message?: string
|
||||
ip_address?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
region?: string
|
||||
city?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProxyAccountSummary {
|
||||
id: number
|
||||
name: string
|
||||
platform: AccountPlatform
|
||||
type: AccountType
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
// Gemini credentials structure for OAuth and API Key authentication
|
||||
export interface GeminiCredentials {
|
||||
// API Key authentication
|
||||
@@ -420,6 +447,7 @@ export interface Account {
|
||||
concurrency: number
|
||||
current_concurrency?: number // Real-time concurrency count from Redis
|
||||
priority: number
|
||||
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
error_message: string | null
|
||||
last_used_at: string | null
|
||||
@@ -443,13 +471,27 @@ export interface Account {
|
||||
session_window_start: string | null
|
||||
session_window_end: string | null
|
||||
session_window_status: 'allowed' | 'allowed_warning' | 'rejected' | null
|
||||
|
||||
// 5h窗口费用控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
window_cost_limit?: number | null
|
||||
window_cost_sticky_reserve?: number | null
|
||||
|
||||
// 会话数量控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
max_sessions?: number | null
|
||||
session_idle_timeout_minutes?: number | null
|
||||
|
||||
// 运行时状态(仅当启用对应限制时返回)
|
||||
current_window_cost?: number | null // 当前窗口费用
|
||||
active_sessions?: number | null // 当前活跃会话数
|
||||
}
|
||||
|
||||
// Account Usage types
|
||||
export interface WindowStats {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
cost: number // Account cost (account multiplier)
|
||||
standard_cost?: number
|
||||
user_cost?: number
|
||||
}
|
||||
|
||||
export interface UsageProgress {
|
||||
@@ -514,6 +556,7 @@ export interface CreateAccountRequest {
|
||||
proxy_id?: number | null
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
|
||||
group_ids?: number[]
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
@@ -529,6 +572,7 @@ export interface UpdateAccountRequest {
|
||||
proxy_id?: number | null
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
|
||||
schedulable?: boolean
|
||||
status?: 'active' | 'inactive'
|
||||
group_ids?: number[]
|
||||
@@ -560,9 +604,6 @@ export interface UpdateProxyRequest {
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
|
||||
|
||||
// 消费类型: 0=钱包余额, 1=订阅套餐
|
||||
export type BillingType = 0 | 1
|
||||
|
||||
export interface UsageLog {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -588,8 +629,8 @@ export interface UsageLog {
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
rate_multiplier: number
|
||||
account_rate_multiplier?: number | null
|
||||
|
||||
billing_type: BillingType
|
||||
stream: boolean
|
||||
duration_ms: number
|
||||
first_token_ms: number | null
|
||||
@@ -601,6 +642,9 @@ export interface UsageLog {
|
||||
// User-Agent
|
||||
user_agent: string | null
|
||||
|
||||
// IP 地址(仅管理员可见)
|
||||
ip_address: string | null
|
||||
|
||||
created_at: string
|
||||
|
||||
user?: User
|
||||
@@ -645,6 +689,9 @@ export interface DashboardStats {
|
||||
total_users: number
|
||||
today_new_users: number // 今日新增用户数
|
||||
active_users: number // 今日有请求的用户数
|
||||
hourly_active_users: number // 当前小时活跃用户数(UTC)
|
||||
stats_updated_at: string // 统计更新时间(UTC RFC3339)
|
||||
stats_stale: boolean // 统计是否过期
|
||||
|
||||
// API Key 统计
|
||||
total_api_keys: number
|
||||
@@ -830,7 +877,6 @@ export interface UsageQueryParams {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
@@ -843,23 +889,27 @@ export interface AccountUsageHistory {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actual_cost: number
|
||||
actual_cost: number // Account cost (account multiplier)
|
||||
user_cost: number // User/API key billed cost (group multiplier)
|
||||
}
|
||||
|
||||
export interface AccountUsageSummary {
|
||||
days: number
|
||||
actual_days_used: number
|
||||
total_cost: number
|
||||
total_cost: number // Account cost (account multiplier)
|
||||
total_user_cost: number
|
||||
total_standard_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
avg_daily_cost: number
|
||||
avg_daily_cost: number // Account cost
|
||||
avg_daily_user_cost: number
|
||||
avg_daily_requests: number
|
||||
avg_daily_tokens: number
|
||||
avg_duration_ms: number
|
||||
today: {
|
||||
date: string
|
||||
cost: number
|
||||
user_cost: number
|
||||
requests: number
|
||||
tokens: number
|
||||
} | null
|
||||
@@ -867,6 +917,7 @@ export interface AccountUsageSummary {
|
||||
date: string
|
||||
label: string
|
||||
cost: number
|
||||
user_cost: number
|
||||
requests: number
|
||||
} | null
|
||||
highest_request_day: {
|
||||
@@ -874,6 +925,7 @@ export interface AccountUsageSummary {
|
||||
label: string
|
||||
requests: number
|
||||
cost: number
|
||||
user_cost: number
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -956,3 +1008,44 @@ export interface UpdateUserAttributeRequest {
|
||||
export interface UserAttributeValuesMap {
|
||||
[attributeId: number]: string
|
||||
}
|
||||
|
||||
// ==================== Promo Code Types ====================
|
||||
|
||||
export interface PromoCode {
|
||||
id: number
|
||||
code: string
|
||||
bonus_amount: number
|
||||
max_uses: number
|
||||
used_count: number
|
||||
status: 'active' | 'disabled'
|
||||
expires_at: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromoCodeUsage {
|
||||
id: number
|
||||
promo_code_id: number
|
||||
user_id: number
|
||||
bonus_amount: number
|
||||
used_at: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
export interface CreatePromoCodeRequest {
|
||||
code?: string
|
||||
bonus_amount: number
|
||||
max_uses?: number
|
||||
expires_at?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface UpdatePromoCodeRequest {
|
||||
code?: string
|
||||
bonus_amount?: number
|
||||
max_uses?: number
|
||||
status?: 'active' | 'disabled'
|
||||
expires_at?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<template>
|
||||
<!-- Custom Home Content: Full Page Mode -->
|
||||
<div v-if="homeContent" class="min-h-screen">
|
||||
<!-- iframe mode -->
|
||||
<iframe
|
||||
v-if="isHomeContentUrl"
|
||||
:src="homeContent.trim()"
|
||||
class="h-screen w-full border-0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<!-- HTML mode - SECURITY: homeContent is admin-only setting, XSS risk is acceptable -->
|
||||
<div v-else v-html="homeContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Default Home Page -->
|
||||
<div
|
||||
class="relative min-h-screen overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
v-else
|
||||
class="relative flex min-h-screen flex-col overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
>
|
||||
<!-- Background Decorations -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
@@ -96,7 +111,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10 px-6 py-16">
|
||||
<main class="relative z-10 flex-1 px-6 py-16">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Hero Section - Left/Right Layout -->
|
||||
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
|
||||
@@ -392,21 +407,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Site settings
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('AI API Gateway Platform')
|
||||
const docUrl = ref('')
|
||||
// Site settings - directly from appStore (already initialized from injected config)
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||
|
||||
// Check if homeContent is a URL (for iframe display)
|
||||
const isHomeContentUrl = computed(() => {
|
||||
const content = homeContent.value.trim()
|
||||
return content.startsWith('http://') || content.startsWith('https://')
|
||||
})
|
||||
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
@@ -446,20 +467,15 @@ function initTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
|
||||
// Check auth state
|
||||
authStore.checkAuth()
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
|
||||
docUrl.value = sanitizeUrl(settings.doc_url || '', { allowRelative: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
// Ensure public settings are loaded (will use cache if already loaded from injected config)
|
||||
if (!appStore.publicSettingsLoaded) {
|
||||
appStore.fetchPublicSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<DataTable :columns="cols" :data="accounts" :loading="loading">
|
||||
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
@@ -34,15 +34,8 @@
|
||||
<template #cell-platform_type="{ row }">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
||||
</template>
|
||||
<template #cell-concurrency="{ row }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span :class="['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /></svg>
|
||||
<span class="font-mono">{{ row.current_concurrency || 0 }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ row.concurrency }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<template #cell-capacity="{ row }">
|
||||
<AccountCapacityCell :account="row" />
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
@@ -56,14 +49,16 @@
|
||||
<AccountTodayStatsCell :account="row" />
|
||||
</template>
|
||||
<template #cell-groups="{ row }">
|
||||
<div v-if="row.groups && row.groups.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<GroupBadge v-for="group in row.groups" :key="group.id" :name="group.name" :platform="group.platform" :subscription-type="group.subscription_type" :rate-multiplier="group.rate_multiplier" :show-rate="false" />
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
<AccountGroupsCell :groups="row.groups" :max-display="4" />
|
||||
</template>
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
<template #cell-rate_multiplier="{ row }">
|
||||
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-priority="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
|
||||
</template>
|
||||
@@ -123,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -145,7 +140,8 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
@@ -185,7 +181,7 @@ const cols = computed(() => {
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
||||
{ key: 'capacity', label: t('admin.accounts.columns.capacity'), sortable: false },
|
||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }
|
||||
@@ -193,10 +189,11 @@ const cols = computed(() => {
|
||||
if (!authStore.isSimpleMode) {
|
||||
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||
}
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
|
||||
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
@@ -205,22 +202,157 @@ const cols = computed(() => {
|
||||
})
|
||||
|
||||
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.acc = a
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const menuWidth = 200
|
||||
const menuHeight = 240
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let left, top
|
||||
|
||||
if (viewportWidth < 768) {
|
||||
// 居中显示,水平位置
|
||||
left = Math.max(padding, Math.min(
|
||||
rect.left + rect.width / 2 - menuWidth / 2,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
|
||||
// 优先显示在按钮下方
|
||||
top = rect.bottom + 4
|
||||
|
||||
// 如果下方空间不够,显示在上方
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = rect.top - menuHeight - 4
|
||||
// 如果上方也不够,就贴在视口顶部
|
||||
if (top < padding) {
|
||||
top = padding
|
||||
}
|
||||
}
|
||||
} else {
|
||||
left = Math.max(padding, Math.min(
|
||||
e.clientX - menuWidth,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
top = e.clientY
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = viewportHeight - menuHeight - padding
|
||||
}
|
||||
}
|
||||
|
||||
menu.pos = { top, left }
|
||||
} else {
|
||||
menu.pos = { top: e.clientY, left: e.clientX - 200 }
|
||||
}
|
||||
|
||||
menu.show = true
|
||||
}
|
||||
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
|
||||
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
|
||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
|
||||
if (accountIds.length === 0) return
|
||||
const idSet = new Set(accountIds)
|
||||
accounts.value = accounts.value.map((account) => (idSet.has(account.id) ? { ...account, schedulable } : account))
|
||||
}
|
||||
const normalizeBulkSchedulableResult = (
|
||||
result: {
|
||||
success?: number
|
||||
failed?: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
results?: Array<{ account_id: number; success: boolean }>
|
||||
},
|
||||
accountIds: number[]
|
||||
) => {
|
||||
const responseSuccessIds = Array.isArray(result.success_ids) ? result.success_ids : []
|
||||
const responseFailedIds = Array.isArray(result.failed_ids) ? result.failed_ids : []
|
||||
if (responseSuccessIds.length > 0 || responseFailedIds.length > 0) {
|
||||
return {
|
||||
successIds: responseSuccessIds,
|
||||
failedIds: responseFailedIds,
|
||||
successCount: typeof result.success === 'number' ? result.success : responseSuccessIds.length,
|
||||
failedCount: typeof result.failed === 'number' ? result.failed : responseFailedIds.length,
|
||||
hasIds: true,
|
||||
hasCounts: true
|
||||
}
|
||||
}
|
||||
|
||||
const results = Array.isArray(result.results) ? result.results : []
|
||||
if (results.length > 0) {
|
||||
const successIds = results.filter(item => item.success).map(item => item.account_id)
|
||||
const failedIds = results.filter(item => !item.success).map(item => item.account_id)
|
||||
return {
|
||||
successIds,
|
||||
failedIds,
|
||||
successCount: typeof result.success === 'number' ? result.success : successIds.length,
|
||||
failedCount: typeof result.failed === 'number' ? result.failed : failedIds.length,
|
||||
hasIds: true,
|
||||
hasCounts: true
|
||||
}
|
||||
}
|
||||
|
||||
const hasExplicitCounts = typeof result.success === 'number' || typeof result.failed === 'number'
|
||||
const successCount = typeof result.success === 'number' ? result.success : 0
|
||||
const failedCount = typeof result.failed === 'number' ? result.failed : 0
|
||||
if (hasExplicitCounts && failedCount === 0 && successCount === accountIds.length && accountIds.length > 0) {
|
||||
return {
|
||||
successIds: accountIds,
|
||||
failedIds: [],
|
||||
successCount,
|
||||
failedCount,
|
||||
hasIds: true,
|
||||
hasCounts: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
successIds: [],
|
||||
failedIds: [],
|
||||
successCount,
|
||||
failedCount,
|
||||
hasIds: false,
|
||||
hasCounts: hasExplicitCounts
|
||||
}
|
||||
}
|
||||
const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
const count = selIds.value.length
|
||||
const accountIds = [...selIds.value]
|
||||
try {
|
||||
const result = await adminAPI.accounts.bulkUpdate(selIds.value, { schedulable });
|
||||
const message = schedulable
|
||||
? t('admin.accounts.bulkSchedulableEnabled', { count: result.success || count })
|
||||
: t('admin.accounts.bulkSchedulableDisabled', { count: result.success || count });
|
||||
appStore.showSuccess(message);
|
||||
selIds.value = [];
|
||||
reload()
|
||||
const result = await adminAPI.accounts.bulkUpdate(accountIds, { schedulable })
|
||||
const { successIds, failedIds, successCount, failedCount, hasIds, hasCounts } = normalizeBulkSchedulableResult(result, accountIds)
|
||||
if (!hasIds && !hasCounts) {
|
||||
appStore.showError(t('admin.accounts.bulkSchedulableResultUnknown'))
|
||||
selIds.value = accountIds
|
||||
load().catch((error) => {
|
||||
console.error('Failed to refresh accounts:', error)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (successIds.length > 0) {
|
||||
updateSchedulableInList(successIds, schedulable)
|
||||
}
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
const message = schedulable
|
||||
? t('admin.accounts.bulkSchedulableEnabled', { count: successCount })
|
||||
: t('admin.accounts.bulkSchedulableDisabled', { count: successCount })
|
||||
appStore.showSuccess(message)
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
const message = hasCounts || hasIds
|
||||
? t('admin.accounts.bulkSchedulablePartial', { success: successCount, failed: failedCount })
|
||||
: t('admin.accounts.bulkSchedulableResultUnknown')
|
||||
appStore.showError(message)
|
||||
selIds.value = failedIds.length > 0 ? failedIds : accountIds
|
||||
} else {
|
||||
selIds.value = hasIds ? [] : accountIds
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk toggle schedulable:', error);
|
||||
console.error('Failed to bulk toggle schedulable:', error)
|
||||
appStore.showError(t('common.error'))
|
||||
}
|
||||
}
|
||||
@@ -236,7 +368,19 @@ const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.
|
||||
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } }
|
||||
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
|
||||
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
|
||||
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
|
||||
const handleToggleSchedulable = async (a: Account) => {
|
||||
const nextSchedulable = !a.schedulable
|
||||
togglingSchedulable.value = a.id
|
||||
try {
|
||||
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
|
||||
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle schedulable:', error)
|
||||
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
|
||||
} finally {
|
||||
togglingSchedulable.value = null
|
||||
}
|
||||
}
|
||||
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
|
||||
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
|
||||
const formatExpiresAt = (value: number | null) => {
|
||||
@@ -259,5 +403,24 @@ const isExpired = (value: number | null) => {
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
menu.show = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||
proxies.value = p
|
||||
groups.value = g
|
||||
} catch (error) {
|
||||
console.error('Failed to load proxies/groups:', error)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -460,6 +460,149 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型路由配置(仅 anthropic 平台) -->
|
||||
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.modelRouting.title') }}
|
||||
</label>
|
||||
<!-- Help Tooltip -->
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.modelRouting.tooltip') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 启用开关 -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="createForm.model_routing_enabled = !createForm.model_routing_enabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
createForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
createForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ createForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="!createForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.modelRouting.disabledHint') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.modelRouting.noRulesHint') }}
|
||||
</p>
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="createForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in createModelRoutingRules"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label>
|
||||
<input
|
||||
v-model="rule.pattern"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label>
|
||||
<!-- 已选账号标签 -->
|
||||
<div v-if="rule.accounts.length > 0" class="flex flex-wrap gap-1.5 mb-2">
|
||||
<span
|
||||
v-for="account in rule.accounts"
|
||||
:key="account.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, false)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`create-${index}`]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`create-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, false)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`create-${index}`] && accountSearchResults[`create-${index}`]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`create-${index}`]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, false)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeCreateRoutingRule(index)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 添加规则按钮(仅在启用时显示) -->
|
||||
<button
|
||||
v-if="createForm.model_routing_enabled"
|
||||
type="button"
|
||||
@click="addCreateRoutingRule"
|
||||
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t('admin.groups.modelRouting.addRule') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -761,6 +904,149 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型路由配置(仅 anthropic 平台) -->
|
||||
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.modelRouting.title') }}
|
||||
</label>
|
||||
<!-- Help Tooltip -->
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.modelRouting.tooltip') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 启用开关 -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.model_routing_enabled = !editForm.model_routing_enabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
editForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
editForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ editForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="!editForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.modelRouting.disabledHint') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.modelRouting.noRulesHint') }}
|
||||
</p>
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="editForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in editModelRoutingRules"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label>
|
||||
<input
|
||||
v-model="rule.pattern"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label>
|
||||
<!-- 已选账号标签 -->
|
||||
<div v-if="rule.accounts.length > 0" class="flex flex-wrap gap-1.5 mb-2">
|
||||
<span
|
||||
v-for="account in rule.accounts"
|
||||
:key="account.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, true)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`edit-${index}`]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`edit-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, true)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`edit-${index}`] && accountSearchResults[`edit-${index}`]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`edit-${index}`]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, true)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeEditRoutingRule(index)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 添加规则按钮(仅在启用时显示) -->
|
||||
<button
|
||||
v-if="editForm.model_routing_enabled"
|
||||
type="button"
|
||||
@click="addEditRoutingRule"
|
||||
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t('admin.groups.modelRouting.addRule') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -816,7 +1102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
@@ -956,9 +1242,160 @@ const createForm = reactive({
|
||||
image_price_4k: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null
|
||||
fallback_group_id: null as number | null,
|
||||
// 模型路由开关
|
||||
model_routing_enabled: false
|
||||
})
|
||||
|
||||
// 简单账号类型(用于模型路由选择)
|
||||
interface SimpleAccount {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// 模型路由规则类型
|
||||
interface ModelRoutingRule {
|
||||
pattern: string
|
||||
accounts: SimpleAccount[] // 选中的账号对象数组
|
||||
}
|
||||
|
||||
// 创建表单的模型路由规则
|
||||
const createModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
|
||||
// 编辑表单的模型路由规则
|
||||
const editModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
|
||||
// 账号搜索相关状态
|
||||
const accountSearchKeyword = ref<Record<string, string>>({}) // 每个规则的搜索关键词 (key: "create-0" 或 "edit-0")
|
||||
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({}) // 每个规则的搜索结果
|
||||
const showAccountDropdown = ref<Record<string, boolean>>({}) // 每个规则的下拉框显示状态
|
||||
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 搜索账号(仅限 anthropic 平台)
|
||||
const searchAccounts = async (key: string) => {
|
||||
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
|
||||
accountSearchTimeout = setTimeout(async () => {
|
||||
const keyword = accountSearchKeyword.value[key] || ''
|
||||
try {
|
||||
const res = await adminAPI.accounts.list(1, 20, {
|
||||
search: keyword,
|
||||
platform: 'anthropic'
|
||||
})
|
||||
accountSearchResults.value[key] = res.items.map((a) => ({ id: a.id, name: a.name }))
|
||||
} catch {
|
||||
accountSearchResults.value[key] = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 选择账号
|
||||
const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
if (!rule) return
|
||||
|
||||
// 检查是否已选择
|
||||
if (!rule.accounts.some(a => a.id === account.id)) {
|
||||
rule.accounts.push(account)
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
accountSearchKeyword.value[key] = ''
|
||||
showAccountDropdown.value[key] = false
|
||||
}
|
||||
|
||||
// 移除已选账号
|
||||
const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
if (!rule) return
|
||||
|
||||
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
|
||||
}
|
||||
|
||||
// 处理账号搜索输入框聚焦
|
||||
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
showAccountDropdown.value[key] = true
|
||||
// 如果没有搜索结果,触发一次搜索
|
||||
if (!accountSearchResults.value[key]?.length) {
|
||||
searchAccounts(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加创建表单的路由规则
|
||||
const addCreateRoutingRule = () => {
|
||||
createModelRoutingRules.value.push({ pattern: '', accounts: [] })
|
||||
}
|
||||
|
||||
// 删除创建表单的路由规则
|
||||
const removeCreateRoutingRule = (index: number) => {
|
||||
createModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `create-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 添加编辑表单的路由规则
|
||||
const addEditRoutingRule = () => {
|
||||
editModelRoutingRules.value.push({ pattern: '', accounts: [] })
|
||||
}
|
||||
|
||||
// 删除编辑表单的路由规则
|
||||
const removeEditRoutingRule = (index: number) => {
|
||||
editModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `edit-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 将 UI 格式的路由规则转换为 API 格式
|
||||
const convertRoutingRulesToApiFormat = (rules: ModelRoutingRule[]): Record<string, number[]> | null => {
|
||||
const result: Record<string, number[]> = {}
|
||||
let hasValidRules = false
|
||||
|
||||
for (const rule of rules) {
|
||||
const pattern = rule.pattern.trim()
|
||||
if (!pattern) continue
|
||||
|
||||
const accountIds = rule.accounts.map(a => a.id).filter(id => id > 0)
|
||||
|
||||
if (accountIds.length > 0) {
|
||||
result[pattern] = accountIds
|
||||
hasValidRules = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasValidRules ? result : null
|
||||
}
|
||||
|
||||
// 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称)
|
||||
const convertApiFormatToRoutingRules = async (apiFormat: Record<string, number[]> | null): Promise<ModelRoutingRule[]> => {
|
||||
if (!apiFormat) return []
|
||||
|
||||
const rules: ModelRoutingRule[] = []
|
||||
for (const [pattern, accountIds] of Object.entries(apiFormat)) {
|
||||
// 加载账号信息
|
||||
const accounts: SimpleAccount[] = []
|
||||
for (const id of accountIds) {
|
||||
try {
|
||||
const account = await adminAPI.accounts.getById(id)
|
||||
accounts.push({ id: account.id, name: account.name })
|
||||
} catch {
|
||||
// 如果账号不存在,仍然显示 ID
|
||||
accounts.push({ id, name: `#${id}` })
|
||||
}
|
||||
}
|
||||
rules.push({ pattern, accounts })
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -976,7 +1413,9 @@ const editForm = reactive({
|
||||
image_price_4k: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null
|
||||
fallback_group_id: null as number | null,
|
||||
// 模型路由开关
|
||||
model_routing_enabled: false
|
||||
})
|
||||
|
||||
// 根据分组类型返回不同的删除确认消息
|
||||
@@ -1058,6 +1497,7 @@ const closeCreateModal = () => {
|
||||
createForm.image_price_4k = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.fallback_group_id = null
|
||||
createModelRoutingRules.value = []
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
@@ -1067,7 +1507,12 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.groups.create(createForm)
|
||||
// 构建请求数据,包含模型路由配置
|
||||
const requestData = {
|
||||
...createForm,
|
||||
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
|
||||
}
|
||||
await adminAPI.groups.create(requestData)
|
||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||
closeCreateModal()
|
||||
loadGroups()
|
||||
@@ -1084,7 +1529,7 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
const handleEdit = async (group: Group) => {
|
||||
editingGroup.value = group
|
||||
editForm.name = group.name
|
||||
editForm.description = group.description || ''
|
||||
@@ -1101,12 +1546,16 @@ const handleEdit = (group: Group) => {
|
||||
editForm.image_price_4k = group.image_price_4k
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.model_routing_enabled = group.model_routing_enabled || false
|
||||
// 加载模型路由规则(异步加载账号名称)
|
||||
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
editModelRoutingRules.value = []
|
||||
}
|
||||
|
||||
const handleUpdateGroup = async () => {
|
||||
@@ -1121,7 +1570,8 @@ const handleUpdateGroup = async () => {
|
||||
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
|
||||
const payload = {
|
||||
...editForm,
|
||||
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id
|
||||
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
|
||||
}
|
||||
await adminAPI.groups.update(editingGroup.value.id, payload)
|
||||
appStore.showSuccess(t('admin.groups.groupUpdated'))
|
||||
@@ -1166,7 +1616,23 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// 点击外部关闭账号搜索下拉框
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
// 检查是否点击在下拉框或输入框内
|
||||
if (!target.closest('.account-search-container')) {
|
||||
Object.keys(showAccountDropdown.value).forEach(key => {
|
||||
showAccountDropdown.value[key] = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
718
frontend/src/views/admin/PromoCodesView.vue
Normal file
718
frontend/src/views/admin/PromoCodesView.vue
Normal file
@@ -0,0 +1,718 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showCreateDialog = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.promo.createCode') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.promo.searchCodes')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="filterStatusOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
<button
|
||||
@click="copyToClipboard(value)"
|
||||
:class="[
|
||||
'flex items-center transition-colors',
|
||||
copiedCode === value
|
||||
? 'text-green-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
]"
|
||||
:title="copiedCode === value ? t('admin.promo.copied') : t('keys.copyToClipboard')"
|
||||
>
|
||||
<Icon v-if="copiedCode !== value" name="copy" size="sm" :stroke-width="2" />
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-bonus_amount="{ value }">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${{ value.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ row.used_count }} / {{ row.max_uses === 0 ? '∞' : row.max_uses }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value, row }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
getStatusClass(value, row)
|
||||
]"
|
||||
>
|
||||
{{ getStatusLabel(value, row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-expires_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? formatDateTime(value) : t('admin.promo.neverExpires') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ formatDateTime(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
@click="copyRegisterLink(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||
:title="t('admin.promo.copyRegisterLink')"
|
||||
>
|
||||
<Icon name="link" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleViewUsages(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.promo.viewUsages')"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
:show="showCreateDialog"
|
||||
:title="t('admin.promo.createCode')"
|
||||
width="normal"
|
||||
@close="showCreateDialog = false"
|
||||
>
|
||||
<form id="create-promo-form" @submit.prevent="handleCreate" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.code') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.autoGenerate') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="createForm.code"
|
||||
type="text"
|
||||
class="input font-mono uppercase"
|
||||
:placeholder="t('admin.promo.codePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.promo.bonusAmount') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.bonus_amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.maxUses') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.zeroUnlimited') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="createForm.max_uses"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.expiresAt') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="createForm.expires_at_str"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.notes') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="createForm.notes"
|
||||
rows="2"
|
||||
class="input"
|
||||
:placeholder="t('admin.promo.notesPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="create-promo-form" :disabled="creating" class="btn btn-primary">
|
||||
{{ creating ? t('common.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
:show="showEditDialog"
|
||||
:title="t('admin.promo.editCode')"
|
||||
width="normal"
|
||||
@close="closeEditDialog"
|
||||
>
|
||||
<form id="edit-promo-form" @submit.prevent="handleUpdate" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.promo.code') }}</label>
|
||||
<input
|
||||
v-model="editForm.code"
|
||||
type="text"
|
||||
class="input font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.promo.bonusAmount') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.bonus_amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.maxUses') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.zeroUnlimited') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="editForm.max_uses"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.promo.status') }}</label>
|
||||
<Select v-model="editForm.status" :options="statusOptions" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.expiresAt') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.expires_at_str"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.promo.notes') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editForm.notes"
|
||||
rows="2"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="closeEditDialog" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="edit-promo-form" :disabled="updating" class="btn btn-primary">
|
||||
{{ updating ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Usages Dialog -->
|
||||
<BaseDialog
|
||||
:show="showUsagesDialog"
|
||||
:title="t('admin.promo.usageRecords')"
|
||||
width="wide"
|
||||
@close="showUsagesDialog = false"
|
||||
>
|
||||
<div v-if="usagesLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
|
||||
</div>
|
||||
<div v-else-if="usages.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.promo.noUsages') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="usage in usages"
|
||||
:key="usage.id"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="user" size="sm" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ usage.user?.email || t('admin.promo.userPrefix', { id: usage.user_id }) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDateTime(usage.used_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-green-600 dark:text-green-400">
|
||||
+${{ usage.bonus_amount.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Usages Pagination -->
|
||||
<div v-if="usagesTotal > usagesPageSize" class="mt-4">
|
||||
<Pagination
|
||||
:page="usagesPage"
|
||||
:total="usagesTotal"
|
||||
:page-size="usagesPageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="handleUsagesPageChange"
|
||||
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="showUsagesDialog = false" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.promo.deleteCode')"
|
||||
:message="t('admin.promo.deleteCodeConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { PromoCode, PromoCodeUsage } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
// State
|
||||
const codes = ref<PromoCode[]>([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const updating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const copiedCode = ref<string | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// Dialogs
|
||||
const showCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showUsagesDialog = ref(false)
|
||||
|
||||
const editingCode = ref<PromoCode | null>(null)
|
||||
const deletingCode = ref<PromoCode | null>(null)
|
||||
|
||||
// Usages
|
||||
const usages = ref<PromoCodeUsage[]>([])
|
||||
const usagesLoading = ref(false)
|
||||
const currentViewingCode = ref<PromoCode | null>(null)
|
||||
const usagesPage = ref(1)
|
||||
const usagesPageSize = ref(20)
|
||||
const usagesTotal = ref(0)
|
||||
|
||||
// Forms
|
||||
const createForm = reactive({
|
||||
code: '',
|
||||
bonus_amount: 1,
|
||||
max_uses: 0,
|
||||
expires_at_str: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
code: '',
|
||||
bonus_amount: 0,
|
||||
max_uses: 0,
|
||||
status: 'active' as 'active' | 'disabled',
|
||||
expires_at_str: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// Options
|
||||
const filterStatusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.promo.allStatus') },
|
||||
{ value: 'active', label: t('admin.promo.statusActive') },
|
||||
{ value: 'disabled', label: t('admin.promo.statusDisabled') }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('admin.promo.statusActive') },
|
||||
{ value: 'disabled', label: t('admin.promo.statusDisabled') }
|
||||
])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'code', label: t('admin.promo.columns.code') },
|
||||
{ key: 'bonus_amount', label: t('admin.promo.columns.bonusAmount'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.promo.columns.usage') },
|
||||
{ key: 'status', label: t('admin.promo.columns.status'), sortable: true },
|
||||
{ key: 'expires_at', label: t('admin.promo.columns.expiresAt'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.promo.columns.createdAt'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.promo.columns.actions') }
|
||||
])
|
||||
|
||||
// Helpers
|
||||
const getStatusClass = (status: string, row: PromoCode) => {
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) {
|
||||
return 'badge-danger'
|
||||
}
|
||||
if (row.max_uses > 0 && row.used_count >= row.max_uses) {
|
||||
return 'badge-gray'
|
||||
}
|
||||
return status === 'active' ? 'badge-success' : 'badge-gray'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string, row: PromoCode) => {
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) {
|
||||
return t('admin.promo.statusExpired')
|
||||
}
|
||||
if (row.max_uses > 0 && row.used_count >= row.max_uses) {
|
||||
return t('admin.promo.statusMaxUsed')
|
||||
}
|
||||
return status === 'active' ? t('admin.promo.statusActive') : t('admin.promo.statusDisabled')
|
||||
}
|
||||
|
||||
// API calls
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const currentController = new AbortController()
|
||||
abortController = currentController
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await adminAPI.promo.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
if (currentController.signal.aborted) return
|
||||
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
appStore.showError(t('admin.promo.failedToLoad'))
|
||||
console.error('Error loading promo codes:', error)
|
||||
} finally {
|
||||
if (abortController === currentController && !currentController.signal.aborted) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
const success = await clipboardCopy(text, t('admin.promo.copied'))
|
||||
if (success) {
|
||||
copiedCode.value = text
|
||||
setTimeout(() => {
|
||||
copiedCode.value = null
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create
|
||||
const handleCreate = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
await adminAPI.promo.create({
|
||||
code: createForm.code || undefined,
|
||||
bonus_amount: createForm.bonus_amount,
|
||||
max_uses: createForm.max_uses,
|
||||
expires_at: createForm.expires_at_str ? Math.floor(new Date(createForm.expires_at_str).getTime() / 1000) : undefined,
|
||||
notes: createForm.notes || undefined
|
||||
})
|
||||
appStore.showSuccess(t('admin.promo.codeCreated'))
|
||||
showCreateDialog.value = false
|
||||
resetCreateForm()
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToCreate'))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.code = ''
|
||||
createForm.bonus_amount = 1
|
||||
createForm.max_uses = 0
|
||||
createForm.expires_at_str = ''
|
||||
createForm.notes = ''
|
||||
}
|
||||
|
||||
// Edit
|
||||
const handleEdit = (code: PromoCode) => {
|
||||
editingCode.value = code
|
||||
editForm.code = code.code
|
||||
editForm.bonus_amount = code.bonus_amount
|
||||
editForm.max_uses = code.max_uses
|
||||
editForm.status = code.status
|
||||
editForm.expires_at_str = code.expires_at ? new Date(code.expires_at).toISOString().slice(0, 16) : ''
|
||||
editForm.notes = code.notes || ''
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const closeEditDialog = () => {
|
||||
showEditDialog.value = false
|
||||
editingCode.value = null
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingCode.value) return
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await adminAPI.promo.update(editingCode.value.id, {
|
||||
code: editForm.code,
|
||||
bonus_amount: editForm.bonus_amount,
|
||||
max_uses: editForm.max_uses,
|
||||
status: editForm.status,
|
||||
expires_at: editForm.expires_at_str ? Math.floor(new Date(editForm.expires_at_str).getTime() / 1000) : 0,
|
||||
notes: editForm.notes
|
||||
})
|
||||
appStore.showSuccess(t('admin.promo.codeUpdated'))
|
||||
closeEditDialog()
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToUpdate'))
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Copy Register Link
|
||||
const copyRegisterLink = async (code: PromoCode) => {
|
||||
const baseUrl = window.location.origin
|
||||
const registerLink = `${baseUrl}/register?promo=${encodeURIComponent(code.code)}`
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(registerLink)
|
||||
appStore.showSuccess(t('admin.promo.registerLinkCopied'))
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = registerLink
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
appStore.showSuccess(t('admin.promo.registerLinkCopied'))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
const handleDelete = (code: PromoCode) => {
|
||||
deletingCode.value = code
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingCode.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.promo.delete(deletingCode.value.id)
|
||||
appStore.showSuccess(t('admin.promo.codeDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingCode.value = null
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
// View Usages
|
||||
const handleViewUsages = async (code: PromoCode) => {
|
||||
currentViewingCode.value = code
|
||||
showUsagesDialog.value = true
|
||||
usagesPage.value = 1
|
||||
await loadUsages()
|
||||
}
|
||||
|
||||
const loadUsages = async () => {
|
||||
if (!currentViewingCode.value) return
|
||||
usagesLoading.value = true
|
||||
usages.value = []
|
||||
|
||||
try {
|
||||
const response = await adminAPI.promo.getUsages(
|
||||
currentViewingCode.value.id,
|
||||
usagesPage.value,
|
||||
usagesPageSize.value
|
||||
)
|
||||
usages.value = response.items
|
||||
usagesTotal.value = response.total
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToLoadUsages'))
|
||||
} finally {
|
||||
usagesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUsagesPageChange = (page: number) => {
|
||||
usagesPage.value = page
|
||||
loadUsages()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCodes()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(searchTimeout)
|
||||
abortController?.abort()
|
||||
})
|
||||
</script>
|
||||
@@ -51,6 +51,24 @@
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleBatchTest"
|
||||
:disabled="batchTesting || loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.proxies.testConnection')"
|
||||
>
|
||||
<Icon name="play" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.testConnection') }}
|
||||
</button>
|
||||
<button
|
||||
@click="openBatchDelete"
|
||||
:disabled="selectedCount === 0"
|
||||
class="btn btn-danger"
|
||||
:title="t('admin.proxies.batchDeleteAction')"
|
||||
>
|
||||
<Icon name="trash" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.batchDeleteAction') }}
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
@@ -61,6 +79,26 @@
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="allVisibleSelected"
|
||||
@click.stop
|
||||
@change="toggleSelectAllVisible($event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="selectedProxyIds.has(row.id)"
|
||||
@click.stop
|
||||
@change="toggleSelectRow(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -79,17 +117,58 @@
|
||||
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
<template #cell-location="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="row.country_code"
|
||||
:src="flagUrl(row.country_code)"
|
||||
:alt="row.country || row.country_code"
|
||||
class="h-4 w-6 rounded-sm"
|
||||
/>
|
||||
<span v-if="formatLocation(row)" class="text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ formatLocation(row) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-account_count="{ row, value }">
|
||||
<button
|
||||
v-if="(value || 0) > 0"
|
||||
type="button"
|
||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-primary-700 hover:bg-gray-200 dark:bg-dark-600 dark:text-primary-300 dark:hover:bg-dark-500"
|
||||
@click="openAccountsModal(row)"
|
||||
>
|
||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
||||
>
|
||||
{{ t('admin.groups.accountsCount', { count: 0 }) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account_count="{ value }">
|
||||
<template #cell-latency="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
||||
v-if="row.latency_status === 'failed'"
|
||||
class="badge badge-danger"
|
||||
:title="row.latency_message || undefined"
|
||||
>
|
||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||
{{ t('admin.proxies.latencyFailed') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="typeof row.latency_ms === 'number'"
|
||||
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
|
||||
>
|
||||
{{ row.latency_ms }}ms
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -515,6 +594,63 @@
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Batch Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showBatchDeleteDialog"
|
||||
:title="t('admin.proxies.batchDelete')"
|
||||
:message="t('admin.proxies.batchDeleteConfirm', { count: selectedCount })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmBatchDelete"
|
||||
@cancel="showBatchDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Proxy Accounts Dialog -->
|
||||
<BaseDialog
|
||||
:show="showAccountsModal"
|
||||
:title="t('admin.proxies.accountsTitle', { name: accountsProxy?.name || '' })"
|
||||
width="normal"
|
||||
@close="closeAccountsModal"
|
||||
>
|
||||
<div v-if="accountsLoading" class="flex items-center justify-center py-8 text-sm text-gray-500">
|
||||
<Icon name="refresh" size="md" class="mr-2 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="proxyAccounts.length === 0" class="py-6 text-center text-sm text-gray-500">
|
||||
{{ t('admin.proxies.accountsEmpty') }}
|
||||
</div>
|
||||
<div v-else class="max-h-80 overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountName') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('admin.accounts.columns.platformType') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountNotes') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<tr v-for="account in proxyAccounts" :key="account.id">
|
||||
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{{ account.name }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<PlatformTypeBadge :platform="account.platform" :type="account.type" />
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
|
||||
{{ account.notes || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="closeAccountsModal" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -523,7 +659,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, ProxyProtocol } from '@/types'
|
||||
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -534,15 +670,19 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
|
||||
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
|
||||
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
|
||||
{ key: 'location', label: t('admin.proxies.columns.location'), sortable: false },
|
||||
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
|
||||
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
|
||||
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
||||
])
|
||||
@@ -592,11 +732,24 @@ const pagination = reactive({
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showBatchDeleteDialog = ref(false)
|
||||
const showAccountsModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const batchTesting = ref(false)
|
||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||
const accountsProxy = ref<Proxy | null>(null)
|
||||
const proxyAccounts = ref<ProxyAccountSummary[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
const editingProxy = ref<Proxy | null>(null)
|
||||
const deletingProxy = ref<Proxy | null>(null)
|
||||
|
||||
const selectedCount = computed(() => selectedProxyIds.value.size)
|
||||
const allVisibleSelected = computed(() => {
|
||||
if (proxies.value.length === 0) return false
|
||||
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
|
||||
})
|
||||
|
||||
// Batch import state
|
||||
const createMode = ref<'standard' | 'batch'>('standard')
|
||||
const batchInput = ref('')
|
||||
@@ -641,6 +794,30 @@ const isAbortError = (error: unknown) => {
|
||||
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: number, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const next = new Set(selectedProxyIds.value)
|
||||
if (target.checked) {
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
selectedProxyIds.value = next
|
||||
}
|
||||
|
||||
const toggleSelectAllVisible = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const next = new Set(selectedProxyIds.value)
|
||||
for (const proxy of proxies.value) {
|
||||
if (target.checked) {
|
||||
next.add(proxy.id)
|
||||
} else {
|
||||
next.delete(proxy.id)
|
||||
}
|
||||
}
|
||||
selectedProxyIds.value = next
|
||||
}
|
||||
|
||||
const loadProxies = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -895,35 +1072,178 @@ const handleUpdateProxy = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async (proxy: Proxy) => {
|
||||
// Create new Set to trigger reactivity
|
||||
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
|
||||
const applyLatencyResult = (
|
||||
proxyId: number,
|
||||
result: {
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
message?: string
|
||||
ip_address?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
region?: string
|
||||
city?: string
|
||||
}
|
||||
) => {
|
||||
const target = proxies.value.find((proxy) => proxy.id === proxyId)
|
||||
if (!target) return
|
||||
if (result.success) {
|
||||
target.latency_status = 'success'
|
||||
target.latency_ms = result.latency_ms
|
||||
target.ip_address = result.ip_address
|
||||
target.country = result.country
|
||||
target.country_code = result.country_code
|
||||
target.region = result.region
|
||||
target.city = result.city
|
||||
} else {
|
||||
target.latency_status = 'failed'
|
||||
target.latency_ms = undefined
|
||||
target.ip_address = undefined
|
||||
target.country = undefined
|
||||
target.country_code = undefined
|
||||
target.region = undefined
|
||||
target.city = undefined
|
||||
}
|
||||
target.latency_message = result.message
|
||||
}
|
||||
|
||||
const formatLocation = (proxy: Proxy) => {
|
||||
const parts = [proxy.country, proxy.city].filter(Boolean) as string[]
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
const flagUrl = (code: string) =>
|
||||
`https://unpkg.com/flag-icons/flags/4x3/${code.toLowerCase()}.svg`
|
||||
|
||||
const startTestingProxy = (proxyId: number) => {
|
||||
testingProxyIds.value = new Set([...testingProxyIds.value, proxyId])
|
||||
}
|
||||
|
||||
const stopTestingProxy = (proxyId: number) => {
|
||||
const next = new Set(testingProxyIds.value)
|
||||
next.delete(proxyId)
|
||||
testingProxyIds.value = next
|
||||
}
|
||||
|
||||
const runProxyTest = async (proxyId: number, notify: boolean) => {
|
||||
startTestingProxy(proxyId)
|
||||
try {
|
||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
||||
if (result.success) {
|
||||
const message = result.latency_ms
|
||||
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
||||
: t('admin.proxies.proxyWorking')
|
||||
appStore.showSuccess(message)
|
||||
} else {
|
||||
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
||||
const result = await adminAPI.proxies.testProxy(proxyId)
|
||||
applyLatencyResult(proxyId, result)
|
||||
if (notify) {
|
||||
if (result.success) {
|
||||
const message = result.latency_ms
|
||||
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
||||
: t('admin.proxies.proxyWorking')
|
||||
appStore.showSuccess(message)
|
||||
} else {
|
||||
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
|
||||
const message = error.response?.data?.detail || t('admin.proxies.failedToTest')
|
||||
applyLatencyResult(proxyId, { success: false, message })
|
||||
if (notify) {
|
||||
appStore.showError(message)
|
||||
}
|
||||
console.error('Error testing proxy:', error)
|
||||
return null
|
||||
} finally {
|
||||
// Create new Set without this proxy id to trigger reactivity
|
||||
const newSet = new Set(testingProxyIds.value)
|
||||
newSet.delete(proxy.id)
|
||||
testingProxyIds.value = newSet
|
||||
stopTestingProxy(proxyId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async (proxy: Proxy) => {
|
||||
await runProxyTest(proxy.id, true)
|
||||
}
|
||||
|
||||
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
|
||||
const pageSize = 200
|
||||
const result: Proxy[] = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (page <= totalPages) {
|
||||
const response = await adminAPI.proxies.list(
|
||||
page,
|
||||
pageSize,
|
||||
{
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
result.push(...response.items)
|
||||
totalPages = response.pages || 1
|
||||
page++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const runBatchProxyTests = async (ids: number[]) => {
|
||||
if (ids.length === 0) return
|
||||
const concurrency = 5
|
||||
let index = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (index < ids.length) {
|
||||
const current = ids[index]
|
||||
index++
|
||||
await runProxyTest(current, false)
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
const handleBatchTest = async () => {
|
||||
if (batchTesting.value) return
|
||||
|
||||
batchTesting.value = true
|
||||
try {
|
||||
let ids: number[] = []
|
||||
if (selectedCount.value > 0) {
|
||||
ids = Array.from(selectedProxyIds.value)
|
||||
} else {
|
||||
const allProxies = await fetchAllProxiesForBatch()
|
||||
ids = allProxies.map((proxy) => proxy.id)
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
appStore.showInfo(t('admin.proxies.batchTestEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
await runBatchProxyTests(ids)
|
||||
appStore.showSuccess(t('admin.proxies.batchTestDone', { count: ids.length }))
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchTestFailed'))
|
||||
console.error('Error batch testing proxies:', error)
|
||||
} finally {
|
||||
batchTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (proxy: Proxy) => {
|
||||
if ((proxy.account_count || 0) > 0) {
|
||||
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
||||
return
|
||||
}
|
||||
deletingProxy.value = proxy
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const openBatchDelete = () => {
|
||||
if (selectedCount.value === 0) {
|
||||
return
|
||||
}
|
||||
showBatchDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProxy.value) return
|
||||
|
||||
@@ -931,6 +1251,11 @@ const confirmDelete = async () => {
|
||||
await adminAPI.proxies.delete(deletingProxy.value.id)
|
||||
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
|
||||
const next = new Set(selectedProxyIds.value)
|
||||
next.delete(deletingProxy.value.id)
|
||||
selectedProxyIds.value = next
|
||||
}
|
||||
deletingProxy.value = null
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
@@ -939,6 +1264,55 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmBatchDelete = async () => {
|
||||
const ids = Array.from(selectedProxyIds.value)
|
||||
if (ids.length === 0) {
|
||||
showBatchDeleteDialog.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await adminAPI.proxies.batchDelete(ids)
|
||||
const deleted = result.deleted_ids?.length || 0
|
||||
const skipped = result.skipped?.length || 0
|
||||
|
||||
if (deleted > 0) {
|
||||
appStore.showSuccess(t('admin.proxies.batchDeleteDone', { deleted, skipped }))
|
||||
} else if (skipped > 0) {
|
||||
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
|
||||
}
|
||||
|
||||
selectedProxyIds.value = new Set()
|
||||
showBatchDeleteDialog.value = false
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchDeleteFailed'))
|
||||
console.error('Error batch deleting proxies:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openAccountsModal = async (proxy: Proxy) => {
|
||||
accountsProxy.value = proxy
|
||||
proxyAccounts.value = []
|
||||
accountsLoading.value = true
|
||||
showAccountsModal.value = true
|
||||
|
||||
try {
|
||||
proxyAccounts.value = await adminAPI.proxies.getProxyAccounts(proxy.id)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.accountsFailed'))
|
||||
console.error('Error loading proxy accounts:', error)
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeAccountsModal = () => {
|
||||
showAccountsModal.value = false
|
||||
accountsProxy.value = null
|
||||
proxyAccounts.value = []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProxies()
|
||||
})
|
||||
|
||||
@@ -147,6 +147,144 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Timeout Settings -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.streamTimeout.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="streamTimeoutLoading" class="flex items-center gap-2 text-gray-500">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Enable Stream Timeout -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.streamTimeout.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="streamTimeoutForm.enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Settings - Only show when enabled -->
|
||||
<div
|
||||
v-if="streamTimeoutForm.enabled"
|
||||
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.streamTimeout.action') }}
|
||||
</label>
|
||||
<select v-model="streamTimeoutForm.action" class="input w-64">
|
||||
<option value="temp_unsched">{{ t('admin.settings.streamTimeout.actionTempUnsched') }}</option>
|
||||
<option value="error">{{ t('admin.settings.streamTimeout.actionError') }}</option>
|
||||
<option value="none">{{ t('admin.settings.streamTimeout.actionNone') }}</option>
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.actionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Temp Unsched Minutes (only show when action is temp_unsched) -->
|
||||
<div v-if="streamTimeoutForm.action === 'temp_unsched'">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.streamTimeout.tempUnschedMinutes') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="streamTimeoutForm.temp_unsched_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
class="input w-32"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.tempUnschedMinutesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Count -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.streamTimeout.thresholdCount') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="streamTimeoutForm.threshold_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
class="input w-32"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.thresholdCountHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Window Minutes -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.streamTimeout.thresholdWindowMinutes') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="streamTimeoutForm.threshold_window_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
class="input w-32"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.streamTimeout.thresholdWindowMinutesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveStreamTimeoutSettings"
|
||||
:disabled="streamTimeoutSaving"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<svg
|
||||
v-if="streamTimeoutSaving"
|
||||
class="mr-1 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ streamTimeoutSaving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Settings -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
@@ -562,6 +700,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Content -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.homeContent') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.home_content"
|
||||
rows="6"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.site.homeContentPlaceholder')"
|
||||
></textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.homeContentHint') }}
|
||||
</p>
|
||||
<!-- iframe CSP Warning -->
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.settings.site.homeContentIframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -820,6 +978,17 @@ const adminApiKeyMasked = ref('')
|
||||
const adminApiKeyOperating = ref(false)
|
||||
const newAdminApiKey = ref('')
|
||||
|
||||
// Stream Timeout 状态
|
||||
const streamTimeoutLoading = ref(true)
|
||||
const streamTimeoutSaving = ref(false)
|
||||
const streamTimeoutForm = reactive({
|
||||
enabled: true,
|
||||
action: 'temp_unsched' as 'temp_unsched' | 'error' | 'none',
|
||||
temp_unsched_minutes: 5,
|
||||
threshold_count: 3,
|
||||
threshold_window_minutes: 10
|
||||
})
|
||||
|
||||
type SettingsForm = SystemSettings & {
|
||||
smtp_password: string
|
||||
turnstile_secret_key: string
|
||||
@@ -837,6 +1006,7 @@ const form = reactive<SettingsForm>({
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -850,17 +1020,29 @@ const form = reactive<SettingsForm>({
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_configured: false,
|
||||
// LinuxDo Connect OAuth(终端用户登录)
|
||||
// LinuxDo Connect OAuth 登录
|
||||
linuxdo_connect_enabled: false,
|
||||
linuxdo_connect_client_id: '',
|
||||
linuxdo_connect_client_secret: '',
|
||||
linuxdo_connect_client_secret_configured: false,
|
||||
linuxdo_connect_redirect_url: '',
|
||||
// Model fallback
|
||||
enable_model_fallback: false,
|
||||
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
||||
fallback_model_openai: 'gpt-4o',
|
||||
fallback_model_gemini: 'gemini-2.5-pro',
|
||||
fallback_model_antigravity: 'gemini-2.5-pro',
|
||||
// Identity patch (Claude -> Gemini)
|
||||
enable_identity_patch: true,
|
||||
identity_patch_prompt: ''
|
||||
identity_patch_prompt: '',
|
||||
// Ops monitoring (vNext)
|
||||
ops_monitoring_enabled: true,
|
||||
ops_realtime_monitoring_enabled: true,
|
||||
ops_query_mode_default: 'auto',
|
||||
ops_metrics_interval_seconds: 60
|
||||
})
|
||||
|
||||
// LinuxDo OAuth redirect URL suggestion
|
||||
const linuxdoRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const origin =
|
||||
@@ -945,6 +1127,7 @@ async function saveSettings() {
|
||||
api_base_url: form.api_base_url,
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
@@ -958,7 +1141,14 @@ async function saveSettings() {
|
||||
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
|
||||
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
||||
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url
|
||||
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
|
||||
enable_model_fallback: form.enable_model_fallback,
|
||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
fallback_model_gemini: form.fallback_model_gemini,
|
||||
fallback_model_antigravity: form.fallback_model_antigravity,
|
||||
enable_identity_patch: form.enable_identity_patch,
|
||||
identity_patch_prompt: form.identity_patch_prompt
|
||||
}
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
Object.assign(form, updated)
|
||||
@@ -1088,8 +1278,43 @@ function copyNewKey() {
|
||||
})
|
||||
}
|
||||
|
||||
// Stream Timeout 方法
|
||||
async function loadStreamTimeoutSettings() {
|
||||
streamTimeoutLoading.value = true
|
||||
try {
|
||||
const settings = await adminAPI.settings.getStreamTimeoutSettings()
|
||||
Object.assign(streamTimeoutForm, settings)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load stream timeout settings:', error)
|
||||
} finally {
|
||||
streamTimeoutLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStreamTimeoutSettings() {
|
||||
streamTimeoutSaving.value = true
|
||||
try {
|
||||
const updated = await adminAPI.settings.updateStreamTimeoutSettings({
|
||||
enabled: streamTimeoutForm.enabled,
|
||||
action: streamTimeoutForm.action,
|
||||
temp_unsched_minutes: streamTimeoutForm.temp_unsched_minutes,
|
||||
threshold_count: streamTimeoutForm.threshold_count,
|
||||
threshold_window_minutes: streamTimeoutForm.threshold_window_minutes
|
||||
})
|
||||
Object.assign(streamTimeoutForm, updated)
|
||||
appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} finally {
|
||||
streamTimeoutSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadAdminApiKey()
|
||||
loadStreamTimeoutSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -44,8 +44,14 @@ let abortController: AbortController | null = null; let exportAbortController: A
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
|
||||
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
|
||||
const formatLD = (d: Date) => d.toISOString().split('T')[0]
|
||||
const now = new Date(); const weekAgo = new Date(Date.now() - 6 * 86400000)
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const formatLD = (d: Date) => {
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
@@ -61,8 +67,8 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
|
||||
const loadChartData = async () => {
|
||||
chartsLoading.value = true
|
||||
try {
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })])
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })])
|
||||
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
@@ -94,9 +100,9 @@ const exportToExcel = async () => {
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.original'), t('usage.billed'),
|
||||
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent')
|
||||
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const rows = all.map(log => [
|
||||
log.created_at,
|
||||
@@ -115,13 +121,15 @@ const exportToExcel = async () => {
|
||||
log.cache_read_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||
log.rate_multiplier?.toFixed(2) || '1.00',
|
||||
(log.account_rate_multiplier ?? 1).toFixed(2),
|
||||
log.total_cost?.toFixed(6) || '0.000000',
|
||||
log.actual_cost?.toFixed(6) || '0.000000',
|
||||
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
|
||||
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms,
|
||||
log.request_id || '',
|
||||
log.user_agent || ''
|
||||
log.user_agent || '',
|
||||
log.ip_address || ''
|
||||
])
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<TablePageLayout>
|
||||
<!-- Single Row: Search, Filters, and Actions -->
|
||||
<template #filters>
|
||||
<div class="flex w-full flex-wrap-reverse items-center justify-between gap-4">
|
||||
<div class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap-reverse md:items-center md:justify-between md:gap-4">
|
||||
<!-- Left: Search + Active Filters -->
|
||||
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3">
|
||||
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3 md:order-1">
|
||||
<!-- Search Box -->
|
||||
<div class="relative w-full sm:w-64">
|
||||
<div class="relative w-full md:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
@@ -100,109 +100,119 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions and Settings -->
|
||||
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadUsers"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Filter Settings Dropdown -->
|
||||
<div class="relative" ref="filterDropdownRef">
|
||||
<div class="flex w-full items-center justify-between gap-2 md:order-2 md:ml-auto md:max-w-full md:flex-wrap md:justify-end md:gap-3">
|
||||
<!-- Mobile: Secondary buttons (icon only) -->
|
||||
<div class="flex items-center gap-2 md:contents">
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="showFilterDropdown = !showFilterDropdown"
|
||||
class="btn btn-secondary"
|
||||
@click="loadUsers"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="filter" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.users.filterSettings') }}
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showFilterDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<!-- Built-in filters -->
|
||||
<!-- Filter Settings Dropdown -->
|
||||
<div class="relative" ref="filterDropdownRef">
|
||||
<button
|
||||
v-for="filter in builtInFilters"
|
||||
:key="filter.key"
|
||||
@click="toggleBuiltInFilter(filter.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
@click="showFilterDropdown = !showFilterDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.filterSettings')"
|
||||
>
|
||||
<span>{{ filter.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(filter.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<Icon name="filter" size="sm" class="md:mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.users.filterSettings') }}</span>
|
||||
</button>
|
||||
<!-- Divider if custom attributes exist -->
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="filterableAttributes.length > 0"
|
||||
class="my-1 border-t border-gray-100 dark:border-dark-700"
|
||||
></div>
|
||||
<!-- Custom attribute filters -->
|
||||
<button
|
||||
v-for="attr in filterableAttributes"
|
||||
:key="attr.id"
|
||||
@click="toggleAttributeFilter(attr)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
v-if="showFilterDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<span>{{ attr.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(`attr_${attr.id}`)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<!-- Built-in filters -->
|
||||
<button
|
||||
v-for="filter in builtInFilters"
|
||||
:key="filter.key"
|
||||
@click="toggleBuiltInFilter(filter.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ filter.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(filter.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<!-- Divider if custom attributes exist -->
|
||||
<div
|
||||
v-if="filterableAttributes.length > 0"
|
||||
class="my-1 border-t border-gray-100 dark:border-dark-700"
|
||||
></div>
|
||||
<!-- Custom attribute filters -->
|
||||
<button
|
||||
v-for="attr in filterableAttributes"
|
||||
:key="attr.id"
|
||||
@click="toggleAttributeFilter(attr)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ attr.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(`attr_${attr.id}`)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attributes Config Button -->
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary"
|
||||
@click="showAttributesModal = true"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.attributes.configButton')"
|
||||
>
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
{{ t('admin.users.columnSettings') }}
|
||||
<Icon name="cog" size="sm" class="md:mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.users.attributes.configButton') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attributes Config Button -->
|
||||
<button @click="showAttributesModal = true" class="btn btn-secondary">
|
||||
<Icon name="cog" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.users.attributes.configButton') }}
|
||||
</button>
|
||||
<!-- Create User Button -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
|
||||
<!-- Create User Button (full width on mobile, auto width on desktop) -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.users.createUser') }}
|
||||
</button>
|
||||
@@ -362,8 +372,7 @@
|
||||
|
||||
<!-- More Actions Menu Trigger -->
|
||||
<button
|
||||
:ref="(el) => setActionButtonRef(row.id, el)"
|
||||
@click="openActionMenu(row)"
|
||||
@click="openActionMenu(row, $event)"
|
||||
class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
|
||||
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
|
||||
>
|
||||
@@ -475,7 +484,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
@@ -735,42 +744,56 @@ let abortController: AbortController | null = null
|
||||
// Action Menu State
|
||||
const activeMenuId = ref<number | null>(null)
|
||||
const menuPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
actionButtonRefs.value.set(userId, el)
|
||||
} else {
|
||||
actionButtonRefs.value.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
const openActionMenu = (user: User) => {
|
||||
const openActionMenu = (user: User, e: MouseEvent) => {
|
||||
if (activeMenuId.value === user.id) {
|
||||
closeActionMenu()
|
||||
} else {
|
||||
const buttonEl = actionButtonRefs.value.get(user.id)
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect()
|
||||
const menuWidth = 192
|
||||
const menuHeight = 240
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - menuWidth, padding),
|
||||
Math.max(viewportWidth - menuWidth - padding, padding)
|
||||
)
|
||||
let top = rect.bottom + 4
|
||||
const target = e.currentTarget as HTMLElement
|
||||
if (!target) {
|
||||
closeActionMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
const menuWidth = 200
|
||||
const menuHeight = 240
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let left, top
|
||||
|
||||
if (viewportWidth < 768) {
|
||||
// 居中显示,水平位置
|
||||
left = Math.max(padding, Math.min(
|
||||
rect.left + rect.width / 2 - menuWidth / 2,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
|
||||
// 优先显示在按钮下方
|
||||
top = rect.bottom + 4
|
||||
|
||||
// 如果下方空间不够,显示在上方
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = Math.max(rect.top - menuHeight - 4, padding)
|
||||
top = rect.top - menuHeight - 4
|
||||
// 如果上方也不够,就贴在视口顶部
|
||||
if (top < padding) {
|
||||
top = padding
|
||||
}
|
||||
}
|
||||
// Position menu near the trigger, clamped to viewport
|
||||
menuPosition.value = {
|
||||
top,
|
||||
left
|
||||
} else {
|
||||
left = Math.max(padding, Math.min(
|
||||
e.clientX - menuWidth,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
top = e.clientY
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = viewportHeight - menuHeight - padding
|
||||
}
|
||||
}
|
||||
|
||||
menuPosition.value = { top, left }
|
||||
activeMenuId.value = user.id
|
||||
}
|
||||
}
|
||||
@@ -1054,16 +1077,24 @@ const closeBalanceModal = () => {
|
||||
showBalanceModal.value = false
|
||||
balanceUser.value = null
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAttributeDefinitions()
|
||||
loadSavedFilters()
|
||||
loadSavedColumns()
|
||||
loadUsers()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
clearTimeout(searchTimeout)
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
724
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
724
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
@@ -0,0 +1,724 @@
|
||||
<template>
|
||||
<component :is="isFullscreen ? 'div' : AppLayout" :class="isFullscreen ? 'flex min-h-screen flex-col justify-center bg-gray-50 dark:bg-dark-950' : ''">
|
||||
<div :class="[isFullscreen ? 'p-4 md:p-6' : '', 'space-y-6 pb-12']">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" :fullscreen="isFullscreen" />
|
||||
|
||||
<OpsDashboardHeader
|
||||
v-else-if="opsEnabled"
|
||||
:overview="overview"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:time-range="timeRange"
|
||||
:query-mode="queryMode"
|
||||
:loading="loading"
|
||||
:last-updated="lastUpdated"
|
||||
:thresholds="metricThresholds"
|
||||
:auto-refresh-enabled="autoRefreshEnabled"
|
||||
:auto-refresh-countdown="autoRefreshCountdown"
|
||||
:fullscreen="isFullscreen"
|
||||
:custom-start-time="customStartTime"
|
||||
:custom-end-time="customEndTime"
|
||||
@update:time-range="onTimeRangeChange"
|
||||
@update:platform="onPlatformChange"
|
||||
@update:group="onGroupChange"
|
||||
@update:query-mode="onQueryModeChange"
|
||||
@update:custom-time-range="onCustomTimeRangeChange"
|
||||
@refresh="fetchData"
|
||||
@open-request-details="handleOpenRequestDetails"
|
||||
@open-error-details="openErrorDetails"
|
||||
@open-settings="showSettingsDialog = true"
|
||||
@open-alert-rules="showAlertRulesCard = true"
|
||||
@enter-fullscreen="enterFullscreen"
|
||||
@exit-fullscreen="exitFullscreen"
|
||||
/>
|
||||
|
||||
<!-- Row: Concurrency + Throughput -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-1 min-h-[360px]">
|
||||
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" :refresh-token="dashboardRefreshToken" />
|
||||
</div>
|
||||
<div class="lg:col-span-2 min-h-[360px]">
|
||||
<OpsThroughputTrendChart
|
||||
:points="throughputTrend?.points ?? []"
|
||||
:by-platform="throughputTrend?.by_platform ?? []"
|
||||
:top-groups="throughputTrend?.top_groups ?? []"
|
||||
:loading="loadingTrend"
|
||||
:time-range="timeRange"
|
||||
:fullscreen="isFullscreen"
|
||||
@select-platform="handleThroughputSelectPlatform"
|
||||
@select-group="handleThroughputSelectGroup"
|
||||
@open-details="handleOpenRequestDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Visual Analysis (baseline 3-up grid) -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<OpsLatencyChart :latency-data="latencyHistogram" :loading="loadingLatency" />
|
||||
<OpsErrorDistributionChart
|
||||
:data="errorDistribution"
|
||||
:loading="loadingErrorDistribution"
|
||||
@open-details="openErrorDetails('request')"
|
||||
/>
|
||||
<OpsErrorTrendChart
|
||||
:points="errorTrend?.points ?? []"
|
||||
:loading="loadingErrorTrend"
|
||||
:time-range="timeRange"
|
||||
@open-request-errors="openErrorDetails('request')"
|
||||
@open-upstream-errors="openErrorDetails('upstream')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<!-- Settings Dialog (hidden in fullscreen mode) -->
|
||||
<template v-if="!isFullscreen">
|
||||
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
|
||||
|
||||
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
|
||||
<OpsAlertRulesCard />
|
||||
</BaseDialog>
|
||||
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
@update:show="showErrorDetails = $event"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="errorDetailsType" />
|
||||
|
||||
<OpsRequestDetailsModal
|
||||
v-model="showRequestDetails"
|
||||
:time-range="timeRange"
|
||||
:preset="requestDetailsPreset"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useDebounceFn, useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import {
|
||||
opsAPI,
|
||||
type OpsDashboardOverview,
|
||||
type OpsErrorDistributionResponse,
|
||||
type OpsErrorTrendResponse,
|
||||
type OpsLatencyHistogramResponse,
|
||||
type OpsThroughputTrendResponse,
|
||||
type OpsMetricThresholds
|
||||
} from '@/api/admin/ops'
|
||||
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
||||
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
|
||||
import OpsDashboardSkeleton from './components/OpsDashboardSkeleton.vue'
|
||||
import OpsConcurrencyCard from './components/OpsConcurrencyCard.vue'
|
||||
import OpsErrorDetailModal from './components/OpsErrorDetailModal.vue'
|
||||
import OpsErrorDistributionChart from './components/OpsErrorDistributionChart.vue'
|
||||
import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
|
||||
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
|
||||
import OpsLatencyChart from './components/OpsLatencyChart.vue'
|
||||
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
|
||||
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
|
||||
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
|
||||
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
|
||||
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const opsEnabled = computed(() => adminSettingsStore.opsMonitoringEnabled)
|
||||
|
||||
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | 'custom'
|
||||
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h', 'custom'])
|
||||
|
||||
type QueryMode = 'auto' | 'raw' | 'preagg'
|
||||
const allowedQueryModes = new Set<QueryMode>(['auto', 'raw', 'preagg'])
|
||||
|
||||
const loading = ref(true)
|
||||
const hasLoadedOnce = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastUpdated = ref<Date | null>(new Date())
|
||||
|
||||
const timeRange = ref<TimeRange>('1h')
|
||||
const platform = ref<string>('')
|
||||
const groupId = ref<number | null>(null)
|
||||
const queryMode = ref<QueryMode>('auto')
|
||||
const customStartTime = ref<string | null>(null)
|
||||
const customEndTime = ref<string | null>(null)
|
||||
|
||||
const QUERY_KEYS = {
|
||||
timeRange: 'tr',
|
||||
platform: 'platform',
|
||||
groupId: 'group_id',
|
||||
queryMode: 'mode',
|
||||
fullscreen: 'fullscreen',
|
||||
|
||||
// Deep links
|
||||
openErrorDetails: 'open_error_details',
|
||||
errorType: 'error_type',
|
||||
alertRuleId: 'alert_rule_id',
|
||||
openAlertRules: 'open_alert_rules'
|
||||
} as const
|
||||
|
||||
const isApplyingRouteQuery = ref(false)
|
||||
const isSyncingRouteQuery = ref(false)
|
||||
|
||||
// Fullscreen mode
|
||||
const isFullscreen = computed(() => {
|
||||
const val = route.query[QUERY_KEYS.fullscreen]
|
||||
return val === '1' || val === 'true'
|
||||
})
|
||||
|
||||
function exitFullscreen() {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery[QUERY_KEYS.fullscreen]
|
||||
router.replace({ query: nextQuery })
|
||||
}
|
||||
|
||||
function enterFullscreen() {
|
||||
const nextQuery = { ...route.query, [QUERY_KEYS.fullscreen]: '1' }
|
||||
router.replace({ query: nextQuery })
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && isFullscreen.value) {
|
||||
exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
let dashboardFetchController: AbortController | null = null
|
||||
let dashboardFetchSeq = 0
|
||||
|
||||
function isCanceledRequest(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as Record<string, unknown>).code === 'ERR_CANCELED'
|
||||
)
|
||||
}
|
||||
|
||||
function abortDashboardFetch() {
|
||||
if (dashboardFetchController) {
|
||||
dashboardFetchController.abort()
|
||||
dashboardFetchController = null
|
||||
}
|
||||
}
|
||||
|
||||
const readQueryString = (key: string): string => {
|
||||
const value = route.query[key]
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
|
||||
return ''
|
||||
}
|
||||
|
||||
const readQueryNumber = (key: string): number | null => {
|
||||
const raw = readQueryString(key)
|
||||
if (!raw) return null
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const applyRouteQueryToState = () => {
|
||||
const nextTimeRange = readQueryString(QUERY_KEYS.timeRange)
|
||||
if (nextTimeRange && allowedTimeRanges.has(nextTimeRange as TimeRange)) {
|
||||
timeRange.value = nextTimeRange as TimeRange
|
||||
}
|
||||
|
||||
platform.value = readQueryString(QUERY_KEYS.platform) || ''
|
||||
|
||||
const groupIdRaw = readQueryNumber(QUERY_KEYS.groupId)
|
||||
groupId.value = typeof groupIdRaw === 'number' && groupIdRaw > 0 ? groupIdRaw : null
|
||||
|
||||
const nextMode = readQueryString(QUERY_KEYS.queryMode)
|
||||
if (nextMode && allowedQueryModes.has(nextMode as QueryMode)) {
|
||||
queryMode.value = nextMode as QueryMode
|
||||
} else {
|
||||
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
|
||||
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
|
||||
}
|
||||
|
||||
// Deep links
|
||||
const openRules = readQueryString(QUERY_KEYS.openAlertRules)
|
||||
if (openRules === '1' || openRules === 'true') {
|
||||
showAlertRulesCard.value = true
|
||||
}
|
||||
|
||||
const ruleID = readQueryNumber(QUERY_KEYS.alertRuleId)
|
||||
if (typeof ruleID === 'number' && ruleID > 0) {
|
||||
showAlertRulesCard.value = true
|
||||
}
|
||||
|
||||
const openErr = readQueryString(QUERY_KEYS.openErrorDetails)
|
||||
if (openErr === '1' || openErr === 'true') {
|
||||
const typ = readQueryString(QUERY_KEYS.errorType)
|
||||
errorDetailsType.value = typ === 'upstream' ? 'upstream' : 'request'
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
}
|
||||
|
||||
applyRouteQueryToState()
|
||||
|
||||
const buildQueryFromState = () => {
|
||||
const next: Record<string, any> = { ...route.query }
|
||||
|
||||
Object.values(QUERY_KEYS).forEach((k) => {
|
||||
delete next[k]
|
||||
})
|
||||
|
||||
if (timeRange.value !== '1h') next[QUERY_KEYS.timeRange] = timeRange.value
|
||||
if (platform.value) next[QUERY_KEYS.platform] = platform.value
|
||||
if (typeof groupId.value === 'number' && groupId.value > 0) next[QUERY_KEYS.groupId] = String(groupId.value)
|
||||
if (queryMode.value !== 'auto') next[QUERY_KEYS.queryMode] = queryMode.value
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const syncQueryToRoute = useDebounceFn(async () => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
const nextQuery = buildQueryFromState()
|
||||
|
||||
const curr = route.query as Record<string, any>
|
||||
const nextKeys = Object.keys(nextQuery)
|
||||
const currKeys = Object.keys(curr)
|
||||
const sameLength = nextKeys.length === currKeys.length
|
||||
const sameValues = sameLength && nextKeys.every((k) => String(curr[k] ?? '') === String(nextQuery[k] ?? ''))
|
||||
if (sameValues) return
|
||||
|
||||
try {
|
||||
isSyncingRouteQuery.value = true
|
||||
await router.replace({ query: nextQuery })
|
||||
} finally {
|
||||
isSyncingRouteQuery.value = false
|
||||
}
|
||||
}, 250)
|
||||
|
||||
const overview = ref<OpsDashboardOverview | null>(null)
|
||||
const metricThresholds = ref<OpsMetricThresholds | null>(null)
|
||||
|
||||
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
|
||||
const loadingTrend = ref(false)
|
||||
|
||||
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
|
||||
const loadingLatency = ref(false)
|
||||
|
||||
const errorTrend = ref<OpsErrorTrendResponse | null>(null)
|
||||
const loadingErrorTrend = ref(false)
|
||||
|
||||
const errorDistribution = ref<OpsErrorDistributionResponse | null>(null)
|
||||
const loadingErrorDistribution = ref(false)
|
||||
|
||||
const selectedErrorId = ref<number | null>(null)
|
||||
const showErrorModal = ref(false)
|
||||
|
||||
const showErrorDetails = ref(false)
|
||||
const errorDetailsType = ref<'request' | 'upstream'>('request')
|
||||
|
||||
const showRequestDetails = ref(false)
|
||||
const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
|
||||
title: '',
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
})
|
||||
|
||||
const showSettingsDialog = ref(false)
|
||||
const showAlertRulesCard = ref(false)
|
||||
|
||||
// Auto refresh settings
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshIntervalMs = ref(30000) // default 30 seconds
|
||||
const autoRefreshCountdown = ref(0)
|
||||
|
||||
// Used to trigger child component refreshes in a single shared cadence.
|
||||
const dashboardRefreshToken = ref(0)
|
||||
|
||||
// Countdown timer (drives auto refresh; updates every second)
|
||||
const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
|
||||
() => {
|
||||
if (!autoRefreshEnabled.value) return
|
||||
if (!opsEnabled.value) return
|
||||
if (loading.value) return
|
||||
|
||||
if (autoRefreshCountdown.value <= 0) {
|
||||
// Fetch immediately when the countdown reaches 0.
|
||||
// fetchData() will reset the countdown to the full interval.
|
||||
fetchData()
|
||||
return
|
||||
}
|
||||
|
||||
autoRefreshCountdown.value -= 1
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Load auto refresh settings from backend
|
||||
async function loadAutoRefreshSettings() {
|
||||
try {
|
||||
const settings = await opsAPI.getAdvancedSettings()
|
||||
autoRefreshEnabled.value = settings.auto_refresh_enabled
|
||||
autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000
|
||||
autoRefreshCountdown.value = settings.auto_refresh_interval_seconds
|
||||
} catch (err) {
|
||||
console.error('[OpsDashboard] Failed to load auto refresh settings', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleThroughputSelectPlatform(nextPlatform: string) {
|
||||
platform.value = nextPlatform || ''
|
||||
groupId.value = null
|
||||
}
|
||||
|
||||
function handleThroughputSelectGroup(nextGroupId: number) {
|
||||
const id = Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null
|
||||
groupId.value = id
|
||||
}
|
||||
|
||||
function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
|
||||
const basePreset: OpsRequestDetailsPreset = {
|
||||
title: t('admin.ops.requestDetails.title'),
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
}
|
||||
|
||||
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
|
||||
if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title
|
||||
// Ensure only one modal visible at a time.
|
||||
showErrorDetails.value = false
|
||||
showErrorModal.value = false
|
||||
showRequestDetails.value = true
|
||||
}
|
||||
|
||||
function openErrorDetails(kind: 'request' | 'upstream') {
|
||||
errorDetailsType.value = kind
|
||||
// Ensure only one modal visible at a time.
|
||||
showRequestDetails.value = false
|
||||
showErrorModal.value = false
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
|
||||
function onTimeRangeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedTimeRanges.has(v as TimeRange)) return
|
||||
timeRange.value = v as TimeRange
|
||||
}
|
||||
|
||||
function onCustomTimeRangeChange(startTime: string, endTime: string) {
|
||||
customStartTime.value = startTime
|
||||
customEndTime.value = endTime
|
||||
}
|
||||
|
||||
function onSettingsSaved() {
|
||||
loadThresholds()
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function onPlatformChange(v: string | number | boolean | null) {
|
||||
platform.value = typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function onGroupChange(v: string | number | boolean | null) {
|
||||
if (v === null) {
|
||||
groupId.value = null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
groupId.value = v > 0 ? v : null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const n = Number.parseInt(v, 10)
|
||||
groupId.value = Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryModeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedQueryModes.has(v as QueryMode)) return
|
||||
queryMode.value = v as QueryMode
|
||||
}
|
||||
|
||||
function openError(id: number) {
|
||||
selectedErrorId.value = id
|
||||
// Ensure only one modal visible at a time.
|
||||
showErrorDetails.value = false
|
||||
showRequestDetails.value = false
|
||||
showErrorModal.value = true
|
||||
}
|
||||
|
||||
function buildApiParams() {
|
||||
const params: any = {
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
}
|
||||
|
||||
if (timeRange.value === 'custom') {
|
||||
if (customStartTime.value && customEndTime.value) {
|
||||
params.start_time = customStartTime.value
|
||||
params.end_time = customEndTime.value
|
||||
} else {
|
||||
// Safety fallback: avoid sending time_range=custom (backend may not support it)
|
||||
params.time_range = '1h'
|
||||
}
|
||||
} else {
|
||||
params.time_range = timeRange.value
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
try {
|
||||
const data = await opsAPI.getDashboardOverview(buildApiParams(), { signal })
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
overview.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
overview.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadOverview'))
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getThroughputTrend(buildApiParams(), { signal })
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
throughputTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
throughputTrend.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadThroughputTrend'))
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingLatency.value = true
|
||||
try {
|
||||
const data = await opsAPI.getLatencyHistogram(buildApiParams(), { signal })
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
latencyHistogram.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
latencyHistogram.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadLatencyHistogram'))
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingLatency.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorTrend(buildApiParams(), { signal })
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorTrend.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorTrend'))
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorDistribution.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorDistribution(buildApiParams(), { signal })
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorDistribution.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorDistribution.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDistribution'))
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorDistribution.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpsDisabledError(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
typeof (err as Record<string, unknown>).code === 'string' &&
|
||||
(err as Record<string, unknown>).code === 'OPS_DISABLED'
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (!opsEnabled.value) return
|
||||
|
||||
abortDashboardFetch()
|
||||
dashboardFetchSeq += 1
|
||||
const fetchSeq = dashboardFetchSeq
|
||||
dashboardFetchController = new AbortController()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await Promise.all([
|
||||
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
|
||||
])
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
|
||||
lastUpdated.value = new Date()
|
||||
|
||||
// Trigger child component refreshes using the same cadence as the header.
|
||||
dashboardRefreshToken.value += 1
|
||||
|
||||
// Reset auto refresh countdown after successful fetch
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isOpsDisabledError(err)) {
|
||||
console.error('[ops] failed to fetch dashboard data', err)
|
||||
errorMessage.value = t('admin.ops.failedToLoadData')
|
||||
}
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loading.value = false
|
||||
hasLoadedOnce.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [timeRange.value, platform.value, groupId.value, queryMode.value] as const,
|
||||
() => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
syncQueryToRoute()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (isSyncingRouteQuery.value) return
|
||||
|
||||
const prevTimeRange = timeRange.value
|
||||
const prevPlatform = platform.value
|
||||
const prevGroupId = groupId.value
|
||||
|
||||
isApplyingRouteQuery.value = true
|
||||
applyRouteQueryToState()
|
||||
isApplyingRouteQuery.value = false
|
||||
|
||||
const changed =
|
||||
prevTimeRange !== timeRange.value || prevPlatform !== platform.value || prevGroupId !== groupId.value
|
||||
if (changed) {
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Fullscreen mode: listen for ESC key
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
await adminSettingsStore.fetch()
|
||||
if (!adminSettingsStore.opsMonitoringEnabled) {
|
||||
await router.replace('/admin/settings')
|
||||
return
|
||||
}
|
||||
|
||||
// Load thresholds configuration
|
||||
loadThresholds()
|
||||
|
||||
// Load auto refresh settings
|
||||
await loadAutoRefreshSettings()
|
||||
|
||||
if (opsEnabled.value) {
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// Start auto refresh if enabled
|
||||
if (autoRefreshEnabled.value) {
|
||||
resumeCountdown()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadThresholds() {
|
||||
try {
|
||||
const thresholds = await opsAPI.getMetricThresholds()
|
||||
metricThresholds.value = thresholds || null
|
||||
} catch (err) {
|
||||
console.warn('[OpsDashboard] Failed to load thresholds', err)
|
||||
metricThresholds.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
abortDashboardFetch()
|
||||
pauseCountdown()
|
||||
})
|
||||
|
||||
// Watch auto refresh settings changes
|
||||
watch(autoRefreshEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
|
||||
resumeCountdown()
|
||||
} else {
|
||||
pauseCountdown()
|
||||
autoRefreshCountdown.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Reload auto refresh settings after settings dialog is closed
|
||||
watch(showSettingsDialog, async (show) => {
|
||||
if (!show) {
|
||||
await loadAutoRefreshSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
648
frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue
Normal file
648
frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue
Normal file
@@ -0,0 +1,648 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { opsAPI, type AlertEventsQuery } from '@/api/admin/ops'
|
||||
import type { AlertEvent } from '../types'
|
||||
import { formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const events = ref<AlertEvent[]>([])
|
||||
const hasMore = ref(true)
|
||||
|
||||
// Detail modal
|
||||
const showDetail = ref(false)
|
||||
const selected = ref<AlertEvent | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailActionLoading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const history = ref<AlertEvent[]>([])
|
||||
const historyRange = ref('7d')
|
||||
const historyRangeOptions = computed(() => [
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
const silenceDuration = ref('1h')
|
||||
const silenceDurationOptions = computed(() => [
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') }
|
||||
])
|
||||
|
||||
// Filters
|
||||
const timeRange = ref('24h')
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '5m', label: t('admin.ops.timeRange.5m') },
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '6h', label: t('admin.ops.timeRange.6h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
const severity = ref<string>('')
|
||||
const severityOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'P0', label: 'P0' },
|
||||
{ value: 'P1', label: 'P1' },
|
||||
{ value: 'P2', label: 'P2' },
|
||||
{ value: 'P3', label: 'P3' }
|
||||
])
|
||||
|
||||
const status = ref<string>('')
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'firing', label: t('admin.ops.alertEvents.status.firing') },
|
||||
{ value: 'resolved', label: t('admin.ops.alertEvents.status.resolved') },
|
||||
{ value: 'manual_resolved', label: t('admin.ops.alertEvents.status.manualResolved') }
|
||||
])
|
||||
|
||||
const emailSent = ref<string>('')
|
||||
const emailSentOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'true', label: t('admin.ops.alertEvents.table.emailSent') },
|
||||
{ value: 'false', label: t('admin.ops.alertEvents.table.emailIgnored') }
|
||||
])
|
||||
|
||||
function buildQuery(overrides: Partial<AlertEventsQuery> = {}): AlertEventsQuery {
|
||||
const q: AlertEventsQuery = {
|
||||
limit: PAGE_SIZE,
|
||||
time_range: timeRange.value
|
||||
}
|
||||
if (severity.value) q.severity = severity.value
|
||||
if (status.value) q.status = status.value
|
||||
if (emailSent.value === 'true') q.email_sent = true
|
||||
if (emailSent.value === 'false') q.email_sent = false
|
||||
return { ...q, ...overrides }
|
||||
}
|
||||
|
||||
async function loadFirstPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await opsAPI.listAlertEvents(buildQuery())
|
||||
events.value = data
|
||||
hasMore.value = data.length === PAGE_SIZE
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert events', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
|
||||
events.value = []
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || loading.value) return
|
||||
if (!hasMore.value) return
|
||||
const last = events.value[events.value.length - 1]
|
||||
if (!last) return
|
||||
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const data = await opsAPI.listAlertEvents(
|
||||
buildQuery({ before_fired_at: last.fired_at || last.created_at, before_id: last.id })
|
||||
)
|
||||
if (!data.length) {
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
events.value = [...events.value, ...data]
|
||||
if (data.length < PAGE_SIZE) hasMore.value = false
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load more alert events', err)
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
const el = e.target as HTMLElement | null
|
||||
if (!el) return
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 120
|
||||
if (nearBottom) loadMore()
|
||||
}
|
||||
|
||||
function getDimensionString(event: AlertEvent | null | undefined, key: string): string {
|
||||
const v = event?.dimensions?.[key]
|
||||
if (v == null) return ''
|
||||
if (typeof v === 'string') return v
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms))
|
||||
const sec = Math.floor(safe / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h`
|
||||
const day = Math.floor(hr / 24)
|
||||
return `${day}d`
|
||||
}
|
||||
|
||||
function formatDurationLabel(event: AlertEvent): string {
|
||||
const firedAt = new Date(event.fired_at || event.created_at)
|
||||
if (Number.isNaN(firedAt.getTime())) return '-'
|
||||
const resolvedAtStr = event.resolved_at || null
|
||||
const status = String(event.status || '').trim().toLowerCase()
|
||||
|
||||
if (resolvedAtStr) {
|
||||
const resolvedAt = new Date(resolvedAtStr)
|
||||
if (!Number.isNaN(resolvedAt.getTime())) {
|
||||
const ms = resolvedAt.getTime() - firedAt.getTime()
|
||||
const prefix = status === 'manual_resolved'
|
||||
? t('admin.ops.alertEvents.status.manualResolved')
|
||||
: t('admin.ops.alertEvents.status.resolved')
|
||||
return `${prefix} ${formatDurationMs(ms)}`
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const ms = now - firedAt.getTime()
|
||||
return `${t('admin.ops.alertEvents.status.firing')} ${formatDurationMs(ms)}`
|
||||
}
|
||||
|
||||
function formatDimensionsSummary(event: AlertEvent): string {
|
||||
const parts: string[] = []
|
||||
const platform = getDimensionString(event, 'platform')
|
||||
if (platform) parts.push(`platform=${platform}`)
|
||||
const groupId = event.dimensions?.group_id
|
||||
if (groupId != null && groupId !== '') parts.push(`group_id=${String(groupId)}`)
|
||||
const region = getDimensionString(event, 'region')
|
||||
if (region) parts.push(`region=${region}`)
|
||||
return parts.length ? parts.join(' ') : '-'
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
selected.value = null
|
||||
history.value = []
|
||||
}
|
||||
|
||||
async function openDetail(row: AlertEvent) {
|
||||
showDetail.value = true
|
||||
selected.value = row
|
||||
detailLoading.value = true
|
||||
historyLoading.value = true
|
||||
|
||||
try {
|
||||
const detail = await opsAPI.getAlertEvent(row.id)
|
||||
selected.value = detail
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert detail', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.loadFailed'))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const ev = selected.value
|
||||
if (!ev) {
|
||||
history.value = []
|
||||
historyLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const platform = getDimensionString(ev, 'platform')
|
||||
const groupIdRaw = ev.dimensions?.group_id
|
||||
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : undefined
|
||||
|
||||
const items = await opsAPI.listAlertEvents({
|
||||
limit: 20,
|
||||
time_range: historyRange.value,
|
||||
platform: platform || undefined,
|
||||
group_id: groupId,
|
||||
status: ''
|
||||
})
|
||||
|
||||
// Best-effort: narrow to same rule_id + dimensions
|
||||
history.value = items.filter((it) => {
|
||||
if (it.rule_id !== ev.rule_id) return false
|
||||
const p1 = getDimensionString(it, 'platform')
|
||||
const p2 = getDimensionString(ev, 'platform')
|
||||
if ((p1 || '') !== (p2 || '')) return false
|
||||
const g1 = it.dimensions?.group_id
|
||||
const g2 = ev.dimensions?.group_id
|
||||
return (g1 ?? null) === (g2 ?? null)
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert history', err)
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function durationToUntilRFC3339(duration: string): string {
|
||||
const now = Date.now()
|
||||
if (duration === '1h') return new Date(now + 60 * 60 * 1000).toISOString()
|
||||
if (duration === '24h') return new Date(now + 24 * 60 * 60 * 1000).toISOString()
|
||||
if (duration === '7d') return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
return new Date(now + 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
async function silenceAlert() {
|
||||
const ev = selected.value
|
||||
if (!ev) return
|
||||
if (detailActionLoading.value) return
|
||||
detailActionLoading.value = true
|
||||
try {
|
||||
const platform = getDimensionString(ev, 'platform')
|
||||
const groupIdRaw = ev.dimensions?.group_id
|
||||
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : null
|
||||
const region = getDimensionString(ev, 'region') || null
|
||||
|
||||
await opsAPI.createAlertSilence({
|
||||
rule_id: ev.rule_id,
|
||||
platform: platform || '',
|
||||
group_id: groupId ?? undefined,
|
||||
region: region ?? undefined,
|
||||
until: durationToUntilRFC3339(silenceDuration.value),
|
||||
reason: `silence from UI (${silenceDuration.value})`
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.ops.alertEvents.detail.silenceSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to silence alert', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.silenceFailed'))
|
||||
} finally {
|
||||
detailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manualResolve() {
|
||||
if (!selected.value) return
|
||||
if (detailActionLoading.value) return
|
||||
detailActionLoading.value = true
|
||||
try {
|
||||
await opsAPI.updateAlertEventStatus(selected.value.id, 'manual_resolved')
|
||||
appStore.showSuccess(t('admin.ops.alertEvents.detail.manualResolvedSuccess'))
|
||||
|
||||
// Refresh detail + first page to reflect new status
|
||||
const detail = await opsAPI.getAlertEvent(selected.value.id)
|
||||
selected.value = detail
|
||||
await loadFirstPage()
|
||||
await loadHistory()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to resolve alert', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.manualResolvedFailed'))
|
||||
} finally {
|
||||
detailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFirstPage()
|
||||
})
|
||||
|
||||
watch([timeRange, severity, status, emailSent], () => {
|
||||
events.value = []
|
||||
hasMore.value = true
|
||||
loadFirstPage()
|
||||
})
|
||||
|
||||
watch(historyRange, () => {
|
||||
if (showDetail.value) loadHistory()
|
||||
})
|
||||
|
||||
function severityBadgeClass(severity: string | undefined): string {
|
||||
const s = String(severity || '').trim().toLowerCase()
|
||||
if (s === 'p0' || s === 'critical') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (s === 'p1' || s === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
if (s === 'p2' || s === 'info') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
if (s === 'p3') return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string | undefined): string {
|
||||
const s = String(status || '').trim().toLowerCase()
|
||||
if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30'
|
||||
if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30'
|
||||
if (s === 'manual_resolved') return 'bg-slate-50 text-slate-700 ring-slate-600/20 dark:bg-slate-900/30 dark:text-slate-300 dark:ring-slate-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string | undefined): string {
|
||||
const s = String(status || '').trim().toLowerCase()
|
||||
if (!s) return '-'
|
||||
if (s === 'firing') return t('admin.ops.alertEvents.status.firing')
|
||||
if (s === 'resolved') return t('admin.ops.alertEvents.status.resolved')
|
||||
if (s === 'manual_resolved') return t('admin.ops.alertEvents.status.manualResolved')
|
||||
return s.toUpperCase()
|
||||
}
|
||||
|
||||
const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="timeRange" :options="timeRangeOptions" class="w-[120px]" @change="timeRange = String($event || '24h')" />
|
||||
<Select :model-value="severity" :options="severityOptions" class="w-[88px]" @change="severity = String($event || '')" />
|
||||
<Select :model-value="status" :options="statusOptions" class="w-[110px]" @change="status = String($event || '')" />
|
||||
<Select :model-value="emailSent" :options="emailSentOptions" class="w-[110px]" @change="emailSent = String($event || '')" />
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="loadFirstPage"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('admin.ops.alertEvents.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="empty" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[600px] overflow-y-auto" @scroll="onScroll">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.time') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.severity') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.platform') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.ruleId') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.title') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.duration') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.dimensions') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.email') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr
|
||||
v-for="row in events"
|
||||
:key="row.id"
|
||||
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
@click="openDetail(row)"
|
||||
:title="row.title || ''"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDateTime(row.fired_at || row.created_at) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
|
||||
{{ row.severity || '-' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
|
||||
{{ formatStatusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ getDimensionString(row, 'platform') || '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-mono">#{{ row.rule_id }}</span>
|
||||
</td>
|
||||
<td class="min-w-[260px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<div class="font-semibold truncate max-w-[360px]">{{ row.title || '-' }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDurationLabel(row) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ formatDimensionsSummary(row) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
|
||||
<span
|
||||
class="inline-flex items-center justify-end gap-1.5"
|
||||
:title="row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored')"
|
||||
>
|
||||
<Icon
|
||||
v-if="row.email_sent"
|
||||
name="checkCircle"
|
||||
size="sm"
|
||||
class="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
name="ban"
|
||||
size="sm"
|
||||
class="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">
|
||||
{{ row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored') }}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loadingMore" class="flex items-center justify-center gap-2 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('admin.ops.alertEvents.loading') }}
|
||||
</div>
|
||||
<div v-else-if="!hasMore && events.length > 0" class="py-3 text-center text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="showDetail"
|
||||
:title="t('admin.ops.alertEvents.detail.title')"
|
||||
width="wide"
|
||||
:close-on-click-outside="true"
|
||||
@close="closeDetail"
|
||||
>
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-10 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="!selected" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(selected.severity || ''))">
|
||||
{{ selected.severity || '-' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(selected.status)">
|
||||
{{ formatStatusLabel(selected.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ selected.title || '-' }}
|
||||
</div>
|
||||
<div v-if="selected.description" class="mt-1 whitespace-pre-wrap text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ selected.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2 rounded-lg bg-white px-2 py-1 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">{{ t('admin.ops.alertEvents.detail.silence') }}</span>
|
||||
<Select
|
||||
:model-value="silenceDuration"
|
||||
:options="silenceDurationOptions"
|
||||
class="w-[110px]"
|
||||
@change="silenceDuration = String($event || '1h')"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="silenceAlert">
|
||||
<Icon name="ban" size="sm" />
|
||||
{{ t('common.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="manualResolve">
|
||||
<Icon name="checkCircle" size="sm" />
|
||||
{{ t('admin.ops.alertEvents.detail.manualResolve') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.firedAt') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ formatDateTime(selected.fired_at || selected.created_at) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.resolvedAt') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ selected.resolved_at ? formatDateTime(selected.resolved_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.ruleId') }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<div class="font-mono text-sm font-bold text-gray-900 dark:text-white">#{{ selected.rule_id }}</div>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
|
||||
:href="`/admin/ops?open_alert_rules=1&alert_rule_id=${selected.rule_id}`"
|
||||
>
|
||||
<Icon name="externalLink" size="xs" />
|
||||
{{ t('admin.ops.alertEvents.detail.viewRule') }}
|
||||
</a>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
|
||||
:href="`/admin/ops?platform=${encodeURIComponent(getDimensionString(selected,'platform')||'')}&group_id=${selected.dimensions?.group_id || ''}&error_type=request&open_error_details=1`"
|
||||
>
|
||||
<Icon name="externalLink" size="xs" />
|
||||
{{ t('admin.ops.alertEvents.detail.viewLogs') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.dimensions') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
<div v-if="getDimensionString(selected, 'platform')">platform={{ getDimensionString(selected, 'platform') }}</div>
|
||||
<div v-if="selected.dimensions?.group_id">group_id={{ selected.dimensions.group_id }}</div>
|
||||
<div v-if="getDimensionString(selected, 'region')">region={{ getDimensionString(selected, 'region') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.detail.historyTitle') }}</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.detail.historyHint') }}</div>
|
||||
</div>
|
||||
<Select :model-value="historyRange" :options="historyRangeOptions" class="w-[140px]" @change="historyRange = String($event || '7d')" />
|
||||
</div>
|
||||
|
||||
<div v-if="historyLoading" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.historyLoading') }}
|
||||
</div>
|
||||
<div v-else-if="history.length === 0" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.historyEmpty') }}
|
||||
</div>
|
||||
<div v-else class="overflow-hidden rounded-lg border border-gray-100 dark:border-dark-700">
|
||||
<table class="min-w-full divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.time') }}</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.status') }}</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.metric') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-for="it in history" :key="it.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">{{ formatDateTime(it.fired_at || it.created_at) }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(it.status)">
|
||||
{{ formatStatusLabel(it.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span v-if="typeof it.metric_value === 'number' && typeof it.threshold_value === 'number'">
|
||||
{{ it.metric_value.toFixed(2) }} / {{ it.threshold_value.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
591
frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue
Normal file
591
frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue
Normal file
@@ -0,0 +1,591 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||
import { adminAPI } from '@/api'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { AlertRule, MetricType, Operator } from '../types'
|
||||
import type { OpsSeverity } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<AlertRule[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
rules.value = await opsAPI.listAlertRules()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to load rules', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.loadFailed'))
|
||||
rules.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadGroups()
|
||||
})
|
||||
|
||||
const sortedRules = computed(() => {
|
||||
return [...rules.value].sort((a, b) => (b.id || 0) - (a.id || 0))
|
||||
})
|
||||
|
||||
const showEditor = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const draft = ref<AlertRule | null>(null)
|
||||
|
||||
type MetricGroup = 'system' | 'group' | 'account'
|
||||
|
||||
interface MetricDefinition {
|
||||
type: MetricType
|
||||
group: MetricGroup
|
||||
label: string
|
||||
description: string
|
||||
recommendedOperator: Operator
|
||||
recommendedThreshold: number
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const groupMetricTypes = new Set<MetricType>([
|
||||
'group_available_accounts',
|
||||
'group_available_ratio',
|
||||
'group_rate_limit_ratio'
|
||||
])
|
||||
|
||||
function parsePositiveInt(value: unknown): number | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'boolean') return null
|
||||
const n = typeof value === 'number' ? value : Number.parseInt(String(value), 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
const groupOptionsBase = ref<SelectOption[]>([])
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const list = await adminAPI.groups.getAll()
|
||||
groupOptionsBase.value = list.map((g) => ({ value: g.id, label: g.name }))
|
||||
} catch (err) {
|
||||
console.error('[OpsAlertRulesCard] Failed to load groups', err)
|
||||
groupOptionsBase.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const isGroupMetricSelected = computed(() => {
|
||||
const metricType = draft.value?.metric_type
|
||||
return metricType ? groupMetricTypes.has(metricType) : false
|
||||
})
|
||||
|
||||
const draftGroupId = computed<number | null>({
|
||||
get() {
|
||||
return parsePositiveInt(draft.value?.filters?.group_id)
|
||||
},
|
||||
set(value) {
|
||||
if (!draft.value) return
|
||||
if (value == null) {
|
||||
if (!draft.value.filters) return
|
||||
delete draft.value.filters.group_id
|
||||
if (Object.keys(draft.value.filters).length === 0) {
|
||||
delete draft.value.filters
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!draft.value.filters) draft.value.filters = {}
|
||||
draft.value.filters.group_id = value
|
||||
}
|
||||
})
|
||||
|
||||
const groupOptions = computed<SelectOption[]>(() => {
|
||||
if (isGroupMetricSelected.value) return groupOptionsBase.value
|
||||
return [{ value: null, label: t('admin.ops.alertRules.form.allGroups') }, ...groupOptionsBase.value]
|
||||
})
|
||||
|
||||
const metricDefinitions = computed(() => {
|
||||
return [
|
||||
// System-level metrics
|
||||
{
|
||||
type: 'success_rate',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.successRate'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.successRate'),
|
||||
recommendedOperator: '<',
|
||||
recommendedThreshold: 99,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'error_rate',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.errorRate'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.errorRate'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'upstream_error_rate',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.upstreamErrorRate'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.upstreamErrorRate'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'cpu_usage_percent',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.cpu'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.cpu'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 80,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'memory_usage_percent',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.memory'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.memory'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 80,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'concurrency_queue_depth',
|
||||
group: 'system',
|
||||
label: t('admin.ops.alertRules.metrics.queueDepth'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.queueDepth'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 10
|
||||
},
|
||||
|
||||
// Group-level metrics (requires group_id filter)
|
||||
{
|
||||
type: 'group_available_accounts',
|
||||
group: 'group',
|
||||
label: t('admin.ops.alertRules.metrics.groupAvailableAccounts'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableAccounts'),
|
||||
recommendedOperator: '<',
|
||||
recommendedThreshold: 1
|
||||
},
|
||||
{
|
||||
type: 'group_available_ratio',
|
||||
group: 'group',
|
||||
label: t('admin.ops.alertRules.metrics.groupAvailableRatio'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableRatio'),
|
||||
recommendedOperator: '<',
|
||||
recommendedThreshold: 50,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'group_rate_limit_ratio',
|
||||
group: 'group',
|
||||
label: t('admin.ops.alertRules.metrics.groupRateLimitRatio'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.groupRateLimitRatio'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 10,
|
||||
unit: '%'
|
||||
},
|
||||
|
||||
// Account-level metrics
|
||||
{
|
||||
type: 'account_rate_limited_count',
|
||||
group: 'account',
|
||||
label: t('admin.ops.alertRules.metrics.accountRateLimitedCount'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.accountRateLimitedCount'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 0
|
||||
},
|
||||
{
|
||||
type: 'account_error_count',
|
||||
group: 'account',
|
||||
label: t('admin.ops.alertRules.metrics.accountErrorCount'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.accountErrorCount'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 0
|
||||
},
|
||||
{
|
||||
type: 'account_error_ratio',
|
||||
group: 'account',
|
||||
label: t('admin.ops.alertRules.metrics.accountErrorRatio'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.accountErrorRatio'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 5,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
type: 'overload_account_count',
|
||||
group: 'account',
|
||||
label: t('admin.ops.alertRules.metrics.overloadAccountCount'),
|
||||
description: t('admin.ops.alertRules.metricDescriptions.overloadAccountCount'),
|
||||
recommendedOperator: '>',
|
||||
recommendedThreshold: 0
|
||||
}
|
||||
] satisfies MetricDefinition[]
|
||||
})
|
||||
|
||||
const selectedMetricDefinition = computed(() => {
|
||||
const metricType = draft.value?.metric_type
|
||||
if (!metricType) return null
|
||||
return metricDefinitions.value.find((m) => m.type === metricType) ?? null
|
||||
})
|
||||
|
||||
const metricOptions = computed(() => {
|
||||
const buildGroup = (group: MetricGroup): SelectOption[] => {
|
||||
const items = metricDefinitions.value.filter((m) => m.group === group)
|
||||
if (items.length === 0) return []
|
||||
const headerValue = `__group__${group}`
|
||||
return [
|
||||
{
|
||||
value: headerValue,
|
||||
label: t(`admin.ops.alertRules.metricGroups.${group}`),
|
||||
disabled: true,
|
||||
kind: 'group'
|
||||
},
|
||||
...items.map((m) => ({ value: m.type, label: m.label }))
|
||||
]
|
||||
}
|
||||
|
||||
return [...buildGroup('system'), ...buildGroup('group'), ...buildGroup('account')]
|
||||
})
|
||||
|
||||
const operatorOptions = computed(() => {
|
||||
const ops: Operator[] = ['>', '>=', '<', '<=', '==', '!=']
|
||||
return ops.map((o) => ({ value: o, label: o }))
|
||||
})
|
||||
|
||||
const severityOptions = computed(() => {
|
||||
const sev: OpsSeverity[] = ['P0', 'P1', 'P2', 'P3']
|
||||
return sev.map((s) => ({ value: s, label: s }))
|
||||
})
|
||||
|
||||
const windowOptions = computed(() => {
|
||||
const windows = [1, 5, 60]
|
||||
return windows.map((m) => ({ value: m, label: `${m}m` }))
|
||||
})
|
||||
|
||||
function newRuleDraft(): AlertRule {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
metric_type: 'error_rate',
|
||||
operator: '>',
|
||||
threshold: 1,
|
||||
window_minutes: 1,
|
||||
sustained_minutes: 2,
|
||||
severity: 'P1',
|
||||
cooldown_minutes: 10,
|
||||
notify_email: true
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
draft.value = newRuleDraft()
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEdit(rule: AlertRule) {
|
||||
editingId.value = rule.id ?? null
|
||||
draft.value = JSON.parse(JSON.stringify(rule))
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
const editorValidation = computed(() => {
|
||||
const errors: string[] = []
|
||||
const r = draft.value
|
||||
if (!r) return { valid: true, errors }
|
||||
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
|
||||
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
|
||||
if (groupMetricTypes.has(r.metric_type) && !parsePositiveInt(r.filters?.group_id)) {
|
||||
errors.push(t('admin.ops.alertRules.validation.groupIdRequired'))
|
||||
}
|
||||
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
|
||||
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
|
||||
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
|
||||
if (!(typeof r.window_minutes === 'number' && Number.isFinite(r.window_minutes) && [1, 5, 60].includes(r.window_minutes))) {
|
||||
errors.push(t('admin.ops.alertRules.validation.windowRange'))
|
||||
}
|
||||
if (!(typeof r.sustained_minutes === 'number' && Number.isFinite(r.sustained_minutes) && r.sustained_minutes >= 1 && r.sustained_minutes <= 1440)) {
|
||||
errors.push(t('admin.ops.alertRules.validation.sustainedRange'))
|
||||
}
|
||||
if (!(typeof r.cooldown_minutes === 'number' && Number.isFinite(r.cooldown_minutes) && r.cooldown_minutes >= 0 && r.cooldown_minutes <= 1440)) {
|
||||
errors.push(t('admin.ops.alertRules.validation.cooldownRange'))
|
||||
}
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!draft.value) return
|
||||
if (!editorValidation.value.valid) {
|
||||
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.alertRules.validation.invalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await opsAPI.updateAlertRule(editingId.value, draft.value)
|
||||
} else {
|
||||
await opsAPI.createAlertRule(draft.value)
|
||||
}
|
||||
showEditor.value = false
|
||||
draft.value = null
|
||||
editingId.value = null
|
||||
await load()
|
||||
appStore.showSuccess(t('admin.ops.alertRules.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to save rule', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const pendingDelete = ref<AlertRule | null>(null)
|
||||
|
||||
function requestDelete(rule: AlertRule) {
|
||||
pendingDelete.value = rule
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete.value?.id) return
|
||||
try {
|
||||
await opsAPI.deleteAlertRule(pendingDelete.value.id)
|
||||
showDeleteConfirm.value = false
|
||||
pendingDelete.value = null
|
||||
await load()
|
||||
appStore.showSuccess(t('admin.ops.alertRules.deleteSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to delete rule', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteConfirm.value = false
|
||||
pendingDelete.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertRules.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertRules.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-sm btn-primary" :disabled="loading" @click="openCreate">
|
||||
{{ t('admin.ops.alertRules.create') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="sortedRules.length === 0" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[520px] overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[520px] overflow-y-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.metric') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.severity') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.enabled') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="row in sortedRules" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ row.name }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
<div v-if="row.updated_at" class="mt-1 text-[10px] text-gray-400">
|
||||
{{ formatDateTime(row.updated_at) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<span class="font-mono">{{ row.metric_type }}</span>
|
||||
<span class="mx-1 text-gray-400">{{ row.operator }}</span>
|
||||
<span class="font-mono">{{ row.threshold }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs font-bold text-gray-700 dark:text-gray-200">
|
||||
{{ row.severity }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
{{ row.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(row)">{{ t('common.edit') }}</button>
|
||||
<button class="ml-2 btn btn-sm btn-danger" @click="requestDelete(row)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="showEditor"
|
||||
:title="editingId ? t('admin.ops.alertRules.editTitle') : t('admin.ops.alertRules.createTitle')"
|
||||
width="wide"
|
||||
@close="showEditor = false"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!editorValidation.valid" class="rounded-xl bg-red-50 p-4 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
<div class="font-bold">{{ t('admin.ops.alertRules.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
<li v-for="e in editorValidation.errors" :key="e">{{ e }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.name') }}</label>
|
||||
<input v-model="draft!.name" class="input" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.description') }}</label>
|
||||
<input v-model="draft!.description" class="input" type="text" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
|
||||
<Select v-model="draft!.metric_type" :options="metricOptions" />
|
||||
<div v-if="selectedMetricDefinition" class="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p>{{ selectedMetricDefinition.description }}</p>
|
||||
<p>
|
||||
{{
|
||||
t('admin.ops.alertRules.hints.recommended', {
|
||||
operator: selectedMetricDefinition.recommendedOperator,
|
||||
threshold: selectedMetricDefinition.recommendedThreshold,
|
||||
unit: selectedMetricDefinition.unit || ''
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.operator') }}</label>
|
||||
<Select v-model="draft!.operator" :options="operatorOptions" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="input-label">
|
||||
{{ t('admin.ops.alertRules.form.groupId') }}
|
||||
<span v-if="isGroupMetricSelected" class="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="draftGroupId"
|
||||
:options="groupOptions"
|
||||
searchable
|
||||
:placeholder="t('admin.ops.alertRules.form.groupPlaceholder')"
|
||||
:error="isGroupMetricSelected && !draftGroupId"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ isGroupMetricSelected ? t('admin.ops.alertRules.hints.groupRequired') : t('admin.ops.alertRules.hints.groupOptional') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
|
||||
<input v-model.number="draft!.threshold" class="input" type="number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.severity') }}</label>
|
||||
<Select v-model="draft!.severity" :options="severityOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.window') }}</label>
|
||||
<Select v-model="draft!.window_minutes" :options="windowOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.sustained') }}</label>
|
||||
<input v-model.number="draft!.sustained_minutes" class="input" type="number" min="1" max="1440" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.cooldown') }}</label>
|
||||
<input v-model.number="draft!.cooldown_minutes" class="input" type="number" min="0" max="1440" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.enabled') }}</span>
|
||||
<input v-model="draft!.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.notifyEmail') }}</span>
|
||||
<input v-model="draft!.notify_email" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-secondary" :disabled="saving" @click="showEditor = false">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
:title="t('admin.ops.alertRules.deleteConfirmTitle')"
|
||||
:message="t('admin.ops.alertRules.deleteConfirmMessage')"
|
||||
:confirmText="t('common.delete')"
|
||||
:cancelText="t('common.cancel')"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
515
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
Normal file
515
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
|
||||
|
||||
interface Props {
|
||||
platformFilter?: string
|
||||
groupIdFilter?: number | null
|
||||
refreshToken: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
platformFilter: '',
|
||||
groupIdFilter: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
|
||||
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
|
||||
|
||||
const realtimeEnabled = computed(() => {
|
||||
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
|
||||
})
|
||||
|
||||
function safeNumber(n: unknown): number {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 计算显示维度
|
||||
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
|
||||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||||
return 'account'
|
||||
}
|
||||
if (props.platformFilter) {
|
||||
return 'group'
|
||||
}
|
||||
return 'platform'
|
||||
})
|
||||
|
||||
// 平台/分组汇总行数据
|
||||
interface SummaryRow {
|
||||
key: string
|
||||
name: string
|
||||
platform?: string
|
||||
// 账号统计
|
||||
total_accounts: number
|
||||
available_accounts: number
|
||||
rate_limited_accounts: number
|
||||
error_accounts: number
|
||||
// 并发统计
|
||||
total_concurrency: number
|
||||
used_concurrency: number
|
||||
waiting_in_queue: number
|
||||
// 计算字段
|
||||
availability_percentage: number
|
||||
concurrency_percentage: number
|
||||
}
|
||||
|
||||
// 账号详细行数据
|
||||
interface AccountRow {
|
||||
key: string
|
||||
name: string
|
||||
platform: string
|
||||
group_name: string
|
||||
// 并发
|
||||
current_in_use: number
|
||||
max_capacity: number
|
||||
waiting_in_queue: number
|
||||
load_percentage: number
|
||||
// 状态
|
||||
is_available: boolean
|
||||
is_rate_limited: boolean
|
||||
rate_limit_remaining_sec?: number
|
||||
is_overloaded: boolean
|
||||
overload_remaining_sec?: number
|
||||
has_error: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
// 平台维度汇总
|
||||
const platformRows = computed((): SummaryRow[] => {
|
||||
const concStats = concurrency.value?.platform || {}
|
||||
const availStats = availability.value?.platform || {}
|
||||
|
||||
const platforms = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
return Array.from(platforms).map(platform => {
|
||||
const conc = concStats[platform] || {}
|
||||
const avail = availStats[platform] || {}
|
||||
|
||||
const totalAccounts = safeNumber(avail.total_accounts)
|
||||
const availableAccounts = safeNumber(avail.available_count)
|
||||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||||
|
||||
return {
|
||||
key: platform,
|
||||
name: platform.toUpperCase(),
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||||
}
|
||||
}).sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||||
})
|
||||
|
||||
// 分组维度汇总
|
||||
const groupRows = computed((): SummaryRow[] => {
|
||||
const concStats = concurrency.value?.group || {}
|
||||
const availStats = availability.value?.group || {}
|
||||
|
||||
const groupIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
const rows = Array.from(groupIds)
|
||||
.map(gid => {
|
||||
const conc = concStats[gid] || {}
|
||||
const avail = availStats[gid] || {}
|
||||
|
||||
// 只显示匹配的平台
|
||||
if (props.platformFilter && conc.platform !== props.platformFilter && avail.platform !== props.platformFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalAccounts = safeNumber(avail.total_accounts)
|
||||
const availableAccounts = safeNumber(avail.available_count)
|
||||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||||
|
||||
return {
|
||||
key: gid,
|
||||
name: String(conc.group_name || avail.group_name || `Group ${gid}`),
|
||||
platform: String(conc.platform || avail.platform || ''),
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
return rows.sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||||
})
|
||||
|
||||
// 账号维度详细
|
||||
const accountRows = computed((): AccountRow[] => {
|
||||
const concStats = concurrency.value?.account || {}
|
||||
const availStats = availability.value?.account || {}
|
||||
|
||||
const accountIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
const rows = Array.from(accountIds)
|
||||
.map(aid => {
|
||||
const conc = concStats[aid] || {}
|
||||
const avail = availStats[aid] || {}
|
||||
|
||||
// 只显示匹配的分组
|
||||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||||
if (conc.group_id !== props.groupIdFilter && avail.group_id !== props.groupIdFilter) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: aid,
|
||||
name: String(conc.account_name || avail.account_name || `Account ${aid}`),
|
||||
platform: String(conc.platform || avail.platform || ''),
|
||||
group_name: String(conc.group_name || avail.group_name || ''),
|
||||
current_in_use: safeNumber(conc.current_in_use),
|
||||
max_capacity: safeNumber(conc.max_capacity),
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
load_percentage: safeNumber(conc.load_percentage),
|
||||
is_available: avail.is_available || false,
|
||||
is_rate_limited: avail.is_rate_limited || false,
|
||||
rate_limit_remaining_sec: avail.rate_limit_remaining_sec,
|
||||
is_overloaded: avail.is_overloaded || false,
|
||||
overload_remaining_sec: avail.overload_remaining_sec,
|
||||
has_error: avail.has_error || false,
|
||||
error_message: avail.error_message || ''
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
// 优先显示异常账号
|
||||
if (a.has_error !== b.has_error) return a.has_error ? -1 : 1
|
||||
if (a.is_rate_limited !== b.is_rate_limited) return a.is_rate_limited ? -1 : 1
|
||||
// 然后按负载排序
|
||||
return b.load_percentage - a.load_percentage
|
||||
})
|
||||
})
|
||||
|
||||
// 根据维度选择数据
|
||||
const displayRows = computed(() => {
|
||||
if (displayDimension.value === 'account') return accountRows.value
|
||||
if (displayDimension.value === 'group') return groupRows.value
|
||||
return platformRows.value
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
|
||||
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
|
||||
return t('admin.ops.concurrency.byPlatform')
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [concData, availData] = await Promise.all([
|
||||
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
|
||||
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
|
||||
])
|
||||
concurrency.value = concData
|
||||
availability.value = availData
|
||||
} catch (err: any) {
|
||||
console.error('[OpsConcurrencyCard] Failed to load data', err)
|
||||
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新节奏由父组件统一控制(OpsDashboard Header 的刷新状态/倒计时)
|
||||
watch(
|
||||
() => props.refreshToken,
|
||||
() => {
|
||||
if (!realtimeEnabled.value) return
|
||||
loadData()
|
||||
}
|
||||
)
|
||||
|
||||
function getLoadBarClass(loadPct: number): string {
|
||||
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
|
||||
if (loadPct >= 50) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
return 'bg-green-500 dark:bg-green-600'
|
||||
}
|
||||
|
||||
function getLoadBarStyle(loadPct: number): string {
|
||||
return `width: ${Math.min(100, Math.max(0, loadPct))}%`
|
||||
}
|
||||
|
||||
function getLoadTextClass(loadPct: number): string {
|
||||
if (loadPct >= 90) return 'text-red-600 dark:text-red-400'
|
||||
if (loadPct >= 70) return 'text-orange-600 dark:text-orange-400'
|
||||
if (loadPct >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds <= 0) return '0s'
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
watch(
|
||||
() => realtimeEnabled.value,
|
||||
async (enabled) => {
|
||||
if (enabled) {
|
||||
await loadData()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<!-- 头部 -->
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between gap-3">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('admin.ops.concurrency.title') }}
|
||||
</h3>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadData"
|
||||
>
|
||||
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errorMessage" class="mb-3 shrink-0 rounded-xl bg-red-50 p-2.5 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 禁用状态 -->
|
||||
<div
|
||||
v-if="!realtimeEnabled"
|
||||
class="flex flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.disabledHint') }}
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<!-- 维度标题栏 -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ displayTitle }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.concurrency.totalRows', { count: displayRows.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="displayRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.concurrency.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- 汇总视图(平台/分组) -->
|
||||
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<!-- 标题行 -->
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<span v-if="displayDimension === 'group' && row.platform" class="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{{ row.platform.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2 text-[10px]">
|
||||
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.used_concurrency }}/{{ row.total_concurrency }} </span>
|
||||
<span :class="['font-bold', getLoadTextClass(row.concurrency_percentage)]"> {{ row.concurrency_percentage }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getLoadBarClass(row.concurrency_percentage)"
|
||||
:style="getLoadBarStyle(row.concurrency_percentage)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]">
|
||||
<!-- 账号统计 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
<span class="font-bold text-green-600 dark:text-green-400">{{ row.available_accounts }}</span
|
||||
>/{{ row.total_accounts }}
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ row.availability_percentage }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- 限流账号 -->
|
||||
<span
|
||||
v-if="row.rate_limited_accounts > 0"
|
||||
class="rounded-full bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- 异常账号 -->
|
||||
<span
|
||||
v-if="row.error_accounts > 0"
|
||||
class="rounded-full bg-red-100 px-1.5 py-0.5 font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.errorAccounts', { count: row.error_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- 等待队列 -->
|
||||
<span
|
||||
v-if="row.waiting_in_queue > 0"
|
||||
class="rounded-full bg-purple-100 px-1.5 py-0.5 font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号详细视图 -->
|
||||
<div v-else class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as AccountRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
|
||||
<!-- 账号名称和并发 -->
|
||||
<div class="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[9px] text-gray-400 dark:text-gray-500">
|
||||
{{ row.group_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<!-- 并发使用 -->
|
||||
<span class="font-mono text-[11px] font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
|
||||
<!-- 状态徽章 -->
|
||||
<span
|
||||
v-if="row.is_available"
|
||||
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('admin.ops.accountAvailability.available') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.is_rate_limited"
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ formatDuration(row.rate_limit_remaining_sec || 0) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.is_overloaded"
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ formatDuration(row.overload_remaining_sec || 0) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.has_error"
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{{ t('admin.ops.accountAvailability.accountError') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.ops.accountAvailability.unavailable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 等待队列 -->
|
||||
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
|
||||
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
</style>
|
||||
1627
frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
Normal file
1627
frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fullscreen: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header (matches OpsDashboardHeader + overview blocks) -->
|
||||
<div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
|
||||
<div class="space-y-2">
|
||||
<div class="h-6 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-3">
|
||||
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-[160px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-[150px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30 lg:col-span-5">
|
||||
<div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
||||
<div class="h-28 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
<div class="space-y-4">
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-for="i in 4" :key="i" class="h-14 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-7">
|
||||
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="i in 6" :key="i" class="h-20 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
|
||||
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-2', props.fullscreen ? 'p-8' : 'p-6']">
|
||||
<div class="h-4 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Visual Analysis (baseline 3-up grid) -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']"
|
||||
>
|
||||
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-6 h-56 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-2">
|
||||
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center justify-between gap-4 rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
<div class="h-7 w-20 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { EmailNotificationConfig, AlertSeverity } from '../types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const config = ref<EmailNotificationConfig | null>(null)
|
||||
|
||||
const showEditor = ref(false)
|
||||
const saving = ref(false)
|
||||
const draft = ref<EmailNotificationConfig | null>(null)
|
||||
const alertRecipientInput = ref('')
|
||||
const reportRecipientInput = ref('')
|
||||
const alertRecipientError = ref('')
|
||||
const reportRecipientError = ref('')
|
||||
|
||||
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
|
||||
{ value: '', label: t('admin.ops.email.minSeverityAll') },
|
||||
{ value: 'critical', label: t('common.critical') },
|
||||
{ value: 'warning', label: t('common.warning') },
|
||||
{ value: 'info', label: t('common.info') }
|
||||
]
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await opsAPI.getEmailNotificationConfig()
|
||||
config.value = data
|
||||
} catch (err: any) {
|
||||
console.error('[OpsEmailNotificationCard] Failed to load config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft.value) return
|
||||
if (!editorValidation.value.valid) {
|
||||
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.email.validation.invalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
config.value = await opsAPI.updateEmailNotificationConfig(draft.value)
|
||||
showEditor.value = false
|
||||
appStore.showSuccess(t('admin.ops.email.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsEmailNotificationCard] Failed to save config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
if (!config.value) return
|
||||
draft.value = JSON.parse(JSON.stringify(config.value))
|
||||
alertRecipientInput.value = ''
|
||||
reportRecipientInput.value = ''
|
||||
alertRecipientError.value = ''
|
||||
reportRecipientError.value = ''
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function isValidEmailAddress(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
function isNonNegativeNumber(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function validateCronField(enabled: boolean, cron: string): string | null {
|
||||
if (!enabled) return null
|
||||
if (!cron || !cron.trim()) return t('admin.ops.email.validation.cronRequired')
|
||||
if (cron.trim().split(/\s+/).length < 5) return t('admin.ops.email.validation.cronFormat')
|
||||
return null
|
||||
}
|
||||
|
||||
const editorValidation = computed(() => {
|
||||
const errors: string[] = []
|
||||
if (!draft.value) return { valid: true, errors }
|
||||
|
||||
if (draft.value.alert.enabled && draft.value.alert.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
|
||||
}
|
||||
if (draft.value.report.enabled && draft.value.report.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
|
||||
}
|
||||
|
||||
const invalidAlertRecipients = draft.value.alert.recipients.filter((e) => !isValidEmailAddress(e))
|
||||
if (invalidAlertRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
|
||||
|
||||
const invalidReportRecipients = draft.value.report.recipients.filter((e) => !isValidEmailAddress(e))
|
||||
if (invalidReportRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
|
||||
|
||||
if (!isNonNegativeNumber(draft.value.alert.rate_limit_per_hour)) {
|
||||
errors.push(t('admin.ops.email.validation.rateLimitRange'))
|
||||
}
|
||||
if (
|
||||
!isNonNegativeNumber(draft.value.alert.batching_window_seconds) ||
|
||||
draft.value.alert.batching_window_seconds > 86400
|
||||
) {
|
||||
errors.push(t('admin.ops.email.validation.batchWindowRange'))
|
||||
}
|
||||
|
||||
const dailyErr = validateCronField(
|
||||
draft.value.report.daily_summary_enabled,
|
||||
draft.value.report.daily_summary_schedule
|
||||
)
|
||||
if (dailyErr) errors.push(dailyErr)
|
||||
const weeklyErr = validateCronField(
|
||||
draft.value.report.weekly_summary_enabled,
|
||||
draft.value.report.weekly_summary_schedule
|
||||
)
|
||||
if (weeklyErr) errors.push(weeklyErr)
|
||||
const digestErr = validateCronField(
|
||||
draft.value.report.error_digest_enabled,
|
||||
draft.value.report.error_digest_schedule
|
||||
)
|
||||
if (digestErr) errors.push(digestErr)
|
||||
const accErr = validateCronField(
|
||||
draft.value.report.account_health_enabled,
|
||||
draft.value.report.account_health_schedule
|
||||
)
|
||||
if (accErr) errors.push(accErr)
|
||||
|
||||
if (!isNonNegativeNumber(draft.value.report.error_digest_min_count)) {
|
||||
errors.push(t('admin.ops.email.validation.digestMinCountRange'))
|
||||
}
|
||||
|
||||
const thr = draft.value.report.account_health_error_rate_threshold
|
||||
if (!(typeof thr === 'number' && Number.isFinite(thr) && thr >= 0 && thr <= 100)) {
|
||||
errors.push(t('admin.ops.email.validation.accountHealthThresholdRange'))
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
function addRecipient(target: 'alert' | 'report') {
|
||||
if (!draft.value) return
|
||||
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
|
||||
if (!raw) return
|
||||
|
||||
if (!isValidEmailAddress(raw)) {
|
||||
const msg = t('common.invalidEmail')
|
||||
if (target === 'alert') alertRecipientError.value = msg
|
||||
else reportRecipientError.value = msg
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = raw.toLowerCase()
|
||||
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
|
||||
if (!list.includes(normalized)) {
|
||||
list.push(normalized)
|
||||
}
|
||||
if (target === 'alert') alertRecipientInput.value = ''
|
||||
else reportRecipientInput.value = ''
|
||||
if (target === 'alert') alertRecipientError.value = ''
|
||||
else reportRecipientError.value = ''
|
||||
}
|
||||
|
||||
function removeRecipient(target: 'alert' | 'report', email: string) {
|
||||
if (!draft.value) return
|
||||
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
|
||||
const idx = list.indexOf(email)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.email.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.description') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="loadConfig"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="!config" @click="openEditor">{{ t('common.edit') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!config" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="loading">{{ t('admin.ops.email.loading') }}</span>
|
||||
<span v-else>{{ t('admin.ops.email.noData') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('common.enabled') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ config.alert.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.recipients') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.recipients.length }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.minSeverity') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{
|
||||
config.alert.min_severity || t('admin.ops.email.minSeverityAll')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.rateLimitPerHour') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.rate_limit_per_hour }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('common.enabled') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ config.report.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.recipients') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.report.recipients.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="showEditor" :title="t('admin.ops.email.title')" width="extra-wide" @close="showEditor = false">
|
||||
<div v-if="draft" class="space-y-6">
|
||||
<div
|
||||
v-if="!editorValidation.valid"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<div class="font-bold">{{ t('admin.ops.email.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||
<li v-for="msg in editorValidation.errors" :key="msg">{{ msg }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.alert.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.alert.enabled ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.minSeverity') }}</div>
|
||||
<Select v-model="draft.alert.min_severity" :options="severityOptions" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="alertRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.email.recipients')"
|
||||
@keydown.enter.prevent="addRecipient('alert')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="alertRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ alertRecipientError }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in draft.alert.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
|
||||
@click="removeRecipient('alert', email)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.recipientsHint') }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.rateLimitPerHour') }}</div>
|
||||
<input v-model.number="draft.alert.rate_limit_per_hour" type="number" min="0" max="100000" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.batchWindowSeconds') }}</div>
|
||||
<input v-model.number="draft.alert.batching_window_seconds" type="number" min="0" max="86400" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.includeResolved') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.alert.include_resolved_alerts" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.alert.include_resolved_alerts ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.report.enabled ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="reportRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.email.recipients')"
|
||||
@keydown.enter.prevent="addRecipient('report')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="reportRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ reportRecipientError }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in draft.report.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
|
||||
@click="removeRecipient('report', email)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.dailySummary') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.daily_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.daily_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.weeklySummary') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.weekly_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.weekly_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigest') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.error_digest_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.error_digest_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigestMinCount') }}</div>
|
||||
<input v-model.number="draft.report.error_digest_min_count" type="number" min="0" max="1000000" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealth') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.account_health_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.account_health_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealthThreshold') }}</div>
|
||||
<input v-model.number="draft.report.account_health_error_rate_threshold" type="number" min="0" max="100" step="0.1" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.reportHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary" @click="showEditor = false">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !editorValidation.valid" @click="saveConfig">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
309
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
Normal file
309
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="title" width="full" :close-on-click-outside="true" @close="close">
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!detail" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6 p-6">
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestId') }}</div>
|
||||
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ requestId || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.time') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDateTime(detail.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ isUpstreamError(detail) ? t('admin.ops.errorDetail.account') : t('admin.ops.errorDetail.user') }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<template v-if="isUpstreamError(detail)">
|
||||
{{ detail.account_name || (detail.account_id != null ? String(detail.account_id) : '—') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ detail.user_email || (detail.user_id != null ? String(detail.user_id) : '—') }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.platform || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.group') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.group_name || (detail.group_id != null ? String(detail.group_id) : '—') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.model || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.status') }}</div>
|
||||
<div class="mt-1">
|
||||
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
|
||||
{{ detail.status_code }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.message') }}</div>
|
||||
<div class="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white" :title="detail.message">
|
||||
{{ detail.message || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') }}</h3>
|
||||
<pre class="mt-4 max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(primaryResponseBody || '') }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Upstream errors list (only for request errors) -->
|
||||
<div v-if="showUpstreamList" class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetails.upstreamErrors') }}</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" v-if="correlatedUpstreamLoading">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!correlatedUpstreamLoading && !correlatedUpstreamErrors.length" class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="(ev, idx) in correlatedUpstreamErrors"
|
||||
:key="ev.id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs font-black text-gray-900 dark:text-white">
|
||||
#{{ idx + 1 }}
|
||||
<span v-if="ev.type" class="ml-2 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-[10px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">{{ ev.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ ev.status_code ?? '—' }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[10px] font-bold text-primary-700 hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-primary-200 dark:hover:bg-dark-700"
|
||||
:disabled="!getUpstreamResponsePreview(ev)"
|
||||
:title="getUpstreamResponsePreview(ev) ? '' : t('common.noData')"
|
||||
@click="toggleUpstreamDetail(ev.id)"
|
||||
>
|
||||
<Icon
|
||||
:name="expandedUpstreamDetailIds.has(ev.id) ? 'chevronDown' : 'chevronRight'"
|
||||
size="xs"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
expandedUpstreamDetailIds.has(ev.id)
|
||||
? t('admin.ops.errorDetail.responsePreview.collapse')
|
||||
: t('admin.ops.errorDetail.responsePreview.expand')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.status_code ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.request_id || ev.client_request_id || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.message" class="mt-3 break-words text-sm font-medium text-gray-900 dark:text-white">{{ ev.message }}</div>
|
||||
|
||||
<pre
|
||||
v-if="expandedUpstreamDetailIds.has(ev.id)"
|
||||
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(getUpstreamResponsePreview(ev)) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
errorId: number | null
|
||||
errorType?: 'request' | 'upstream'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<OpsErrorDetail | null>(null)
|
||||
|
||||
const showUpstreamList = computed(() => props.errorType === 'request')
|
||||
|
||||
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
|
||||
|
||||
const primaryResponseBody = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
if (props.errorType === 'upstream') {
|
||||
return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
|
||||
}
|
||||
return detail.value.error_body || ''
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.errorId) return t('admin.ops.errorDetail.title')
|
||||
return t('admin.ops.errorDetail.titleWithId', { id: String(props.errorId) })
|
||||
})
|
||||
|
||||
const emptyText = computed(() => t('admin.ops.errorDetail.noErrorSelected'))
|
||||
|
||||
function isUpstreamError(d: OpsErrorDetail | null): boolean {
|
||||
if (!d) return false
|
||||
const phase = String(d.phase || '').toLowerCase()
|
||||
const owner = String(d.error_owner || '').toLowerCase()
|
||||
return phase === 'upstream' && owner === 'provider'
|
||||
}
|
||||
|
||||
const correlatedUpstream = ref<OpsErrorDetail[]>([])
|
||||
const correlatedUpstreamLoading = ref(false)
|
||||
|
||||
const correlatedUpstreamErrors = computed<OpsErrorDetail[]>(() => correlatedUpstream.value)
|
||||
|
||||
const expandedUpstreamDetailIds = ref(new Set<number>())
|
||||
|
||||
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
|
||||
return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim()
|
||||
}
|
||||
|
||||
function toggleUpstreamDetail(id: number) {
|
||||
const next = new Set(expandedUpstreamDetailIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedUpstreamDetailIds.value = next
|
||||
}
|
||||
|
||||
async function fetchCorrelatedUpstreamErrors(requestErrorId: number) {
|
||||
correlatedUpstreamLoading.value = true
|
||||
try {
|
||||
const res = await opsAPI.listRequestErrorUpstreamErrors(
|
||||
requestErrorId,
|
||||
{ page: 1, page_size: 100, view: 'all' },
|
||||
{ include_detail: true }
|
||||
)
|
||||
correlatedUpstream.value = res.items || []
|
||||
} catch (err) {
|
||||
console.error('[OpsErrorDetailModal] Failed to load correlated upstream errors', err)
|
||||
correlatedUpstream.value = []
|
||||
} finally {
|
||||
correlatedUpstreamLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function prettyJSON(raw?: string): string {
|
||||
if (!raw) return 'N/A'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const kind = props.errorType || (detail.value?.phase === 'upstream' ? 'upstream' : 'request')
|
||||
const d = kind === 'upstream' ? await opsAPI.getUpstreamErrorDetail(id) : await opsAPI.getRequestErrorDetail(id)
|
||||
detail.value = d
|
||||
} catch (err: any) {
|
||||
detail.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.errorId] as const,
|
||||
([show, id]) => {
|
||||
if (!show) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number' && id > 0) {
|
||||
expandedUpstreamDetailIds.value = new Set()
|
||||
fetchDetail(id)
|
||||
if (props.errorType === 'request') {
|
||||
fetchCorrelatedUpstreamErrors(id)
|
||||
} else {
|
||||
correlatedUpstream.value = []
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const code = detail.value?.status_code ?? 0
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
|
||||
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
})
|
||||
|
||||
</script>
|
||||
270
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
Normal file
270
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import OpsErrorLogTable from './OpsErrorLogTable.vue'
|
||||
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
timeRange: string
|
||||
platform?: string
|
||||
groupId?: number | null
|
||||
errorType: 'request' | 'upstream'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'openErrorDetail', errorId: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<OpsErrorLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const q = ref('')
|
||||
const statusCode = ref<number | 'other' | null>(null)
|
||||
const phase = ref<string>('')
|
||||
const errorOwner = ref<string>('')
|
||||
const viewMode = ref<'errors' | 'excluded' | 'all'>('errors')
|
||||
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors')
|
||||
})
|
||||
|
||||
const statusCodeSelectOptions = computed(() => {
|
||||
const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529]
|
||||
return [
|
||||
{ value: null, label: t('common.all') },
|
||||
...codes.map((c) => ({ value: c, label: String(c) })),
|
||||
{ value: 'other', label: t('admin.ops.errorDetails.statusCodeOther') || 'Other' }
|
||||
]
|
||||
})
|
||||
|
||||
const ownerSelectOptions = computed(() => {
|
||||
return [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'provider', label: t('admin.ops.errorDetails.owner.provider') || 'provider' },
|
||||
{ value: 'client', label: t('admin.ops.errorDetails.owner.client') || 'client' },
|
||||
{ value: 'platform', label: t('admin.ops.errorDetails.owner.platform') || 'platform' }
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
const viewModeSelectOptions = computed(() => {
|
||||
return [
|
||||
{ value: 'errors', label: t('admin.ops.errorDetails.viewErrors') || 'errors' },
|
||||
{ value: 'excluded', label: t('admin.ops.errorDetails.viewExcluded') || 'excluded' },
|
||||
{ value: 'all', label: t('common.all') }
|
||||
]
|
||||
})
|
||||
|
||||
const phaseSelectOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'request', label: t('admin.ops.errorDetails.phase.request') || 'request' },
|
||||
{ value: 'auth', label: t('admin.ops.errorDetails.phase.auth') || 'auth' },
|
||||
{ value: 'routing', label: t('admin.ops.errorDetails.phase.routing') || 'routing' },
|
||||
{ value: 'upstream', label: t('admin.ops.errorDetails.phase.upstream') || 'upstream' },
|
||||
{ value: 'network', label: t('admin.ops.errorDetails.phase.network') || 'network' },
|
||||
{ value: 'internal', label: t('admin.ops.errorDetails.phase.internal') || 'internal' }
|
||||
]
|
||||
return options
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
async function fetchErrorLogs() {
|
||||
if (!props.show) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
time_range: props.timeRange,
|
||||
view: viewMode.value
|
||||
}
|
||||
|
||||
const platform = String(props.platform || '').trim()
|
||||
if (platform) params.platform = platform
|
||||
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
|
||||
|
||||
if (q.value.trim()) params.q = q.value.trim()
|
||||
if (statusCode.value === 'other') params.status_codes_other = '1'
|
||||
else if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value)
|
||||
|
||||
const phaseVal = String(phase.value || '').trim()
|
||||
if (phaseVal) params.phase = phaseVal
|
||||
|
||||
const ownerVal = String(errorOwner.value || '').trim()
|
||||
if (ownerVal) params.error_owner = ownerVal
|
||||
|
||||
|
||||
const res = props.errorType === 'upstream'
|
||||
? await opsAPI.listUpstreamErrors(params)
|
||||
: await opsAPI.listRequestErrors(params)
|
||||
rows.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch (err) {
|
||||
console.error('[OpsErrorDetailsModal] Failed to fetch error logs', err)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
q.value = ''
|
||||
statusCode.value = null
|
||||
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
|
||||
errorOwner.value = ''
|
||||
viewMode.value = 'errors'
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
page.value = 1
|
||||
pageSize.value = 10
|
||||
resetFilters()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.timeRange, props.platform, props.groupId] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [page.value, pageSize.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
|
||||
let searchTimeout: number | null = null
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
if (searchTimeout) window.clearTimeout(searchTimeout)
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}, 350)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [statusCode.value, phase.value, errorOwner.value, viewMode.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="modalTitle" width="full" @close="close">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
|
||||
<div class="grid grid-cols-8 gap-2">
|
||||
<div class="col-span-2 compact-select">
|
||||
<div class="relative group">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="q"
|
||||
type="text"
|
||||
class="w-full rounded-lg border-gray-200 bg-gray-50/50 py-1.5 pl-9 pr-3 text-xs font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
|
||||
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="statusCode" :options="statusCodeSelectOptions" @update:model-value="statusCode = $event as any" />
|
||||
</div>
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="phase" :options="phaseSelectOptions" @update:model-value="phase = String($event ?? '')" />
|
||||
</div>
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="button" class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="mb-2 flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetails.total') }} {{ total }}
|
||||
</div>
|
||||
|
||||
<OpsErrorLogTable
|
||||
class="min-h-0 flex-1"
|
||||
:rows="rows"
|
||||
:total="total"
|
||||
:loading="loading"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@openErrorDetail="emit('openErrorDetail', $event)"
|
||||
|
||||
@update:page="page = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.compact-select .select-trigger {
|
||||
@apply py-1.5 px-3 text-xs rounded-lg;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, ArcElement, Legend, Tooltip } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import type { OpsErrorDistributionResponse } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
data: OpsErrorDistributionResponse | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openDetails'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
red: '#ef4444',
|
||||
orange: '#f59e0b',
|
||||
gray: '#9ca3af',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const hasData = computed(() => (props.data?.total ?? 0) > 0)
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (hasData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
interface ErrorCategory {
|
||||
label: string
|
||||
count: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const categories = computed<ErrorCategory[]>(() => {
|
||||
if (!props.data) return []
|
||||
|
||||
let upstream = 0 // 502, 503, 504
|
||||
let client = 0 // 4xx
|
||||
let system = 0 // 500
|
||||
let other = 0
|
||||
|
||||
for (const item of props.data.items || []) {
|
||||
const code = Number(item.status_code || 0)
|
||||
const count = Number(item.total || 0)
|
||||
if (!Number.isFinite(code) || !Number.isFinite(count)) continue
|
||||
|
||||
if ([502, 503, 504].includes(code)) upstream += count
|
||||
else if (code >= 400 && code < 500) client += count
|
||||
else if (code === 500) system += count
|
||||
else other += count
|
||||
}
|
||||
|
||||
const out: ErrorCategory[] = []
|
||||
if (upstream > 0) out.push({ label: t('admin.ops.upstream'), count: upstream, color: colors.value.orange })
|
||||
if (client > 0) out.push({ label: t('admin.ops.client'), count: client, color: colors.value.blue })
|
||||
if (system > 0) out.push({ label: t('admin.ops.system'), count: system, color: colors.value.red })
|
||||
if (other > 0) out.push({ label: t('admin.ops.other'), count: other, color: colors.value.gray })
|
||||
return out
|
||||
})
|
||||
|
||||
const topReason = computed(() => {
|
||||
if (categories.value.length === 0) return null
|
||||
return categories.value.reduce((prev, cur) => (cur.count > prev.count ? cur : prev))
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!hasData.value || categories.value.length === 0) return null
|
||||
return {
|
||||
labels: categories.value.map((c) => c.label),
|
||||
datasets: [
|
||||
{
|
||||
data: categories.value.map((c) => c.count),
|
||||
backgroundColor: categories.value.map((c) => c.color),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563'
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.errorDistribution') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.errorDistribution')" />
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.errorTrend')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div v-if="state === 'ready' && chartData" class="flex h-full flex-col">
|
||||
<div class="flex-1">
|
||||
<Doughnut :data="chartData" :options="{ ...options, cutout: '65%' }" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col items-center gap-2">
|
||||
<div v-if="topReason" class="text-xs font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.top') }}: <span :style="{ color: topReason.color }">{{ topReason.label }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<div v-for="item in categories" :key="item.label" class="flex items-center gap-1.5 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ backgroundColor: item.color }"></span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
266
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
Normal file
266
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-dark-900">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<table class="w-full border-separate border-spacing-0">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.time') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.type') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.platform') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.model') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.group') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.user') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.status') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.message') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.action') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="9" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.ops.errorLog.noErrors') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="log in rows"
|
||||
:key="log.id"
|
||||
class="group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
|
||||
@click="emit('openErrorDetail', log.id)"
|
||||
>
|
||||
<!-- Time -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<el-tooltip :content="log.request_id || log.client_request_id" placement="top" :show-after="500">
|
||||
<span class="font-mono text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ formatDateTime(log.created_at).split(' ')[1] }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</td>
|
||||
|
||||
<!-- Type -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
|
||||
getTypeBadge(log).className
|
||||
]"
|
||||
>
|
||||
{{ getTypeBadge(log).label }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Platform -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ log.platform || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Model -->
|
||||
<td class="px-4 py-2">
|
||||
<div class="max-w-[120px] truncate" :title="log.model">
|
||||
<span v-if="log.model" class="font-mono text-[11px] text-gray-700 dark:text-gray-300">
|
||||
{{ log.model }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Group -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.group_id" :content="t('admin.ops.errorLog.id') + ' ' + log.group_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.group_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- User / Account -->
|
||||
<td class="px-4 py-2">
|
||||
<template v-if="isUpstreamRow(log)">
|
||||
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.account_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.user_email || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
|
||||
getStatusClass(log.status_code)
|
||||
]"
|
||||
>
|
||||
{{ log.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.severity"
|
||||
:class="['rounded px-1.5 py-0.5 text-[10px] font-bold', getSeverityClass(log.severity)]"
|
||||
>
|
||||
{{ log.severity }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Message (Response Content) -->
|
||||
<td class="px-4 py-2">
|
||||
<div class="max-w-[200px]">
|
||||
<p class="truncate text-[11px] font-medium text-gray-600 dark:text-gray-400" :title="log.message">
|
||||
{{ formatSmartMessage(log.message) || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="whitespace-nowrap px-4 py-2 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button type="button" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold" @click="emit('openErrorDetail', log.id)">
|
||||
{{ t('admin.ops.errorLog.details') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="bg-gray-50/50 dark:bg-dark-800/50">
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10]"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function isUpstreamRow(log: OpsErrorLog): boolean {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
const owner = String(log.error_owner || '').toLowerCase()
|
||||
return phase === 'upstream' && owner === 'provider'
|
||||
}
|
||||
|
||||
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
const owner = String(log.error_owner || '').toLowerCase()
|
||||
|
||||
if (isUpstreamRow(log)) {
|
||||
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30' }
|
||||
}
|
||||
if (phase === 'request' && owner === 'client') {
|
||||
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30' }
|
||||
}
|
||||
if (phase === 'auth' && owner === 'client') {
|
||||
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-500/30' }
|
||||
}
|
||||
if (phase === 'routing' && owner === 'platform') {
|
||||
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30' }
|
||||
}
|
||||
if (phase === 'internal' && owner === 'platform') {
|
||||
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
|
||||
}
|
||||
|
||||
const fallback = phase || owner || t('common.unknown')
|
||||
return { label: fallback, className: 'bg-gray-50 text-gray-700 ring-gray-600/10 dark:bg-dark-900 dark:text-gray-300 dark:ring-dark-700' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: OpsErrorLog[]
|
||||
total: number
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'openErrorDetail', id: number): void
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:pageSize', value: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function getStatusClass(code: number): string {
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
|
||||
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
}
|
||||
|
||||
function formatSmartMessage(msg: string): string {
|
||||
if (!msg) return ''
|
||||
|
||||
if (msg.startsWith('{') || msg.startsWith('[')) {
|
||||
try {
|
||||
const obj = JSON.parse(msg)
|
||||
if (obj?.error?.message) return String(obj.error.message)
|
||||
if (obj?.message) return String(obj.message)
|
||||
if (obj?.detail) return String(obj.detail)
|
||||
if (typeof obj === 'object') return JSON.stringify(obj).substring(0, 150)
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.includes('context deadline exceeded')) return t('admin.ops.errorLog.commonErrors.contextDeadlineExceeded')
|
||||
if (msg.includes('connection refused')) return t('admin.ops.errorLog.commonErrors.connectionRefused')
|
||||
if (msg.toLowerCase().includes('rate limit')) return t('admin.ops.errorLog.commonErrors.rateLimit')
|
||||
|
||||
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
|
||||
|
||||
}
|
||||
</script>
|
||||
200
frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue
Normal file
200
frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { OpsErrorTrendPoint } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
|
||||
|
||||
interface Props {
|
||||
points: OpsErrorTrendPoint[]
|
||||
loading: boolean
|
||||
timeRange: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openRequestErrors'): void
|
||||
(e: 'openUpstreamErrors'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
red: '#ef4444',
|
||||
redAlpha: '#ef444420',
|
||||
purple: '#8b5cf6',
|
||||
purpleAlpha: '#8b5cf620',
|
||||
gray: '#9ca3af',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const totalRequestErrors = computed(() =>
|
||||
sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.business_limited_count ?? 0)))
|
||||
)
|
||||
|
||||
const totalUpstreamErrors = computed(() =>
|
||||
sumNumbers(
|
||||
props.points.map((p) => (p.upstream_error_count_excl_429_529 ?? 0) + (p.upstream_429_count ?? 0) + (p.upstream_529_count ?? 0))
|
||||
)
|
||||
)
|
||||
|
||||
const totalDisplayed = computed(() =>
|
||||
sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.upstream_error_count_excl_429_529 ?? 0) + (p.business_limited_count ?? 0)))
|
||||
)
|
||||
|
||||
const hasRequestErrors = computed(() => totalRequestErrors.value > 0)
|
||||
const hasUpstreamErrors = computed(() => totalUpstreamErrors.value > 0)
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.points.length || totalDisplayed.value <= 0) return null
|
||||
return {
|
||||
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.ops.errorsSla'),
|
||||
data: props.points.map((p) => p.error_count_sla ?? 0),
|
||||
borderColor: colors.value.red,
|
||||
backgroundColor: colors.value.redAlpha,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.upstreamExcl429529'),
|
||||
data: props.points.map((p) => p.upstream_error_count_excl_429_529 ?? 0),
|
||||
borderColor: colors.value.purple,
|
||||
backgroundColor: colors.value.purpleAlpha,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.businessLimited'),
|
||||
data: props.points.map((p) => p.business_limited_count ?? 0),
|
||||
borderColor: colors.value.gray,
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [6, 6],
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (chartData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' as const },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
align: 'end' as const,
|
||||
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
|
||||
borderColor: c.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category' as const,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: c.text,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 8,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 }, precision: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.errorTrend') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.errorTrend')" />
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="!hasRequestErrors"
|
||||
@click="emit('openRequestErrors')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.requestErrors') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="!hasUpstreamErrors"
|
||||
@click="emit('openUpstreamErrors')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.upstreamErrors') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Line v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
101
frontend/src/views/admin/ops/components/OpsLatencyChart.vue
Normal file
101
frontend/src/views/admin/ops/components/OpsLatencyChart.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, BarElement, CategoryScale, Legend, LinearScale, Tooltip } from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import type { OpsLatencyHistogramResponse } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
latencyData: OpsLatencyHistogramResponse | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const hasData = computed(() => (props.latencyData?.total_requests ?? 0) > 0)
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (hasData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.latencyData || !hasData.value) return null
|
||||
const c = colors.value
|
||||
return {
|
||||
labels: props.latencyData.buckets.map((b) => b.range),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.ops.requests'),
|
||||
data: props.latencyData.buckets.map((b) => b.count),
|
||||
backgroundColor: c.blue,
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.6
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.latencyHistogram') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.latencyHistogram')" />
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Bar v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsRequestDetailsParams, type OpsRequestDetail } from '@/api/admin/ops'
|
||||
import { parseTimeRangeMinutes, formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
export interface OpsRequestDetailsPreset {
|
||||
title: string
|
||||
kind?: OpsRequestDetailsParams['kind']
|
||||
sort?: OpsRequestDetailsParams['sort']
|
||||
min_duration_ms?: number
|
||||
max_duration_ms?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
timeRange: string
|
||||
preset: OpsRequestDetailsPreset
|
||||
platform?: string
|
||||
groupId?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'openErrorDetail', errorId: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<OpsRequestDetail[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const rangeLabel = computed(() => {
|
||||
const minutes = parseTimeRangeMinutes(props.timeRange)
|
||||
if (minutes >= 60) return t('admin.ops.requestDetails.rangeHours', { n: Math.round(minutes / 60) })
|
||||
return t('admin.ops.requestDetails.rangeMinutes', { n: minutes })
|
||||
})
|
||||
|
||||
function buildTimeParams(): Pick<OpsRequestDetailsParams, 'start_time' | 'end_time'> {
|
||||
const minutes = parseTimeRangeMinutes(props.timeRange)
|
||||
const endTime = new Date()
|
||||
const startTime = new Date(endTime.getTime() - minutes * 60 * 1000)
|
||||
return {
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!props.modelValue) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params: OpsRequestDetailsParams = {
|
||||
...buildTimeParams(),
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
kind: props.preset.kind ?? 'all',
|
||||
sort: props.preset.sort ?? 'created_at_desc'
|
||||
}
|
||||
|
||||
const platform = (props.platform || '').trim()
|
||||
if (platform) params.platform = platform
|
||||
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
|
||||
|
||||
if (typeof props.preset.min_duration_ms === 'number') params.min_duration_ms = props.preset.min_duration_ms
|
||||
if (typeof props.preset.max_duration_ms === 'number') params.max_duration_ms = props.preset.max_duration_ms
|
||||
|
||||
const res = await opsAPI.listRequestDetails(params)
|
||||
items.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('[OpsRequestDetailsModal] Failed to fetch request details', e)
|
||||
appStore.showError(e?.message || t('admin.ops.requestDetails.failedToLoad'))
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
page.value = 1
|
||||
pageSize.value = 10
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.timeRange,
|
||||
props.platform,
|
||||
props.groupId,
|
||||
props.preset.kind,
|
||||
props.preset.sort,
|
||||
props.preset.min_duration_ms,
|
||||
props.preset.max_duration_ms
|
||||
],
|
||||
() => {
|
||||
if (!props.modelValue) return
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
)
|
||||
|
||||
function handlePageChange(next: number) {
|
||||
page.value = next
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(next: number) {
|
||||
pageSize.value = next
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleCopyRequestId(requestId: string) {
|
||||
const ok = await copyToClipboard(requestId, t('admin.ops.requestDetails.requestIdCopied'))
|
||||
if (ok) return
|
||||
// `useClipboard` already shows toast on failure; this keeps UX consistent with older ops modal.
|
||||
appStore.showWarning(t('admin.ops.requestDetails.copyFailed'))
|
||||
}
|
||||
|
||||
function openErrorDetail(errorId: number | null | undefined) {
|
||||
if (!errorId) return
|
||||
close()
|
||||
emit('openErrorDetail', errorId)
|
||||
}
|
||||
|
||||
const kindBadgeClass = (kind: string) => {
|
||||
if (kind === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
|
||||
<template #default>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="mb-4 flex flex-shrink-0 items-center justify-between">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="fetchData"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div v-if="items.length === 0" class="rounded-xl border border-dashed border-gray-200 p-10 text-center dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.requestDetails.empty') }}</div>
|
||||
<div class="mt-1 text-xs text-gray-400">{{ t('admin.ops.requestDetails.emptyHint') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.time') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.kind') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.platform') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.model') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.duration') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.requestId') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="(row, idx) in items" :key="idx" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="kindBadgeClass(row.kind)">
|
||||
{{ row.kind === 'error' ? t('admin.ops.requestDetails.kind.error') : t('admin.ops.requestDetails.kind.success') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ (row.platform || 'unknown').toUpperCase() }}
|
||||
</td>
|
||||
<td class="max-w-[240px] truncate px-4 py-3 text-xs text-gray-600 dark:text-gray-300" :title="row.model || ''">
|
||||
{{ row.model || '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ typeof row.duration_ms === 'number' ? `${row.duration_ms} ms` : '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ row.status_code ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div v-if="row.request_id" class="flex items-center gap-2">
|
||||
<span class="max-w-[220px] truncate font-mono text-[11px] text-gray-700 dark:text-gray-200" :title="row.request_id">
|
||||
{{ row.request_id }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
@click="handleCopyRequestId(row.request_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button
|
||||
v-if="row.kind === 'error' && row.error_id"
|
||||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
@click="openErrorDetail(row.error_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.viewError') }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { OpsAlertRuntimeSettings } from '../types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const alertSettings = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
|
||||
const showAlertEditor = ref(false)
|
||||
const draftAlert = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
|
||||
type ValidationResult = { valid: boolean; errors: string[] }
|
||||
|
||||
function normalizeSeverities(input: Array<string | null | undefined> | null | undefined): string[] {
|
||||
if (!input || input.length === 0) return []
|
||||
const allowed = new Set(['P0', 'P1', 'P2', 'P3'])
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (!s) continue
|
||||
if (!allowed.has(s)) continue
|
||||
if (seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
const evalSeconds = settings.evaluation_interval_seconds
|
||||
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
|
||||
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
|
||||
}
|
||||
|
||||
// Thresholds validation
|
||||
const thresholds = settings.thresholds
|
||||
if (thresholds) {
|
||||
if (thresholds.sla_percent_min != null) {
|
||||
if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) {
|
||||
errors.push(t('admin.ops.runtime.validation.slaMinPercentRange'))
|
||||
}
|
||||
}
|
||||
if (thresholds.ttft_p99_ms_max != null) {
|
||||
if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) {
|
||||
errors.push(t('admin.ops.runtime.validation.ttftP99MaxRange'))
|
||||
}
|
||||
}
|
||||
if (thresholds.request_error_rate_percent_max != null) {
|
||||
if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) {
|
||||
errors.push(t('admin.ops.runtime.validation.requestErrorRateMaxRange'))
|
||||
}
|
||||
}
|
||||
if (thresholds.upstream_error_rate_percent_max != null) {
|
||||
if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) {
|
||||
errors.push(t('admin.ops.runtime.validation.upstreamErrorRateMaxRange'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lock = settings.distributed_lock
|
||||
if (lock?.enabled) {
|
||||
if (!lock.key || lock.key.trim().length < 3) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockKeyRequired'))
|
||||
} else if (!lock.key.startsWith('ops:')) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockKeyPrefix', { prefix: 'ops:' }))
|
||||
}
|
||||
if (!Number.isFinite(lock.ttl_seconds) || lock.ttl_seconds < 1 || lock.ttl_seconds > 86400) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockTtlRange'))
|
||||
}
|
||||
}
|
||||
|
||||
// Silencing validation (alert-only)
|
||||
const silencing = settings.silencing
|
||||
if (silencing?.enabled) {
|
||||
const until = (silencing.global_until_rfc3339 || '').trim()
|
||||
if (until) {
|
||||
const parsed = Date.parse(until)
|
||||
if (!Number.isFinite(parsed)) errors.push(t('admin.ops.runtime.silencing.validation.timeFormat'))
|
||||
}
|
||||
|
||||
const entries = Array.isArray(silencing.entries) ? silencing.entries : []
|
||||
for (let idx = 0; idx < entries.length; idx++) {
|
||||
const entry = entries[idx]
|
||||
const untilEntry = (entry?.until_rfc3339 || '').trim()
|
||||
if (!untilEntry) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilRequired'))
|
||||
break
|
||||
}
|
||||
const parsedEntry = Date.parse(untilEntry)
|
||||
if (!Number.isFinite(parsedEntry)) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilFormat'))
|
||||
break
|
||||
}
|
||||
const ruleId = (entry as any)?.rule_id
|
||||
if (typeof ruleId === 'number' && (!Number.isFinite(ruleId) || ruleId <= 0)) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.ruleIdPositive'))
|
||||
break
|
||||
}
|
||||
if ((entry as any)?.severities) {
|
||||
const raw = (entry as any).severities
|
||||
const normalized = normalizeSeverities(Array.isArray(raw) ? raw : [raw])
|
||||
if (Array.isArray(raw) && raw.length > 0 && normalized.length === 0) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.severitiesFormat'))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const alertValidation = computed(() => {
|
||||
if (!draftAlert.value) return { valid: true, errors: [] as string[] }
|
||||
return validateRuntimeSettings(draftAlert.value)
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
alertSettings.value = await opsAPI.getAlertRuntimeSettings()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsRuntimeSettingsCard] Failed to load runtime settings', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAlertEditor() {
|
||||
if (!alertSettings.value) return
|
||||
draftAlert.value = JSON.parse(JSON.stringify(alertSettings.value))
|
||||
|
||||
// Backwards-compat: ensure nested settings exist even if API payload is older.
|
||||
if (draftAlert.value) {
|
||||
if (!draftAlert.value.distributed_lock) {
|
||||
draftAlert.value.distributed_lock = { enabled: true, key: 'ops:alert:evaluator:leader', ttl_seconds: 30 }
|
||||
}
|
||||
if (!draftAlert.value.silencing) {
|
||||
draftAlert.value.silencing = { enabled: false, global_until_rfc3339: '', global_reason: '', entries: [] }
|
||||
}
|
||||
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
||||
draftAlert.value.silencing.entries = []
|
||||
}
|
||||
if (!draftAlert.value.thresholds) {
|
||||
draftAlert.value.thresholds = {
|
||||
sla_percent_min: 99.5,
|
||||
ttft_p99_ms_max: 500,
|
||||
request_error_rate_percent_max: 5,
|
||||
upstream_error_rate_percent_max: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAlertEditor.value = true
|
||||
}
|
||||
|
||||
function addSilenceEntry() {
|
||||
if (!draftAlert.value) return
|
||||
if (!draftAlert.value.silencing) {
|
||||
draftAlert.value.silencing = { enabled: true, global_until_rfc3339: '', global_reason: '', entries: [] }
|
||||
}
|
||||
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
||||
draftAlert.value.silencing.entries = []
|
||||
}
|
||||
draftAlert.value.silencing.entries.push({
|
||||
rule_id: undefined,
|
||||
severities: [],
|
||||
until_rfc3339: '',
|
||||
reason: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeSilenceEntry(index: number) {
|
||||
if (!draftAlert.value?.silencing?.entries) return
|
||||
draftAlert.value.silencing.entries.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateSilenceEntryRuleId(index: number, raw: string) {
|
||||
const entries = draftAlert.value?.silencing?.entries
|
||||
if (!entries || !entries[index]) return
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
delete (entries[index] as any).rule_id
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(trimmed, 10)
|
||||
;(entries[index] as any).rule_id = Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function updateSilenceEntrySeverities(index: number, raw: string) {
|
||||
const entries = draftAlert.value?.silencing?.entries
|
||||
if (!entries || !entries[index]) return
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
;(entries[index] as any).severities = normalizeSeverities(parts)
|
||||
}
|
||||
|
||||
async function saveAlertSettings() {
|
||||
if (!draftAlert.value) return
|
||||
if (!alertValidation.value.valid) {
|
||||
appStore.showError(alertValidation.value.errors[0] || t('admin.ops.runtime.validation.invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
alertSettings.value = await opsAPI.updateAlertRuntimeSettings(draftAlert.value)
|
||||
showAlertEditor.value = false
|
||||
appStore.showSuccess(t('admin.ops.runtime.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsRuntimeSettingsCard] Failed to save alert runtime settings', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="loadSettings"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!alertSettings" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="loading">{{ t('admin.ops.runtime.loading') }}</span>
|
||||
<span v-else>{{ t('admin.ops.runtime.noData') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.alertTitle') }}</h4>
|
||||
<button class="btn btn-sm btn-secondary" @click="openAlertEditor">{{ t('common.edit') }}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.runtime.evalIntervalSeconds') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ alertSettings.evaluation_interval_seconds }}s</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="alertSettings.silencing?.enabled && alertSettings.silencing.global_until_rfc3339"
|
||||
class="text-xs text-gray-600 dark:text-gray-300 md:col-span-2"
|
||||
>
|
||||
{{ t('admin.ops.runtime.silencing.globalUntil') }}:
|
||||
<span class="ml-1 font-mono text-gray-900 dark:text-white">{{ alertSettings.silencing.global_until_rfc3339 }}</span>
|
||||
</div>
|
||||
|
||||
<details class="col-span-1 md:col-span-2">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{{ t('admin.ops.runtime.showAdvancedDeveloperSettings') }}
|
||||
</summary>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 rounded-lg bg-gray-100 p-3 dark:bg-dark-800 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockEnabled') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.enabled }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockKey') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.key }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockTTLSeconds') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.ttl_seconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="showAlertEditor" :title="t('admin.ops.runtime.alertTitle')" width="extra-wide" @close="showAlertEditor = false">
|
||||
<div v-if="draftAlert" class="space-y-4">
|
||||
<div
|
||||
v-if="!alertValidation.valid"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<div class="font-bold">{{ t('admin.ops.runtime.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||
<li v-for="msg in alertValidation.errors" :key="msg">{{ msg }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.evalIntervalSeconds') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.evaluation_interval_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="86400"
|
||||
class="input"
|
||||
:aria-invalid="!alertValidation.valid"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.metricThresholds') }}</div>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.metricThresholdsHint') }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.slaMinPercent') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.thresholds.sla_percent_min"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
placeholder="99.5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.slaMinPercentHint') }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.ttftP99MaxMs') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.thresholds.ttft_p99_ms_max"
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
class="input"
|
||||
placeholder="500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.ttftP99MaxMsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.requestErrorRateMaxPercent') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.thresholds.request_error_rate_percent_max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
placeholder="5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.requestErrorRateMaxPercentHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercent') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
placeholder="5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercentHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draftAlert.silencing.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ t('admin.ops.runtime.silencing.enabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="draftAlert.silencing.enabled" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.globalUntil') }}</div>
|
||||
<input
|
||||
v-model="draftAlert.silencing.global_until_rfc3339"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="2026-01-05T00:00:00Z"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.reason') }}</div>
|
||||
<input
|
||||
v-model="draftAlert.silencing.global_reason"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.entries.title') }}</div>
|
||||
<p class="text-[11px] text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.entries.hint') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="addSilenceEntry">
|
||||
{{ t('admin.ops.runtime.silencing.entries.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!draftAlert.silencing.entries?.length" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-900 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.silencing.entries.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="(entry, idx) in draftAlert.silencing.entries"
|
||||
:key="idx"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.runtime.silencing.entries.entryTitle', { n: idx + 1 }) }}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeSilenceEntry(idx)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.ruleId') }}</div>
|
||||
<input
|
||||
:value="typeof (entry as any).rule_id === 'number' ? String((entry as any).rule_id) : ''"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.entries.ruleIdPlaceholder')"
|
||||
@input="updateSilenceEntryRuleId(idx, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.severities') }}</div>
|
||||
<input
|
||||
:value="Array.isArray((entry as any).severities) ? (entry as any).severities.join(', ') : ''"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.entries.severitiesPlaceholder')"
|
||||
@input="updateSilenceEntrySeverities(idx, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.until') }}</div>
|
||||
<input
|
||||
v-model="(entry as any).until_rfc3339"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="2026-01-05T00:00:00Z"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.reason') }}</div>
|
||||
<input
|
||||
v-model="(entry as any).reason"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<summary class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.ops.runtime.advancedSettingsSummary') }}</summary>
|
||||
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draftAlert.distributed_lock.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ t('admin.ops.runtime.lockEnabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockKey') }}</div>
|
||||
<input v-model="draftAlert.distributed_lock.key" type="text" class="input text-xs font-mono" />
|
||||
<p v-if="draftAlert.distributed_lock.enabled" class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.validation.lockKeyHint', { prefix: 'ops:' }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockTTLSeconds') }}</div>
|
||||
<input v-model.number="draftAlert.distributed_lock.ttl_seconds" type="number" min="1" max="86400" class="input text-xs font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary" @click="showAlertEditor = false">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !alertValidation.valid" @click="saveAlertSettings">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
549
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
Normal file
549
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 运行时设置
|
||||
const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
// 邮件通知配置
|
||||
const emailConfig = ref<EmailNotificationConfig | null>(null)
|
||||
// 高级设置
|
||||
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
|
||||
// 指标阈值配置
|
||||
const metricThresholds = ref<OpsMetricThresholds>({
|
||||
sla_percent_min: 99.5,
|
||||
ttft_p99_ms_max: 500,
|
||||
request_error_rate_percent_max: 5,
|
||||
upstream_error_rate_percent_max: 5
|
||||
})
|
||||
|
||||
// 加载所有配置
|
||||
async function loadAllSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [runtime, email, advanced, thresholds] = await Promise.all([
|
||||
opsAPI.getAlertRuntimeSettings(),
|
||||
opsAPI.getEmailNotificationConfig(),
|
||||
opsAPI.getAdvancedSettings(),
|
||||
opsAPI.getMetricThresholds()
|
||||
])
|
||||
runtimeSettings.value = runtime
|
||||
emailConfig.value = email
|
||||
advancedSettings.value = advanced
|
||||
// 如果后端返回了阈值,使用后端的值;否则保持默认值
|
||||
if (thresholds && Object.keys(thresholds).length > 0) {
|
||||
metricThresholds.value = {
|
||||
sla_percent_min: thresholds.sla_percent_min ?? 99.5,
|
||||
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
|
||||
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
|
||||
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSettingsDialog] Failed to load settings', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听弹窗打开
|
||||
watch(() => props.show, (show) => {
|
||||
if (show) {
|
||||
loadAllSettings()
|
||||
}
|
||||
})
|
||||
|
||||
// 邮件输入
|
||||
const alertRecipientInput = ref('')
|
||||
const reportRecipientInput = ref('')
|
||||
|
||||
// 严重级别选项
|
||||
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
|
||||
{ value: '', label: t('admin.ops.email.minSeverityAll') },
|
||||
{ value: 'critical', label: t('common.critical') },
|
||||
{ value: 'warning', label: t('common.warning') },
|
||||
{ value: 'info', label: t('common.info') }
|
||||
]
|
||||
|
||||
// 验证邮箱
|
||||
function isValidEmailAddress(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
// 添加收件人
|
||||
function addRecipient(target: 'alert' | 'report') {
|
||||
if (!emailConfig.value) return
|
||||
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
|
||||
if (!raw) return
|
||||
|
||||
if (!isValidEmailAddress(raw)) {
|
||||
appStore.showError(t('common.invalidEmail'))
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = raw.toLowerCase()
|
||||
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
|
||||
if (!list.includes(normalized)) {
|
||||
list.push(normalized)
|
||||
}
|
||||
if (target === 'alert') alertRecipientInput.value = ''
|
||||
else reportRecipientInput.value = ''
|
||||
}
|
||||
|
||||
// 移除收件人
|
||||
function removeRecipient(target: 'alert' | 'report', email: string) {
|
||||
if (!emailConfig.value) return
|
||||
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
|
||||
const idx = list.indexOf(email)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 验证
|
||||
const validation = computed(() => {
|
||||
const errors: string[] = []
|
||||
|
||||
// 验证运行时设置
|
||||
if (runtimeSettings.value) {
|
||||
const evalSeconds = runtimeSettings.value.evaluation_interval_seconds
|
||||
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
|
||||
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证邮件配置
|
||||
if (emailConfig.value) {
|
||||
if (emailConfig.value.alert.enabled && emailConfig.value.alert.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
|
||||
}
|
||||
if (emailConfig.value.report.enabled && emailConfig.value.report.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证高级设置
|
||||
if (advancedSettings.value) {
|
||||
const { error_log_retention_days, minute_metrics_retention_days, hourly_metrics_retention_days } = advancedSettings.value.data_retention
|
||||
if (error_log_retention_days < 1 || error_log_retention_days > 365) {
|
||||
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
|
||||
}
|
||||
if (minute_metrics_retention_days < 1 || minute_metrics_retention_days > 365) {
|
||||
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
|
||||
}
|
||||
if (hourly_metrics_retention_days < 1 || hourly_metrics_retention_days > 365) {
|
||||
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证指标阈值
|
||||
if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) {
|
||||
errors.push(t('admin.ops.settings.validation.slaMinPercentRange'))
|
||||
}
|
||||
if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) {
|
||||
errors.push(t('admin.ops.settings.validation.ttftP99MaxRange'))
|
||||
}
|
||||
if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) {
|
||||
errors.push(t('admin.ops.settings.validation.requestErrorRateMaxRange'))
|
||||
}
|
||||
if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) {
|
||||
errors.push(t('admin.ops.settings.validation.upstreamErrorRateMaxRange'))
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
// 保存所有配置
|
||||
async function saveAllSettings() {
|
||||
if (!validation.value.valid) {
|
||||
appStore.showError(validation.value.errors[0])
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
|
||||
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
|
||||
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve(),
|
||||
opsAPI.updateMetricThresholds(metricThresholds.value)
|
||||
])
|
||||
appStore.showSuccess(t('admin.ops.settings.saveSuccess'))
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSettingsDialog] Failed to save settings', err)
|
||||
appStore.showError(err?.response?.data?.message || err?.response?.data?.detail || t('admin.ops.settings.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.ops.settings.title')" width="extra-wide" @close="emit('close')">
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-gray-500">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="runtimeSettings && emailConfig && advancedSettings" class="space-y-6">
|
||||
<!-- 验证错误 -->
|
||||
<div v-if="!validation.valid" class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<div class="font-bold">{{ t('admin.ops.settings.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||
<li v-for="msg in validation.errors" :key="msg">{{ msg }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 数据采集频率 -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.dataCollection') }}</h4>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.evaluationInterval') }}</label>
|
||||
<input
|
||||
v-model.number="runtimeSettings.evaluation_interval_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="86400"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.evaluationIntervalHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警配置 -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.alertConfig') }}</h4>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableAlert') }}</label>
|
||||
</div>
|
||||
<Toggle v-model="emailConfig.alert.enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.alert.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.settings.alertRecipients') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="alertRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.settings.emailPlaceholder')"
|
||||
@keydown.enter.prevent="addRecipient('alert')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in emailConfig.alert.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('alert', email)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.settings.recipientsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.alert.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.settings.minSeverity') }}</label>
|
||||
<Select v-model="emailConfig.alert.min_severity" :options="severityOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评估报告配置 -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.reportConfig') }}</h4>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableReport') }}</label>
|
||||
</div>
|
||||
<Toggle v-model="emailConfig.report.enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.report.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.settings.reportRecipients') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="reportRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.settings.emailPlaceholder')"
|
||||
@keydown.enter.prevent="addRecipient('report')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in emailConfig.report.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('report', email)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.settings.recipientsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.report.enabled" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dailySummary') }}</label>
|
||||
<Toggle v-model="emailConfig.report.daily_summary_enabled" />
|
||||
</div>
|
||||
<div v-if="emailConfig.report.daily_summary_enabled">
|
||||
<input v-model="emailConfig.report.daily_summary_schedule" type="text" class="input" placeholder="0 9 * * *" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.weeklySummary') }}</label>
|
||||
<Toggle v-model="emailConfig.report.weekly_summary_enabled" />
|
||||
</div>
|
||||
<div v-if="emailConfig.report.weekly_summary_enabled">
|
||||
<input v-model="emailConfig.report.weekly_summary_schedule" type="text" class="input" placeholder="0 9 * * 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指标阈值配置 -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.metricThresholds') }}</h4>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.settings.metricThresholdsHint') }}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.slaMinPercent') }}</label>
|
||||
<input
|
||||
v-model.number="metricThresholds.sla_percent_min"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label>
|
||||
<input
|
||||
v-model.number="metricThresholds.ttft_p99_ms_max"
|
||||
type="number"
|
||||
min="0"
|
||||
step="50"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.ttftP99MaxMsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.requestErrorRateMaxPercent') }}</label>
|
||||
<input
|
||||
v-model.number="metricThresholds.request_error_rate_percent_max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.requestErrorRateMaxPercentHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.upstreamErrorRateMaxPercent') }}</label>
|
||||
<input
|
||||
v-model.number="metricThresholds.upstream_error_rate_percent_max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.upstreamErrorRateMaxPercentHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级设置 -->
|
||||
<details class="rounded-2xl bg-gray-50 dark:bg-dark-700/50">
|
||||
<summary class="cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.settings.advancedSettings') }}
|
||||
</summary>
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<!-- 数据保留策略 -->
|
||||
<div class="space-y-3">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dataRetention') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableCleanup') }}</label>
|
||||
<Toggle v-model="advancedSettings.data_retention.cleanup_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="advancedSettings.data_retention.cleanup_enabled">
|
||||
<label class="input-label">{{ t('admin.ops.settings.cleanupSchedule') }}</label>
|
||||
<input
|
||||
v-model="advancedSettings.data_retention.cleanup_schedule"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="0 2 * * *"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.cleanupScheduleHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.errorLogRetentionDays') }}</label>
|
||||
<input
|
||||
v-model.number="advancedSettings.data_retention.error_log_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.minuteMetricsRetentionDays') }}</label>
|
||||
<input
|
||||
v-model.number="advancedSettings.data_retention.minute_metrics_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.settings.hourlyMetricsRetentionDays') }}</label>
|
||||
<input
|
||||
v-model.number="advancedSettings.data_retention.hourly_metrics_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">{{ t('admin.ops.settings.retentionDaysHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 预聚合任务 -->
|
||||
<div class="space-y-3">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.aggregation') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableAggregation') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.aggregationHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Filtering -->
|
||||
<div class="space-y-3">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.errorFiltering') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreCountTokensErrors') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.ignoreCountTokensErrorsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_count_tokens_errors" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreContextCanceled') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.ignoreContextCanceledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_context_canceled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreNoAvailableAccounts') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.ignoreNoAvailableAccountsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Refresh -->
|
||||
<div class="space-y-3">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.autoRefresh') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableAutoRefresh') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.enableAutoRefreshHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.auto_refresh_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="advancedSettings.auto_refresh_enabled">
|
||||
<label class="input-label">{{ t('admin.ops.settings.refreshInterval') }}</label>
|
||||
<Select
|
||||
v-model="advancedSettings.auto_refresh_interval_seconds"
|
||||
:options="[
|
||||
{ value: 15, label: t('admin.ops.settings.refreshInterval15s') },
|
||||
{ value: 30, label: t('admin.ops.settings.refreshInterval30s') },
|
||||
{ value: 60, label: t('admin.ops.settings.refreshInterval60s') }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !validation.valid" @click="saveAllSettings">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, CategoryScale, Filler, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { ChartComponentRef } from 'vue-chartjs'
|
||||
import type { OpsThroughputGroupBreakdownItem, OpsThroughputPlatformBreakdownItem, OpsThroughputTrendPoint } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
|
||||
|
||||
interface Props {
|
||||
points: OpsThroughputTrendPoint[]
|
||||
loading: boolean
|
||||
timeRange: string
|
||||
byPlatform?: OpsThroughputPlatformBreakdownItem[]
|
||||
topGroups?: OpsThroughputGroupBreakdownItem[]
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectPlatform', platform: string): void
|
||||
(e: 'selectGroup', groupId: number): void
|
||||
(e: 'openDetails'): void
|
||||
}>()
|
||||
|
||||
const throughputChartRef = ref<ChartComponentRef | null>(null)
|
||||
watch(
|
||||
() => props.timeRange,
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (chart && typeof chart.resetZoom === 'function') {
|
||||
chart.resetZoom()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
)
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
blueAlpha: '#3b82f620',
|
||||
green: '#10b981',
|
||||
greenAlpha: '#10b98120',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const totalRequests = computed(() => sumNumbers(props.points.map((p) => p.request_count)))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.points.length || totalRequests.value <= 0) return null
|
||||
return {
|
||||
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'QPS',
|
||||
data: props.points.map((p) => p.qps ?? 0),
|
||||
borderColor: colors.value.blue,
|
||||
backgroundColor: colors.value.blueAlpha,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.tpsK'),
|
||||
data: props.points.map((p) => (p.tps ?? 0) / 1000),
|
||||
borderColor: colors.value.green,
|
||||
backgroundColor: colors.value.greenAlpha,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (chartData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' as const },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
align: 'end' as const,
|
||||
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
|
||||
borderColor: c.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) label += ': '
|
||||
if (context.raw !== null) label += context.parsed.y.toFixed(1)
|
||||
return label
|
||||
}
|
||||
}
|
||||
},
|
||||
// Optional: if chartjs-plugin-zoom is installed, these options will enable zoom/pan.
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: 'x' as const, modifierKey: 'ctrl' as const },
|
||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' as const }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category' as const,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: c.text,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 8,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: { display: false },
|
||||
ticks: { color: c.green, font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function resetZoom() {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (chart && typeof chart.resetZoom === 'function') chart.resetZoom()
|
||||
}
|
||||
|
||||
function downloadChart() {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (!chart || typeof chart.toBase64Image !== 'function') return
|
||||
const url = chart.toBase64Image('image/png', 1)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `ops-throughput-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`
|
||||
a.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
{{ t('admin.ops.throughputTrend') }}
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>QPS</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
|
||||
<template v-if="!props.fullscreen">
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.requestDetails.title')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.resetZoomHint')"
|
||||
@click="resetZoom"
|
||||
>
|
||||
{{ t('admin.ops.charts.resetZoom') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.downloadChartHint')"
|
||||
@click="downloadChart"
|
||||
>
|
||||
{{ t('admin.ops.charts.downloadChart') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drilldown chips (baseline interaction: click to set global filter) -->
|
||||
<div v-if="(props.topGroups?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="g in props.topGroups"
|
||||
:key="g.group_id"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
|
||||
@click="emit('selectGroup', g.group_id)"
|
||||
>
|
||||
<span class="max-w-[180px] truncate">{{ g.group_name || `#${g.group_id}` }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(g.request_count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="(props.byPlatform?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="p in props.byPlatform"
|
||||
:key="p.platform"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
|
||||
@click="emit('selectPlatform', p.platform)"
|
||||
>
|
||||
<span class="uppercase">{{ p.platform }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(p.request_count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Line v-if="state === 'ready' && chartData" ref="throughputChartRef" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
21
frontend/src/views/admin/ops/types.ts
Normal file
21
frontend/src/views/admin/ops/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Ops 前端视图层的共享类型(与后端 DTO 解耦)。
|
||||
|
||||
export type ChartState = 'loading' | 'empty' | 'ready'
|
||||
|
||||
// Re-export ops alert/settings types so view components can import from a single place
|
||||
// while keeping the API contract centralized in `@/api/admin/ops`.
|
||||
export type {
|
||||
AlertRule,
|
||||
AlertEvent,
|
||||
AlertSeverity,
|
||||
ThresholdMode,
|
||||
MetricType,
|
||||
Operator,
|
||||
EmailNotificationConfig,
|
||||
OpsDistributedLockSettings,
|
||||
OpsAlertRuntimeSettings,
|
||||
OpsMetricThresholds,
|
||||
OpsAdvancedSettings,
|
||||
OpsDataRetentionSettings,
|
||||
OpsAggregationSettings
|
||||
} from '@/api/admin/ops'
|
||||
75
frontend/src/views/admin/ops/utils/opsFormatters.ts
Normal file
75
frontend/src/views/admin/ops/utils/opsFormatters.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Ops 页面共享的格式化/样式工具。
|
||||
*
|
||||
* 目标:尽量对齐 `docs/sub2api` 备份版本的视觉表现(需求一致部分保持一致),
|
||||
* 同时避免引入额外 UI 依赖。
|
||||
*/
|
||||
|
||||
import type { OpsSeverity } from '@/api/admin/ops'
|
||||
import { formatBytes } from '@/utils/format'
|
||||
|
||||
export function getSeverityClass(severity: OpsSeverity): string {
|
||||
const classes: Record<string, string> = {
|
||||
P0: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}
|
||||
return classes[String(severity || '')] || classes.P3
|
||||
}
|
||||
|
||||
export function truncateMessage(msg: string, maxLength = 80): string {
|
||||
if (!msg) return ''
|
||||
return msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(短格式,和旧 Ops 页面一致)。
|
||||
* 输出: `MM-DD HH:mm:ss`
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function sumNumbers(values: Array<number | null | undefined>): number {
|
||||
return values.reduce<number>((acc, v) => {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||
return acc + n
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 time_range 为分钟数。
|
||||
* 支持:`5m/30m/1h/6h/24h`
|
||||
*/
|
||||
export function parseTimeRangeMinutes(range: string): number {
|
||||
const trimmed = (range || '').trim()
|
||||
if (!trimmed) return 60
|
||||
if (trimmed.endsWith('m')) {
|
||||
const v = Number.parseInt(trimmed.slice(0, -1), 10)
|
||||
return Number.isFinite(v) && v > 0 ? v : 60
|
||||
}
|
||||
if (trimmed.endsWith('h')) {
|
||||
const v = Number.parseInt(trimmed.slice(0, -1), 10)
|
||||
return Number.isFinite(v) && v > 0 ? v * 60 : 60
|
||||
}
|
||||
return 60
|
||||
}
|
||||
|
||||
export function formatHistoryLabel(date: string | undefined, timeRange: string): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const minutes = parseTimeRangeMinutes(timeRange)
|
||||
if (minutes >= 24 * 60) {
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatByteRate(bytes: number, windowMinutes: number): string {
|
||||
const seconds = Math.max(1, (windowMinutes || 1) * 60)
|
||||
return `${formatBytes(bytes / seconds, 1)}/s`
|
||||
}
|
||||
@@ -200,6 +200,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const initialTurnstileToken = ref<string>('')
|
||||
const promoCode = ref<string>('')
|
||||
const hasRegisterData = ref<boolean>(false)
|
||||
|
||||
// Public settings
|
||||
@@ -228,6 +229,7 @@ onMounted(async () => {
|
||||
email.value = registerData.email || ''
|
||||
password.value = registerData.password || ''
|
||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||
promoCode.value = registerData.promo_code || ''
|
||||
hasRegisterData.value = !!(email.value && password.value)
|
||||
} catch {
|
||||
hasRegisterData.value = false
|
||||
@@ -381,7 +383,8 @@ async function handleVerify(): Promise<void> {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: initialTurnstileToken.value || undefined
|
||||
turnstile_token: initialTurnstileToken.value || undefined,
|
||||
promo_code: promoCode.value || undefined
|
||||
})
|
||||
|
||||
// Clear session data
|
||||
|
||||
@@ -95,6 +95,57 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Promo Code Input (Optional) -->
|
||||
<div>
|
||||
<label for="promo_code" class="input-label">
|
||||
{{ t('auth.promoCodeLabel') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="gift" size="md" :class="promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
|
||||
</div>
|
||||
<input
|
||||
id="promo_code"
|
||||
v-model="formData.promo_code"
|
||||
type="text"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-10"
|
||||
:class="{
|
||||
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
|
||||
}"
|
||||
:placeholder="t('auth.promoCodePlaceholder')"
|
||||
@input="handlePromoCodeInput"
|
||||
/>
|
||||
<!-- Validation indicator -->
|
||||
<div v-if="promoValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="promoValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<Icon name="checkCircle" size="md" class="text-green-500" />
|
||||
</div>
|
||||
<div v-else-if="promoValidation.invalid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Promo code validation result -->
|
||||
<transition name="fade">
|
||||
<div v-if="promoValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
|
||||
<Icon name="gift" size="sm" class="text-green-600 dark:text-green-400" />
|
||||
<span class="text-sm text-green-700 dark:text-green-400">
|
||||
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else-if="promoValidation.invalid" class="input-error-text">
|
||||
{{ promoValidation.message }}
|
||||
</p>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<TurnstileWidget
|
||||
@@ -180,21 +231,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { getPublicSettings, validatePromoCode } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -217,9 +269,20 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
|
||||
// Promo code validation
|
||||
const promoValidating = ref<boolean>(false)
|
||||
const promoValidation = reactive({
|
||||
valid: false,
|
||||
invalid: false,
|
||||
bonusAmount: null as number | null,
|
||||
message: ''
|
||||
})
|
||||
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
password: '',
|
||||
promo_code: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
@@ -231,6 +294,14 @@ const errors = reactive({
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
// Read promo code from URL parameter
|
||||
const promoParam = route.query.promo as string
|
||||
if (promoParam) {
|
||||
formData.promo_code = promoParam
|
||||
// Validate the promo code from URL
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
registrationEnabled.value = settings.registration_enabled
|
||||
@@ -246,6 +317,85 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (promoValidateTimeout) {
|
||||
clearTimeout(promoValidateTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Promo Code Validation ====================
|
||||
|
||||
function handlePromoCodeInput(): void {
|
||||
const code = formData.promo_code.trim()
|
||||
|
||||
// Clear previous validation
|
||||
promoValidation.valid = false
|
||||
promoValidation.invalid = false
|
||||
promoValidation.bonusAmount = null
|
||||
promoValidation.message = ''
|
||||
|
||||
if (!code) {
|
||||
promoValidating.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce validation
|
||||
if (promoValidateTimeout) {
|
||||
clearTimeout(promoValidateTimeout)
|
||||
}
|
||||
|
||||
promoValidateTimeout = setTimeout(() => {
|
||||
validatePromoCodeDebounced(code)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function validatePromoCodeDebounced(code: string): Promise<void> {
|
||||
if (!code.trim()) return
|
||||
|
||||
promoValidating.value = true
|
||||
|
||||
try {
|
||||
const result = await validatePromoCode(code)
|
||||
|
||||
if (result.valid) {
|
||||
promoValidation.valid = true
|
||||
promoValidation.invalid = false
|
||||
promoValidation.bonusAmount = result.bonus_amount || 0
|
||||
promoValidation.message = ''
|
||||
} else {
|
||||
promoValidation.valid = false
|
||||
promoValidation.invalid = true
|
||||
promoValidation.bonusAmount = null
|
||||
// 根据错误码显示对应的翻译
|
||||
promoValidation.message = getPromoErrorMessage(result.error_code)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to validate promo code:', error)
|
||||
promoValidation.valid = false
|
||||
promoValidation.invalid = true
|
||||
promoValidation.message = t('auth.promoCodeInvalid')
|
||||
} finally {
|
||||
promoValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPromoErrorMessage(errorCode?: string): string {
|
||||
switch (errorCode) {
|
||||
case 'PROMO_CODE_NOT_FOUND':
|
||||
return t('auth.promoCodeNotFound')
|
||||
case 'PROMO_CODE_EXPIRED':
|
||||
return t('auth.promoCodeExpired')
|
||||
case 'PROMO_CODE_DISABLED':
|
||||
return t('auth.promoCodeDisabled')
|
||||
case 'PROMO_CODE_MAX_USED':
|
||||
return t('auth.promoCodeMaxUsed')
|
||||
case 'PROMO_CODE_ALREADY_USED':
|
||||
return t('auth.promoCodeAlreadyUsed')
|
||||
default:
|
||||
return t('auth.promoCodeInvalid')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
@@ -316,6 +466,20 @@ async function handleRegister(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Check promo code validation status
|
||||
if (formData.promo_code.trim()) {
|
||||
// If promo code is being validated, wait
|
||||
if (promoValidating.value) {
|
||||
errorMessage.value = t('auth.promoCodeValidating')
|
||||
return
|
||||
}
|
||||
// If promo code is invalid, block submission
|
||||
if (promoValidation.invalid) {
|
||||
errorMessage.value = t('auth.promoCodeInvalidCannotRegister')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -327,7 +491,8 @@ async function handleRegister(): Promise<void> {
|
||||
JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileToken.value
|
||||
turnstile_token: turnstileToken.value,
|
||||
promo_code: formData.promo_code || undefined
|
||||
})
|
||||
)
|
||||
|
||||
@@ -340,7 +505,8 @@ async function handleRegister(): Promise<void> {
|
||||
await authStore.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
promo_code: formData.promo_code || undefined
|
||||
})
|
||||
|
||||
// Show success toast
|
||||
|
||||
@@ -46,8 +46,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-name="{ value, row }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<Icon
|
||||
v-if="row.ip_whitelist?.length > 0 || row.ip_blacklist?.length > 0"
|
||||
name="shield"
|
||||
size="sm"
|
||||
class="text-blue-500"
|
||||
:title="t('keys.ipRestrictionEnabled')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
@@ -278,6 +287,52 @@
|
||||
:placeholder="t('keys.selectStatus')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- IP Restriction Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('keys.ipRestriction') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="formData.enable_ip_restriction = !formData.enable_ip_restriction"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
formData.enable_ip_restriction ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
formData.enable_ip_restriction ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_ip_restriction" class="space-y-4 pt-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.ipWhitelist') }}</label>
|
||||
<textarea
|
||||
v-model="formData.ip_whitelist"
|
||||
rows="3"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('keys.ipWhitelistPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('keys.ipWhitelistHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.ipBlacklist') }}</label>
|
||||
<textarea
|
||||
v-model="formData.ip_blacklist"
|
||||
rows="3"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('keys.ipBlacklistPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('keys.ipBlacklistHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -528,7 +583,10 @@ const formData = ref({
|
||||
group_id: null as number | null,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
})
|
||||
|
||||
// 自定义Key验证
|
||||
@@ -664,12 +722,16 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
||||
formData.value = {
|
||||
name: key.name,
|
||||
group_id: key.group_id,
|
||||
status: key.status,
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
custom_key: '',
|
||||
enable_ip_restriction: hasIPRestriction,
|
||||
ip_whitelist: (key.ip_whitelist || []).join('\n'),
|
||||
ip_blacklist: (key.ip_blacklist || []).join('\n')
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -751,14 +813,26 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse IP lists only if IP restriction is enabled
|
||||
const parseIPList = (text: string): string[] =>
|
||||
text.split('\n').map(ip => ip.trim()).filter(ip => ip.length > 0)
|
||||
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
|
||||
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (showEditModal.value && selectedKey.value) {
|
||||
await keysAPI.update(selectedKey.value.id, formData.value)
|
||||
await keysAPI.update(selectedKey.value.id, {
|
||||
name: formData.value.name,
|
||||
group_id: formData.value.group_id,
|
||||
status: formData.value.status,
|
||||
ip_whitelist: ipWhitelist,
|
||||
ip_blacklist: ipBlacklist
|
||||
})
|
||||
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
|
||||
} else {
|
||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey, ipWhitelist, ipBlacklist)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||
@@ -805,7 +879,10 @@ const closeModals = () => {
|
||||
group_id: null,
|
||||
status: 'active',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,19 +273,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="
|
||||
row.billing_type === 1
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
|
||||
"
|
||||
>
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span
|
||||
v-if="row.first_token_ms != null"
|
||||
@@ -482,7 +469,6 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
@@ -745,7 +731,6 @@ const exportToCSV = async () => {
|
||||
'Rate Multiplier',
|
||||
'Billed Cost',
|
||||
'Original Cost',
|
||||
'Billing Type',
|
||||
'First Token (ms)',
|
||||
'Duration (ms)'
|
||||
]
|
||||
@@ -762,7 +747,6 @@ const exportToCSV = async () => {
|
||||
log.rate_multiplier,
|
||||
log.actual_cost.toFixed(8),
|
||||
log.total_cost.toFixed(8),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms
|
||||
].map(escapeCSVValue)
|
||||
|
||||
Reference in New Issue
Block a user