merge: 合并main分支最新改动
解决冲突: - backend/internal/config/config.go: 合并Ops和Dashboard配置 - backend/internal/server/api_contract_test.go: 合并handler初始化 - backend/internal/service/openai_gateway_service.go: 保留Ops错误追踪逻辑 - backend/internal/service/wire.go: 合并Ops和APIKeyAuth provider 主要合并内容: - Dashboard缓存和预聚合功能 - API Key认证缓存优化 - Codex转换支持 - 使用日志分区表
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -83,7 +83,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 +210,7 @@ interface Props {
|
||||
stickyActionsColumn?: boolean
|
||||
expandableActions?: boolean
|
||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||
rowKey?: string | ((row: any) => string | number)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -222,6 +223,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 会临时修改它,会导致无限循环
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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,35 @@ 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`
|
||||
})()
|
||||
|
||||
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', apiBase, 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 +401,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 +520,76 @@ 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,
|
||||
...(platform === 'openai' ? { store: false } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
const openaiModels = {
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
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 content = JSON.stringify(
|
||||
{
|
||||
provider,
|
||||
$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) {
|
||||
|
||||
@@ -368,6 +368,12 @@ export default {
|
||||
note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.',
|
||||
noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.',
|
||||
},
|
||||
cliTabs: {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
description: 'Configure API access for Antigravity group. Select the configuration method based on your client.',
|
||||
claudeCode: 'Claude Code',
|
||||
@@ -380,6 +386,11 @@ export default {
|
||||
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
subtitle: 'opencode.json',
|
||||
hint: 'This is a group configuration example. Adjust model and options as needed.',
|
||||
},
|
||||
},
|
||||
customKeyLabel: 'Custom Key',
|
||||
customKeyPlaceholder: 'Enter your custom key (min 16 chars)',
|
||||
@@ -1109,6 +1120,8 @@ export default {
|
||||
rateLimitCleared: 'Rate limit cleared successfully',
|
||||
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
|
||||
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
|
||||
bulkSchedulablePartial: 'Scheduling updated partially: {success} succeeded, {failed} failed',
|
||||
bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.',
|
||||
bulkActions: {
|
||||
selected: '{count} account(s) selected',
|
||||
selectCurrentPage: 'Select this page',
|
||||
|
||||
@@ -366,6 +366,12 @@ export default {
|
||||
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
|
||||
noteWindows: '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
|
||||
},
|
||||
cliTabs: {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
|
||||
claudeCode: 'Claude Code',
|
||||
@@ -378,6 +384,11 @@ export default {
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
hint: '示例仅用于演示分组配置,模型与选项可按需调整。',
|
||||
},
|
||||
},
|
||||
customKeyLabel: '自定义密钥',
|
||||
customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
|
||||
@@ -1246,6 +1257,8 @@ export default {
|
||||
accountDeletedSuccess: '账号删除成功',
|
||||
bulkSchedulableEnabled: '成功启用 {count} 个账号的调度',
|
||||
bulkSchedulableDisabled: '成功停止 {count} 个账号的调度',
|
||||
bulkSchedulablePartial: '部分调度更新成功:成功 {success} 个,失败 {failed} 个',
|
||||
bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表',
|
||||
bulkActions: {
|
||||
selected: '已选择 {count} 个账号',
|
||||
selectCurrentPage: '本页全选',
|
||||
|
||||
@@ -652,6 +652,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
|
||||
|
||||
@@ -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>
|
||||
@@ -209,18 +209,107 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top
|
||||
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
|
||||
}
|
||||
load().catch((error) => {
|
||||
console.error('Failed to refresh accounts:', error)
|
||||
})
|
||||
} 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 +325,22 @@ 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)
|
||||
load().catch((error) => {
|
||||
console.error('Failed to refresh accounts:', error)
|
||||
})
|
||||
} 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) => {
|
||||
|
||||
Reference in New Issue
Block a user