Merge upstream main into feat/500-api-key-ip-whitelist

This commit is contained in:
RWDai
2026-05-20 10:26:56 +08:00
501 changed files with 47013 additions and 3667 deletions
@@ -272,6 +272,8 @@ function formatClientFamily(family?: string | null): string {
return 'OpenCode'
case 'claude_code':
return 'Claude Code'
case 'openai_js_sdk':
return 'OpenAI JS SDK'
case 'generic':
return '通用'
case undefined:
+121 -4
View File
@@ -64,6 +64,35 @@
/>
</div>
<LeaderboardTable
title="API Key 用量排行"
:items="apiKeyLeaderboard"
:metric="apiKeyLeaderboardMetric"
:loading="apiKeyLeaderboardLoading"
:show-metric-select="false"
@update:metric="apiKeyLeaderboardMetric = $event"
>
<template #actions>
<LeaderboardControls
:metric="apiKeyLeaderboardMetric"
:time-range="apiKeyLeaderboardTimeRange"
@update:metric="apiKeyLeaderboardMetric = $event"
@update:time-range="apiKeyLeaderboardTimeRange = $event"
/>
</template>
<template #pagination>
<Pagination
v-if="apiKeyLeaderboardTotal > 0"
:current="apiKeyLeaderboardPage"
:total="apiKeyLeaderboardTotal"
:page-size="apiKeyLeaderboardPageSize"
:page-size-options="apiKeyLeaderboardPageSizeOptions"
@update:current="apiKeyLeaderboardPage = $event"
@update:page-size="apiKeyLeaderboardPageSize = $event"
/>
</template>
</LeaderboardTable>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
@@ -74,10 +103,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { Pagination } from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
@@ -90,16 +120,26 @@ const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const apiKeyLeaderboard = ref<LeaderboardItem[]>([])
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const apiKeyLeaderboardPage = ref(1)
const apiKeyLeaderboardPageSize = ref(10)
const apiKeyLeaderboardTotal = ref(0)
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const apiKeyLeaderboardLoading = ref(false)
let forecastRequestId = 0
let savingsRequestId = 0
let quotaRequestId = 0
let providerStatsRequestId = 0
let apiKeyLeaderboardRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
@@ -159,12 +199,55 @@ async function loadProviderStats() {
providerStats.value = stats
}
async function loadApiKeyLeaderboard() {
const requestId = ++apiKeyLeaderboardRequestId
apiKeyLeaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardApiKeys({
...buildApiKeyLeaderboardTimeRangeParams(),
metric: apiKeyLeaderboardMetric.value,
order: 'desc',
limit: apiKeyLeaderboardPageSize.value,
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
include_inactive: false,
exclude_admin: false
})
if (requestId !== apiKeyLeaderboardRequestId) return
apiKeyLeaderboard.value = response.items
apiKeyLeaderboardTotal.value = response.total
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
apiKeyLeaderboardPage.value = 1
scheduleApiKeyLeaderboardLoad()
}
} finally {
if (requestId === apiKeyLeaderboardRequestId) {
apiKeyLeaderboardLoading.value = false
}
}
}
function buildApiKeyLeaderboardTimeRangeParams() {
return {
start_date: apiKeyLeaderboardTimeRange.value.start_date,
end_date: apiKeyLeaderboardTimeRange.value.end_date,
preset: apiKeyLeaderboardTimeRange.value.preset,
timezone: apiKeyLeaderboardTimeRange.value.timezone,
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
}
}
async function loadAll() {
if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
loadAllPromise = Promise.all([
loadForecast(),
loadSavings(),
loadQuotaUsage(),
loadProviderStats(),
loadApiKeyLeaderboard()
])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
@@ -186,7 +269,36 @@ function scheduleLoadAll() {
}, 120)
}
watch(timeRange, scheduleLoadAll, { deep: true })
function scheduleApiKeyLeaderboardLoad() {
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
}
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
apiKeyLeaderboardDebounceTimer = null
void loadApiKeyLeaderboard()
}, 120)
}
function resetApiKeyLeaderboardPage() {
if (apiKeyLeaderboardPage.value === 1) {
return
}
apiKeyLeaderboardPage.value = 1
}
watch(timeRange, () => {
resetApiKeyLeaderboardPage()
scheduleLoadAll()
}, { deep: true })
watch(apiKeyLeaderboardMetric, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
})
watch(apiKeyLeaderboardTimeRange, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
}, { deep: true })
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
onMounted(() => {
void loadAll()
@@ -197,11 +309,16 @@ onUnmounted(() => {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
apiKeyLeaderboardDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
forecastRequestId += 1
savingsRequestId += 1
quotaRequestId += 1
providerStatsRequestId += 1
apiKeyLeaderboardRequestId += 1
})
</script>
+189 -31
View File
@@ -10,7 +10,7 @@
黑名单 IP 数量
</p>
<h3 class="text-2xl font-bold mt-2">
{{ blacklistStats.total || 0 }}
{{ blacklistData.total || blacklistStats.total || 0 }}
</h3>
</div>
<div class="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
@@ -66,7 +66,7 @@
</Button>
<RefreshButton
:loading="loadingBlacklist"
@click="loadBlacklistStats"
@click="loadBlacklist"
/>
</div>
</div>
@@ -85,16 +85,33 @@
>
<div
v-if="!blacklistStats.available"
class="mb-4 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-muted-foreground"
>
<div class="flex items-start gap-3">
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p class="font-medium text-foreground">
黑名单状态不可用,列表可能不是最新
</p>
<p class="mt-1 text-xs">
{{ blacklistStats.error }}
</p>
</div>
</div>
</div>
<div
v-if="blacklistListError"
class="text-center py-8 text-muted-foreground"
>
<AlertCircle class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Redis 不可用,无法管理黑名单</p>
<p>无法获取黑名单列表</p>
<p class="text-xs mt-1">
{{ blacklistStats.error }}
{{ blacklistListError }}
</p>
</div>
<div
v-else-if="blacklistStats.total === 0"
v-else-if="blacklistData.items.length === 0"
class="text-center py-8 text-muted-foreground"
>
<ShieldX class="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -102,9 +119,79 @@
</div>
<div
v-else
class="text-sm text-muted-foreground"
class="space-y-4"
>
当前共有 <span class="font-semibold text-foreground">{{ blacklistStats.total }}</span> 个 IP 在黑名单中
<div class="text-sm text-muted-foreground">
当前共有 <span class="font-semibold text-foreground">{{ blacklistData.total || blacklistStats.total || 0 }}</span> 个 IP 在黑名单中
</div>
<Table class="hidden sm:table">
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
<TableHead>原因</TableHead>
<TableHead>剩余时长</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in blacklistData.items"
:key="entry.ip_address"
>
<TableCell class="font-mono text-sm">
{{ entry.ip_address }}
</TableCell>
<TableCell class="max-w-[28rem] truncate">
{{ entry.reason }}
</TableCell>
<TableCell class="whitespace-nowrap">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
class="h-8 px-3"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4 mr-1.5" />
移除
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="entry in blacklistData.items"
:key="entry.ip_address"
class="p-4 flex items-start justify-between gap-3"
>
<div class="min-w-0 space-y-1">
<div class="font-mono text-sm break-all">
{{ entry.ip_address }}
</div>
<div class="text-xs text-muted-foreground leading-5 break-words">
{{ entry.reason }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</div>
</div>
<Button
variant="ghost"
size="sm"
class="h-8 px-3 shrink-0"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</Card>
@@ -213,15 +300,17 @@
<!-- 添加黑名单对话框 -->
<Dialog v-model:open="showAddBlacklistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到黑名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到黑名单
</DialogTitle>
<DialogDescription class="!mt-1">
被加入黑名单的 IP 将无法访问任何接口
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址</label>
<Input
v-model="blacklistForm.ip_address"
@@ -229,7 +318,7 @@
class="font-mono"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">原因</label>
<Input
v-model="blacklistForm.reason"
@@ -237,7 +326,7 @@
maxlength="200"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">过期时间(可选)</label>
<Input
v-model.number="blacklistForm.ttl"
@@ -250,7 +339,7 @@
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddBlacklistDialog = false"
@@ -270,27 +359,29 @@
<!-- 添加白名单对话框 -->
<Dialog v-model:open="showAddWhitelistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到白名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到白名单
</DialogTitle>
<DialogDescription class="!mt-1">
白名单中的 IP 不受速率限制
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址或 CIDR</label>
<Input
v-model="whitelistForm.ip_address"
placeholder="例如: 192.168.1.0/24 或 192.168.1.100"
class="font-mono"
/>
<p class="text-xs text-muted-foreground">
<p class="text-xs text-muted-foreground leading-5">
支持单个 IP 或 CIDR 网段格式
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddWhitelistDialog = false"
@@ -330,7 +421,7 @@ import {
TableRow,
RefreshButton
} from '@/components/ui'
import { blacklistApi, whitelistApi, type BlacklistStats, type WhitelistResponse } from '@/api/security'
import { blacklistApi, whitelistApi, type BlacklistStats, type BlacklistResponse, type WhitelistResponse } from '@/api/security'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
@@ -344,6 +435,11 @@ const blacklistStats = ref<BlacklistStats>({
available: false,
total: 0
})
const blacklistData = ref<BlacklistResponse>({
items: [],
total: 0
})
const blacklistListError = ref<string | null>(null)
const showAddBlacklistDialog = ref(false)
const blacklistForm = ref({
ip_address: '',
@@ -363,14 +459,38 @@ const whitelistForm = ref({
})
/**
* 加载黑名单统计
* 加载黑名单统计和列表
*/
async function loadBlacklistStats() {
async function loadBlacklist() {
loadingBlacklist.value = true
blacklistListError.value = null
try {
blacklistStats.value = await blacklistApi.getStats()
const [statsResult, listResult] = await Promise.allSettled([
blacklistApi.getStats(),
blacklistApi.getList()
])
if (statsResult.status === 'fulfilled') {
blacklistStats.value = statsResult.value
} else {
blacklistStats.value = {
available: false,
total: 0,
error: parseApiError(statsResult.reason, '无法获取黑名单统计')
}
}
if (listResult.status === 'fulfilled') {
blacklistData.value = listResult.value
} else {
blacklistData.value = {
items: [],
total: 0
}
blacklistListError.value = parseApiError(listResult.reason, '无法获取黑名单列表')
}
} catch (err: unknown) {
error(parseApiError(err, '无法获取黑名单统计'))
error(parseApiError(err, '无法获取黑名单数据'))
} finally {
loadingBlacklist.value = false
}
@@ -405,7 +525,7 @@ async function handleAddToBlacklist() {
showAddBlacklistDialog.value = false
blacklistForm.value = { ip_address: '', reason: '', ttl: undefined }
await loadBlacklistStats()
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法添加 IP 到黑名单'))
}
@@ -452,8 +572,46 @@ async function handleRemoveFromWhitelist(ip: string) {
}
}
/**
* 从黑名单移除 IP
*/
async function handleRemoveFromBlacklist(ip: string) {
const confirmed = await confirmDanger(
`确定要从黑名单移除 ${ip} 吗?\n\n此操作无法撤销。`,
'移除黑名单'
)
if (!confirmed) return
try {
await blacklistApi.remove(ip)
success(`IP ${ip} 已从黑名单移除`)
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法从黑名单移除 IP'))
}
}
function formatBlacklistTTL(ttlSeconds?: number | null) {
if (ttlSeconds == null) return '永久'
if (ttlSeconds <= 0) return '即将过期'
const days = Math.floor(ttlSeconds / 86400)
if (days > 0) return `${days} 天`
const hours = Math.floor(ttlSeconds / 3600)
if (hours > 0) return `${hours} 小时`
const minutes = Math.floor(ttlSeconds / 60)
if (minutes > 0) return `${minutes} 分钟`
return `${ttlSeconds} 秒`
}
onMounted(() => {
loadBlacklistStats()
loadBlacklist()
loadWhitelist()
})
</script>
+205 -187
View File
@@ -131,197 +131,209 @@
</div>
</template>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如:My OIDC Provider"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如:My OIDC Provider"
autocomplete="off"
/>
</div>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 高级选项(折叠) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔;留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
</p>
</div>
</div>
</div>
</details>
</div>
<!-- 测试结果 -->
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 图标 URL -->
<div>
<Label class="block text-sm font-medium">图标 URL</Label>
<Input
v-model="form.icon_url"
class="mt-1"
placeholder="https://example.com/icon.svg"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
登录页显示的 Provider 图标,留空使用默认图标
</p>
</div>
<!-- 高级选项(折叠) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔;留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
</p>
</div>
</div>
</div>
</details>
</div>
<div
v-if="lastTestResult"
class="mt-6 rounded-lg border border-border p-4 text-sm"
@@ -402,6 +414,7 @@ interface OAuthConfigForm {
frontend_callback_url: string
attribute_mapping_json: string
extra_config_json: string
icon_url: string
new_provider_type: string
new_display_name: string
}
@@ -423,6 +436,7 @@ const form = ref<OAuthConfigForm>({
frontend_callback_url: '',
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: '',
new_display_name: '',
})
@@ -589,6 +603,7 @@ function handleClickAdd() {
frontend_callback_url: defaultFrontendCallbackUrl(),
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: providerType,
new_display_name: '',
}
@@ -633,6 +648,7 @@ function syncFormFromSelected() {
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
icon_url: cfg?.icon_url || '',
new_provider_type: '',
new_display_name: '',
}
@@ -658,6 +674,7 @@ async function toggleProviderEnabled(providerType: string, enabled: boolean, for
frontend_callback_url: cfg.frontend_callback_url,
attribute_mapping: cfg.attribute_mapping || null,
extra_config: cfg.extra_config || null,
icon_url: cfg.icon_url || null,
is_enabled: enabled,
force,
}
@@ -732,6 +749,7 @@ async function handleSave() {
frontend_callback_url: form.value.frontend_callback_url.trim(),
attribute_mapping: parseJsonOrNull(form.value.attribute_mapping_json),
extra_config: parseJsonOrNull(form.value.extra_config_json),
icon_url: form.value.icon_url.trim() || null,
is_enabled: existingConfig?.is_enabled || false,
}
+144 -48
View File
@@ -573,7 +573,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key) }}
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
</span>
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Button
@@ -603,7 +603,7 @@
</span>
</template>
<Badge
v-if="key.oauth_plan_type"
v-if="keyUiStateMap[key.key_id]?.planLabel"
variant="outline"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''"
@@ -637,10 +637,11 @@
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressDisplayText(item)"
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="item.detail"
>{{ getQuotaProgressDisplayText(item) }}</span>
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
@@ -651,9 +652,10 @@
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ item.remainingPercent.toFixed(1) }}%</span>
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
</div>
@@ -1153,11 +1155,12 @@
>
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressDisplayText(item)"
class="text-muted-foreground/80 tabular-nums truncate"
:title="item.detail"
>{{ getQuotaProgressDisplayText(item) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
@@ -1168,9 +1171,10 @@
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ item.remainingPercent.toFixed(1) }}%</span>
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
</div>
@@ -2073,6 +2077,7 @@ const showAccountQuotaColumn = computed(() => {
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
})
@@ -2377,8 +2382,9 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
const visibleOAuthState = getVisibleOAuthState(key)
const oauthOrgBadge = getOAuthOrgBadge(key)
const quotaFallbackText = getQuotaFallbackText(key)
const planType = resolvePoolKeyPlanType(key)
const canRefreshToken = canRefreshOAuthCredential(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key, selectedProviderType.value)
map[key.key_id] = {
rowClass: getRowClass(key),
@@ -2391,8 +2397,8 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
showOAuthRefreshControl,
canRefreshToken,
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : '',
planClass: key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : '',
planLabel: planType ? formatOAuthPlanType(planType) : '',
planClass: planType ? getOAuthPlanTypeClass(planType) : '',
quotaFallbackText,
quotaTextClass: quotaFallbackText ? getQuotaTextClass(quotaFallbackText) : '',
importedAtRelative: formatPoolKeyImportedAt(key),
@@ -2470,6 +2476,7 @@ const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
})
@@ -2589,7 +2596,12 @@ async function refreshCurrentPageQuotaInBackground(
if (!options.silent) {
const skippedText = skippedCount > 0 ? `,冷却跳过 ${skippedCount}` : ''
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
const firstFailureMessage = result.results.find(item => item.status !== 'success')?.message?.trim()
if (successCount === 0 && failedCount > 0 && firstFailureMessage) {
showError(`当前页额度刷新失败:${firstFailureMessage}${skippedText}`)
} else {
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
}
}
return true
} catch (err) {
@@ -2714,7 +2726,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
id: key.key_id,
provider_id: selectedProviderId.value || '',
api_formats: key.api_formats || [],
api_key_masked: getProviderMaskedSecretLabel(key),
api_key_masked: getProviderMaskedSecretLabel(key, selectedProviderType.value),
auth_type: normalizeAuthTypeForEdit(key),
auth_type_by_format: key.auth_type_by_format ?? null,
credential_kind: key.credential_kind ?? null,
@@ -3524,6 +3536,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
const accountAlert = getAccountAlertLabel(key)
const oauthState = getVisibleOAuthState(key)
const orgBadge = getOAuthOrgBadge(key)
const planType = resolvePoolKeyPlanType(key)
return buildPoolMobileTagItems({
accountStatusLabel: compactPoolStatusLabel(accountAlert),
@@ -3532,7 +3545,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
oauthStatusTone: getMobileOAuthTone(key),
priorityLabel: `P${key.internal_priority ?? 50}`,
authLabel: getAuthTypeChipLabel(key),
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null,
planLabel: planType ? formatOAuthPlanType(planType) : null,
orgLabel: orgBadge?.label ?? null,
proxyLabel: key.proxy?.node_id ? '独立代理' : null,
})
@@ -3565,6 +3578,9 @@ function formatOAuthPlanType(planType: string): string {
ultra: 'Ultra',
'pro+': 'Pro+',
power: 'Power',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
return labelMap[planType.toLowerCase()] || planType
}
@@ -3580,6 +3596,9 @@ function getOAuthPlanTypeClass(planType: string): string {
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType.toLowerCase()] || ''
}
@@ -3674,7 +3693,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (!['5H', '周', 'Spark5H', 'Spark周'].includes(item.label)) return null
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3704,11 +3723,16 @@ function shouldHideQuotaProgressDetailText(text: string | null | undefined): boo
return (text ?? '').trim().includes('已重置')
}
function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
function getQuotaProgressResetDisplayText(item: QuotaProgressItem): string {
const countdownText = getQuotaProgressCountdownText(item)
if (countdownText) return formatCompactQuotaCountdownText(countdownText)
return ''
}
function getQuotaProgressMeterDisplayText(item: QuotaProgressItem): string {
const detail = item.detail?.trim() || ''
return shouldHideQuotaProgressDetailText(detail) ? '' : detail
if (!shouldHideQuotaProgressDetailText(detail) && detail) return detail
return `${item.remainingPercent.toFixed(1)}%`
}
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
@@ -3718,6 +3742,11 @@ function getQuotaFallbackText(key: PoolKeyDetail): string | null {
function getQuotaLabelOrder(label: string): number {
if (label === 'Auto') return 0
if (label === 'Fast') return 1
if (label === 'Expert') return 2
if (label === 'Heavy') return 3
if (label === 'Grok 4.3') return 4
if (label === '5H') return 0
if (label === '周') return 1
if (label === 'Spark5H') return 2
@@ -3770,6 +3799,14 @@ function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | un
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
}
function getQuotaSnapshotResetAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeUnixSeconds(quota?.reset_at ?? null)
}
function getQuotaSnapshotResetSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeRemainingSeconds(quota?.reset_seconds ?? null)
}
function getQuotaSnapshotWindow(
quota: QuotaStatusSnapshot | null | undefined,
code: string,
@@ -3830,6 +3867,47 @@ function formatQuotaValue(value: number | null | undefined): string {
return normalized.toFixed(1)
}
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | undefined {
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return undefined
if (typeof window.remaining_value === 'number') {
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (typeof window.used_value === 'number') {
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
}
return undefined
}
function resolvePoolKeyPlanType(key: PoolKeyDetail): string | null {
const direct = key.oauth_plan_type?.trim()
if (direct) return direct
const quota = getQuotaSnapshot(key)
const quotaPlan = quota?.plan_type?.trim()
if (quotaPlan) return quotaPlan
const quotaPoolTier = quota?.pool_tier?.trim()
return quotaPoolTier || null
}
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
quota_auto: 'Auto',
auto: 'Auto',
quota_fast: 'Fast',
fast: 'Fast',
quota_expert: 'Expert',
expert: 'Expert',
quota_heavy: 'Heavy',
heavy: 'Heavy',
quota_grok_4_3: 'Grok 4.3',
'grok-420-computer-use-sa': 'Grok 4.3',
}
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
const code = String(window.code || '').trim().replace(/^model:/i, '')
const label = String(window.label || window.model || code).trim()
const normalized = (label || code).toLowerCase()
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[code.toLowerCase()] || label || code || '模式'
}
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
const quota = getQuotaSnapshot(key)
if (!quota) return []
@@ -3838,6 +3916,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
if (providerType === 'codex') {
const items: QuotaProgressItem[] = []
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
for (const [label, code] of [
['5H', '5h'],
['周', 'weekly'],
@@ -3850,8 +3930,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
items.push({
label,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
@@ -3859,6 +3939,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
}
if (providerType === 'kiro') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
@@ -3873,8 +3955,45 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
label: '剩余',
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
if (providerType === 'grok') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const modelWindows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (modelWindows.length > 0) {
return modelWindows
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
return {
label: getGrokQuotaWindowLabel(window),
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
return [{
label: '剩余',
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
@@ -4075,29 +4194,6 @@ function getQuotaTextClass(quotaText: string): string {
return 'text-[11px] text-foreground/90 leading-4'
}
function formatStatInteger(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
return Math.round(n).toLocaleString('en-US')
}
function formatTokenCount(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(Math.round(n))
}
function formatStatUsd(value: number | string | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '$0.00'
if (n < 0.01) return `$${n.toFixed(4)}`
if (n < 1) return `$${n.toFixed(3)}`
if (n < 1000) return `$${n.toFixed(2)}`
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPoolScore(value: number | null | undefined): string {
const n = Number(value)
if (!Number.isFinite(n)) return '-'
+4 -4
View File
@@ -524,7 +524,7 @@
<Dialog
:model-value="showAddDialog"
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-proxy,也可手动添加已有 HTTP/SOCKS 代理'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动添加已有 HTTP/SOCKS 代理'"
:icon="editingNode ? SquarePen : Plus"
size="lg"
@update:model-value="handleDialogClose"
@@ -733,11 +733,11 @@
</template>
</Dialog>
<!-- 远程配置对话框 (aether-proxy 节点) -->
<!-- 远程配置对话框 (aether-tunnel 节点) -->
<Dialog
:model-value="showConfigDialog"
title="远程配置"
description="修改后将在下次心跳时自动下发到 aether-proxy 节点"
description="修改后将在下次心跳时自动下发到 aether-tunnel 节点"
:icon="Settings"
size="md"
@update:model-value="handleConfigDialogClose"
@@ -1053,7 +1053,7 @@ const proxyInstallHint = computed(() => {
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
})
// 远程配置对话框 (aether-proxy 节点)
// 远程配置对话框 (aether-tunnel 节点)
const showConfigDialog = ref(false)
const savingConfig = ref(false)
const configNode = ref<ProxyNode | null>(null)
@@ -0,0 +1,439 @@
<template>
<div class="space-y-6 pb-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-foreground">
邀请返利
</h1>
<p class="mt-1 text-sm text-muted-foreground">
查看邀请关系、返利记录和失败返利处理状态
</p>
</div>
<Button
variant="outline"
:disabled="loading"
@click="loadAll"
>
<RefreshCw
class="mr-2 h-4 w-4"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
<Card
v-for="item in statCards"
:key="item.label"
class="p-4"
>
<p class="text-xs text-muted-foreground">
{{ item.label }}
</p>
<p class="mt-2 text-xl font-semibold">
{{ item.value }}
</p>
</Card>
</div>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
邀请关系
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="relationshipFilters.inviter"
placeholder="邀请人"
/>
<Input
v-model="relationshipFilters.invitee"
placeholder="被邀请人"
/>
<Input
v-model="relationshipFilters.invite_code"
placeholder="邀请码"
/>
<Select v-model="firstPaidFilter">
<SelectTrigger>
<SelectValue placeholder="首付状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="true">
已首付
</SelectItem>
<SelectItem value="false">
未首付
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
@click="loadRelationships"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>邀请人</TableHead>
<TableHead>被邀请人</TableHead>
<TableHead>邀请码</TableHead>
<TableHead>绑定时间</TableHead>
<TableHead>首付状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in relationships"
:key="item.id"
>
<TableCell>{{ item.inviter_username || item.inviter_user_id }}</TableCell>
<TableCell>{{ item.invitee_username || item.invitee_user_id }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.invite_code_snapshot }}
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell>
<Badge :variant="item.first_paid_order_id ? 'success' : 'secondary'">
{{ item.first_paid_order_id ? '已首付' : '未首付' }}
</Badge>
</TableCell>
</TableRow>
<TableRow v-if="relationships.length === 0">
<TableCell
colspan="5"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无邀请关系
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
返利记录
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="rewardFilters.order_id"
placeholder="订单号"
/>
<Select v-model="rewardFilters.reward_type">
<SelectTrigger>
<SelectValue placeholder="返利类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部类型
</SelectItem>
<SelectItem value="percent">
比例返利
</SelectItem>
<SelectItem value="headcount">
人头返利
</SelectItem>
</SelectContent>
</Select>
<Select v-model="rewardFilters.status">
<SelectTrigger>
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="pending">
待发
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="applied">
已发
</SelectItem>
<SelectItem value="voided">
已作废
</SelectItem>
<SelectItem value="reversed">
已冲回
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
class="md:col-start-5"
@click="loadRewards"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>类型</TableHead>
<TableHead>来源订单</TableHead>
<TableHead>金额</TableHead>
<TableHead>状态</TableHead>
<TableHead>冲回</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in rewards"
:key="item.id"
>
<TableCell>{{ getRewardTypeLabel(item.reward_type) }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.source_order_id || '-' }}
</TableCell>
<TableCell>{{ formatUsd(item.amount_usd) }}</TableCell>
<TableCell>
<Badge :variant="getRewardStatusVariant(item.status)">
{{ getRewardStatusLabel(item.status) }}
</Badge>
</TableCell>
<TableCell>
{{ formatUsd(item.reversed_amount_usd) }}
<span
v-if="item.pending_reversal_amount_usd > 0"
class="text-xs text-amber-600 dark:text-amber-400"
>
/ 待冲回 {{ formatUsd(item.pending_reversal_amount_usd) }}
</span>
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
v-if="item.status === 'failed'"
variant="outline"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="retryReward(item)"
>
补发
</Button>
<Button
v-if="item.status === 'failed' || item.status === 'pending'"
variant="ghost"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="voidReward(item)"
>
作废
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="rewards.length === 0">
<TableCell
colspan="7"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无返利记录
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import {
referralApi,
type ReferralRelationshipRecord,
type ReferralRewardRecord,
type ReferralSummary
} from '@/api/referrals'
import {
Badge,
Button,
Card,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
const relationships = ref<ReferralRelationshipRecord[]>([])
const rewards = ref<ReferralRewardRecord[]>([])
const stats = ref<ReferralSummary>({
total_invites: 0,
effective_invites: 0,
paid_reward_usd: 0,
pending_reward_usd: 0,
reversed_reward_usd: 0
})
const loading = ref(false)
const mutatingRewardId = ref<string | null>(null)
const relationshipFilters = ref({
inviter: '',
invitee: '',
invite_code: ''
})
const firstPaidFilter = ref('all')
const rewardFilters = ref({
order_id: '',
reward_type: 'all',
status: 'all'
})
const { success, error: showError } = useToast()
const statCards = computed(() => [
{ label: '总邀请', value: stats.value.total_invites },
{ label: '有效邀请', value: stats.value.effective_invites },
{ label: '已发返利', value: formatUsd(stats.value.paid_reward_usd) },
{ label: '待发返利', value: formatUsd(stats.value.pending_reward_usd) },
{ label: '已冲回返利', value: formatUsd(stats.value.reversed_reward_usd) },
])
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
function formatUnix(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
}
function getRewardTypeLabel(value: string): string {
if (value === 'percent') return '比例返利'
if (value === 'headcount') return '人头返利'
return value
}
function getRewardStatusLabel(value: string): string {
switch (value) {
case 'applied':
return '已发'
case 'pending':
return '待发'
case 'failed':
return '失败'
case 'voided':
return '已作废'
case 'reversed':
return '已冲回'
default:
return value
}
}
function getRewardStatusVariant(value: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
switch (value) {
case 'applied':
return 'success'
case 'failed':
return 'destructive'
case 'pending':
return 'warning'
case 'voided':
return 'secondary'
default:
return 'outline'
}
}
async function loadRelationships() {
const firstPaid =
firstPaidFilter.value === 'true' ? true : firstPaidFilter.value === 'false' ? false : null
const response = await referralApi.getAdminReferrals({
...relationshipFilters.value,
first_paid: firstPaid,
limit: 100,
offset: 0
})
relationships.value = response.items
stats.value = response.stats
}
async function loadRewards() {
const response = await referralApi.getAdminReferralRewards({
order_id: rewardFilters.value.order_id,
reward_type: rewardFilters.value.reward_type === 'all' ? undefined : rewardFilters.value.reward_type,
status: rewardFilters.value.status === 'all' ? undefined : rewardFilters.value.status,
limit: 100,
offset: 0
})
rewards.value = response.items
stats.value = response.stats
}
async function loadAll() {
loading.value = true
try {
await Promise.all([loadRelationships(), loadRewards()])
} catch {
showError('加载邀请返利数据失败')
} finally {
loading.value = false
}
}
async function retryReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.retryReferralReward(item.id, '管理员后台补发')
replaceReward(response.reward)
success('返利已补发')
} catch {
showError('补发失败')
} finally {
mutatingRewardId.value = null
}
}
async function voidReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.voidReferralReward(item.id, '管理员后台作废')
replaceReward(response.reward)
success('返利已作废')
} catch {
showError('作废失败')
} finally {
mutatingRewardId.value = null
}
}
function replaceReward(updated: ReferralRewardRecord) {
rewards.value = rewards.value.map(item => item.id === updated.id ? updated : item)
}
onMounted(() => {
void loadAll()
})
</script>
@@ -58,6 +58,15 @@
:turnstile-secret-key="systemConfig.turnstile_secret_key"
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
:referral-enabled="systemConfig.referral_enabled"
:referral-reward-mode="systemConfig.referral_reward_mode"
:referral-recharge-percent="systemConfig.referral_recharge_percent"
:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd"
:referral-headcount-trigger="systemConfig.referral_headcount_trigger"
:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled"
:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format"
:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content"
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
@@ -73,6 +82,15 @@
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
@clear-turnstile-secret="clearTurnstileSecret"
@update:referral-enabled="systemConfig.referral_enabled = $event"
@update:referral-reward-mode="systemConfig.referral_reward_mode = $event"
@update:referral-recharge-percent="systemConfig.referral_recharge_percent = $event"
@update:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd = $event"
@update:referral-headcount-trigger="systemConfig.referral_headcount_trigger = $event"
@update:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled = $event"
@update:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format = $event"
@update:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content = $event"
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
@@ -684,7 +684,7 @@ function tunnelErrorAction(category: string) {
case 'ws_write_error': return '检查 gateway 是否重启、负载均衡/NAT/防火墙是否重置长连接,并确认 proxy 是否已自动重连。'
case 'ws_ping_error': return '检查中间代理是否清理空闲 WebSocket,或对端是否提前关闭连接。'
case 'ws_read_error': return '对照同一时间的 gateway 日志和网络监控,确认是否存在链路中断。'
case 'tunnel_connect_error': return '检查 Aether 地址、DNS、TLS、管理 token,以及 AETHER_PROXY_AETHER_PROXY_URL 配置。'
case 'tunnel_connect_error': return '检查 Aether 地址、DNS、TLS、管理 token,以及 AETHER_TUNNEL_AETHER_OUTBOUND_PROXY_URL 配置。'
case 'frame_decode_error': return '检查 proxy/gateway 版本兼容性,确认中间层没有改写 WebSocket 二进制帧。'
case 'stream_dispatch_timeout': return '检查 proxy CPU/内存、并发上限和上游 provider 慢请求。'
case 'heartbeat_ack_empty':
@@ -262,6 +262,203 @@
</p>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="referral-enabled"
:checked="referralEnabled"
@update:checked="$emit('update:referralEnabled', $event)"
/>
<div>
<Label
for="referral-enabled"
class="cursor-pointer"
>
邀请返利
</Label>
<p class="text-xs text-muted-foreground">
开启后可按充值比例、人头或两者同时发放赠款返利
</p>
</div>
</div>
</div>
<div>
<Label
for="referral-reward-mode"
class="block text-sm font-medium mb-2"
>
返利方式
</Label>
<Select
:model-value="referralRewardMode"
@update:model-value="$emit('update:referralRewardMode', $event)"
>
<SelectTrigger id="referral-reward-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">
按充值比例
</SelectItem>
<SelectItem value="headcount">
按邀请人头
</SelectItem>
<SelectItem value="both">
两者同时启用
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="referral-recharge-percent"
class="block text-sm font-medium"
>
充值返利比例 (%)
</Label>
<Input
id="referral-recharge-percent"
:model-value="referralRechargePercent"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-amount"
class="block text-sm font-medium"
>
人头返利金额 (美元)
</Label>
<Input
id="referral-headcount-amount"
:model-value="referralHeadcountAmountUsd"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralHeadcountAmountUsd', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-trigger"
class="block text-sm font-medium mb-2"
>
人头返利触发时机
</Label>
<Select
:model-value="referralHeadcountTrigger"
@update:model-value="$emit('update:referralHeadcountTrigger', $event)"
>
<SelectTrigger id="referral-headcount-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="registration">
注册成功
</SelectItem>
<SelectItem value="email_verified">
邮箱验证完成
</SelectItem>
<SelectItem value="first_paid_order">
首笔真实支付完成
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="privacy-policy-enabled"
:checked="registrationPrivacyPolicyEnabled"
@update:checked="$emit('update:registrationPrivacyPolicyEnabled', $event)"
/>
<div>
<Label
for="privacy-policy-enabled"
class="cursor-pointer"
>
注册隐私政策确认
</Label>
<p class="text-xs text-muted-foreground">
开启后注册时必须确认当前版本
</p>
</div>
</div>
</div>
<div>
<Label
for="privacy-policy-version"
class="block text-sm font-medium"
>
隐私政策版本
</Label>
<Input
id="privacy-policy-version"
:model-value="registrationPrivacyPolicyVersion"
type="text"
placeholder="2026-05-16"
class="mt-1"
@update:model-value="$emit('update:registrationPrivacyPolicyVersion', String($event || '').trim())"
/>
</div>
<div>
<Label
for="privacy-policy-format"
class="block text-sm font-medium mb-2"
>
隐私政策格式
</Label>
<Select
:model-value="registrationPrivacyPolicyFormat"
@update:model-value="$emit('update:registrationPrivacyPolicyFormat', $event)"
>
<SelectTrigger id="privacy-policy-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="markdown">
Markdown
</SelectItem>
<SelectItem value="html">
HTML
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="md:col-span-2">
<Label
for="privacy-policy-content"
class="block text-sm font-medium"
>
隐私政策内容
</Label>
<Textarea
id="privacy-policy-content"
:model-value="registrationPrivacyPolicyContent"
rows="8"
class="mt-1"
placeholder="填写 Markdown 或 HTML 内容"
@update:model-value="$emit('update:registrationPrivacyPolicyContent', $event)"
/>
</div>
</div>
</div>
</CardSection>
</template>
@@ -270,6 +467,7 @@
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Textarea from '@/components/ui/textarea.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
@@ -288,6 +486,15 @@ defineProps<{
turnstileSecretKey: string
turnstileSecretConfigured: boolean
turnstileAllowedHostnamesStr: string
referralEnabled: boolean
referralRewardMode: string
referralRechargePercent: number
referralHeadcountAmountUsd: number
referralHeadcountTrigger: string
registrationPrivacyPolicyEnabled: boolean
registrationPrivacyPolicyFormat: string
registrationPrivacyPolicyContent: string
registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
@@ -306,6 +513,15 @@ defineEmits<{
'update:turnstileSecretKey': [value: string]
'update:turnstileAllowedHostnamesStr': [value: string]
clearTurnstileSecret: []
'update:referralEnabled': [value: boolean]
'update:referralRewardMode': [value: string]
'update:referralRechargePercent': [value: number]
'update:referralHeadcountAmountUsd': [value: number]
'update:referralHeadcountTrigger': [value: string]
'update:registrationPrivacyPolicyEnabled': [value: boolean]
'update:registrationPrivacyPolicyFormat': [value: string]
'update:registrationPrivacyPolicyContent': [value: string]
'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
@@ -20,6 +20,15 @@ export interface SystemConfig {
turnstile_secret_key: string
turnstile_secret_key_is_set: boolean
turnstile_allowed_hostnames: string[]
referral_enabled: boolean
referral_reward_mode: string
referral_recharge_percent: number
referral_headcount_amount_usd: number
referral_headcount_trigger: string
registration_privacy_policy_enabled: boolean
registration_privacy_policy_format: string
registration_privacy_policy_content: string
registration_privacy_policy_version: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -65,6 +74,15 @@ const CONFIG_KEYS = [
'turnstile_site_key',
'turnstile_secret_key',
'turnstile_allowed_hostnames',
'referral_enabled',
'referral_reward_mode',
'referral_recharge_percent',
'referral_headcount_amount_usd',
'referral_headcount_trigger',
'registration_privacy_policy_enabled',
'registration_privacy_policy_format',
'registration_privacy_policy_content',
'registration_privacy_policy_version',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -112,6 +130,15 @@ function createDefaultConfig(): SystemConfig {
turnstile_secret_key: '',
turnstile_secret_key_is_set: false,
turnstile_allowed_hostnames: [],
referral_enabled: false,
referral_reward_mode: 'percent',
referral_recharge_percent: 5,
referral_headcount_amount_usd: 0,
referral_headcount_trigger: 'registration',
registration_privacy_policy_enabled: false,
registration_privacy_policy_format: 'markdown',
registration_privacy_policy_content: '',
registration_privacy_policy_version: '1',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -184,6 +211,19 @@ export function useSystemConfig() {
systemConfig.value.turnstile_secret_key.trim() !== '' ||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
systemConfig.value.referral_enabled !== originalConfig.value.referral_enabled ||
systemConfig.value.referral_reward_mode !== originalConfig.value.referral_reward_mode ||
systemConfig.value.referral_recharge_percent !== originalConfig.value.referral_recharge_percent ||
systemConfig.value.referral_headcount_amount_usd !== originalConfig.value.referral_headcount_amount_usd ||
systemConfig.value.referral_headcount_trigger !== originalConfig.value.referral_headcount_trigger ||
systemConfig.value.registration_privacy_policy_enabled !==
originalConfig.value.registration_privacy_policy_enabled ||
systemConfig.value.registration_privacy_policy_format !==
originalConfig.value.registration_privacy_policy_format ||
systemConfig.value.registration_privacy_policy_content !==
originalConfig.value.registration_privacy_policy_content ||
systemConfig.value.registration_privacy_policy_version !==
originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
@@ -386,6 +426,51 @@ export function useSystemConfig() {
value: systemConfig.value.turnstile_allowed_hostnames,
description: 'Cloudflare Turnstile 允许的 hostname 列表',
},
{
key: 'referral_enabled',
value: systemConfig.value.referral_enabled,
description: '邀请返利开关',
},
{
key: 'referral_reward_mode',
value: systemConfig.value.referral_reward_mode,
description: '邀请返利方式',
},
{
key: 'referral_recharge_percent',
value: systemConfig.value.referral_recharge_percent,
description: '邀请充值比例返利百分比',
},
{
key: 'referral_headcount_amount_usd',
value: systemConfig.value.referral_headcount_amount_usd,
description: '邀请人头返利金额(美元)',
},
{
key: 'referral_headcount_trigger',
value: systemConfig.value.referral_headcount_trigger,
description: '邀请人头返利触发时机',
},
{
key: 'registration_privacy_policy_enabled',
value: systemConfig.value.registration_privacy_policy_enabled,
description: '注册隐私政策确认开关',
},
{
key: 'registration_privacy_policy_format',
value: systemConfig.value.registration_privacy_policy_format,
description: '注册隐私政策内容格式',
},
{
key: 'registration_privacy_policy_content',
value: systemConfig.value.registration_privacy_policy_content,
description: '注册隐私政策内容',
},
{
key: 'registration_privacy_policy_version',
value: systemConfig.value.registration_privacy_policy_version,
description: '注册隐私政策版本',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -426,6 +511,21 @@ export function useSystemConfig() {
originalConfig.value.turnstile_allowed_hostnames = [
...systemConfig.value.turnstile_allowed_hostnames,
]
originalConfig.value.referral_enabled = systemConfig.value.referral_enabled
originalConfig.value.referral_reward_mode = systemConfig.value.referral_reward_mode
originalConfig.value.referral_recharge_percent = systemConfig.value.referral_recharge_percent
originalConfig.value.referral_headcount_amount_usd =
systemConfig.value.referral_headcount_amount_usd
originalConfig.value.referral_headcount_trigger =
systemConfig.value.referral_headcount_trigger
originalConfig.value.registration_privacy_policy_enabled =
systemConfig.value.registration_privacy_policy_enabled
originalConfig.value.registration_privacy_policy_format =
systemConfig.value.registration_privacy_policy_format
originalConfig.value.registration_privacy_policy_content =
systemConfig.value.registration_privacy_policy_content
originalConfig.value.registration_privacy_policy_version =
systemConfig.value.registration_privacy_policy_version
if (turnstileSecret) {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = true
+102
View File
@@ -0,0 +1,102 @@
<template>
<main class="min-h-screen bg-[#faf9f5] text-[#3d3929] dark:bg-[#191714] dark:text-[#e3e0d3]">
<header class="border-b border-[#3d3929]/10 dark:border-white/10">
<div class="mx-auto flex max-w-4xl items-center justify-between px-5 py-4">
<RouterLink
to="/"
class="flex items-center gap-3"
>
<HeaderLogo
size="h-9 w-9"
class-name="text-[#191919] dark:text-white"
/>
<div>
<div class="text-sm font-semibold">
{{ siteName }}
</div>
<div class="text-xs text-muted-foreground">
隐私政策
</div>
</div>
</RouterLink>
<RouterLink
to="/"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
返回首页
</RouterLink>
</div>
</header>
<section class="mx-auto max-w-4xl px-5 py-8">
<div class="mb-6">
<h1 class="text-2xl font-semibold">
隐私政策
</h1>
<p class="mt-2 text-sm text-muted-foreground">
当前版本:{{ policy.version || '1' }}
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-background/70 p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<div
v-else-if="loadError"
class="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-sm text-destructive"
>
{{ loadError }}
</div>
<!-- eslint-disable vue/no-v-html -->
<article
v-else
class="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-background/70 p-6"
v-html="renderedPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { marked } from 'marked'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
const { siteName } = useSiteInfo()
const loading = ref(true)
const loadError = ref('')
const policy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: '1'
})
const renderedPolicy = computed(() => {
if (!policy.value.content) return '<p>暂无隐私政策内容。</p>'
if (policy.value.format === 'html') {
return sanitizeHtml(policy.value.content)
}
return sanitizeMarkdown(marked(policy.value.content) as string)
})
onMounted(async () => {
loading.value = true
loadError.value = ''
try {
const settings = await authApi.getRegistrationSettings()
policy.value = settings.privacy_policy ?? policy.value
} catch {
loadError.value = '隐私政策加载失败,请稍后重试。'
} finally {
loading.value = false
}
})
</script>
@@ -80,52 +80,39 @@ import { Settings } from 'lucide-vue-next'
</section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12 pt-8 border-t border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]">
<!-- 6. 能力标签 -->
<section
id="capabilities"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
6. 能力标签
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
为特定的 Key 或模型添加自定义标签(如 Vision, Function Calling, Long Context),通过标签约束路由,只选择具备该能力的可用通道。
</p>
</section>
<!-- 7. 余额监控 -->
<!-- 6. 余额监控 -->
<section
id="balance-monitor"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
7. 余额监控
6. 余额监控
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
针对各大提供商的官方接口或常见聚合平台,自动抓取并记录剩余额度,在余额低于阈值时触发报警或禁用策略。
</p>
</section>
<!-- 8. 配置导入/出 -->
<!-- 7. 配置导入/出 -->
<section
id="config-export"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
8. 配置导入/出
7. 配置导入/出
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
支持将统一模型配置、提供商端点及网关路由策略一键导出为 JSON,并在其他部署实例中迁移导入。
</p>
</section>
<!-- 9. 锁定用户密钥 -->
<!-- 8. 锁定用户密钥 -->
<section
id="lock-key"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
9. 锁定用户密钥
8. 锁定用户密钥
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
若监控发现恶意使用、异常调用或高频报错,管理员可以临时或永久锁定特定密钥,以阻断攻击源头。
@@ -158,7 +158,6 @@ import { BookOpen } from 'lucide-vue-next'
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">熔断探测:</strong><br>
当同一个提供商Key字连续若干次请求失败后,会进入熔断状态,之后每间N分钟进行探测请求,若请求成功解除熔断后续正常请求,否则按以指数级增长探测时间以待下次探测,最大探测间隔不会增长超过32分钟。
</li>
<li><strong class="text-[#262624] dark:text-[#f1ead8] font-medium">能力标签:</strong>定义该Key可以使用的能力。</li>
<li>
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">自动获取上游模型:</strong><br>
在上游获取模型端点支持的情况下,从接口自动获取可以用模型列表。且按一定时间自动刷新,不开启则默认任意模型可用,或在后续模型权限中手动添加。
+1 -1
View File
@@ -397,7 +397,7 @@ function copyStep(stepId: string, code: string) {
<h3>1. Aether-Proxy</h3>
<p>Rust实现, 超小资源占有, 适合性能低的VPS直接使用。</p>
<a
href="https://github.com/fawney19/Aether/tree/main/aether-proxy"
href="https://github.com/fawney19/Aether/tree/main/aether-tunnel"
target="_blank"
rel="noopener noreferrer"
class="text-[#cc785c] dark:text-[#d4a27f] hover:underline mt-2 inline-block"
@@ -101,7 +101,7 @@ make dev
1. **Aether-Proxy**
Rust实现, 超小资源占有, 适合性能低的vps直接使用。
[https://github.com/fawney19/Aether/tree/main/aether-proxy](https://github.com/fawney19/Aether/tree/main/aether-proxy)
[https://github.com/fawney19/Aether/tree/main/aether-tunnel](https://github.com/fawney19/Aether/tree/main/aether-tunnel)
2. **代理节点**
在模块管理中, 开启代理模块后可以添加和使用代理功能, 包括手动添加和Aether-Proxy自动连接。
@@ -85,7 +85,6 @@ export const guideNavItems: GuideNavItem[] = [
{ name: '请求头/体编辑', hash: '#header-body-edit' },
{ name: '模型映射', hash: '#model-mapping' },
{ name: '正则映射', hash: '#regex-mapping' },
{ name: '能力标签', hash: '#capabilities' },
{ name: '余额监控', hash: '#balance-monitor' },
{ name: '配置导入/出', hash: '#config-export' },
{ name: '锁定用户密钥', hash: '#lock-key' }
+30 -1
View File
@@ -90,9 +90,11 @@
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:filter-client-family="filterClientFamily"
:available-users="availableUsers"
:available-models="availableModels"
:available-providers="availableProviders"
:available-client-families="availableClientFamilies"
:current-page="currentPage"
:page-size="pageSize"
:total-records="effectiveTotalRecords"
@@ -105,6 +107,7 @@
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@update:filter-status="handleFilterStatusChange"
@update:filter-client-family="handleFilterClientFamilyChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@@ -226,6 +229,7 @@ const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
const filterClientFamily = ref('__all__')
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
@@ -372,9 +376,15 @@ const filteredRecords = computed(() => {
records = records.filter(record => record.status === 'cancelled')
} else if (filterStatus.value === 'has_fallback') {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => record.has_retry === true)
}
}
if (filterClientFamily.value !== '__all__') {
records = records.filter(record => record.client_family === filterClientFamily.value)
}
return records
}
return currentRecords.value
@@ -704,6 +714,15 @@ const effectiveTotalRecords = computed(() => {
// 显示的记录
const displayRecords = computed(() => paginatedRecords.value)
const availableClientFamilies = computed(() => {
const families = new Set<string>()
currentRecords.value.forEach((record) => {
const family = record.client_family?.trim()
if (family) families.add(family)
})
return Array.from(families).sort()
})
// 详情弹窗状态
const detailModalOpen = ref(false)
@@ -787,7 +806,8 @@ function getCurrentFilters() {
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined,
client_family: filterClientFamily.value !== '__all__' ? filterClientFamily.value : undefined
}
}
@@ -848,6 +868,15 @@ async function handleFilterStatusChange(value: string) {
}
}
async function handleFilterClientFamilyChange(value: string) {
filterClientFamily.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
}
// 刷新数据
async function refreshData() {
if (!isPageVisible.value) return
+32 -3
View File
@@ -132,6 +132,13 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
@@ -240,6 +247,13 @@
:class="getIconColor(announcement.type)"
/>
<span class="font-medium text-sm">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] shrink-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
@@ -433,6 +447,18 @@
class="cursor-pointer text-sm"
>置顶公告</Label>
</div>
<div class="flex items-center gap-2">
<input
id="requires-ack"
v-model="formData.requires_ack"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
>
<Label
for="requires-ack"
class="cursor-pointer text-sm"
>必读确认</Label>
</div>
<div
v-if="editingAnnouncement"
class="flex items-center gap-2"
@@ -611,7 +637,8 @@ const formData = ref({
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
})
onMounted(() => {
@@ -663,7 +690,8 @@ function openCreateDialog() {
type: 'info',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
}
dialogOpen.value = true
}
@@ -676,7 +704,8 @@ function openEditDialog(announcement: Announcement) {
type: announcement.type,
priority: announcement.priority,
is_pinned: announcement.is_pinned,
is_active: announcement.is_active
is_active: announcement.is_active,
requires_ack: !!announcement.requires_ack
}
dialogOpen.value = true
}
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 pb-8">
<div>
<h1 class="text-2xl font-semibold text-foreground">
我的邀请
</h1>
<p class="mt-1 text-sm text-muted-foreground">
分享邀请码后,符合规则的返利会进入赠款余额
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<template v-else-if="dashboard">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
总邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.total_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
有效邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.effective_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已发返利
</p>
<p class="mt-2 text-2xl font-semibold">
{{ formatUsd(dashboard.summary.paid_reward_usd) }}
</p>
</Card>
</div>
<Card class="p-5">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[240px_1fr]">
<div>
<Label class="text-xs text-muted-foreground">
邀请码
</Label>
<div class="mt-2 flex items-center gap-2">
<code class="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-sm">
{{ dashboard.invite_code }}
</code>
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(dashboard.invite_code)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
<div>
<Label class="text-xs text-muted-foreground">
邀请链接
</Label>
<div class="mt-2 flex min-w-0 items-center gap-2">
<Input
:model-value="dashboard.invitation_link"
readonly
class="min-w-0"
/>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="copyToClipboard(dashboard.invitation_link)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
</div>
</Card>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
待发返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.pending_reward_usd) }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已冲回返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.reversed_reward_usd) }}
</p>
</Card>
</div>
</template>
<div
v-else
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
邀请数据暂不可用
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Copy } from 'lucide-vue-next'
import { referralApi, type ReferralDashboardResponse } from '@/api/referrals'
import { Button, Card, Input, Label } from '@/components/ui'
import { useClipboard } from '@/composables/useClipboard'
import { useToast } from '@/composables/useToast'
const dashboard = ref<ReferralDashboardResponse | null>(null)
const loading = ref(false)
const { copyToClipboard } = useClipboard()
const { error: showError } = useToast()
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
async function loadReferralDashboard() {
loading.value = true
try {
dashboard.value = await referralApi.getMyReferral()
} catch {
dashboard.value = null
showError('加载邀请数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadReferralDashboard()
})
</script>
+1 -1
View File
@@ -387,7 +387,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
class="oauth-icon shrink-0"
v-html="getOAuthIcon(p.provider_type)"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<div class="min-w-0">