Commit 3a67002c authored by IanShaw027's avatar IanShaw027
Browse files

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

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

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

接受的主分支改动:
- Linux DO OAuth 集成
- 账号过期功能
- IP 地址限制功能
- 用量统计优化
- 其他 bug 修复和功能改进
parents c48dc097 7d1fe818
...@@ -16,7 +16,7 @@ import type { ...@@ -16,7 +16,7 @@ import type {
* List all groups with pagination * List all groups with pagination
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20) * @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (platform, status, is_exclusive) * @param filters - Optional filters (platform, status, is_exclusive, search)
* @returns Paginated list of groups * @returns Paginated list of groups
*/ */
export async function list( export async function list(
...@@ -26,6 +26,7 @@ export async function list( ...@@ -26,6 +26,7 @@ export async function list(
platform?: GroupPlatform platform?: GroupPlatform
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
is_exclusive?: boolean is_exclusive?: boolean
search?: string
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
......
...@@ -54,15 +54,20 @@ export async function list( ...@@ -54,15 +54,20 @@ export async function list(
/** /**
* Get usage statistics with optional filters (admin only) * Get usage statistics with optional filters (admin only)
* @param params - Query parameters (user_id, api_key_id, period/date range) * @param params - Query parameters for filtering
* @returns Usage statistics * @returns Usage statistics
*/ */
export async function getStats(params: { export async function getStats(params: {
user_id?: number user_id?: number
api_key_id?: number api_key_id?: number
account_id?: number
group_id?: number
model?: string
stream?: boolean
period?: string period?: string
start_date?: string start_date?: string
end_date?: string end_date?: string
timezone?: string
}): Promise<AdminUsageStatsResponse> { }): Promise<AdminUsageStatsResponse> {
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', { const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
params params
......
...@@ -42,12 +42,16 @@ export async function getById(id: number): Promise<ApiKey> { ...@@ -42,12 +42,16 @@ export async function getById(id: number): Promise<ApiKey> {
* @param name - Key name * @param name - Key name
* @param groupId - Optional group ID * @param groupId - Optional group ID
* @param customKey - Optional custom key value * @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist
* @returns Created API key * @returns Created API key
*/ */
export async function create( export async function create(
name: string, name: string,
groupId?: number | null, groupId?: number | null,
customKey?: string customKey?: string,
ipWhitelist?: string[],
ipBlacklist?: string[]
): Promise<ApiKey> { ): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name } const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) { if (groupId !== undefined) {
...@@ -56,6 +60,12 @@ export async function create( ...@@ -56,6 +60,12 @@ export async function create(
if (customKey) { if (customKey) {
payload.custom_key = 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) const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data return data
......
...@@ -166,7 +166,7 @@ ...@@ -166,7 +166,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based' accountCategory === 'oauth-based'
? 'bg-orange-500 text-white' ? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -196,7 +196,7 @@ ...@@ -196,7 +196,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'apikey' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -232,7 +232,7 @@ ...@@ -232,7 +232,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based' accountCategory === 'oauth-based'
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -258,7 +258,7 @@ ...@@ -258,7 +258,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'apikey' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -302,7 +302,7 @@ ...@@ -302,7 +302,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based' accountCategory === 'oauth-based'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -332,7 +332,7 @@ ...@@ -332,7 +332,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'apikey' accountCategory === 'apikey'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -397,7 +397,7 @@ ...@@ -397,7 +397,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one' geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white' ? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -440,7 +440,7 @@ ...@@ -440,7 +440,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist' geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -518,7 +518,7 @@ ...@@ -518,7 +518,7 @@
> >
<div <div
:class="[ :class="[
'flex h-8 w-8 items-center justify-center rounded-lg', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio' geminiOAuthType === 'ai_studio'
? 'bg-amber-500 text-white' ? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
...@@ -621,7 +621,7 @@ ...@@ -621,7 +621,7 @@
<div <div
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
> >
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white"> <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-purple-500 text-white">
<Icon name="key" size="sm" /> <Icon name="key" size="sm" />
</div> </div>
<div> <div>
...@@ -1012,7 +1012,7 @@ ...@@ -1012,7 +1012,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
...@@ -1213,46 +1213,81 @@ ...@@ -1213,46 +1213,81 @@
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<!-- Mixed Scheduling (only for antigravity accounts) --> <div>
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2"> <div class="flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-2"> <div>
<input <label class="input-label mb-0">{{
type="checkbox" t('admin.accounts.autoPauseOnExpired')
v-model="mixedScheduling" }}</label>
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500" <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
/> {{ t('admin.accounts.autoPauseOnExpiredDesc') }}
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> </p>
{{ t('admin.accounts.mixedScheduling') }} </div>
</span> <button
</label> type="button"
<div class="group relative"> @click="autoPauseOnExpired = !autoPauseOnExpired"
<span :class="[
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500" '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',
> autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
? ]"
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
> >
{{ t('admin.accounts.mixedSchedulingTooltip') }} <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',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<!-- Mixed Scheduling (only for antigravity accounts) -->
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="mixedScheduling"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div <div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700" class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
></div> >
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div> </div>
</div> </div>
</div>
<!-- Group Selection - 仅标准模式显示 --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <GroupSelector
v-if="!authStore.isSimpleMode" v-if="!authStore.isSimpleMode"
v-model="form.group_ids" v-model="form.group_ids"
:groups="groups" :groups="groups"
:platform="form.platform" :platform="form.platform"
:mixed-scheduling="mixedScheduling" :mixed-scheduling="mixedScheduling"
data-tour="account-form-groups" data-tour="account-form-groups"
/> />
</div>
</form> </form>
...@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
...@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false) ...@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
...@@ -1795,7 +1832,8 @@ const form = reactive({ ...@@ -1795,7 +1832,8 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, priority: 1,
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
// Helper to check if current type needs OAuth flow // Helper to check if current type needs OAuth flow
...@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => { ...@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual' return oauthFlowRef.value?.inputMethod === 'manual'
}) })
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') { if (form.platform === 'openai') {
...@@ -2055,6 +2100,7 @@ const resetForm = () => { ...@@ -2055,6 +2100,7 @@ const resetForm = () => {
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
addMethod.value = 'oauth' addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyBaseUrl.value = 'https://api.anthropic.com'
...@@ -2066,6 +2112,7 @@ const resetForm = () => { ...@@ -2066,6 +2112,7 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
...@@ -2133,7 +2180,6 @@ const handleSubmit = async () => { ...@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true credentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
...@@ -2144,7 +2190,8 @@ const handleSubmit = async () => { ...@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
try { try {
await adminAPI.accounts.create({ await adminAPI.accounts.create({
...form, ...form,
group_ids: form.group_ids group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
...@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => { ...@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
} }
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Create account and handle success/failure // Create account and handle success/failure
const createAccountAndFinish = async ( const createAccountAndFinish = async (
platform: AccountPlatform, platform: AccountPlatform,
...@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async ( ...@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
group_ids: form.group_ids group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
...@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
extra, extra,
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority priority: form.priority,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
successCount++ successCount++
......
...@@ -365,7 +365,7 @@ ...@@ -365,7 +365,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
...@@ -565,39 +565,74 @@ ...@@ -565,39 +565,74 @@
/> />
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('common.status') }}</label> <div class="flex items-center justify-between">
<Select v-model="form.status" :options="statusOptions" /> <div>
<label class="input-label mb-0">{{
t('admin.accounts.autoPauseOnExpired')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
</p>
</div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
: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',
autoPauseOnExpired ? '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',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div> </div>
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) --> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2"> <div>
<label class="flex cursor-not-allowed items-center gap-2 opacity-60"> <label class="input-label">{{ t('common.status') }}</label>
<input <Select v-model="form.status" :options="statusOptions" />
type="checkbox" </div>
v-model="mixedScheduling"
disabled <!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500" <div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
/> <label class="flex cursor-not-allowed items-center gap-2 opacity-60">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> <input
{{ t('admin.accounts.mixedScheduling') }} type="checkbox"
</span> v-model="mixedScheduling"
</label> disabled
<div class="group relative"> class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
<span />
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500" <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
> {{ t('admin.accounts.mixedScheduling') }}
? </span>
</span> </label>
<!-- Tooltip向下显示避免被弹窗裁剪 --> <div class="group relative">
<div <span
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700" class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
> >
{{ t('admin.accounts.mixedSchedulingTooltip') }} ?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div <div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700" class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
></div> >
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { import {
getPresetMappingsByPlatform, getPresetMappingsByPlatform,
commonErrorCodes, commonErrorCodes,
...@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false) ...@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
...@@ -771,7 +808,8 @@ const form = reactive({ ...@@ -771,7 +808,8 @@ const form = reactive({
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
const statusOptions = computed(() => [ const statusOptions = computed(() => [
...@@ -779,6 +817,13 @@ const statusOptions = computed(() => [ ...@@ -779,6 +817,13 @@ const statusOptions = computed(() => [
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('common.inactive') }
]) ])
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
// Watchers // Watchers
watch( watch(
() => props.account, () => props.account,
...@@ -791,10 +836,12 @@ watch( ...@@ -791,10 +836,12 @@ watch(
form.priority = newAccount.priority form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null
// Load intercept warmup requests setting (applies to all account types) // Load intercept warmup requests setting (applies to all account types)
const credentials = newAccount.credentials as Record<string, unknown> | undefined const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
// Load mixed scheduling setting (only for antigravity accounts) // Load mixed scheduling setting (only for antigravity accounts)
const extra = newAccount.extra as Record<string, unknown> | undefined const extra = newAccount.extra as Record<string, unknown> | undefined
...@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) { ...@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num) return Math.trunc(num)
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Methods // Methods
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
...@@ -1057,6 +1107,10 @@ const handleSubmit = async () => { ...@@ -1057,6 +1107,10 @@ const handleSubmit = async () => {
if (updatePayload.proxy_id === null) { if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0 updatePayload.proxy_id = 0
} }
if (form.expires_at === null) {
updatePayload.expires_at = 0
}
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { if (props.account.type === 'apikey') {
...@@ -1097,7 +1151,6 @@ const handleSubmit = async () => { ...@@ -1097,7 +1151,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
newCredentials.intercept_warmup_requests = true newCredentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return
...@@ -1114,7 +1167,6 @@ const handleSubmit = async () => { ...@@ -1114,7 +1167,6 @@ const handleSubmit = async () => {
} else { } else {
delete newCredentials.intercept_warmup_requests delete newCredentials.intercept_warmup_requests
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return
...@@ -1140,7 +1192,7 @@ const handleSubmit = async () => { ...@@ -1140,7 +1192,7 @@ const handleSubmit = async () => {
emit('updated') emit('updated')
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate')) appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally { } finally {
submitting.value = false submitting.value = false
} }
......
...@@ -73,113 +73,48 @@ ...@@ -73,113 +73,48 @@
</div> </div>
</fieldset> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Display (read-only) -->
<fieldset v-if="isGemini" class="border-0 p-0"> <div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<div class="mt-2 grid grid-cols-3 gap-3"> {{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
<button </div>
type="button" <div class="flex items-center gap-3">
@click="handleSelectGeminiOAuthType('google_one')" <div
:class="[ :class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one' geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-purple-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : geminiOAuthType === 'code_assist'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
<span class="text-xs text-gray-500 dark:text-gray-400">个人账号</span>
</div>
</button>
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-amber-500 text-white'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
</div>
</button>
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]" ]"
> >
<div <Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
:class="[ <Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
'flex h-8 w-8 items-center justify-center rounded-lg', <Icon v-else name="sparkles" size="sm" />
geminiOAuthType === 'ai_studio' </div>
? 'bg-purple-500 text-white' <div>
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' <span class="block text-sm font-medium text-gray-900 dark:text-white">
]" {{
> geminiOAuthType === 'google_one'
<Icon name="sparkles" size="sm" /> ? 'Google One'
</div> : geminiOAuthType === 'code_assist'
<div class="min-w-0"> ? t('admin.accounts.gemini.oauthType.builtInTitle')
<span class="block text-sm font-medium text-gray-900 dark:text-white"> : t('admin.accounts.gemini.oauthType.customTitle')
{{ t('admin.accounts.gemini.oauthType.customTitle') }} }}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }} {{
</span> geminiOAuthType === 'google_one'
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block"> ? '个人账号'
<span : geminiOAuthType === 'code_assist'
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" ? t('admin.accounts.gemini.oauthType.builtInDesc')
> : t('admin.accounts.gemini.oauthType.customDesc')
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} }}
</span> </span>
<div </div>
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div>
</div>
</div>
</button>
</div> </div>
</fieldset> </div>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
...@@ -299,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -299,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
...@@ -367,14 +301,6 @@ watch( ...@@ -367,14 +301,6 @@ watch(
? 'ai_studio' ? 'ai_studio'
: 'code_assist' : 'code_assist'
} }
if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => {
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
geminiOAuthType.value = 'code_assist'
}
})
}
} else { } else {
resetState() resetState()
} }
...@@ -385,7 +311,6 @@ watch( ...@@ -385,7 +311,6 @@ watch(
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -393,14 +318,6 @@ const resetState = () => { ...@@ -393,14 +318,6 @@ const resetState = () => {
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
}
geminiOAuthType.value = oauthType
}
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
} }
......
<template> <template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg"> <div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span> <div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
</span>
<button
@click="$emit('select-page')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
</button>
<span class="text-gray-300 dark:text-primary-800"></span>
<button
@click="$emit('clear')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.clear') }}
</button>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button> <button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button> <button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div> </div>
</div> </div>
...@@ -10,5 +29,5 @@ ...@@ -10,5 +29,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit']); const { t } = useI18n() defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable']); const { t } = useI18n()
</script> </script>
\ No newline at end of file
...@@ -73,111 +73,48 @@ ...@@ -73,111 +73,48 @@
</div> </div>
</fieldset> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Display (read-only) -->
<fieldset v-if="isGemini" class="border-0 p-0"> <div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<div class="mt-2 grid grid-cols-3 gap-3"> {{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
<button </div>
type="button" <div class="flex items-center gap-3">
@click="handleSelectGeminiOAuthType('google_one')" <div
:class="[ :class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all', 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one' geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-purple-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : geminiOAuthType === 'code_assist'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="user" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
<span class="text-xs text-gray-500 dark:text-gray-400">个人账号</span>
</div>
</button>
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-amber-500 text-white'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
</div>
</button>
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]" ]"
> >
<div <Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
:class="[ <Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
'flex h-8 w-8 items-center justify-center rounded-lg', <Icon v-else name="sparkles" size="sm" />
geminiOAuthType === 'ai_studio' </div>
? 'bg-purple-500 text-white' <div>
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' <span class="block text-sm font-medium text-gray-900 dark:text-white">
]" {{
> geminiOAuthType === 'google_one'
<Icon name="sparkles" size="sm" /> ? 'Google One'
</div> : geminiOAuthType === 'code_assist'
<div class="min-w-0"> ? t('admin.accounts.gemini.oauthType.builtInTitle')
<span class="block text-sm font-medium text-gray-900 dark:text-white"> : t('admin.accounts.gemini.oauthType.customTitle')
{{ t('admin.accounts.gemini.oauthType.customTitle') }} }}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }} {{
</span> geminiOAuthType === 'google_one'
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block"> ? '个人账号'
<span : geminiOAuthType === 'code_assist'
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" ? t('admin.accounts.gemini.oauthType.builtInDesc')
> : t('admin.accounts.gemini.oauthType.customDesc')
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} }}
</span> </span>
<div </div>
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div>
</div>
</div>
</button>
</div> </div>
</fieldset> </div>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
...@@ -297,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -297,7 +234,6 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
...@@ -365,14 +301,6 @@ watch( ...@@ -365,14 +301,6 @@ watch(
? 'ai_studio' ? 'ai_studio'
: 'code_assist' : 'code_assist'
} }
if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => {
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
geminiOAuthType.value = 'code_assist'
}
})
}
} else { } else {
resetState() resetState()
} }
...@@ -383,7 +311,6 @@ watch( ...@@ -383,7 +311,6 @@ watch(
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -391,14 +318,6 @@ const resetState = () => { ...@@ -391,14 +318,6 @@ const resetState = () => {
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
}
geminiOAuthType.value = oauthType
}
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
} }
......
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
class="input pr-8" class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')" :placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch" @input="debounceApiKeySearch"
@focus="showApiKeyDropdown = true" @focus="onApiKeyFocus"
/> />
<button <button
v-if="filters.api_key_id" v-if="filters.api_key_id"
...@@ -62,7 +62,7 @@ ...@@ -62,7 +62,7 @@
</button> </button>
<div <div
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)" v-if="showApiKeyDropdown && apiKeyResults.length > 0"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
> >
<button <button
...@@ -85,9 +85,40 @@ ...@@ -85,9 +85,40 @@
</div> </div>
<!-- Account Filter --> <!-- Account Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]"> <div ref="accountSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label> <label class="input-label">{{ t('admin.usage.account') }}</label>
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" /> <input
v-model="accountKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchAccountPlaceholder')"
@input="debounceAccountSearch"
@focus="showAccountDropdown = true"
/>
<button
v-if="filters.account_id"
type="button"
@click="clearAccount"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear account filter"
>
</button>
<div
v-if="showAccountDropdown && (accountResults.length > 0 || accountKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="a in accountResults"
:key="a.id"
type="button"
@click="selectAccount(a)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ a.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ a.id }}</span>
</button>
</div>
</div> </div>
<!-- Stream Type Filter --> <!-- Stream Type Filter -->
...@@ -96,12 +127,6 @@ ...@@ -96,12 +127,6 @@
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" /> <Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
</div> </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 --> <!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]"> <div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label> <label class="input-label">{{ t('admin.usage.group') }}</label>
...@@ -166,6 +191,7 @@ const filters = toRef(props, 'modelValue') ...@@ -166,6 +191,7 @@ const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null) const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null) const apiKeySearchRef = ref<HTMLElement | null>(null)
const accountSearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('') const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([]) const userResults = ref<SimpleUser[]>([])
...@@ -177,9 +203,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([]) ...@@ -177,9 +203,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false) const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
interface SimpleAccount {
id: number
name: string
}
const accountKeyword = ref('')
const accountResults = ref<SimpleAccount[]>([])
const showAccountDropdown = ref(false)
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }]) const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }]) const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
const streamTypeOptions = ref<SelectOption[]>([ const streamTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') }, { value: null, label: t('admin.usage.allTypes') },
...@@ -187,12 +221,6 @@ const streamTypeOptions = ref<SelectOption[]>([ ...@@ -187,12 +221,6 @@ const streamTypeOptions = ref<SelectOption[]>([
{ value: false, label: t('usage.sync') } { 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 emitChange = () => emit('change')
const updateStartDate = (value: string) => { const updateStartDate = (value: string) => {
...@@ -223,14 +251,10 @@ const debounceUserSearch = () => { ...@@ -223,14 +251,10 @@ const debounceUserSearch = () => {
const debounceApiKeySearch = () => { const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout) if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => { apiKeySearchTimeout = setTimeout(async () => {
if (!apiKeyKeyword.value) {
apiKeyResults.value = []
return
}
try { try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys( apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id, filters.value.user_id,
apiKeyKeyword.value apiKeyKeyword.value || ''
) )
} catch { } catch {
apiKeyResults.value = [] apiKeyResults.value = []
...@@ -238,11 +262,19 @@ const debounceApiKeySearch = () => { ...@@ -238,11 +262,19 @@ const debounceApiKeySearch = () => {
}, 300) }, 300)
} }
const selectUser = (u: SimpleUser) => { const selectUser = async (u: SimpleUser) => {
userKeyword.value = u.email userKeyword.value = u.email
showUserDropdown.value = false showUserDropdown.value = false
filters.value.user_id = u.id filters.value.user_id = u.id
clearApiKey() clearApiKey()
// Auto-load API keys for this user
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
} catch {
apiKeyResults.value = []
}
emitChange() emitChange()
} }
...@@ -274,15 +306,56 @@ const onClearApiKey = () => { ...@@ -274,15 +306,56 @@ const onClearApiKey = () => {
emitChange() emitChange()
} }
const debounceAccountSearch = () => {
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
accountSearchTimeout = setTimeout(async () => {
if (!accountKeyword.value) {
accountResults.value = []
return
}
try {
const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value })
accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name }))
} catch {
accountResults.value = []
}
}, 300)
}
const selectAccount = (a: SimpleAccount) => {
accountKeyword.value = a.name
showAccountDropdown.value = false
filters.value.account_id = a.id
emitChange()
}
const clearAccount = () => {
accountKeyword.value = ''
accountResults.value = []
showAccountDropdown.value = false
filters.value.account_id = undefined
emitChange()
}
const onApiKeyFocus = () => {
showApiKeyDropdown.value = true
// Trigger search if no results yet
if (apiKeyResults.value.length === 0) {
debounceApiKeySearch()
}
}
const onDocumentClick = (e: MouseEvent) => { const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null const target = e.target as Node | null
if (!target) return if (!target) return
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false if (!clickedInsideApiKey) showApiKeyDropdown.value = false
if (!clickedInsideAccount) showAccountDropdown.value = false
} }
watch( watch(
...@@ -321,20 +394,27 @@ watch( ...@@ -321,20 +394,27 @@ watch(
} }
) )
watch(
() => filters.value.account_id,
(accountId) => {
if (!accountId) {
accountKeyword.value = ''
accountResults.value = []
}
}
)
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
try { try {
const [gs, ms, as] = await Promise.all([ const [gs, ms] = await Promise.all([
adminAPI.groups.list(1, 1000), adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }), adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
adminAPI.accounts.list(1, 1000)
]) ])
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name }))) groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
const uniqueModels = new Set<string>() const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model)) ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push( modelOptions.value.push(
......
...@@ -4,17 +4,34 @@ ...@@ -4,17 +4,34 @@
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" /> <Icon name="document" size="md" />
</div> </div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div> <div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div> <div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div> <div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500">
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
</p>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"> <div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" /> <Icon name="dollar" size="md" />
</div> </div>
<div><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></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>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"> <div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
......
...@@ -44,38 +44,56 @@ ...@@ -44,38 +44,56 @@
<span class="text-gray-400">({{ row.image_size || '2K' }})</span> <span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div> </div>
<!-- Token 请求 --> <!-- Token 请求 -->
<div v-else class="space-y-1 text-sm"> <div v-else class="flex items-center gap-1.5">
<div class="flex items-center gap-2"> <div class="space-y-1 text-sm">
<div class="inline-flex items-center gap-1"> <div class="flex items-center gap-2">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" /> <div class="inline-flex items-center gap-1">
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span> <Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div> </div>
<div class="inline-flex items-center gap-1"> <div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" /> <div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span> <svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
</div>
</div> </div>
</div> </div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2"> <!-- Token Detail Tooltip -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1"> <div
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg> class="group relative"
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span> @mouseenter="showTokenTooltip($event, row)"
</div> @mouseleave="hideTokenTooltip"
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1"> >
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg> <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">
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span> <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>
</div> </div>
</template> </template>
<template #cell-cost="{ row }"> <template #cell-cost="{ row }">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span> <div class="flex items-center gap-1.5 text-sm">
</template> <span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<template #cell-billing_type="{ row }"> <div
<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'"> class="group relative"
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }} @mouseenter="showTooltip($event, row)"
</span> @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>
</template> </template>
<template #cell-first_token="{ row }"> <template #cell-first_token="{ row }">
...@@ -91,36 +109,135 @@ ...@@ -91,36 +109,135 @@
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-request_id="{ row }"> <template #cell-user_agent="{ row }">
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]"> <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 class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span> <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"> </template>
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" /> <template #cell-ip_address="{ row }">
</button> <span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
</div> <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
</template> </template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template> <template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable> </DataTable>
</div> </div>
</div> </div>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div 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">
<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>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</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.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</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>
</div>
</Teleport>
<!-- Cost Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div 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">{{ (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.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>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</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>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { UsageLog } from '@/types'
defineProps(['data', 'loading']) defineProps(['data', 'loading'])
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null) // Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
const cols = computed(() => [ const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false }, { key: 'user', label: t('admin.usage.user'), sortable: false },
...@@ -131,11 +248,11 @@ const cols = computed(() => [ ...@@ -131,11 +248,11 @@ const cols = computed(() => [
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), 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: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), 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: 'request_id', label: t('admin.usage.requestId'), 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 => { const formatCacheTokens = (tokens: number): string => {
...@@ -144,20 +261,52 @@ const formatCacheTokens = (tokens: number): string => { ...@@ -144,20 +261,52 @@ const formatCacheTokens = (tokens: number): string => {
return tokens.toString() return tokens.toString()
} }
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 formatDuration = (ms: number | null | undefined): string => { const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-' if (ms == null) return '-'
if (ms < 1000) return `${ms}ms` if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s` return `${(ms / 1000).toFixed(2)}s`
} }
const copyRequestId = async (requestId: string) => { // Cost tooltip functions
try { const showTooltip = (event: MouseEvent, row: UsageLog) => {
await navigator.clipboard.writeText(requestId) const target = event.currentTarget as HTMLElement
copiedRequestId.value = requestId const rect = target.getBoundingClientRect()
appStore.showSuccess(t('admin.usage.requestIdCopied')) tooltipData.value = row
setTimeout(() => { copiedRequestId.value = null }, 2000) tooltipPosition.value.x = rect.right + 8
} catch { tooltipPosition.value.y = rect.top + rect.height / 2
appStore.showError(t('common.copyFailed')) tooltipVisible.value = true
} }
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
} }
</script> </script>
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<svg
class="icon mr-2"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
style="color: rgb(233, 84, 32); width: 20px; height: 20px"
aria-hidden="true"
>
<g id="linuxdo_icon" data-name="linuxdo_icon">
<path
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill="#EFEFEF"
></path>
<path
d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
fill="#FEB005"
></path>
<path
d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
fill="#1D1D1F"
></path>
</g>
</svg>
{{ t('auth.linuxdo.signIn') }}
</button>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.linuxdo.orContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
defineProps<{
disabled?: boolean
}>()
const route = useRoute()
const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>
...@@ -105,10 +105,7 @@ ...@@ -105,10 +105,7 @@
</button> </button>
</div> </div>
<!-- Code Content --> <!-- Code Content -->
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"> <pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-if="file.highlighted" v-html="file.highlighted"></code><code v-else v-text="file.content"></code></pre>
<code v-if="file.highlighted" v-html="file.highlighted"></code>
<code v-else v-text="file.content"></code>
</pre>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p> <p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p> <p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
</div> </div>
<Icon <Icon
name="chevronRight" name="chevronRight"
......
...@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
} }
abortController = new AbortController() const currentController = new AbortController()
abortController = currentController
loading.value = true loading.value = true
try { try {
...@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -51,9 +52,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
toRaw(params) as P, toRaw(params) as P,
{ signal: abortController.signal } { signal: currentController.signal }
) )
items.value = response.items || [] items.value = response.items || []
pagination.total = response.total || 0 pagination.total = response.total || 0
pagination.pages = response.pages || 0 pagination.pages = response.pages || 0
...@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
throw error throw error
} }
} finally { } finally {
if (abortController && !abortController.signal.aborted) { if (abortController === currentController) {
loading.value = false loading.value = false
} }
} }
...@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const debouncedReload = useDebounceFn(reload, debounceMs) const debouncedReload = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.page = page // 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
load() load()
} }
......
...@@ -233,6 +233,15 @@ export default { ...@@ -233,6 +233,15 @@ export default {
sendingCode: 'Sending...', sendingCode: 'Sending...',
clickToResend: 'Click to resend code', clickToResend: 'Click to resend code',
resendCode: 'Resend verification code', resendCode: 'Resend verification code',
linuxdo: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
callbackTitle: 'Signing you in',
callbackProcessing: 'Completing login, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login'
},
oauth: { oauth: {
code: 'Code', code: 'Code',
state: 'State', state: 'State',
...@@ -365,6 +374,14 @@ export default { ...@@ -365,6 +374,14 @@ export default {
customKeyTooShort: 'Custom key must be at least 16 characters', customKeyTooShort: 'Custom key must be at least 16 characters',
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens', customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
customKeyRequired: 'Please enter a custom key', customKeyRequired: 'Please enter a custom key',
ipRestriction: 'IP Restriction',
ipWhitelist: 'IP Whitelist',
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.',
ipBlacklist: 'IP Blacklist',
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.',
ipRestrictionEnabled: 'IP restriction enabled',
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.', ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.',
ccsClientSelect: { ccsClientSelect: {
title: 'Select Client', title: 'Select Client',
...@@ -380,6 +397,8 @@ export default { ...@@ -380,6 +397,8 @@ export default {
usage: { usage: {
title: 'Usage Records', title: 'Usage Records',
description: 'View and analyze your API usage history', description: 'View and analyze your API usage history',
costDetails: 'Cost Breakdown',
tokenDetails: 'Token Breakdown',
totalRequests: 'Total Requests', totalRequests: 'Total Requests',
totalTokens: 'Total Tokens', totalTokens: 'Total Tokens',
totalCost: 'Total Cost', totalCost: 'Total Cost',
...@@ -423,10 +442,8 @@ export default { ...@@ -423,10 +442,8 @@ export default {
exportFailed: 'Failed to export usage data', exportFailed: 'Failed to export usage data',
exportExcelSuccess: 'Usage data exported successfully (Excel format)', exportExcelSuccess: 'Usage data exported successfully (Excel format)',
exportExcelFailed: 'Failed to export usage data', exportExcelFailed: 'Failed to export usage data',
billingType: 'Billing', imageUnit: ' images',
balance: 'Balance', userAgent: 'User-Agent'
subscription: 'Subscription',
imageUnit: ' images'
}, },
// Redeem // Redeem
...@@ -858,6 +875,15 @@ export default { ...@@ -858,6 +875,15 @@ export default {
imagePricing: { imagePricing: {
title: 'Image Generation Pricing', title: 'Image Generation Pricing',
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.' description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
},
claudeCode: {
title: 'Claude Code Client Restriction',
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
enabled: 'Claude Code Only',
disabled: 'Allow All Clients',
fallbackGroup: 'Fallback Group',
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)'
} }
}, },
...@@ -1013,6 +1039,7 @@ export default { ...@@ -1013,6 +1039,7 @@ export default {
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
tempUnschedulable: { tempUnschedulable: {
...@@ -1067,12 +1094,16 @@ export default { ...@@ -1067,12 +1094,16 @@ export default {
tokenRefreshed: 'Token refreshed successfully', tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully', accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully', rateLimitCleared: 'Rate limit cleared successfully',
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
bulkActions: { bulkActions: {
selected: '{count} account(s) selected', selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page', selectCurrentPage: 'Select this page',
clear: 'Clear selection', clear: 'Clear selection',
edit: 'Bulk Edit', edit: 'Bulk Edit',
delete: 'Bulk Delete' delete: 'Bulk Delete',
enableScheduling: 'Enable Scheduling',
disableScheduling: 'Disable Scheduling'
}, },
bulkEdit: { bulkEdit: {
title: 'Bulk Edit Accounts', title: 'Bulk Edit Accounts',
...@@ -1154,12 +1185,17 @@ export default { ...@@ -1154,12 +1185,17 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
expired: 'Expired',
proxy: 'Proxy', proxy: 'Proxy',
noProxy: 'No Proxy', noProxy: 'No Proxy',
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Higher priority accounts are used first', priorityHint: 'Lower value accounts are used first',
higherPriorityFirst: 'Higher value means higher priority', expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
mixedScheduling: 'Use in /v1/messages', mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip: mixedSchedulingTooltip:
...@@ -1472,6 +1508,7 @@ export default { ...@@ -1472,6 +1508,7 @@ export default {
testing: 'Testing...', testing: 'Testing...',
retry: 'Retry', retry: 'Retry',
copyOutput: 'Copy output', copyOutput: 'Copy output',
outputCopied: 'Output copied',
startingTestForAccount: 'Starting test for account: {name}', startingTestForAccount: 'Starting test for account: {name}',
testAccountTypeLabel: 'Account type: {type}', testAccountTypeLabel: 'Account type: {type}',
selectTestModel: 'Select Test Model', selectTestModel: 'Select Test Model',
...@@ -1556,6 +1593,7 @@ export default { ...@@ -1556,6 +1593,7 @@ export default {
protocol: 'Protocol', protocol: 'Protocol',
address: 'Address', address: 'Address',
status: 'Status', status: 'Status',
accounts: 'Accounts',
actions: 'Actions' actions: 'Actions'
}, },
testConnection: 'Test Connection', testConnection: 'Test Connection',
...@@ -1695,6 +1733,7 @@ export default { ...@@ -1695,6 +1733,7 @@ export default {
userFilter: 'User', userFilter: 'User',
searchUserPlaceholder: 'Search user by email...', searchUserPlaceholder: 'Search user by email...',
searchApiKeyPlaceholder: 'Search API key by name...', searchApiKeyPlaceholder: 'Search API key by name...',
searchAccountPlaceholder: 'Search account by name...',
selectedUser: 'Selected', selectedUser: 'Selected',
user: 'User', user: 'User',
account: 'Account', account: 'Account',
...@@ -1705,7 +1744,6 @@ export default { ...@@ -1705,7 +1744,6 @@ export default {
allAccounts: 'All Accounts', allAccounts: 'All Accounts',
allGroups: 'All Groups', allGroups: 'All Groups',
allTypes: 'All Types', allTypes: 'All Types',
allBillingTypes: 'All Billing',
inputCost: 'Input Cost', inputCost: 'Input Cost',
outputCost: 'Output Cost', outputCost: 'Output Cost',
cacheCreationCost: 'Cache Creation Cost', cacheCreationCost: 'Cache Creation Cost',
...@@ -1714,7 +1752,8 @@ export default { ...@@ -1714,7 +1752,8 @@ export default {
outputTokens: 'Output Tokens', outputTokens: 'Output Tokens',
cacheCreationTokens: 'Cache Creation Tokens', cacheCreationTokens: 'Cache Creation Tokens',
cacheReadTokens: 'Cache Read Tokens', cacheReadTokens: 'Cache Read Tokens',
failedToLoad: 'Failed to load usage records' failedToLoad: 'Failed to load usage records',
ipAddress: 'IP'
}, },
// Ops Monitoring // Ops Monitoring
...@@ -2207,6 +2246,26 @@ export default { ...@@ -2207,6 +2246,26 @@ export default {
cloudflareDashboard: 'Cloudflare Dashboard', cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' },
linuxdo: {
title: 'LinuxDo Connect Login',
description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login',
enable: 'Enable LinuxDo Login',
enableHint: 'Show LinuxDo login on the login/register pages',
clientId: 'Client ID',
clientIdPlaceholder: 'e.g., hprJ5pC3...',
clientIdHint: 'Get this from Connect.Linux.Do',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
redirectUrl: 'Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback',
redirectUrlHint:
'Must match the redirect URL configured in Connect.Linux.Do (must be an absolute http(s) URL)',
quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
},
defaults: { defaults: {
title: 'Default User Settings', title: 'Default User Settings',
description: 'Default values for new users', description: 'Default values for new users',
...@@ -2471,7 +2530,7 @@ export default { ...@@ -2471,7 +2530,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. Priority (Optional)', title: '⚖️ 4. Priority (Optional)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>', description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Lower number = higher priority</li><li>System uses low-value accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to lower value, backup accounts to higher value</p></div>',
nextBtn: 'Next' nextBtn: 'Next'
}, },
accountGroups: { accountGroups: {
......
...@@ -231,6 +231,15 @@ export default { ...@@ -231,6 +231,15 @@ export default {
sendingCode: '发送中...', sendingCode: '发送中...',
clickToResend: '点击重新发送验证码', clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码', resendCode: '重新发送验证码',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
callbackTitle: '正在完成登录',
callbackProcessing: '正在验证登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录'
},
oauth: { oauth: {
code: '授权码', code: '授权码',
state: '状态', state: '状态',
...@@ -362,6 +371,14 @@ export default { ...@@ -362,6 +371,14 @@ export default {
customKeyTooShort: '自定义密钥至少需要16个字符', customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符', customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥', customKeyRequired: '请输入自定义密钥',
ipRestriction: 'IP 限制',
ipWhitelist: 'IP 白名单',
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥',
ipBlacklist: 'IP 黑名单',
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥',
ipRestrictionEnabled: '已配置 IP 限制',
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。', ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
ccsClientSelect: { ccsClientSelect: {
title: '选择客户端', title: '选择客户端',
...@@ -377,6 +394,8 @@ export default { ...@@ -377,6 +394,8 @@ export default {
usage: { usage: {
title: '使用记录', title: '使用记录',
description: '查看和分析您的 API 使用历史', description: '查看和分析您的 API 使用历史',
costDetails: '成本明细',
tokenDetails: 'Token 明细',
totalRequests: '总请求数', totalRequests: '总请求数',
totalTokens: '总 Token', totalTokens: '总 Token',
totalCost: '总消费', totalCost: '总消费',
...@@ -420,10 +439,8 @@ export default { ...@@ -420,10 +439,8 @@ export default {
exportFailed: '使用数据导出失败', exportFailed: '使用数据导出失败',
exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelSuccess: '使用数据导出成功(Excel格式)',
exportExcelFailed: '使用数据导出失败', exportExcelFailed: '使用数据导出失败',
billingType: '消费类型', imageUnit: '',
balance: '余额', userAgent: 'User-Agent'
subscription: '订阅',
imageUnit: ''
}, },
// Redeem // Redeem
...@@ -861,7 +878,7 @@ export default { ...@@ -861,7 +878,7 @@ export default {
accountsLabel: '指定账号', accountsLabel: '指定账号',
accountsPlaceholder: '选择账号(留空则不限制)', accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高,用于账号调度', priorityHint: '数值越优先级越高,用于账号调度',
statusLabel: '状态' statusLabel: '状态'
}, },
exclusiveObj: { exclusiveObj: {
...@@ -935,6 +952,15 @@ export default { ...@@ -935,6 +952,15 @@ export default {
imagePricing: { imagePricing: {
title: '图片生成计费', title: '图片生成计费',
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格' description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip: '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
enabled: '仅限 Claude Code',
disabled: '允许所有客户端',
fallbackGroup: '降级分组',
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
} }
}, },
...@@ -1063,6 +1089,7 @@ export default { ...@@ -1063,6 +1089,7 @@ export default {
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制', clearRateLimit: '清除速率限制',
...@@ -1182,7 +1209,7 @@ export default { ...@@ -1182,7 +1209,7 @@ export default {
credentialsLabel: '凭证', credentialsLabel: '凭证',
credentialsPlaceholder: '请输入 Cookie 或 API Key', credentialsPlaceholder: '请输入 Cookie 或 API Key',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高', priorityHint: '数值越优先级越高',
weightLabel: '权重', weightLabel: '权重',
weightHint: '用于负载均衡的权重值', weightHint: '用于负载均衡的权重值',
statusLabel: '状态' statusLabel: '状态'
...@@ -1203,12 +1230,16 @@ export default { ...@@ -1203,12 +1230,16 @@ export default {
accountCreatedSuccess: '账号添加成功', accountCreatedSuccess: '账号添加成功',
accountUpdatedSuccess: '账号更新成功', accountUpdatedSuccess: '账号更新成功',
accountDeletedSuccess: '账号删除成功', accountDeletedSuccess: '账号删除成功',
bulkSchedulableEnabled: '成功启用 {count} 个账号的调度',
bulkSchedulableDisabled: '成功停止 {count} 个账号的调度',
bulkActions: { bulkActions: {
selected: '已选择 {count} 个账号', selected: '已选择 {count} 个账号',
selectCurrentPage: '本页全选', selectCurrentPage: '本页全选',
clear: '清除选择', clear: '清除选择',
edit: '批量编辑账号', edit: '批量编辑账号',
delete: '批量删除' delete: '批量删除',
enableScheduling: '批量启用调度',
disableScheduling: '批量停止调度'
}, },
bulkEdit: { bulkEdit: {
title: '批量编辑账号', title: '批量编辑账号',
...@@ -1288,12 +1319,17 @@ export default { ...@@ -1288,12 +1319,17 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理', proxy: '代理',
noProxy: '无代理', noProxy: '无代理',
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越高的账号优先使用', priorityHint: '优先级越小的账号优先使用',
higherPriorityFirst: '数值越高优先级越高', expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
mixedScheduling: '在 /v1/messages 中使用', mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip: mixedSchedulingTooltip:
...@@ -1587,6 +1623,7 @@ export default { ...@@ -1587,6 +1623,7 @@ export default {
startTest: '开始测试', startTest: '开始测试',
retry: '重试', retry: '重试',
copyOutput: '复制输出', copyOutput: '复制输出',
outputCopied: '输出已复制',
startingTestForAccount: '开始测试账号:{name}', startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}', testAccountTypeLabel: '账号类型:{type}',
selectTestModel: '选择测试模型', selectTestModel: '选择测试模型',
...@@ -1642,6 +1679,7 @@ export default { ...@@ -1642,6 +1679,7 @@ export default {
protocol: '协议', protocol: '协议',
address: '地址', address: '地址',
status: '状态', status: '状态',
accounts: '账号数',
actions: '操作', actions: '操作',
nameLabel: '名称', nameLabel: '名称',
namePlaceholder: '请输入代理名称', namePlaceholder: '请输入代理名称',
...@@ -1840,6 +1878,7 @@ export default { ...@@ -1840,6 +1878,7 @@ export default {
userFilter: '用户', userFilter: '用户',
searchUserPlaceholder: '按邮箱搜索用户...', searchUserPlaceholder: '按邮箱搜索用户...',
searchApiKeyPlaceholder: '按名称搜索 API 密钥...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
searchAccountPlaceholder: '按名称搜索账号...',
selectedUser: '已选择', selectedUser: '已选择',
user: '用户', user: '用户',
account: '账户', account: '账户',
...@@ -1850,7 +1889,6 @@ export default { ...@@ -1850,7 +1889,6 @@ export default {
allAccounts: '全部账户', allAccounts: '全部账户',
allGroups: '全部分组', allGroups: '全部分组',
allTypes: '全部类型', allTypes: '全部类型',
allBillingTypes: '全部计费',
inputCost: '输入成本', inputCost: '输入成本',
outputCost: '输出成本', outputCost: '输出成本',
cacheCreationCost: '缓存创建成本', cacheCreationCost: '缓存创建成本',
...@@ -1859,7 +1897,8 @@ export default { ...@@ -1859,7 +1897,8 @@ export default {
outputTokens: '输出 Token', outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token', cacheCreationTokens: '缓存创建 Token',
cacheReadTokens: '缓存读取 Token', cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败' failedToLoad: '加载使用记录失败',
ipAddress: 'IP'
}, },
// Ops Monitoring // Ops Monitoring
...@@ -2352,6 +2391,25 @@ export default { ...@@ -2352,6 +2391,25 @@ export default {
cloudflareDashboard: 'Cloudflare Dashboard', cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)', secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
linuxdo: {
title: 'LinuxDo Connect 登录',
description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录',
enable: '启用 LinuxDo 登录',
enableHint: '在登录/注册页面显示 LinuxDo 登录入口',
clientId: 'Client ID',
clientIdPlaceholder: '例如:hprJ5pC3...',
clientIdHint: '从 Connect.Linux.Do 后台获取',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: '用于后端交换 token(请保密)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
redirectUrl: '回调地址(Redirect URL)',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback',
redirectUrlHint: '需与 Connect.Linux.Do 中配置的回调地址一致(必须是 http(s) 完整 URL)',
quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
},
defaults: { defaults: {
title: '用户默认设置', title: '用户默认设置',
description: '新用户的默认值', description: '新用户的默认值',
...@@ -2613,7 +2671,7 @@ export default { ...@@ -2613,7 +2671,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. 优先级(可选)', title: '⚖️ 4. 优先级(可选)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>', description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
nextBtn: '下一步' nextBtn: '下一步'
}, },
accountGroups: { accountGroups: {
......
...@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [ ...@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [
title: 'OAuth Callback' title: 'OAuth Callback'
} }
}, },
{
path: '/auth/linuxdo/callback',
name: 'LinuxDoOAuthCallback',
component: () => import('@/views/auth/LinuxDoCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'LinuxDo OAuth Callback'
}
},
// ==================== User Routes ==================== // ==================== User Routes ====================
{ {
......
...@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
const contactInfo = ref<string>('') const contactInfo = ref<string>('')
const apiBaseUrl = ref<string>('') const apiBaseUrl = ref<string>('')
const docUrl = ref<string>('') const docUrl = ref<string>('')
const cachedPublicSettings = ref<PublicSettings | null>(null)
// Version cache state // Version cache state
const versionLoaded = ref<boolean>(false) const versionLoaded = ref<boolean>(false)
...@@ -285,6 +286,9 @@ export const useAppStore = defineStore('app', () => { ...@@ -285,6 +286,9 @@ export const useAppStore = defineStore('app', () => {
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> { async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
// Return cached data if available and not forcing refresh // Return cached data if available and not forcing refresh
if (publicSettingsLoaded.value && !force) { if (publicSettingsLoaded.value && !force) {
if (cachedPublicSettings.value) {
return { ...cachedPublicSettings.value }
}
return { return {
registration_enabled: false, registration_enabled: false,
email_verify_enabled: false, email_verify_enabled: false,
...@@ -296,6 +300,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -296,6 +300,7 @@ export const useAppStore = defineStore('app', () => {
api_base_url: apiBaseUrl.value, api_base_url: apiBaseUrl.value,
contact_info: contactInfo.value, contact_info: contactInfo.value,
doc_url: docUrl.value, doc_url: docUrl.value,
linuxdo_oauth_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
} }
...@@ -308,6 +313,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -308,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
publicSettingsLoading.value = true publicSettingsLoading.value = true
try { try {
const data = await fetchPublicSettingsAPI() const data = await fetchPublicSettingsAPI()
cachedPublicSettings.value = data
siteName.value = data.site_name || 'Sub2API' siteName.value = data.site_name || 'Sub2API'
siteLogo.value = data.site_logo || '' siteLogo.value = data.site_logo || ''
siteVersion.value = data.version || '' siteVersion.value = data.version || ''
...@@ -329,6 +335,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -329,6 +335,7 @@ export const useAppStore = defineStore('app', () => {
*/ */
function clearPublicSettingsCache(): void { function clearPublicSettingsCache(): void {
publicSettingsLoaded.value = false publicSettingsLoaded.value = false
cachedPublicSettings.value = null
} }
// ==================== Return Store API ==================== // ==================== Return Store API ====================
......
...@@ -159,6 +159,27 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -159,6 +159,27 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
/**
* 直接设置 token(用于 OAuth/SSO 回调),并加载当前用户信息。
* @param newToken - 后端签发的 JWT access token
*/
async function setToken(newToken: string): Promise<User> {
// Clear any previous state first (avoid mixing sessions)
clearAuth()
token.value = newToken
localStorage.setItem(AUTH_TOKEN_KEY, newToken)
try {
const userData = await refreshUser()
startAutoRefresh()
return userData
} catch (error) {
clearAuth()
throw error
}
}
/** /**
* User logout * User logout
* Clears all authentication state and persisted data * Clears all authentication state and persisted data
...@@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => {
// Actions // Actions
login, login,
register, register,
setToken,
logout, logout,
checkAuth, checkAuth,
refreshUser refreshUser
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment