Merge upstream/main: sync latest updates
- feat(gateway): aggregate all text chunks in non-streaming Gemini responses - feat(gateway): add SUGGESTION MODE request interception - feat(oauth): support Anthropic Team accounts with sk authorization - fix(oauth): update Anthropic OAuth parameters to sync with latest client - feat: add PromoCodeEnabled setting (default: true) - resolved conflict: keep TianShuAPI site name while adding PromoCode feature Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export interface SystemSettings {
|
||||
// Registration settings
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
promo_code_enabled: boolean
|
||||
// Default settings
|
||||
default_balance: number
|
||||
default_concurrency: number
|
||||
@@ -64,6 +65,7 @@ export interface SystemSettings {
|
||||
export interface UpdateSettingsRequest {
|
||||
registration_enabled?: boolean
|
||||
email_verify_enabled?: boolean
|
||||
promo_code_enabled?: boolean
|
||||
default_balance?: number
|
||||
default_concurrency?: number
|
||||
site_name?: string
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overload Display (529) - Two-line layout -->
|
||||
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Error Info Indicator -->
|
||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||
@@ -42,44 +56,6 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
429
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
529
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,8 +63,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatCountdownWithSuffix } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -123,6 +98,16 @@ const hasError = computed(() => {
|
||||
return props.account.status === 'error'
|
||||
})
|
||||
|
||||
// Computed: countdown text for rate limit (429)
|
||||
const rateLimitCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
||||
})
|
||||
|
||||
// Computed: countdown text for overload (529)
|
||||
const overloadCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.overload_until)
|
||||
})
|
||||
|
||||
// Computed: status badge class
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
@@ -131,7 +116,7 @@ const statusClass = computed(() => {
|
||||
if (isTempUnschedulable.value) {
|
||||
return 'badge-warning'
|
||||
}
|
||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
||||
if (!props.account.schedulable) {
|
||||
return 'badge-gray'
|
||||
}
|
||||
switch (props.account.status) {
|
||||
@@ -157,9 +142,6 @@ const statusText = computed(() => {
|
||||
if (!props.account.schedulable) {
|
||||
return t('admin.accounts.status.paused')
|
||||
}
|
||||
if (isRateLimited.value || isOverloaded.value) {
|
||||
return t('admin.accounts.status.limited')
|
||||
}
|
||||
return t(`admin.accounts.status.${props.account.status}`)
|
||||
})
|
||||
|
||||
@@ -167,5 +149,4 @@ const handleTempUnschedClick = () => {
|
||||
if (!isTempUnschedulable.value) return
|
||||
emit('show-temp-unsched', props.account)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,50 +1,78 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
|
||||
<div class="py-1">
|
||||
<template v-if="account">
|
||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
<div v-if="show && position">
|
||||
<!-- Backdrop: click anywhere outside to close -->
|
||||
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
|
||||
<div
|
||||
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
|
||||
:style="{ top: position.top + 'px', left: position.left + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="py-1">
|
||||
<template v-if="account">
|
||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
</button>
|
||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sync" size="sm" />
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="clock" size="sm" />
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sync" size="sm" />
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="clock" size="sm" />
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Icon } from '@/components/icons'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -279,18 +279,143 @@ interface Props {
|
||||
expandableActions?: boolean
|
||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||
rowKey?: string | ((row: any) => string | number)
|
||||
/**
|
||||
* Default sort configuration (only applied when there is no persisted sort state)
|
||||
*/
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
/**
|
||||
* Persist sort state (key + order) to localStorage using this key.
|
||||
* If provided, DataTable will load the stored sort state on mount.
|
||||
*/
|
||||
sortStorageKey?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
stickyFirstColumn: true,
|
||||
stickyActionsColumn: true,
|
||||
expandableActions: true
|
||||
expandableActions: true,
|
||||
defaultSortOrder: 'asc'
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const actionsExpanded = ref(false)
|
||||
|
||||
type PersistedSortState = {
|
||||
key: string
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
|
||||
const getSortableKeys = () => {
|
||||
const keys = new Set<string>()
|
||||
for (const col of props.columns) {
|
||||
if (col.sortable) keys.add(col.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const normalizeSortKey = (candidate: string) => {
|
||||
if (!candidate) return ''
|
||||
const sortableKeys = getSortableKeys()
|
||||
return sortableKeys.has(candidate) ? candidate : ''
|
||||
}
|
||||
|
||||
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
|
||||
return candidate === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
const readPersistedSortState = (): PersistedSortState | null => {
|
||||
if (!props.sortStorageKey) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(props.sortStorageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
|
||||
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
|
||||
if (!key) return null
|
||||
return { key, order: normalizeSortOrder(parsed.order) }
|
||||
} catch (e) {
|
||||
console.error('[DataTable] Failed to read persisted sort state:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writePersistedSortState = (state: PersistedSortState) => {
|
||||
if (!props.sortStorageKey) return
|
||||
try {
|
||||
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
|
||||
} catch (e) {
|
||||
console.error('[DataTable] Failed to persist sort state:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveInitialSortState = (): PersistedSortState | null => {
|
||||
const persisted = readPersistedSortState()
|
||||
if (persisted) return persisted
|
||||
|
||||
const key = normalizeSortKey(props.defaultSortKey || '')
|
||||
if (!key) return null
|
||||
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
|
||||
}
|
||||
|
||||
const applySortState = (state: PersistedSortState | null) => {
|
||||
if (!state) return
|
||||
sortKey.value = state.key
|
||||
sortOrder.value = state.order
|
||||
}
|
||||
|
||||
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
|
||||
|
||||
const toFiniteNumberOrNull = (value: any): number | null => {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'boolean') return value ? 1 : 0
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const toSortableString = (value: any): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const compareSortValues = (a: any, b: any): number => {
|
||||
const aEmpty = isNullishOrEmpty(a)
|
||||
const bEmpty = isNullishOrEmpty(b)
|
||||
if (aEmpty && bEmpty) return 0
|
||||
if (aEmpty) return 1
|
||||
if (bEmpty) return -1
|
||||
|
||||
const aNum = toFiniteNumberOrNull(a)
|
||||
const bNum = toFiniteNumberOrNull(b)
|
||||
if (aNum !== null && bNum !== null) {
|
||||
if (aNum === bNum) return 0
|
||||
return aNum < bNum ? -1 : 1
|
||||
}
|
||||
|
||||
const aStr = toSortableString(a)
|
||||
const bStr = toSortableString(b)
|
||||
const res = collator.compare(aStr, bStr)
|
||||
if (res === 0) return 0
|
||||
return res < 0 ? -1 : 1
|
||||
}
|
||||
const resolveRowKey = (row: any, index: number) => {
|
||||
if (typeof props.rowKey === 'function') {
|
||||
const key = props.rowKey(row)
|
||||
@@ -334,15 +459,18 @@ const handleSort = (key: string) => {
|
||||
const sortedData = computed(() => {
|
||||
if (!sortKey.value || !props.data) return props.data
|
||||
|
||||
return [...props.data].sort((a, b) => {
|
||||
const aVal = a[sortKey.value]
|
||||
const bVal = b[sortKey.value]
|
||||
const key = sortKey.value
|
||||
const order = sortOrder.value
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
const comparison = aVal > bVal ? 1 : -1
|
||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
||||
})
|
||||
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
|
||||
return props.data
|
||||
.map((row, index) => ({ row, index }))
|
||||
.sort((a, b) => {
|
||||
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
|
||||
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
|
||||
return a.index - b.index
|
||||
})
|
||||
.map(item => item.row)
|
||||
})
|
||||
|
||||
const hasActionsColumn = computed(() => {
|
||||
@@ -396,6 +524,51 @@ const getAdaptivePaddingClass = () => {
|
||||
return 'px-6' // 24px (原始值)
|
||||
}
|
||||
}
|
||||
|
||||
// Init + keep persisted sort state consistent with current columns
|
||||
const didInitSort = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const initial = resolveInitialSortState()
|
||||
applySortState(initial)
|
||||
didInitSort.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
if (!sortKey.value) {
|
||||
const initial = resolveInitialSortState()
|
||||
applySortState(initial)
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
const fallback = resolveInitialSortState()
|
||||
if (fallback) {
|
||||
applySortState(fallback)
|
||||
} else {
|
||||
sortKey.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[sortKey, sortOrder],
|
||||
([nextKey, nextOrder]) => {
|
||||
if (!didInitSort.value) return
|
||||
if (!props.sortStorageKey) return
|
||||
const key = normalizeSortKey(nextKey)
|
||||
if (!key) return
|
||||
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
|
||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||
- `data: any[]` - Array of data objects to display
|
||||
- `loading?: boolean` - Show loading skeleton
|
||||
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
|
||||
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
|
||||
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
|
||||
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
||||
|
||||
**Slots:**
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface OAuthState {
|
||||
export interface TokenInfo {
|
||||
org_uuid?: string
|
||||
account_uuid?: string
|
||||
email_address?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
|
||||
if (tokenInfo.account_uuid) {
|
||||
extra.account_uuid = tokenInfo.account_uuid
|
||||
}
|
||||
if (tokenInfo.email_address) {
|
||||
extra.email_address = tokenInfo.email_address
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,13 @@ export default {
|
||||
justNow: 'Just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
daysAgo: '{n}d ago'
|
||||
daysAgo: '{n}d ago',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} to lift'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1022,6 +1028,13 @@ export default {
|
||||
title: 'Account Management',
|
||||
description: 'Manage AI platform accounts and credentials',
|
||||
createAccount: 'Create Account',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
refreshInterval5s: '5 seconds',
|
||||
refreshInterval10s: '10 seconds',
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||
syncFromCrsDesc:
|
||||
@@ -1083,6 +1096,8 @@ export default {
|
||||
cooldown: 'Cooldown',
|
||||
paused: 'Paused',
|
||||
limited: 'Limited',
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
@@ -2726,7 +2741,9 @@ export default {
|
||||
enableRegistration: 'Enable Registration',
|
||||
enableRegistrationHint: 'Allow new users to register',
|
||||
emailVerification: 'Email Verification',
|
||||
emailVerificationHint: 'Require email verification for new registrations'
|
||||
emailVerificationHint: 'Require email verification for new registrations',
|
||||
promoCode: 'Promo Code',
|
||||
promoCodeHint: 'Allow users to use promo codes during registration'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
|
||||
@@ -166,7 +166,13 @@ export default {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n}分钟前',
|
||||
hoursAgo: '{n}小时前',
|
||||
daysAgo: '{n}天前'
|
||||
daysAgo: '{n}天前',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} 后解除'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1096,6 +1102,13 @@ export default {
|
||||
title: '账号管理',
|
||||
description: '管理 AI 平台账号和 Cookie',
|
||||
createAccount: '添加账号',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
refreshInterval5s: '5 秒',
|
||||
refreshInterval10s: '10 秒',
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
syncFromCrsTitle: '从 CRS 同步账号',
|
||||
syncFromCrsDesc:
|
||||
@@ -1205,6 +1218,8 @@ export default {
|
||||
cooldown: '冷却中',
|
||||
paused: '暂停',
|
||||
limited: '限流',
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
@@ -2879,7 +2894,9 @@ export default {
|
||||
enableRegistration: '开放注册',
|
||||
enableRegistrationHint: '允许新用户注册',
|
||||
emailVerification: '邮箱验证',
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱'
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||
promoCode: '优惠码',
|
||||
promoCodeHint: '允许用户在注册时使用优惠码'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
|
||||
@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
return {
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: siteName.value,
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface SendVerifyCodeResponse {
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
promo_code_enabled: boolean
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
site_name: string
|
||||
|
||||
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
|
||||
*/
|
||||
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
|
||||
if (!targetDate) return null
|
||||
|
||||
const now = new Date()
|
||||
const target = new Date(targetDate)
|
||||
const diffMs = target.getTime() - now.getTime()
|
||||
|
||||
// 如果目标时间已过或无效
|
||||
if (diffMs <= 0 || isNaN(diffMs)) return null
|
||||
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
const remainingHours = diffHours % 24
|
||||
const remainingMins = diffMins % 60
|
||||
|
||||
if (diffDays > 0) {
|
||||
// 超过1天:显示 "Xd Yh"
|
||||
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
// 小于1天:显示 "Xh Ym"
|
||||
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
|
||||
}
|
||||
// 小于1小时:显示 "Ym"
|
||||
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
|
||||
*/
|
||||
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
|
||||
const countdown = formatCountdown(targetDate)
|
||||
if (!countdown) return null
|
||||
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||
}
|
||||
|
||||
@@ -17,10 +17,58 @@
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #after>
|
||||
<!-- Auto Refresh Dropdown -->
|
||||
<div class="relative" ref="autoRefreshDropdownRef">
|
||||
<button
|
||||
@click="
|
||||
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
||||
showColumnDropdown = false
|
||||
"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.accounts.autoRefresh')"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
|
||||
<span class="hidden md:inline">
|
||||
{{
|
||||
autoRefreshEnabled
|
||||
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
|
||||
: t('admin.accounts.autoRefresh')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showAutoRefreshDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
|
||||
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
|
||||
<button
|
||||
v-for="sec in autoRefreshIntervals"
|
||||
:key="sec"
|
||||
@click="setAutoRefreshInterval(sec)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
|
||||
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
@click="
|
||||
showColumnDropdown = !showColumnDropdown;
|
||||
showAutoRefreshDropdown = false
|
||||
"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
@@ -53,12 +101,29 @@
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
|
||||
<DataTable
|
||||
:columns="cols"
|
||||
:data="accounts"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
default-sort-key="name"
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
>
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-name="{ row, value }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<span
|
||||
v-if="row.extra?.email_address"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
|
||||
:title="row.extra.email_address"
|
||||
>
|
||||
{{ row.extra.email_address }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-notes="{ value }">
|
||||
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
||||
@@ -161,6 +226,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -221,6 +287,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
// Sorting settings
|
||||
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||
|
||||
// Auto refresh settings
|
||||
const showAutoRefreshDropdown = ref(false)
|
||||
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
|
||||
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
|
||||
const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||
const autoRefreshCountdown = ref(0)
|
||||
|
||||
const autoRefreshIntervalLabel = (sec: number) => {
|
||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||
if (sec === 10) return t('admin.accounts.refreshInterval10s')
|
||||
if (sec === 15) return t('admin.accounts.refreshInterval15s')
|
||||
if (sec === 30) return t('admin.accounts.refreshInterval30s')
|
||||
return `${sec}s`
|
||||
}
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
@@ -244,6 +330,60 @@ const saveColumnsToStorage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSavedAutoRefresh = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
|
||||
if (!saved) return
|
||||
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
|
||||
autoRefreshEnabled.value = parsed.enabled === true
|
||||
const interval = Number(parsed.interval_seconds)
|
||||
if (autoRefreshIntervals.includes(interval as any)) {
|
||||
autoRefreshIntervalSeconds.value = interval as any
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved auto refresh settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveAutoRefreshToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
AUTO_REFRESH_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
enabled: autoRefreshEnabled.value,
|
||||
interval_seconds: autoRefreshIntervalSeconds.value
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to save auto refresh settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
loadSavedColumns()
|
||||
loadSavedAutoRefresh()
|
||||
}
|
||||
|
||||
const setAutoRefreshEnabled = (enabled: boolean) => {
|
||||
autoRefreshEnabled.value = enabled
|
||||
saveAutoRefreshToStorage()
|
||||
if (enabled) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
resumeAutoRefresh()
|
||||
} else {
|
||||
pauseAutoRefresh()
|
||||
autoRefreshCountdown.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
|
||||
autoRefreshIntervalSeconds.value = seconds
|
||||
saveAutoRefreshToStorage()
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = seconds
|
||||
}
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
@@ -260,6 +400,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
})
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
showEdit.value ||
|
||||
showSync.value ||
|
||||
showBulkEdit.value ||
|
||||
showTempUnsched.value ||
|
||||
showDeleteDialog.value ||
|
||||
showReAuth.value ||
|
||||
showTest.value ||
|
||||
showStats.value
|
||||
)
|
||||
})
|
||||
|
||||
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
async () => {
|
||||
if (!autoRefreshEnabled.value) return
|
||||
if (document.hidden) return
|
||||
if (loading.value) return
|
||||
if (isAnyModalOpen.value) return
|
||||
if (menu.show) return
|
||||
|
||||
if (autoRefreshCountdown.value <= 0) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
console.error('Auto refresh failed:', e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
autoRefreshCountdown.value -= 1
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// All available columns
|
||||
const allColumns = computed(() => {
|
||||
const c = [
|
||||
@@ -512,10 +690,12 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
||||
showAutoRefreshDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadSavedColumns()
|
||||
load()
|
||||
try {
|
||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||
@@ -526,6 +706,13 @@ onMounted(async () => {
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
resumeAutoRefresh()
|
||||
} else {
|
||||
pauseAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -238,7 +238,30 @@
|
||||
v-model="generateForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{
|
||||
t('admin.redeem.selectGroupPlaceholder')
|
||||
}}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.validityDays') }}</label>
|
||||
@@ -370,7 +393,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -378,12 +401,23 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
platform: GroupPlatform
|
||||
subscriptionType: SubscriptionType
|
||||
rate: number
|
||||
}
|
||||
|
||||
const showGenerateDialog = ref(false)
|
||||
const showResultDialog = ref(false)
|
||||
const generatedCodes = ref<RedeemCode[]>([])
|
||||
@@ -395,7 +429,11 @@ const subscriptionGroupOptions = computed(() => {
|
||||
.filter((g) => g.subscription_type === 'subscription')
|
||||
.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.name
|
||||
label: g.name,
|
||||
description: g.description,
|
||||
platform: g.platform,
|
||||
subscriptionType: g.subscription_type,
|
||||
rate: g.rate_multiplier
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -323,6 +323,21 @@
|
||||
</div>
|
||||
<Toggle v-model="form.email_verify_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Promo Code -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.promoCode')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.promoCodeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.promo_code_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1013,6 +1028,7 @@ type SettingsForm = SystemSettings & {
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'TianShuAPI',
|
||||
@@ -1135,6 +1151,7 @@ async function saveSettings() {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
promo_code_enabled: form.promo_code_enabled,
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
site_name: form.site_name,
|
||||
|
||||
@@ -466,7 +466,28 @@
|
||||
v-model="assignForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.subscriptions.selectGroup')"
|
||||
/>
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('admin.subscriptions.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.groupHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -599,7 +620,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group } from '@/types'
|
||||
import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { SimpleUser } from '@/api/admin/usage'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
@@ -612,11 +633,21 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
platform: GroupPlatform
|
||||
subscriptionType: SubscriptionType
|
||||
rate: number
|
||||
}
|
||||
|
||||
// User column display mode: 'email' or 'username'
|
||||
const userColumnMode = ref<'email' | 'username'>('email')
|
||||
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
|
||||
@@ -792,7 +823,14 @@ const groupOptions = computed(() => [
|
||||
const subscriptionGroupOptions = computed(() =>
|
||||
groups.value
|
||||
.filter((g) => g.subscription_type === 'subscription' && g.status === 'active')
|
||||
.map((g) => ({ value: g.id, label: g.name }))
|
||||
.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.name,
|
||||
description: g.description,
|
||||
platform: g.platform,
|
||||
subscriptionType: g.subscription_type,
|
||||
rate: g.rate_multiplier
|
||||
}))
|
||||
)
|
||||
|
||||
const applyFilters = () => {
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Promo Code Input (Optional) -->
|
||||
<div>
|
||||
<div v-if="promoCodeEnabled">
|
||||
<label for="promo_code" class="input-label">
|
||||
{{ t('auth.promoCodeLabel') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
|
||||
@@ -260,6 +260,7 @@ const showPassword = ref<boolean>(false)
|
||||
// Public settings
|
||||
const registrationEnabled = ref<boolean>(true)
|
||||
const emailVerifyEnabled = ref<boolean>(false)
|
||||
const promoCodeEnabled = ref<boolean>(true)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('TianShuAPI')
|
||||
@@ -294,22 +295,25 @@ const errors = reactive({
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
// Read promo code from URL parameter
|
||||
const promoParam = route.query.promo as string
|
||||
if (promoParam) {
|
||||
formData.promo_code = promoParam
|
||||
// Validate the promo code from URL
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
registrationEnabled.value = settings.registration_enabled
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled
|
||||
promoCodeEnabled.value = settings.promo_code_enabled
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'TianShuAPI'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
|
||||
// Read promo code from URL parameter only if promo code is enabled
|
||||
if (promoCodeEnabled.value) {
|
||||
const promoParam = route.query.promo as string
|
||||
if (promoParam) {
|
||||
formData.promo_code = promoParam
|
||||
// Validate the promo code from URL
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, Plugin } from 'vite'
|
||||
import { defineConfig, loadEnv, Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import checker from 'vite-plugin-checker'
|
||||
import { resolve } from 'path'
|
||||
@@ -7,9 +7,7 @@ import { resolve } from 'path'
|
||||
* Vite 插件:开发模式下注入公开配置到 index.html
|
||||
* 与生产模式的后端注入行为保持一致,消除闪烁
|
||||
*/
|
||||
function injectPublicSettings(): Plugin {
|
||||
const backendUrl = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
|
||||
|
||||
function injectPublicSettings(backendUrl: string): Plugin {
|
||||
return {
|
||||
name: 'inject-public-settings',
|
||||
transformIndexHtml: {
|
||||
@@ -35,15 +33,21 @@ function injectPublicSettings(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
checker({
|
||||
typescript: true,
|
||||
vueTsc: true
|
||||
}),
|
||||
injectPublicSettings()
|
||||
],
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendUrl = env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
|
||||
const devPort = Number(env.VITE_DEV_PORT || 3000)
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
checker({
|
||||
typescript: true,
|
||||
vueTsc: true
|
||||
}),
|
||||
injectPublicSettings(backendUrl)
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
@@ -102,17 +106,18 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: Number(process.env.VITE_DEV_PORT || 3000),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/setup': {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: devPort,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/setup': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user