merge: 合并主分支改动并保留 ops 监控实现

合并 main 分支的最新改动到 ops 监控分支。
冲突解决策略:保留当前分支的 ops 相关改动,接受主分支的其他改动。

保留的 ops 改动:
- 运维监控配置和依赖注入
- 运维监控 API 处理器和中间件
- 运维监控服务层和数据访问层
- 运维监控前端界面和状态管理

接受的主分支改动:
- Linux DO OAuth 集成
- 账号过期功能
- IP 地址限制功能
- 用量统计优化
- 其他 bug 修复和功能改进
This commit is contained in:
IanShaw027
2026-01-10 13:24:40 +08:00
155 changed files with 9227 additions and 1355 deletions

View File

@@ -61,7 +61,7 @@
<!-- Login / Dashboard Button -->
<router-link
v-if="isAuthenticated"
to="/dashboard"
:to="dashboardPath"
class="inline-flex items-center gap-1.5 rounded-full bg-gray-900 py-1 pl-1 pr-2.5 transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<span
@@ -114,7 +114,7 @@
<!-- CTA Button -->
<div>
<router-link
:to="isAuthenticated ? '/dashboard' : '/login'"
:to="isAuthenticated ? dashboardPath : '/login'"
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
>
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
@@ -416,6 +416,8 @@ const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isAdmin = computed(() => authStore.isAdmin)
const dashboardPath = computed(() => isAdmin.value ? '/admin/dashboard' : '/dashboard')
const userInitial = computed(() => {
const user = authStore.user
if (!user || !user.email) return ''

View File

@@ -6,7 +6,8 @@
<AccountTableFilters
v-model:searchQuery="params.search"
:filters="params"
@change="reload"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="debouncedReload"
@update:searchQuery="debouncedReload"
/>
<AccountTableActions
@@ -18,7 +19,7 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" />
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<DataTable :columns="cols" :data="accounts" :loading="loading">
<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" />
@@ -69,6 +70,25 @@
<template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<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-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
@@ -87,7 +107,7 @@
</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" /></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>
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" />
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="load" />
@@ -127,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatRelativeTime } from '@/utils/format'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n()
@@ -155,7 +175,7 @@ const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange } = useTableLoader<Account, any>({
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' }
})
@@ -177,6 +197,7 @@ const cols = computed(() => {
{ 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 },
{ 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 }
)
@@ -188,6 +209,21 @@ 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 handleBulkToggleSchedulable = async (schedulable: boolean) => {
const count = selIds.value.length
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()
} catch (error) {
console.error('Failed to bulk toggle schedulable:', error);
appStore.showError(t('common.error'))
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
@@ -203,6 +239,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } 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) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
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) } })
</script>

View File

