Commit 61f55674 authored by yangjianbo's avatar yangjianbo
Browse files
parents eeb1282f 7d1fe818
<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
...@@ -127,12 +127,6 @@ ...@@ -127,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>
...@@ -227,12 +221,6 @@ const streamTypeOptions = ref<SelectOption[]>([ ...@@ -227,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) => {
......
...@@ -96,12 +96,6 @@ ...@@ -96,12 +96,6 @@
</div> </div>
</template> </template>
<template #cell-billing_type="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }"> <template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span> <span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
...@@ -120,6 +114,11 @@ ...@@ -120,6 +114,11 @@
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template> </template>
<template #cell-ip_address="{ row }">
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template> <template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable> </DataTable>
</div> </div>
...@@ -249,11 +248,11 @@ const cols = computed(() => [ ...@@ -249,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: 'user_agent', label: t('usage.userAgent'), 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 => {
......
<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 ...@@ -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()
} }
......
...@@ -229,6 +229,15 @@ export default { ...@@ -229,6 +229,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',
...@@ -361,6 +370,14 @@ export default { ...@@ -361,6 +370,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',
...@@ -421,9 +438,6 @@ export default { ...@@ -421,9 +438,6 @@ 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',
balance: 'Balance',
subscription: 'Subscription',
imageUnit: ' images', imageUnit: ' images',
userAgent: 'User-Agent' userAgent: 'User-Agent'
}, },
...@@ -1076,12 +1090,16 @@ export default { ...@@ -1076,12 +1090,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',
...@@ -1486,6 +1504,7 @@ export default { ...@@ -1486,6 +1504,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',
...@@ -1721,7 +1740,6 @@ export default { ...@@ -1721,7 +1740,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',
...@@ -1730,7 +1748,8 @@ export default { ...@@ -1730,7 +1748,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'
}, },
// Settings // Settings
...@@ -1756,6 +1775,26 @@ export default { ...@@ -1756,6 +1775,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',
......
...@@ -227,6 +227,15 @@ export default { ...@@ -227,6 +227,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: '状态',
...@@ -358,6 +367,14 @@ export default { ...@@ -358,6 +367,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: '选择客户端',
...@@ -418,9 +435,6 @@ export default { ...@@ -418,9 +435,6 @@ export default {
exportFailed: '使用数据导出失败', exportFailed: '使用数据导出失败',
exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelSuccess: '使用数据导出成功(Excel格式)',
exportExcelFailed: '使用数据导出失败', exportExcelFailed: '使用数据导出失败',
billingType: '消费类型',
balance: '余额',
subscription: '订阅',
imageUnit: '', imageUnit: '',
userAgent: 'User-Agent' userAgent: 'User-Agent'
}, },
...@@ -1212,12 +1226,16 @@ export default { ...@@ -1212,12 +1226,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: '批量编辑账号',
...@@ -1601,6 +1619,7 @@ export default { ...@@ -1601,6 +1619,7 @@ export default {
startTest: '开始测试', startTest: '开始测试',
retry: '重试', retry: '重试',
copyOutput: '复制输出', copyOutput: '复制输出',
outputCopied: '输出已复制',
startingTestForAccount: '开始测试账号:{name}', startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}', testAccountTypeLabel: '账号类型:{type}',
selectTestModel: '选择测试模型', selectTestModel: '选择测试模型',
...@@ -1866,7 +1885,6 @@ export default { ...@@ -1866,7 +1885,6 @@ export default {
allAccounts: '全部账户', allAccounts: '全部账户',
allGroups: '全部分组', allGroups: '全部分组',
allTypes: '全部类型', allTypes: '全部类型',
allBillingTypes: '全部计费',
inputCost: '输入成本', inputCost: '输入成本',
outputCost: '输出成本', outputCost: '输出成本',
cacheCreationCost: '缓存创建成本', cacheCreationCost: '缓存创建成本',
...@@ -1875,7 +1893,8 @@ export default { ...@@ -1875,7 +1893,8 @@ export default {
outputTokens: '输出 Token', outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token', cacheCreationTokens: '缓存创建 Token',
cacheReadTokens: '缓存读取 Token', cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败' failedToLoad: '加载使用记录失败',
ipAddress: 'IP'
}, },
// Settings // Settings
...@@ -1901,6 +1920,25 @@ export default { ...@@ -1901,6 +1920,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: '新用户的默认值',
......
...@@ -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
......
...@@ -73,6 +73,7 @@ export interface PublicSettings { ...@@ -73,6 +73,7 @@ export interface PublicSettings {
api_base_url: string api_base_url: string
contact_info: string contact_info: string
doc_url: string doc_url: string
linuxdo_oauth_enabled: boolean
version: string version: string
} }
...@@ -278,6 +279,8 @@ export interface ApiKey { ...@@ -278,6 +279,8 @@ export interface ApiKey {
name: string name: string
group_id: number | null group_id: number | null
status: 'active' | 'inactive' status: 'active' | 'inactive'
ip_whitelist: string[]
ip_blacklist: string[]
created_at: string created_at: string
updated_at: string updated_at: string
group?: Group group?: Group
...@@ -287,12 +290,16 @@ export interface CreateApiKeyRequest { ...@@ -287,12 +290,16 @@ export interface CreateApiKeyRequest {
name: string name: string
group_id?: number | null group_id?: number | null
custom_key?: string // Optional custom API Key custom_key?: string // Optional custom API Key
ip_whitelist?: string[]
ip_blacklist?: string[]
} }
export interface UpdateApiKeyRequest { export interface UpdateApiKeyRequest {
name?: string name?: string
group_id?: number | null group_id?: number | null
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
ip_whitelist?: string[]
ip_blacklist?: string[]
} }
export interface CreateGroupRequest { export interface CreateGroupRequest {
...@@ -559,9 +566,6 @@ export interface UpdateProxyRequest { ...@@ -559,9 +566,6 @@ export interface UpdateProxyRequest {
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
// 消费类型: 0=钱包余额, 1=订阅套餐
export type BillingType = 0 | 1
export interface UsageLog { export interface UsageLog {
id: number id: number
user_id: number user_id: number
...@@ -588,7 +592,6 @@ export interface UsageLog { ...@@ -588,7 +592,6 @@ export interface UsageLog {
actual_cost: number actual_cost: number
rate_multiplier: number rate_multiplier: number
billing_type: BillingType
stream: boolean stream: boolean
duration_ms: number duration_ms: number
first_token_ms: number | null first_token_ms: number | null
...@@ -600,6 +603,9 @@ export interface UsageLog { ...@@ -600,6 +603,9 @@ export interface UsageLog {
// User-Agent // User-Agent
user_agent: string | null user_agent: string | null
// IP 地址(仅管理员可见)
ip_address: string | null
created_at: string created_at: string
user?: User user?: User
...@@ -829,7 +835,6 @@ export interface UsageQueryParams { ...@@ -829,7 +835,6 @@ export interface UsageQueryParams {
group_id?: number group_id?: number
model?: string model?: string
stream?: boolean stream?: boolean
billing_type?: number
start_date?: string start_date?: string
end_date?: string end_date?: string
} }
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
v-model:searchQuery="params.search" v-model:searchQuery="params.search"
:filters="params" :filters="params"
@update:filters="(newFilters) => Object.assign(params, newFilters)" @update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="reload" @change="debouncedReload"
@update:searchQuery="debouncedReload" @update:searchQuery="debouncedReload"
/> />
<AccountTableActions <AccountTableActions
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
</div> </div>
</template> </template>
<template #table> <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"> <DataTable :columns="cols" :data="accounts" :loading="loading">
<template #cell-select="{ row }"> <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" /> <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 @@ ...@@ -107,7 +107,7 @@
</template> </template>
</DataTable> </DataTable>
</template> </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> </TablePageLayout>
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" /> <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" /> <EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="load" />
...@@ -175,7 +175,7 @@ const statsAcc = ref<Account | null>(null) ...@@ -175,7 +175,7 @@ const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | 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 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, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' } initialParams: { platform: '', type: '', status: '', search: '' }
}) })
...@@ -209,6 +209,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top ...@@ -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 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 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 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 handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
type="text" type="text"
:placeholder="t('admin.groups.searchGroups')" :placeholder="t('admin.groups.searchGroups')"
class="input pl-10" class="input pl-10"
@input="handleSearch"
/> />
</div> </div>
<Select <Select
...@@ -64,7 +65,7 @@ ...@@ -64,7 +65,7 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="displayedGroups" :loading="loading"> <DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -932,16 +933,6 @@ const pagination = reactive({ ...@@ -932,16 +933,6 @@ const pagination = reactive({
let abortController: AbortController | null = null 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 showCreateModal = ref(false)
const showEditModal = ref(false) const showEditModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
...@@ -1011,7 +1002,8 @@ const loadGroups = async () => { ...@@ -1011,7 +1002,8 @@ const loadGroups = async () => {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined, platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any, 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 }) }, { signal })
if (signal.aborted) return if (signal.aborted) return
groups.value = response.items groups.value = response.items
...@@ -1030,6 +1022,15 @@ const loadGroups = async () => { ...@@ -1030,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) => { const handlePageChange = (page: number) => {
pagination.page = page pagination.page = page
loadGroups() loadGroups()
......
...@@ -519,7 +519,7 @@ ...@@ -519,7 +519,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
...@@ -942,4 +942,9 @@ const confirmDelete = async () => { ...@@ -942,4 +942,9 @@ const confirmDelete = async () => {
onMounted(() => { onMounted(() => {
loadProxies() loadProxies()
}) })
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script> </script>
...@@ -364,7 +364,7 @@ ...@@ -364,7 +364,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
...@@ -693,4 +693,9 @@ onMounted(() => { ...@@ -693,4 +693,9 @@ onMounted(() => {
loadCodes() loadCodes()
loadSubscriptionGroups() loadSubscriptionGroups()
}) })
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script> </script>
...@@ -261,6 +261,106 @@ ...@@ -261,6 +261,106 @@
</div> </div>
</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 --> <!-- Default Settings -->
<div class="card"> <div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
...@@ -692,17 +792,19 @@ ...@@ -692,17 +792,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api' import { adminAPI } from '@/api'
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings' import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
...@@ -721,6 +823,7 @@ const newAdminApiKey = ref('') ...@@ -721,6 +823,7 @@ const newAdminApiKey = ref('')
type SettingsForm = SystemSettings & { type SettingsForm = SystemSettings & {
smtp_password: string smtp_password: string
turnstile_secret_key: string turnstile_secret_key: string
linuxdo_connect_client_secret: string
} }
const form = reactive<SettingsForm>({ const form = reactive<SettingsForm>({
...@@ -747,11 +850,32 @@ const form = reactive<SettingsForm>({ ...@@ -747,11 +850,32 @@ const form = reactive<SettingsForm>({
turnstile_site_key: '', turnstile_site_key: '',
turnstile_secret_key: '', turnstile_secret_key: '',
turnstile_secret_key_configured: false, 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) // Identity patch (Claude -> Gemini)
enable_identity_patch: true, enable_identity_patch: true,
identity_patch_prompt: '' 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) { function handleLogoUpload(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
...@@ -797,6 +921,7 @@ async function loadSettings() { ...@@ -797,6 +921,7 @@ async function loadSettings() {
Object.assign(form, settings) Object.assign(form, settings)
form.smtp_password = '' form.smtp_password = ''
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
} catch (error: any) { } catch (error: any) {
appStore.showError( appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')) t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
...@@ -829,12 +954,17 @@ async function saveSettings() { ...@@ -829,12 +954,17 @@ async function saveSettings() {
smtp_use_tls: form.smtp_use_tls, smtp_use_tls: form.smtp_use_tls,
turnstile_enabled: form.turnstile_enabled, turnstile_enabled: form.turnstile_enabled,
turnstile_site_key: form.turnstile_site_key, 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) const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated) Object.assign(form, updated)
form.smtp_password = '' form.smtp_password = ''
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
// Refresh cached public settings so sidebar/header update immediately // Refresh cached public settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true) await appStore.fetchPublicSettings(true)
appStore.showSuccess(t('admin.settings.settingsSaved')) appStore.showSuccess(t('admin.settings.settingsSaved'))
......
...@@ -95,8 +95,8 @@ const exportToExcel = async () => { ...@@ -95,8 +95,8 @@ const exportToExcel = async () => {
t('admin.usage.inputCost'), t('admin.usage.outputCost'), t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'), t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent') t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
] ]
const rows = all.map(log => [ const rows = all.map(log => [
log.created_at, log.created_at,
...@@ -117,11 +117,11 @@ const exportToExcel = async () => { ...@@ -117,11 +117,11 @@ const exportToExcel = async () => {
log.rate_multiplier?.toFixed(2) || '1.00', log.rate_multiplier?.toFixed(2) || '1.00',
log.total_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms,
log.request_id || '', log.request_id || '',
log.user_agent || '' log.user_agent || '',
log.ip_address || ''
]) ])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]) const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
......
...@@ -893,12 +893,13 @@ const loadUsers = async () => { ...@@ -893,12 +893,13 @@ const loadUsers = async () => {
} }
} }
} }
} catch (error) { } catch (error: any) {
const errorInfo = error as { name?: string; code?: string } const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') { if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return 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) console.error('Error loading users:', error)
} finally { } finally {
if (abortController === currentAbortController) { if (abortController === currentAbortController) {
...@@ -917,7 +918,9 @@ const handleSearch = () => { ...@@ -917,7 +918,9 @@ const handleSearch = () => {
} }
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
loadUsers() loadUsers()
} }
...@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => { ...@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters.add(key) visibleFilters.add(key)
} }
saveFiltersToStorage() saveFiltersToStorage()
pagination.page = 1
loadUsers() loadUsers()
} }
...@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => { ...@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
activeAttributeFilters[attr.id] = '' activeAttributeFilters[attr.id] = ''
} }
saveFiltersToStorage() saveFiltersToStorage()
pagination.page = 1
loadUsers() loadUsers()
} }
...@@ -1059,5 +1064,7 @@ onMounted(async () => { ...@@ -1059,5 +1064,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
clearTimeout(searchTimeout)
abortController?.abort()
}) })
</script> </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 @@ ...@@ -11,6 +11,9 @@
</p> </p>
</div> </div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
<!-- Login Form --> <!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5"> <form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input --> <!-- Email Input -->
...@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue' ...@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
...@@ -179,6 +183,7 @@ const showPassword = ref<boolean>(false) ...@@ -179,6 +183,7 @@ const showPassword = ref<boolean>(false)
// Public settings // Public settings
const turnstileEnabled = ref<boolean>(false) const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
// Turnstile // Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null) const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
...@@ -210,6 +215,7 @@ onMounted(async () => { ...@@ -210,6 +215,7 @@ onMounted(async () => {
const settings = await getPublicSettings() const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error) console.error('Failed to load public settings:', error)
} }
......
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