Commit 11bfc807 authored by song's avatar song
Browse files

merge upstream/main

parents c2a6ca8d 62dc0b95
......@@ -115,15 +115,9 @@
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-request_id="{ row }">
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</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')">
<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" />
</button>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
<template #cell-user_agent="{ row }">
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
......@@ -228,7 +222,6 @@
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format'
import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
......@@ -236,8 +229,6 @@ import type { UsageLog } from '@/types'
defineProps(['data', 'loading'])
const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null)
// Tooltip state - cost
const tooltipVisible = ref(false)
......@@ -262,7 +253,7 @@ const cols = computed(() => [
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
])
const formatCacheTokens = (tokens: number): string => {
......@@ -271,23 +262,25 @@ const formatCacheTokens = (tokens: number): string => {
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 => {
if (ms == null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
const copyRequestId = async (requestId: string) => {
try {
await navigator.clipboard.writeText(requestId)
copiedRequestId.value = requestId
appStore.showSuccess(t('admin.usage.requestIdCopied'))
setTimeout(() => { copiedRequestId.value = null }, 2000)
} catch {
appStore.showError(t('common.copyFailed'))
}
}
// Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
......
<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>
......@@ -43,7 +43,8 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
const currentController = new AbortController()
abortController = currentController
loading.value = true
try {
......@@ -51,7 +52,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
pagination.page,
pagination.page_size,
toRaw(params) as P,
{ signal: abortController.signal }
{ signal: currentController.signal }
)
items.value = response.items || []
......@@ -63,7 +64,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
throw error
}
} finally {
if (abortController && !abortController.signal.aborted) {
if (abortController === currentController) {
loading.value = false
}
}
......@@ -77,7 +78,9 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const debouncedReload = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => {
pagination.page = page
// 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
load()
}
......
......@@ -229,6 +229,15 @@ export default {
sendingCode: 'Sending...',
clickToResend: 'Click to resend 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: {
code: 'Code',
state: 'State',
......@@ -424,7 +433,8 @@ export default {
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription',
imageUnit: ' images'
imageUnit: ' images',
userAgent: 'User-Agent'
},
// Redeem
......@@ -856,6 +866,15 @@ export default {
imagePricing: {
title: 'Image Generation Pricing',
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)'
}
},
......@@ -1066,12 +1085,16 @@ export default {
tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully',
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
bulkActions: {
selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page',
clear: 'Clear selection',
edit: 'Bulk Edit',
delete: 'Bulk Delete'
delete: 'Bulk Delete',
enableScheduling: 'Enable Scheduling',
disableScheduling: 'Disable Scheduling'
},
bulkEdit: {
title: 'Bulk Edit Accounts',
......@@ -1476,6 +1499,7 @@ export default {
testing: 'Testing...',
retry: 'Retry',
copyOutput: 'Copy output',
outputCopied: 'Output copied',
startingTestForAccount: 'Starting test for account: {name}',
testAccountTypeLabel: 'Account type: {type}',
selectTestModel: 'Select Test Model',
......@@ -1560,6 +1584,7 @@ export default {
protocol: 'Protocol',
address: 'Address',
status: 'Status',
accounts: 'Accounts',
actions: 'Actions'
},
testConnection: 'Test Connection',
......@@ -1745,6 +1770,26 @@ export default {
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)',
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: {
title: 'Default User Settings',
description: 'Default values for new users',
......
......@@ -227,6 +227,15 @@ export default {
sendingCode: '发送中...',
clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
callbackTitle: '正在完成登录',
callbackProcessing: '正在验证登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录'
},
oauth: {
code: '授权码',
state: '状态',
......@@ -421,7 +430,8 @@ export default {
billingType: '消费类型',
balance: '余额',
subscription: '订阅',
imageUnit: ''
imageUnit: '',
userAgent: 'User-Agent'
},
// Redeem
......@@ -933,6 +943,15 @@ export default {
imagePricing: {
title: '图片生成计费',
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip: '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
enabled: '仅限 Claude Code',
disabled: '允许所有客户端',
fallbackGroup: '降级分组',
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
}
},
......@@ -1202,12 +1221,16 @@ export default {
accountCreatedSuccess: '账号添加成功',
accountUpdatedSuccess: '账号更新成功',
accountDeletedSuccess: '账号删除成功',
bulkSchedulableEnabled: '成功启用 {count} 个账号的调度',
bulkSchedulableDisabled: '成功停止 {count} 个账号的调度',
bulkActions: {
selected: '已选择 {count} 个账号',
selectCurrentPage: '本页全选',
clear: '清除选择',
edit: '批量编辑账号',
delete: '批量删除'
delete: '批量删除',
enableScheduling: '批量启用调度',
disableScheduling: '批量停止调度'
},
bulkEdit: {
title: '批量编辑账号',
......@@ -1591,6 +1614,7 @@ export default {
startTest: '开始测试',
retry: '重试',
copyOutput: '复制输出',
outputCopied: '输出已复制',
startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}',
selectTestModel: '选择测试模型',
......@@ -1646,6 +1670,7 @@ export default {
protocol: '协议',
address: '地址',
status: '状态',
accounts: '账号数',
actions: '操作',
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
......@@ -1890,6 +1915,25 @@ export default {
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)',
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: {
title: '用户默认设置',
description: '新用户的默认值',
......
......@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [
title: 'OAuth Callback'
}
},
{
path: '/auth/linuxdo/callback',
name: 'LinuxDoOAuthCallback',
component: () => import('@/views/auth/LinuxDoCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'LinuxDo OAuth Callback'
}
},
// ==================== User Routes ====================
{
......
......@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
const contactInfo = ref<string>('')
const apiBaseUrl = ref<string>('')
const docUrl = ref<string>('')
const cachedPublicSettings = ref<PublicSettings | null>(null)
// Version cache state
const versionLoaded = ref<boolean>(false)
......@@ -285,6 +286,9 @@ export const useAppStore = defineStore('app', () => {
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
// Return cached data if available and not forcing refresh
if (publicSettingsLoaded.value && !force) {
if (cachedPublicSettings.value) {
return { ...cachedPublicSettings.value }
}
return {
registration_enabled: false,
email_verify_enabled: false,
......@@ -296,6 +300,7 @@ export const useAppStore = defineStore('app', () => {
api_base_url: apiBaseUrl.value,
contact_info: contactInfo.value,
doc_url: docUrl.value,
linuxdo_oauth_enabled: false,
version: siteVersion.value
}
}
......@@ -308,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
publicSettingsLoading.value = true
try {
const data = await fetchPublicSettingsAPI()
cachedPublicSettings.value = data
siteName.value = data.site_name || 'Sub2API'
siteLogo.value = data.site_logo || ''
siteVersion.value = data.version || ''
......@@ -329,6 +335,7 @@ export const useAppStore = defineStore('app', () => {
*/
function clearPublicSettingsCache(): void {
publicSettingsLoaded.value = false
cachedPublicSettings.value = null
}
// ==================== Return Store API ====================
......
......@@ -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
* Clears all authentication state and persisted data
......@@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => {
// Actions
login,
register,
setToken,
logout,
checkAuth,
refreshUser
......
......@@ -73,6 +73,7 @@ export interface PublicSettings {
api_base_url: string
contact_info: string
doc_url: string
linuxdo_oauth_enabled: boolean
version: string
}
......@@ -263,6 +264,9 @@ export interface Group {
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
account_count?: number
created_at: string
updated_at: string
......@@ -298,6 +302,15 @@ export interface CreateGroupRequest {
platform?: GroupPlatform
rate_multiplier?: number
is_exclusive?: boolean
subscription_type?: SubscriptionType
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
}
export interface UpdateGroupRequest {
......@@ -307,6 +320,15 @@ export interface UpdateGroupRequest {
rate_multiplier?: number
is_exclusive?: boolean
status?: 'active' | 'inactive'
subscription_type?: SubscriptionType
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
}
// ==================== Account & Proxy Types ====================
......@@ -576,6 +598,9 @@ export interface UsageLog {
image_count: number
image_size: string | null
// User-Agent
user_agent: string | null
created_at: string
user?: User
......
......@@ -61,7 +61,7 @@
<!-- Login / Dashboard Button -->
<router-link
v-if="isAuthenticated"
to="/dashboard"
:to="dashboardPath"
class="inline-flex items-center gap-1.5 rounded-full bg-gray-900 py-1 pl-1 pr-2.5 transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<span
......@@ -114,7 +114,7 @@
<!-- CTA Button -->
<div>
<router-link
:to="isAuthenticated ? '/dashboard' : '/login'"
:to="isAuthenticated ? dashboardPath : '/login'"
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
>
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
......@@ -416,6 +416,8 @@ const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isAdmin = computed(() => authStore.isAdmin)
const dashboardPath = computed(() => isAdmin.value ? '/admin/dashboard' : '/dashboard')
const userInitial = computed(() => {
const user = authStore.user
if (!user || !user.email) return ''
......
......@@ -7,7 +7,7 @@
v-model:searchQuery="params.search"
:filters="params"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="reload"
@change="debouncedReload"
@update:searchQuery="debouncedReload"
/>
<AccountTableActions
......@@ -19,7 +19,7 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" />
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<DataTable :columns="cols" :data="accounts" :loading="loading">
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
......@@ -107,7 +107,7 @@
</template>
</DataTable>
</template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" /></template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
</TablePageLayout>
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" />
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="load" />
......@@ -175,7 +175,7 @@ const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange } = useTableLoader<Account, any>({
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' }
})
......@@ -209,6 +209,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const handleBulkToggleSchedulable = async (schedulable: boolean) => {
const count = selIds.value.length
try {
const result = await adminAPI.accounts.bulkUpdate(selIds.value, { schedulable });
const message = schedulable
? t('admin.accounts.bulkSchedulableEnabled', { count: result.success || count })
: t('admin.accounts.bulkSchedulableDisabled', { count: result.success || count });
appStore.showSuccess(message);
selIds.value = [];
reload()
} catch (error) {
console.error('Failed to bulk toggle schedulable:', error);
appStore.showError(t('common.error'))
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
......
......@@ -16,6 +16,7 @@
type="text"
:placeholder="t('admin.groups.searchGroups')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<Select
......@@ -64,7 +65,7 @@
</template>
<template #table>
<DataTable :columns="columns" :data="displayedGroups" :loading="loading">
<DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
......@@ -403,6 +404,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.claude_code_only = !createForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="createForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="createForm.fallback_group_id"
:options="fallbackGroupOptions"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
......@@ -648,6 +705,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.claude_code_only = !editForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="editForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="editForm.fallback_group_id"
:options="fallbackGroupOptionsForEdit"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
......@@ -774,6 +887,35 @@ const subscriptionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
])
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const fallbackGroupOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active'
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 降级分组选项(编辑时)- 排除自身
const fallbackGroupOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active' && g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
const groups = ref<Group[]>([])
const loading = ref(false)
const searchQuery = ref('')
......@@ -791,16 +933,6 @@ const pagination = reactive({
let abortController: AbortController | null = null
const displayedGroups = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return groups.value
return groups.value.filter((group) => {
const name = group.name?.toLowerCase?.() ?? ''
const description = group.description?.toLowerCase?.() ?? ''
return name.includes(q) || description.includes(q)
})
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
......@@ -821,7 +953,10 @@ const createForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
const editForm = reactive({
......@@ -838,7 +973,10 @@ const editForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
// 根据分组类型返回不同的删除确认消息
......@@ -864,7 +1002,8 @@ const loadGroups = async () => {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined,
search: searchQuery.value.trim() || undefined
}, { signal })
if (signal.aborted) return
groups.value = response.items
......@@ -883,6 +1022,15 @@ const loadGroups = async () => {
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadGroups()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadGroups()
......@@ -908,6 +1056,8 @@ const closeCreateModal = () => {
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
}
const handleCreateGroup = async () => {
......@@ -949,6 +1099,8 @@ const handleEdit = (group: Group) => {
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
showEditModal.value = true
}
......@@ -966,7 +1118,12 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
await adminAPI.groups.update(editingGroup.value.id, editForm)
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id
}
await adminAPI.groups.update(editingGroup.value.id, payload)
appStore.showSuccess(t('admin.groups.groupUpdated'))
closeEditModal()
loadGroups()
......
......@@ -85,6 +85,14 @@
</span>
</template>
<template #cell-account_count="{ value }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
......@@ -511,7 +519,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
......@@ -534,6 +542,7 @@ const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
])
......@@ -933,4 +942,9 @@ const confirmDelete = async () => {
onMounted(() => {
loadProxies()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
......@@ -364,7 +364,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
......@@ -693,4 +693,9 @@ onMounted(() => {
loadCodes()
loadSubscriptionGroups()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
......@@ -261,6 +261,106 @@
</div>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.linuxdo.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.linuxdo.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.linuxdo.enable')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.linuxdo.enableHint') }}
</p>
</div>
<Toggle v-model="form.linuxdo_connect_enabled" />
</div>
<div
v-if="form.linuxdo_connect_enabled"
class="border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.linuxdo.clientId') }}
</label>
<input
v-model="form.linuxdo_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.linuxdo.clientIdPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.linuxdo.clientIdHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.linuxdo.clientSecret') }}
</label>
<input
v-model="form.linuxdo_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.linuxdo_connect_client_secret_configured
? t('admin.settings.linuxdo.clientSecretConfiguredPlaceholder')
: t('admin.settings.linuxdo.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.linuxdo_connect_client_secret_configured
? t('admin.settings.linuxdo.clientSecretConfiguredHint')
: t('admin.settings.linuxdo.clientSecretHint')
}}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.linuxdo.redirectUrl') }}
</label>
<input
v-model="form.linuxdo_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.linuxdo.redirectUrlPlaceholder')"
/>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="button"
class="btn btn-secondary btn-sm w-fit"
@click="setAndCopyLinuxdoRedirectUrl"
>
{{ t('admin.settings.linuxdo.quickSetCopy') }}
</button>
<code
v-if="linuxdoRedirectUrlSuggestion"
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{ linuxdoRedirectUrlSuggestion }}
</code>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.linuxdo.redirectUrlHint') }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Default Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
......@@ -692,17 +792,19 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import Toggle from '@/components/common/Toggle.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const loading = ref(true)
const saving = ref(false)
......@@ -721,6 +823,7 @@ const newAdminApiKey = ref('')
type SettingsForm = SystemSettings & {
smtp_password: string
turnstile_secret_key: string
linuxdo_connect_client_secret: string
}
const form = reactive<SettingsForm>({
......@@ -747,11 +850,32 @@ const form = reactive<SettingsForm>({
turnstile_site_key: '',
turnstile_secret_key: '',
turnstile_secret_key_configured: false,
// LinuxDo Connect OAuth(终端用户登录)
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: '',
linuxdo_connect_client_secret: '',
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: '',
// Identity patch (Claude -> Gemini)
enable_identity_patch: true,
identity_patch_prompt: ''
})
const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
const origin =
window.location.origin || `${window.location.protocol}//${window.location.host}`
return `${origin}/api/v1/auth/oauth/linuxdo/callback`
})
async function setAndCopyLinuxdoRedirectUrl() {
const url = linuxdoRedirectUrlSuggestion.value
if (!url) return
form.linuxdo_connect_redirect_url = url
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
}
function handleLogoUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
......@@ -797,6 +921,7 @@ async function loadSettings() {
Object.assign(form, settings)
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
......@@ -829,12 +954,17 @@ async function saveSettings() {
smtp_use_tls: form.smtp_use_tls,
turnstile_enabled: form.turnstile_enabled,
turnstile_site_key: form.turnstile_site_key,
turnstile_secret_key: form.turnstile_secret_key || undefined
turnstile_secret_key: form.turnstile_secret_key || undefined,
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
// Refresh cached public settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))
......
......@@ -96,7 +96,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId')
t('admin.usage.requestId'), t('usage.userAgent')
]
const rows = all.map(log => [
log.created_at,
......@@ -120,7 +120,8 @@ const exportToExcel = async () => {
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || ''
log.request_id || '',
log.user_agent || ''
])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new()
......
......@@ -893,12 +893,13 @@ const loadUsers = async () => {
}
}
}
} catch (error) {
} catch (error: any) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.users.failedToLoad'))
const message = error.response?.data?.detail || error.message || t('admin.users.failedToLoad')
appStore.showError(message)
console.error('Error loading users:', error)
} finally {
if (abortController === currentAbortController) {
......@@ -917,7 +918,9 @@ const handleSearch = () => {
}
const handlePageChange = (page: number) => {
pagination.page = page
// 确保页码在有效范围内
const validPage = Math.max(1, Math.min(page, pagination.pages || 1))
pagination.page = validPage
loadUsers()
}
......@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters.add(key)
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
......@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
activeAttributeFilters[attr.id] = ''
}
saveFiltersToStorage()
pagination.page = 1
loadUsers()
}
......@@ -1059,5 +1064,7 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.linuxdo.callbackTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ isProcessing ? t('auth.linuxdo.callbackProcessing') : t('auth.linuxdo.callbackHint') }}
</p>
</div>
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<div class="space-y-2">
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<router-link to="/login" class="btn btn-primary">
{{ t('auth.linuxdo.backToLogin') }}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import { useAuthStore, useAppStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const isProcessing = ref(true)
const errorMessage = ref('')
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
onMounted(async () => {
const params = parseFragmentParams()
const token = params.get('access_token') || ''
const redirect = sanitizeRedirectPath(
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
)
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
if (!token) {
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
try {
await authStore.setToken(token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { detail?: string } } }
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
appStore.showError(errorMessage.value)
isProcessing.value = false
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
......@@ -11,6 +11,9 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input -->
......@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
......@@ -179,6 +183,7 @@ const showPassword = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
......@@ -210,6 +215,7 @@ onMounted(async () => {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
}
......
......@@ -11,6 +11,9 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
<!-- Registration Disabled Message -->
<div
v-if="!registrationEnabled && settingsLoaded"
......@@ -181,6 +184,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
......@@ -207,6 +211,7 @@ const emailVerifyEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
......@@ -233,6 +238,7 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
......
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