@@ -16,6 +16,7 @@
type="text"
:placeholder="t('admin.groups.searchGroups')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<Select
@@ -64,7 +65,7 @@
</template>
<template #table>
<DataTable :columns="columns" :data="displayedGroups" :loading="loading">
<DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
@@ -403,6 +404,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 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.claudeCode.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-72 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.claudeCode.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">
<button
type="button"
@click="createForm.claude_code_only = !createForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.claude_code_only ? '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.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="createForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="createForm.fallback_group_id"
:options="fallbackGroupOptions"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
@@ -648,6 +705,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 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.claudeCode.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-72 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.claudeCode.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">
<button
type="button"
@click="editForm.claude_code_only = !editForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.claude_code_only ? '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.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="editForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="editForm.fallback_group_id"
:options="fallbackGroupOptionsForEdit"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
@@ -774,6 +887,35 @@ const subscriptionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
])
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const fallbackGroupOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active'
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 降级分组选项(编辑时)- 排除自身
const fallbackGroupOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active' && g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
const groups = ref<Group[]>([])
const loading = ref(false)
const searchQuery = ref('')
@@ -791,16 +933,6 @@ const pagination = reactive({
let abortController: AbortController | null = null
const displayedGroups = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return groups.value
return groups.value.filter((group) => {
const name = group.name?.toLowerCase?.() ?? ''
const description = group.description?.toLowerCase?.() ?? ''
return name.includes(q) || description.includes(q)
})
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
@@ -821,7 +953,10 @@ const createForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
const editForm = reactive({
@@ -838,7 +973,10 @@ const editForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
// 根据分组类型返回不同的删除确认消息
@@ -864,7 +1002,8 @@ const loadGroups = async () => {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined,
search: searchQuery.value.trim() || undefined
}, { signal })
if (signal.aborted) return
groups.value = response.items
@@ -883,6 +1022,15 @@ const loadGroups = async () => {
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadGroups()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadGroups()
@@ -908,6 +1056,8 @@ const closeCreateModal = () => {
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
}
const handleCreateGroup = async () => {
@@ -949,6 +1099,8 @@ const handleEdit = (group: Group) => {
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
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
showEditModal.value = true
}
@@ -966,7 +1118,12 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
await adminAPI.groups.update(editingGroup.value.id, editForm)
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id
}
await adminAPI.groups.update(editingGroup.value.id, payload)
appStore.showSuccess(t('admin.groups.groupUpdated'))
closeEditModal()
loadGroups()

View File

@@ -85,6 +85,14 @@
</span>
</template>
<template #cell-account_count="{ value }">
<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"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@@ -511,7 +519,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 { adminAPI } from '@/api/admin'
@@ -534,6 +542,7 @@ const columns = computed<Column[]>(() => [
{ 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: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
])
@@ -933,4 +942,9 @@ const confirmDelete = async () => {
onMounted(() => {
loadProxies()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -364,7 +364,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 { useClipboard } from '@/composables/useClipboard'
@@ -693,4 +693,9 @@ onMounted(() => {
loadCodes()
loadSubscriptionGroups()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -85,11 +85,49 @@ const exportToExcel = async () => {
if (all.length >= total || res.items.length < 100) break; p++
}
if(!c.signal.aborted) {
// 动态加载 xlsx降低首屏包体并减少高危依赖的常驻暴露面。
const XLSX = await import('xlsx')
const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`)
appStore.showSuccess('Export Success')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
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.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
]
const rows = all.map(log => [
log.created_at,
log.user?.email || '',
log.api_key?.name || '',
log.account?.name || '',
log.model,
log.group?.name || '',
log.stream ? t('usage.stream') : t('usage.sync'),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000',
log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00',
log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000',
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || '',
log.user_agent || '',
log.ip_address || ''
])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
appStore.showSuccess(t('usage.exportSuccess'))
}
} catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }

View File

@@ -893,12 +893,13 @@ const loadUsers = async () => {
}
}
}
} catch (error) {
} catch (error: any) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.users.failedToLoad'))
const message = error.response?.data?.detail || error.message || t('admin.users.failedToLoad')
appStore.showError(message)
console.error('Error loading users:', error)
} finally {
if (abortController === currentAbortController) {
@@ -917,7 +918,9 @@ const handleSearch = () => {
}
const handlePageChange = (page: number) => {
pagination.page = page
// 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
loadUsers()
}
@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters.add(key)
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
activeAttributeFilters[attr.id] = ''
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
@@ -1059,5 +1064,7 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.linuxdo.callbackTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ isProcessing ? t('auth.linuxdo.callbackProcessing') : t('auth.linuxdo.callbackHint') }}
</p>
</div>
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<div class="space-y-2">
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<router-link to="/login" class="btn btn-primary">
{{ t('auth.linuxdo.backToLogin') }}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import { useAuthStore, useAppStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const isProcessing = ref(true)
const errorMessage = ref('')
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
onMounted(async () => {
const params = parseFragmentParams()
const token = params.get('access_token') || ''
const redirect = sanitizeRedirectPath(
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
)
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
if (!token) {
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
try {
await authStore.setToken(token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { detail?: string } } }
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
appStore.showError(errorMessage.value)
isProcessing.value = false
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -11,6 +11,9 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input -->
@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } 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'
@@ -179,6 +183,7 @@ const showPassword = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -210,6 +215,7 @@ onMounted(async () => {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
}

View File

@@ -11,6 +11,9 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
<!-- Registration Disabled Message -->
<div
v-if="!registrationEnabled && settingsLoaded"
@@ -181,6 +184,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } 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'
@@ -207,6 +211,7 @@ const emailVerifyEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -233,6 +238,7 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {

View File

@@ -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: ''
}
}

View File

@@ -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"
@@ -308,6 +295,11 @@
}}</span>
</template>
<template #cell-user_agent="{ row }">
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty>
<EmptyState :message="t('usage.noRecords')" />
</template>
@@ -342,8 +334,8 @@
>
<div class="space-y-1.5">
<!-- Token Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
<div>
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
@@ -389,6 +381,27 @@
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400"
@@ -456,10 +469,10 @@ 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 }
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
])
const usageLogs = ref<UsageLog[]>([])
@@ -524,6 +537,19 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s`
}
const formatUserAgent = (ua: string): string => {
// 提取主要客户端标识
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
if (ua.includes('Cursor')) return 'Cursor'
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
if (ua.includes('Continue')) return 'Continue'
if (ua.includes('Cline')) return 'Cline'
if (ua.includes('OpenAI')) return 'OpenAI SDK'
if (ua.includes('anthropic')) return 'Anthropic SDK'
// 截断过长的 UA
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -705,7 +731,6 @@ const exportToCSV = async () => {
'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)'
]
@@ -722,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)