mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat: add affinity clients hover popover with last active times
- New API: GET /accounts/:id/affinity-clients returns client list with last_active timestamps from Redis ZSET scores - New Lua script: get_affinity_clients_with_scores.lua uses ZREVRANGEBYSCORE WITHSCORES to return both client IDs and their timestamps - New frontend component: AffinityBadge.vue replaces inline badge with hover popover that lazy-loads client details on first hover - Shows client ID (monospace) + relative time (e.g. "3h ago") Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1417,6 +1417,42 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// GetAffinityClients returns the list of affinity clients for an account with last active timestamps.
|
||||
// GET /api/v1/admin/accounts/:id/affinity-clients
|
||||
func (h *AccountHandler) GetAffinityClients(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !account.IsClientAffinityEnabled() {
|
||||
response.Success(c, []service.AffinityClient{})
|
||||
return
|
||||
}
|
||||
|
||||
if h.gatewayCache == nil || len(account.GroupIDs) == 0 {
|
||||
response.Success(c, []service.AffinityClient{})
|
||||
return
|
||||
}
|
||||
|
||||
clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores(
|
||||
c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL,
|
||||
)
|
||||
if err != nil {
|
||||
response.Success(c, []service.AffinityClient{})
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, clients)
|
||||
}
|
||||
|
||||
// GetTempUnschedulable handles getting temporary unschedulable status
|
||||
// GET /api/v1/admin/accounts/:id/temp-unschedulable
|
||||
func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) {
|
||||
|
||||
@@ -26,11 +26,14 @@ var (
|
||||
getAffinityCountLua string
|
||||
//go:embed lua/get_affinity_clients.lua
|
||||
getAffinityClientsLua string
|
||||
//go:embed lua/get_affinity_clients_with_scores.lua
|
||||
getAffinityClientsWithScoresLua string
|
||||
|
||||
getAffinityScript = redis.NewScript(getAffinityLua)
|
||||
updateAffinityScript = redis.NewScript(updateAffinityLua)
|
||||
getAffinityCountScript = redis.NewScript(getAffinityCountLua)
|
||||
getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
|
||||
getAffinityScript = redis.NewScript(getAffinityLua)
|
||||
updateAffinityScript = redis.NewScript(updateAffinityLua)
|
||||
getAffinityCountScript = redis.NewScript(getAffinityCountLua)
|
||||
getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
|
||||
getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua)
|
||||
)
|
||||
|
||||
type gatewayCache struct {
|
||||
@@ -211,3 +214,58 @@ func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accou
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间戳,去重取最近)。
|
||||
func (c *gatewayCache) GetAccountAffinityClientsWithScores(
|
||||
ctx context.Context,
|
||||
accountID int64,
|
||||
groupIDs []int64,
|
||||
ttl time.Duration,
|
||||
) ([]service.AffinityClient, error) {
|
||||
if len(groupIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
expireThreshold := now - int64(ttl.Seconds())
|
||||
|
||||
ensureScriptLoaded(ctx, c.rdb, getAffinityClientsWithScoresScript)
|
||||
|
||||
pipe := c.rdb.Pipeline()
|
||||
cmds := make([]*redis.Cmd, len(groupIDs))
|
||||
for i, gID := range groupIDs {
|
||||
key := buildAffinityReverseKey(gID, accountID)
|
||||
cmds[i] = getAffinityClientsWithScoresScript.Run(ctx, pipe, []string{key}, expireThreshold)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 合并跨组结果,同一 clientID 取最近的 lastActive
|
||||
seen := make(map[string]int64) // clientID → max timestamp
|
||||
for _, cmd := range cmds {
|
||||
vals, _ := cmd.StringSlice()
|
||||
// vals 格式: [clientID1, score1, clientID2, score2, ...]
|
||||
for j := 0; j+1 < len(vals); j += 2 {
|
||||
clientID := vals[j]
|
||||
ts, _ := strconv.ParseInt(vals[j+1], 10, 64)
|
||||
if existing, ok := seen[clientID]; !ok || ts > existing {
|
||||
seen[clientID] = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]service.AffinityClient, 0, len(seen))
|
||||
for clientID, ts := range seen {
|
||||
result = append(result, service.AffinityClient{
|
||||
ClientID: clientID,
|
||||
LastActive: time.Unix(ts, 0),
|
||||
})
|
||||
}
|
||||
|
||||
// 按最后活跃时间降序排序
|
||||
service.SortAffinityClients(result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 清理过期成员后返回反向索引的 clientID 列表及其 score(最后活跃时间戳)
|
||||
-- KEYS[1] = client_affinity_rev:{groupID}:{accountID}
|
||||
-- ARGV[1] = 过期阈值时间戳 (now - ttl)
|
||||
-- 返回: {clientID1, score1, clientID2, score2, ...}(按最近使用降序)
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
|
||||
return redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', '-inf', 'WITHSCORES')
|
||||
@@ -253,6 +253,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats)
|
||||
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
|
||||
accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota)
|
||||
accounts.GET("/:id/affinity-clients", h.Admin.Account.GetAffinityClients)
|
||||
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
|
||||
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
|
||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||
|
||||
@@ -42,6 +42,9 @@ func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _
|
||||
func (c *stubSmartRetryCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (c *stubSmartRetryCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream
|
||||
type mockSmartRetryUpstream struct {
|
||||
|
||||
@@ -52,6 +52,9 @@ func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, gr
|
||||
func (m *mockAffinityCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (m *mockAffinityCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: 构造启用了客户端亲和的 Anthropic 账号
|
||||
|
||||
@@ -155,6 +155,9 @@ func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.C
|
||||
func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
||||
s.listByGroupCalls.Add(1)
|
||||
|
||||
@@ -247,6 +247,9 @@ func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Con
|
||||
func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type mockGroupRepoForGateway struct {
|
||||
groups map[int64]*Group
|
||||
|
||||
@@ -380,6 +380,21 @@ type GatewayCache interface {
|
||||
// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)
|
||||
// accountGroups: map[accountID][]groupID
|
||||
GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
|
||||
// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间)
|
||||
GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error)
|
||||
}
|
||||
|
||||
// AffinityClient 亲和客户端信息(含最后活跃时间)
|
||||
type AffinityClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
LastActive time.Time `json:"last_active"`
|
||||
}
|
||||
|
||||
// SortAffinityClients 按最后活跃时间降序排序
|
||||
func SortAffinityClients(clients []AffinityClient) {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i].LastActive.After(clients[j].LastActive)
|
||||
})
|
||||
}
|
||||
|
||||
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
|
||||
|
||||
@@ -300,6 +300,9 @@ func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Conte
|
||||
func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
|
||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
|
||||
|
||||
@@ -293,6 +293,9 @@ func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int
|
||||
func (c *stubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (c *stubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -205,6 +205,9 @@ func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ con
|
||||
func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) {
|
||||
probe := &openAIWSStateStoreTimeoutProbeCache{}
|
||||
|
||||
@@ -109,6 +109,9 @@ func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int6
|
||||
func (c StubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
func (c StubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// StubSessionLimitCache — service.SessionLimitCache 的空实现
|
||||
|
||||
@@ -571,6 +571,18 @@ export async function validateSoraSessionToken(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get affinity clients for an account with last active timestamps
|
||||
* @param id - Account ID
|
||||
* @returns List of affinity clients
|
||||
*/
|
||||
export async function getAffinityClients(id: number): Promise<{ client_id: string; last_active: string }[]> {
|
||||
const { data } = await apiClient.get<{ client_id: string; last_active: string }[]>(
|
||||
`/admin/accounts/${id}/affinity-clients`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
@@ -604,7 +616,8 @@ export const accountsAPI = {
|
||||
syncFromCrs,
|
||||
exportData,
|
||||
importData,
|
||||
getAntigravityDefaultModelMapping
|
||||
getAntigravityDefaultModelMapping,
|
||||
getAffinityClients
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<span
|
||||
:class="badgeClass"
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0 cursor-pointer"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
<!-- 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"
|
||||
class="fixed z-50 w-72 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
:style="popoverStyle"
|
||||
@mouseenter="handlePopoverEnter"
|
||||
@mouseleave="handlePopoverLeave"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-3 py-2 dark:border-dark-700">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.affinityClients', { count }) }}
|
||||
</span>
|
||||
<span v-if="loading" class="text-xs text-gray-400">
|
||||
<svg class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Client list -->
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<div v-if="loading && clients.length === 0" class="px-3 py-4 text-center text-xs text-gray-400">
|
||||
{{ t('common.loading') }}...
|
||||
</div>
|
||||
<div v-else-if="clients.length === 0" class="px-3 py-4 text-center text-xs text-gray-400">
|
||||
{{ t('admin.accounts.affinityNoClients') }}
|
||||
</div>
|
||||
<div v-else class="divide-y divide-gray-50 dark:divide-dark-700">
|
||||
<div
|
||||
v-for="(client, index) in clients"
|
||||
:key="index"
|
||||
class="flex items-center justify-between px-3 py-1.5"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 truncate mr-2" :title="client.client_id">
|
||||
{{ client.client_id }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0">
|
||||
{{ formatRelativeTime(client.last_active) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getAffinityClients } from '@/api/admin/accounts'
|
||||
|
||||
interface Props {
|
||||
accountId: number
|
||||
count: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showPopover = ref(false)
|
||||
const loading = ref(false)
|
||||
const clients = ref<{ client_id: string; last_active: string }[]>([])
|
||||
let loaded = false
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const c = props.count
|
||||
if (c >= 16) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
if (c >= 6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
if (c > 0) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
})
|
||||
|
||||
const popoverStyle = computed(() => {
|
||||
if (!containerRef.value) return {}
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
let top = rect.bottom + 6
|
||||
let left = rect.left - 40
|
||||
|
||||
// 下方空间不足时显示在上方
|
||||
if (top + 280 > viewportHeight) {
|
||||
top = Math.max(8, rect.top - 280)
|
||||
}
|
||||
// 右侧空间不足时向左偏移
|
||||
if (left + 288 > viewportWidth) {
|
||||
left = Math.max(8, viewportWidth - 296)
|
||||
}
|
||||
// 不超出左边界
|
||||
if (left < 8) left = 8
|
||||
|
||||
return { top: `${top}px`, left: `${left}px` }
|
||||
})
|
||||
|
||||
function clearTimers() {
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
|
||||
if (showTimer) { clearTimeout(showTimer); showTimer = null }
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
clearTimers()
|
||||
showTimer = setTimeout(() => {
|
||||
showPopover.value = true
|
||||
if (!loaded) fetchClients()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
clearTimers()
|
||||
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
|
||||
}
|
||||
|
||||
function handlePopoverEnter() {
|
||||
clearTimers()
|
||||
}
|
||||
|
||||
function handlePopoverLeave() {
|
||||
clearTimers()
|
||||
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
|
||||
}
|
||||
|
||||
async function fetchClients() {
|
||||
loading.value = true
|
||||
try {
|
||||
clients.value = await getAffinityClients(props.accountId)
|
||||
loaded = true
|
||||
} catch {
|
||||
clients.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(isoStr).getTime()
|
||||
const diffSec = Math.floor((now - then) / 1000)
|
||||
|
||||
if (diffSec < 60) return t('common.justNow', 'just now')
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
||||
return `${Math.floor(diffSec / 86400)}d ago`
|
||||
}
|
||||
</script>
|
||||
@@ -244,6 +244,7 @@ export default {
|
||||
// Common
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
justNow: 'just now',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
|
||||
@@ -244,6 +244,7 @@ export default {
|
||||
// Common
|
||||
common: {
|
||||
loading: '加载中...',
|
||||
justNow: '刚刚',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
|
||||
@@ -177,12 +177,11 @@
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span v-if="row.client_affinity_enabled && row.affinity_client_count != null"
|
||||
:class="affinityBadgeClass(row.affinity_client_count)"
|
||||
:title="affinityTooltip(row)"
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0">
|
||||
{{ row.affinity_client_count }}
|
||||
</span>
|
||||
<AffinityBadge
|
||||
v-if="row.client_affinity_enabled && row.affinity_client_count != null"
|
||||
:account-id="row.id"
|
||||
:count="row.affinity_client_count"
|
||||
/>
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,6 +309,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
||||
import ScheduledTestsPanel from '@/components/admin/account/ScheduledTestsPanel.vue'
|
||||
import type { SelectOption } from '@/components/common/Select.vue'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
import AffinityBadge from '@/components/account/AffinityBadge.vue'
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
@@ -406,19 +406,6 @@ const buildDefaultTodayStats = (): WindowStats => ({
|
||||
user_cost: 0
|
||||
})
|
||||
|
||||
function affinityBadgeClass(count: number): string {
|
||||
if (count >= 16) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
if (count >= 6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
if (count > 0) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}
|
||||
|
||||
function affinityTooltip(row: Account): string {
|
||||
const clients = row.affinity_clients
|
||||
if (!clients || clients.length === 0) return t('admin.accounts.affinityNoClients')
|
||||
return t('admin.accounts.affinityClients', { count: clients.length }) + '\n' + clients.join('\n')
|
||||
}
|
||||
|
||||
const refreshTodayStatsBatch = async () => {
|
||||
if (hiddenColumns.has('today_stats')) {
|
||||
todayStatsLoading.value = false
|
||||
|
||||
Reference in New Issue
Block a user