mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 |
@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
|
|||||||
export function useClipboard() {
|
export function useClipboard() {
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
async function copyToClipboard(text: string): Promise<boolean> {
|
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
success('已复制到剪贴板')
|
if (showToast) success('已复制到剪贴板')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,17 +25,17 @@ export function useClipboard() {
|
|||||||
try {
|
try {
|
||||||
const successful = document.execCommand('copy')
|
const successful = document.execCommand('copy')
|
||||||
if (successful) {
|
if (successful) {
|
||||||
success('已复制到剪贴板')
|
if (showToast) success('已复制到剪贴板')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
showError('复制失败,请手动复制')
|
if (showToast) showError('复制失败,请手动复制')
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textArea)
|
document.body.removeChild(textArea)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('复制失败:', err)
|
log.error('复制失败:', err)
|
||||||
showError('复制失败,请手动选择文本进行复制')
|
if (showToast) showError('复制失败,请手动选择文本进行复制')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -700,6 +700,7 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -731,6 +732,7 @@ const emit = defineEmits<{
|
|||||||
'refreshProviders': []
|
'refreshProviders': []
|
||||||
}>()
|
}>()
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: GlobalModelResponse | null
|
model: GlobalModelResponse | null
|
||||||
@@ -763,16 +765,6 @@ function handleClose() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
|
|||||||
@@ -661,6 +661,7 @@ import Button from '@/components/ui/button.vue'
|
|||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||||
import {
|
import {
|
||||||
KeyFormDialog,
|
KeyFormDialog,
|
||||||
@@ -706,6 +707,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const provider = ref<any>(null)
|
const provider = ref<any>(null)
|
||||||
@@ -1250,16 +1252,6 @@ function getHealthScoreBarColor(score: number): string {
|
|||||||
return 'bg-red-500 dark:bg-red-400'
|
return 'bg-red-500 dark:bg-red-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败', '错误')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载 Provider 信息
|
// 加载 Provider 信息
|
||||||
async function loadProvider() {
|
async function loadProvider() {
|
||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image,
|
|||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
|
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseTestModelError } from '@/utils/errorParser'
|
import { parseTestModelError } from '@/utils/errorParser'
|
||||||
@@ -239,6 +240,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -257,12 +259,7 @@ const sortedModels = computed(() => {
|
|||||||
|
|
||||||
// 复制模型 ID 到剪贴板
|
// 复制模型 ID 到剪贴板
|
||||||
async function copyModelId(modelId: string) {
|
async function copyModelId(modelId: string) {
|
||||||
try {
|
await copyToClipboard(modelId)
|
||||||
await navigator.clipboard.writeText(modelId)
|
|
||||||
showSuccess('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败', '错误')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载模型
|
// 加载模型
|
||||||
|
|||||||
@@ -473,6 +473,7 @@
|
|||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Separator from '@/components/ui/separator.vue'
|
import Separator from '@/components/ui/separator.vue'
|
||||||
@@ -505,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
|
|||||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||||
const currentExpandDepth = ref(1)
|
const currentExpandDepth = ref(1)
|
||||||
const dataSource = ref<'client' | 'provider'>('client')
|
const dataSource = ref<'client' | 'provider'>('client')
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
const historicalPricing = ref<{
|
const historicalPricing = ref<{
|
||||||
input_price: string
|
input_price: string
|
||||||
output_price: string
|
output_price: string
|
||||||
@@ -784,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
copyToClipboard(JSON.stringify(data, null, 2), false)
|
||||||
copiedStates.value[tabName] = true
|
copiedStates.value[tabName] = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copiedStates.value[tabName] = false
|
copiedStates.value[tabName] = false
|
||||||
|
|||||||
@@ -86,6 +86,34 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isEditMode && form.password.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<Label class="text-sm font-medium">
|
||||||
|
确认新密码 <span class="text-muted-foreground">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`pwd-confirm-${formNonce}`"
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
:name="`confirm-${formNonce}`"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
class="h-10"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
|
||||||
|
class="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
两次输入的密码不一致
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
for="form-email"
|
for="form-email"
|
||||||
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 10,
|
quota: 10,
|
||||||
role: 'user' as 'admin' | 'user',
|
role: 'user' as 'admin' | 'user',
|
||||||
@@ -443,6 +472,7 @@ function resetForm() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 10,
|
quota: 10,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -461,6 +491,7 @@ function loadUserData() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
username: props.user.username,
|
username: props.user.username,
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
email: props.user.email || '',
|
email: props.user.email || '',
|
||||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||||
role: props.user.role,
|
role: props.user.role,
|
||||||
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
|
|||||||
const hasUsername = form.value.username.trim().length > 0
|
const hasUsername = form.value.username.trim().length > 0
|
||||||
const hasEmail = form.value.email.trim().length > 0
|
const hasEmail = form.value.email.trim().length > 0
|
||||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||||
return hasUsername && hasEmail && hasPassword
|
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||||
|
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||||
|
return hasUsername && hasEmail && hasPassword && passwordConfirmed
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载访问控制选项
|
// 加载访问控制选项
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
|
|||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
const apiKeys = ref<AdminApiKey[]>([])
|
const apiKeys = ref<AdminApiKey[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -927,20 +929,14 @@ function selectKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyKey() {
|
async function copyKey() {
|
||||||
try {
|
await copyToClipboard(newKeyValue.value)
|
||||||
await navigator.clipboard.writeText(newKeyValue.value)
|
|
||||||
success('API Key 已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
error('复制失败,请手动复制')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||||
await navigator.clipboard.writeText(response.key)
|
await copyToClipboard(response.key)
|
||||||
success('完整密钥已复制到剪贴板')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('复制密钥失败:', err)
|
log.error('复制密钥失败:', err)
|
||||||
error('复制失败,请重试')
|
error('复制失败,请重试')
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
|
|||||||
import type { Model } from '@/api/endpoints'
|
import type { Model } from '@/api/endpoints'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -743,6 +744,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
|||||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -1066,16 +1068,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
|||||||
selectModel(model)
|
selectModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
success('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectModel(model: GlobalModelResponse) {
|
async function selectModel(model: GlobalModelResponse) {
|
||||||
selectedModel.value = model
|
selectedModel.value = model
|
||||||
detailTab.value = 'basic'
|
detailTab.value = 'basic'
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
|
|||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger, confirmWarning } = useConfirm()
|
const { confirmDanger, confirmWarning } = useConfirm()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
const usersStore = useUsersStore()
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
// 用户表单对话框状态
|
// 用户表单对话框状态
|
||||||
@@ -1001,12 +1003,7 @@ function selectApiKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyApiKey() {
|
async function copyApiKey() {
|
||||||
try {
|
await copyToClipboard(newApiKey.value)
|
||||||
await navigator.clipboard.writeText(newApiKey.value)
|
|
||||||
success('API Key已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
error('复制失败,请手动复制')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeNewApiKeyDialog() {
|
async function closeNewApiKeyDialog() {
|
||||||
@@ -1035,8 +1032,7 @@ async function copyFullKey(apiKey: any) {
|
|||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||||
await navigator.clipboard.writeText(response.key)
|
await copyToClipboard(response.key)
|
||||||
success('完整密钥已复制到剪贴板')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log.error('复制密钥失败:', err)
|
log.error('复制密钥失败:', err)
|
||||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Table,
|
Table,
|
||||||
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
|
|||||||
return (tiered?.tiers?.length || 0) > 1
|
return (tiered?.tiers?.length || 0) > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
success('已复制')
|
|
||||||
} catch (err) {
|
|
||||||
log.error('复制失败:', err)
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshData()
|
refreshData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -375,6 +376,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: PublicGlobalModel | null
|
model: PublicGlobalModel | null
|
||||||
@@ -408,15 +410,6 @@ function handleClose() {
|
|||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
showSuccess('已复制')
|
|
||||||
} catch {
|
|
||||||
showError('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstTierPrice(
|
function getFirstTierPrice(
|
||||||
tieredPricing: TieredPricingConfig | undefined | null,
|
tieredPricing: TieredPricingConfig | undefined | null,
|
||||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ class UpdateUserRequest(BaseModel):
|
|||||||
|
|
||||||
username: Optional[str] = Field(None, min_length=1, max_length=50)
|
username: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||||
email: Optional[str] = Field(None, max_length=100)
|
email: Optional[str] = Field(None, max_length=100)
|
||||||
|
password: Optional[str] = Field(None, min_length=6, max_length=128, description="新密码(留空保持不变)")
|
||||||
quota_usd: Optional[float] = Field(None, ge=0)
|
quota_usd: Optional[float] = Field(None, ge=0)
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
"""分布式任务协调器,确保仅有一个 worker 执行特定任务"""
|
"""分布式任务协调器,确保仅有一个 worker 执行特定任务
|
||||||
|
|
||||||
|
锁清理策略:
|
||||||
|
- 单实例模式(默认):启动时使用原子操作清理旧锁并获取新锁
|
||||||
|
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
- 默认行为:启动时清理旧锁(适用于单机部署)
|
||||||
|
- 多实例部署:设置 SINGLE_INSTANCE_MODE=false 禁用启动清理
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import uuid
|
import uuid
|
||||||
@@ -19,6 +27,10 @@ except ImportError: # pragma: no cover - Windows 环境
|
|||||||
class StartupTaskCoordinator:
|
class StartupTaskCoordinator:
|
||||||
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
|
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
|
||||||
|
|
||||||
|
# 类级别标记:在当前进程中是否已尝试过启动清理
|
||||||
|
# 注意:这在 fork 模式下每个 worker 都是独立的
|
||||||
|
_startup_cleanup_attempted = False
|
||||||
|
|
||||||
def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
|
def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
|
||||||
self.redis = redis_client
|
self.redis = redis_client
|
||||||
self._tokens: Dict[str, str] = {}
|
self._tokens: Dict[str, str] = {}
|
||||||
@@ -26,6 +38,8 @@ class StartupTaskCoordinator:
|
|||||||
self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
|
self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
|
||||||
if not self._lock_dir.exists():
|
if not self._lock_dir.exists():
|
||||||
self._lock_dir.mkdir(parents=True, exist_ok=True)
|
self._lock_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 单实例模式:启动时清理旧锁(适用于单机部署,避免残留锁问题)
|
||||||
|
self._single_instance_mode = os.getenv("SINGLE_INSTANCE_MODE", "true").lower() == "true"
|
||||||
|
|
||||||
def _redis_key(self, name: str) -> str:
|
def _redis_key(self, name: str) -> str:
|
||||||
return f"task_lock:{name}"
|
return f"task_lock:{name}"
|
||||||
@@ -36,12 +50,51 @@ class StartupTaskCoordinator:
|
|||||||
if self.redis:
|
if self.redis:
|
||||||
token = str(uuid.uuid4())
|
token = str(uuid.uuid4())
|
||||||
try:
|
try:
|
||||||
acquired = await self.redis.set(self._redis_key(name), token, nx=True, ex=ttl)
|
if self._single_instance_mode:
|
||||||
if acquired:
|
# 单实例模式:使用 Lua 脚本原子性地"清理旧锁 + 竞争获取"
|
||||||
self._tokens[name] = token
|
# 只有当锁不存在或成功获取时才返回 1
|
||||||
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
# 这样第一个执行的 worker 会清理旧锁并获取,后续 worker 会正常竞争
|
||||||
return True
|
script = """
|
||||||
return False
|
local key = KEYS[1]
|
||||||
|
local token = ARGV[1]
|
||||||
|
local ttl = tonumber(ARGV[2])
|
||||||
|
local startup_key = KEYS[1] .. ':startup'
|
||||||
|
|
||||||
|
-- 检查是否已有 worker 执行过启动清理
|
||||||
|
local cleaned = redis.call('GET', startup_key)
|
||||||
|
if not cleaned then
|
||||||
|
-- 第一个 worker:删除旧锁,标记已清理
|
||||||
|
redis.call('DEL', key)
|
||||||
|
redis.call('SET', startup_key, '1', 'EX', 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 尝试获取锁(NX 模式)
|
||||||
|
local result = redis.call('SET', key, token, 'NX', 'EX', ttl)
|
||||||
|
if result then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
"""
|
||||||
|
result = await self.redis.eval(
|
||||||
|
script, 2,
|
||||||
|
self._redis_key(name), self._redis_key(name),
|
||||||
|
token, ttl
|
||||||
|
)
|
||||||
|
if result == 1:
|
||||||
|
self._tokens[name] = token
|
||||||
|
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# 多实例模式:直接使用 NX 选项竞争锁
|
||||||
|
acquired = await self.redis.set(
|
||||||
|
self._redis_key(name), token, nx=True, ex=ttl
|
||||||
|
)
|
||||||
|
if acquired:
|
||||||
|
self._tokens[name] = token
|
||||||
|
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
except Exception as exc: # pragma: no cover - Redis 异常回退
|
except Exception as exc: # pragma: no cover - Redis 异常回退
|
||||||
logger.warning(f"Redis 锁获取失败,回退到文件锁: {exc}")
|
logger.warning(f"Redis 锁获取失败,回退到文件锁: {exc}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user