mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-05 17:22:28 +08:00
Initial commit
This commit is contained in:
677
frontend/src/views/user/Announcements.vue
Normal file
677
frontend/src/views/user/Announcements.vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 公告列表卡片 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">公告管理</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ isAdmin ? '管理系统公告和通知' : '查看系统公告和通知' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge v-if="unreadCount > 0" variant="default" class="px-3 py-1">
|
||||
{{ unreadCount }} 条未读
|
||||
</Badge>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openCreateDialog"
|
||||
title="新建公告"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loading" @click="loadAnnouncements(currentPage)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Bell class="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 class="text-sm font-medium text-foreground">暂无公告</h3>
|
||||
<p class="text-xs text-muted-foreground mt-1">系统暂时没有发布任何公告</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">类型</TableHead>
|
||||
<TableHead class="h-12 font-semibold">概要</TableHead>
|
||||
<TableHead class="w-[120px] h-12 font-semibold">发布者</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold">发布时间</TableHead>
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[80px] h-12 font-semibold text-center">置顶</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[80px] h-12 font-semibold text-center">启用</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[100px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
:class="'border-b border-border/40 transition-colors cursor-pointer ' + (announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10')"
|
||||
@click="viewAnnouncementDetail(announcement)"
|
||||
>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<component :is="getAnnouncementIcon(announcement.type)" class="w-5 h-5" :class="getIconColor(announcement.type)" />
|
||||
<span :class="['text-xs font-medium', getTypeTextColor(announcement.type)]">
|
||||
{{ getTypeLabel(announcement.type) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<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>
|
||||
<Pin v-if="announcement.is_pinned" class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground line-clamp-1">
|
||||
{{ getPlainText(announcement.content) }}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||
{{ announcement.author.username }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||
{{ formatDate(announcement.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge v-if="announcement.is_read" variant="secondary" class="text-xs px-2.5 py-0.5">
|
||||
已读
|
||||
</Badge>
|
||||
<Badge v-else variant="default" class="text-xs px-2.5 py-0.5">
|
||||
未读
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center">
|
||||
<Switch
|
||||
:model-value="announcement.is_pinned"
|
||||
@update:model-value="toggleAnnouncementPin(announcement, $event)"
|
||||
class="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center">
|
||||
<Switch
|
||||
:model-value="announcement.is_active"
|
||||
@update:model-value="toggleAnnouncementActive(announcement, $event)"
|
||||
class="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button @click="openEditDialog(announcement)" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<SquarePen class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button @click="confirmDelete(announcement)" variant="ghost" size="icon" class="h-9 w-9 hover:bg-rose-500/10 hover:text-rose-600">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && total > 0"
|
||||
:current="currentPage"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
@update:current="loadAnnouncements($event)"
|
||||
@update:page-size="pageSize = $event; loadAnnouncements(1)"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑公告对话框 -->
|
||||
<Dialog v-model="dialogOpen" size="xl">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Bell class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ editingAnnouncement ? '编辑公告' : '新建公告' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ editingAnnouncement ? '修改公告内容和设置' : '发布新的系统公告' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="saveAnnouncement" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title" class="text-sm font-medium">标题 *</Label>
|
||||
<Input id="title" v-model="formData.title" placeholder="输入公告标题" class="h-11" required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="content" class="text-sm font-medium">内容 * (支持 Markdown)</Label>
|
||||
<Textarea id="content" v-model="formData.content" placeholder="输入公告内容,支持 Markdown 格式" rows="10" required />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="type" class="text-sm font-medium">类型</Label>
|
||||
<Select v-model="formData.type" v-model:open="typeSelectOpen">
|
||||
<SelectTrigger id="type" class="h-11">
|
||||
<SelectValue placeholder="选择类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="info">信息</SelectItem>
|
||||
<SelectItem value="warning">警告</SelectItem>
|
||||
<SelectItem value="maintenance">维护</SelectItem>
|
||||
<SelectItem value="important">重要</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="priority" class="text-sm font-medium">优先级</Label>
|
||||
<Input id="priority" v-model.number="formData.priority" type="number" placeholder="0" class="h-11" min="0" max="10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="pinned"
|
||||
v-model="formData.is_pinned"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="pinned" class="cursor-pointer text-sm">置顶公告</Label>
|
||||
</div>
|
||||
<div v-if="editingAnnouncement" class="flex items-center gap-2">
|
||||
<input
|
||||
id="active"
|
||||
v-model="formData.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="active" class="cursor-pointer text-sm">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="saveAnnouncement" :disabled="saving" class="h-10 px-5">
|
||||
<Loader2 v-if="saving" class="animate-spin h-4 w-4 mr-2" />
|
||||
{{ editingAnnouncement ? '保存' : '创建' }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="dialogOpen = false" type="button" class="h-10 px-5">取消</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="deleteDialogOpen"
|
||||
type="danger"
|
||||
title="确认删除"
|
||||
:description="`确定要删除公告「${deletingAnnouncement?.title}」吗?此操作无法撤销。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="deleteAnnouncement"
|
||||
@cancel="deleteDialogOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<Dialog v-model="detailDialogOpen" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg flex-shrink-0" :class="getDialogIconClass(viewingAnnouncement?.type)">
|
||||
<component
|
||||
v-if="viewingAnnouncement"
|
||||
:is="getAnnouncementIcon(viewingAnnouncement.type)"
|
||||
class="h-5 w-5"
|
||||
:class="getIconColor(viewingAnnouncement.type)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight truncate">{{ viewingAnnouncement?.title || '公告详情' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="viewingAnnouncement" class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-muted-foreground">
|
||||
<span>{{ viewingAnnouncement.author.username }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatFullDate(viewingAnnouncement.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-html="renderMarkdown(viewingAnnouncement.content)"
|
||||
class="prose prose-sm dark:prose-invert max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="detailDialogOpen = false" type="button" class="h-10 px-5">关闭</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Dialog,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Switch
|
||||
} from '@/components/ui'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { AlertDialog } from '@/components/common'
|
||||
import { Bell, AlertCircle, AlertTriangle, Info, Pin, Wrench, Loader2, Plus, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const unreadCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 对话框状态
|
||||
const dialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const detailDialogOpen = ref(false)
|
||||
const typeSelectOpen = ref(false)
|
||||
const editingAnnouncement = ref<Announcement | null>(null)
|
||||
const deletingAnnouncement = ref<Announcement | null>(null)
|
||||
const viewingAnnouncement = ref<Announcement | null>(null)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
async function loadAnnouncements(page = 1) {
|
||||
loading.value = true
|
||||
currentPage.value = page
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncements({
|
||||
active_only: !isAdmin.value, // 管理员可以看到所有公告
|
||||
limit: pageSize.value,
|
||||
offset: (page - 1) * pageSize.value
|
||||
})
|
||||
announcements.value = response.items
|
||||
total.value = response.total
|
||||
unreadCount.value = response.unread_count || 0
|
||||
} catch (error) {
|
||||
console.error('加载公告失败:', error)
|
||||
showError('加载公告失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(announcement: Announcement) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
success('已标记为已读')
|
||||
} catch (error) {
|
||||
console.error('标记失败:', error)
|
||||
showError('标记失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function viewAnnouncementDetail(announcement: Announcement) {
|
||||
// 标记为已读
|
||||
if (!announcement.is_read && !isAdmin.value) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示详情对话框
|
||||
viewingAnnouncement.value = announcement
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingAnnouncement.value = null
|
||||
formData.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(announcement: Announcement) {
|
||||
editingAnnouncement.value = announcement
|
||||
formData.value = {
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
type: announcement.type,
|
||||
priority: announcement.priority,
|
||||
is_pinned: announcement.is_pinned,
|
||||
is_active: announcement.is_active
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function toggleAnnouncementPin(announcement: Announcement, newStatus: boolean) {
|
||||
try {
|
||||
await announcementApi.updateAnnouncement(announcement.id, {
|
||||
is_pinned: newStatus
|
||||
})
|
||||
announcement.is_pinned = newStatus
|
||||
success(newStatus ? '已置顶' : '已取消置顶')
|
||||
} catch (error) {
|
||||
console.error('更新置顶状态失败:', error)
|
||||
showError('更新置顶状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAnnouncementActive(announcement: Announcement, newStatus: boolean) {
|
||||
try {
|
||||
await announcementApi.updateAnnouncement(announcement.id, {
|
||||
is_active: newStatus
|
||||
})
|
||||
announcement.is_active = newStatus
|
||||
success(newStatus ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
console.error('更新启用状态失败:', error)
|
||||
showError('更新启用状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnnouncement() {
|
||||
if (!formData.value.title || !formData.value.content) {
|
||||
showError('请填写标题和内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingAnnouncement.value) {
|
||||
// 更新
|
||||
await announcementApi.updateAnnouncement(editingAnnouncement.value.id, formData.value)
|
||||
success('公告更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
await announcementApi.createAnnouncement(formData.value)
|
||||
success('公告创建成功')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
loadAnnouncements(currentPage.value)
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
showError('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(announcement: Announcement) {
|
||||
deletingAnnouncement.value = announcement
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function deleteAnnouncement() {
|
||||
if (!deletingAnnouncement.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await announcementApi.deleteAnnouncement(deletingAnnouncement.value.id)
|
||||
success('公告已删除')
|
||||
deleteDialogOpen.value = false
|
||||
loadAnnouncements(currentPage.value)
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
showError('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getAnnouncementIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'maintenance':
|
||||
return Wrench
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'text-red-500'
|
||||
case 'warning':
|
||||
return 'text-yellow-500'
|
||||
case 'maintenance':
|
||||
return 'text-orange-500'
|
||||
default:
|
||||
return 'text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getIconBgClass(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'bg-red-50 dark:bg-red-900/20'
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20'
|
||||
case 'maintenance':
|
||||
return 'bg-orange-50 dark:bg-orange-900/20'
|
||||
default:
|
||||
return 'bg-primary/10'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeTextColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
case 'warning':
|
||||
return 'text-yellow-600 dark:text-yellow-400'
|
||||
case 'maintenance':
|
||||
return 'text-orange-600 dark:text-orange-400'
|
||||
default:
|
||||
return 'text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeBadgeVariant(type: string): 'default' | 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'destructive'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'maintenance':
|
||||
return 'secondary'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return '重要'
|
||||
case 'warning':
|
||||
return '警告'
|
||||
case 'maintenance':
|
||||
return '维护'
|
||||
default:
|
||||
return '信息'
|
||||
}
|
||||
}
|
||||
|
||||
function getDialogIconClass(type?: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'bg-rose-100 dark:bg-rose-900/30'
|
||||
case 'warning':
|
||||
return 'bg-amber-100 dark:bg-amber-900/30'
|
||||
case 'maintenance':
|
||||
return 'bg-orange-100 dark:bg-orange-900/30'
|
||||
default:
|
||||
return 'bg-primary/10 dark:bg-primary/20'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const rawHtml = marked(content) as string
|
||||
return sanitizeMarkdown(rawHtml)
|
||||
}
|
||||
|
||||
function getPlainText(content: string): string {
|
||||
// 简单地移除 Markdown 标记,用于预览
|
||||
return content
|
||||
.replace(/[#*_`~\[\]()]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 200)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分钟前`
|
||||
} else if (hours < 24) {
|
||||
return `${hours} 小时前`
|
||||
} else if (days < 7) {
|
||||
return `${days} 天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Markdown 内容样式 */
|
||||
:deep(.prose) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:deep(.prose p) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose ul) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose li) {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3) {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose code) {
|
||||
@apply bg-gray-100 dark:bg-muted px-1 py-0.5 rounded text-sm;
|
||||
}
|
||||
|
||||
:deep(.prose pre) {
|
||||
@apply bg-gray-100 dark:bg-card p-3 rounded-lg overflow-x-auto;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
444
frontend/src/views/user/ModelCatalog.vue
Normal file
444
frontend/src/views/user/ModelCatalog.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 模型列表 -->
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-base font-semibold">可用模型</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
id="model-search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索模型名称..."
|
||||
class="w-44 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 能力筛选 -->
|
||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
||||
title="Vision"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
||||
title="Tool Use"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
||||
title="Extended Thinking"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="refreshData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table class="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[140px] h-12 font-semibold">模型名称</TableHead>
|
||||
<TableHead class="w-[120px] h-12 font-semibold">模型偏好</TableHead>
|
||||
<TableHead class="w-[100px] h-12 font-semibold">能力</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold text-center">价格 ($/M)</TableHead>
|
||||
<TableHead class="w-[70px] h-12 font-semibold text-center">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell colspan="5" class="text-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-else-if="filteredModels.length === 0">
|
||||
<TableCell colspan="5" class="text-center py-12 text-muted-foreground">
|
||||
没有找到匹配的模型
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else>
|
||||
<TableRow
|
||||
v-for="model in paginatedModels"
|
||||
:key="model.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
@mousedown="handleMouseDown"
|
||||
@click="openModelDetail(model, $event)"
|
||||
>
|
||||
<TableCell class="py-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium hover:text-primary transition-colors">{{ model.display_name || model.name }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<span>{{ model.name }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="复制模型 ID"
|
||||
@click.stop="copyToClipboard(model.name)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="getModelSupportedCapabilities(model).length > 0">
|
||||
<button
|
||||
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
|
||||
:key="cap.name"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
isCapabilityEnabled(model.name, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="cap.description"
|
||||
@click.stop="toggleCapability(model.name, cap.name)"
|
||||
>
|
||||
<Check v-if="isCapabilityEnabled(model.name, cap.name)" class="w-3 h-3" />
|
||||
<Plus v-else class="w-3 h-3" />
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5">
|
||||
<Eye v-if="model.default_supports_vision" class="w-4 h-4 text-muted-foreground" title="Vision" />
|
||||
<Wrench v-if="model.default_supports_function_calling" class="w-4 h-4 text-muted-foreground" title="Tool Use" />
|
||||
<Brain v-if="model.default_supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="Extended Thinking" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<!-- 按 Token 计费 -->
|
||||
<div v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')">
|
||||
<span class="text-muted-foreground">In:</span>
|
||||
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}</span>
|
||||
<span class="text-muted-foreground mx-1">/</span>
|
||||
<span class="text-muted-foreground">Out:</span>
|
||||
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}</span>
|
||||
<span v-if="hasTieredPricing(model)" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0">
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 无计费配置 -->
|
||||
<div v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request" class="text-muted-foreground">-</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge :variant="model.is_active ? 'success' : 'secondary'">
|
||||
{{ model.is_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && filteredModels.length > 0"
|
||||
:current="currentPage"
|
||||
:total="filteredModels.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 模型详情抽屉 -->
|
||||
<UserModelDetailDrawer
|
||||
v-model:open="drawerOpen"
|
||||
:model="selectedModel"
|
||||
:capabilities="allCapabilities"
|
||||
:user-configurable-capabilities="userConfigurableCapabilities"
|
||||
:model-capability-settings="modelCapabilitySettings"
|
||||
@toggle-capability="toggleCapability"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
Input,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
getPublicGlobalModels,
|
||||
type PublicGlobalModel,
|
||||
} from '@/api/public-models'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
getUserConfigurableCapabilities,
|
||||
getAllCapabilities,
|
||||
type CapabilityDefinition
|
||||
} from '@/api/endpoints'
|
||||
import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const models = ref<PublicGlobalModel[]>([])
|
||||
|
||||
// 抽屉状态
|
||||
const drawerOpen = ref(false)
|
||||
const selectedModel = ref<PublicGlobalModel | null>(null)
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
function openModelDetail(model: PublicGlobalModel, event: MouseEvent) {
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
selectedModel.value = model
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 能力筛选
|
||||
const capabilityFilters = ref({
|
||||
vision: false,
|
||||
toolUse: false,
|
||||
extendedThinking: false,
|
||||
})
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const allCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const modelCapabilitySettings = ref<Record<string, Record<string, boolean>>>({})
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "modelName:capName"
|
||||
|
||||
// 获取模型支持的可配置能力名称列表(从 supported_capabilities 字段读取)
|
||||
function getModelSupportedCapabilities(model: PublicGlobalModel): string[] {
|
||||
if (!model.supported_capabilities) return []
|
||||
// 只返回用户可配置的能力
|
||||
return model.supported_capabilities.filter(capName =>
|
||||
userConfigurableCapabilities.value.some(cap => cap.name === capName)
|
||||
)
|
||||
}
|
||||
|
||||
// 获取模型支持的可配置能力详情列表
|
||||
function getModelSupportedCapabilitiesDetails(model: PublicGlobalModel): CapabilityDefinition[] {
|
||||
const supportedNames = getModelSupportedCapabilities(model)
|
||||
return userConfigurableCapabilities.value.filter(cap => supportedNames.includes(cap.name))
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(modelName: string, capName: string): boolean {
|
||||
return modelCapabilitySettings.value[modelName]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(modelName: string, capName: string) {
|
||||
const capKey = `${modelName}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(modelName, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 更新本地状态
|
||||
const newSettings = { ...modelCapabilitySettings.value }
|
||||
if (!newSettings[modelName]) {
|
||||
newSettings[modelName] = {}
|
||||
}
|
||||
|
||||
if (newEnabled) {
|
||||
newSettings[modelName][capName] = true
|
||||
} else {
|
||||
delete newSettings[modelName][capName]
|
||||
// 如果该模型没有任何能力配置了,删除整个模型条目
|
||||
if (Object.keys(newSettings[modelName]).length === 0) {
|
||||
delete newSettings[modelName]
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateModelCapabilitySettings({
|
||||
model_capability_settings: Object.keys(newSettings).length > 0 ? newSettings : null
|
||||
})
|
||||
|
||||
// 更新本地状态
|
||||
modelCapabilitySettings.value = newSettings
|
||||
} catch (err) {
|
||||
console.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选后的模型列表
|
||||
const filteredModels = computed(() => {
|
||||
let result = models.value
|
||||
|
||||
// 搜索
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.vision) {
|
||||
result = result.filter(m => m.default_supports_vision)
|
||||
}
|
||||
if (capabilityFilters.value.toolUse) {
|
||||
result = result.filter(m => m.default_supports_function_calling)
|
||||
}
|
||||
if (capabilityFilters.value.extendedThinking) {
|
||||
result = result.filter(m => m.default_supports_extended_thinking)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 分页计算
|
||||
const paginatedModels = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredModels.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([searchQuery, capabilityFilters], () => {
|
||||
currentPage.value = 1
|
||||
}, { deep: true })
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getPublicGlobalModels({ limit: 1000 })
|
||||
models.value = response.models || []
|
||||
} catch (err: any) {
|
||||
console.error('加载模型失败:', err)
|
||||
showError(err.response?.data?.detail || err.message, '加载模型失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
const [userCaps, allCaps] = await Promise.all([
|
||||
getUserConfigurableCapabilities(),
|
||||
getAllCapabilities()
|
||||
])
|
||||
availableCapabilities.value = userCaps
|
||||
allCapabilities.value = allCaps
|
||||
} catch (err) {
|
||||
console.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelCapabilitySettings() {
|
||||
try {
|
||||
const response = await meApi.getModelCapabilitySettings()
|
||||
modelCapabilitySettings.value = response.model_capability_settings || {}
|
||||
} catch (err) {
|
||||
console.error('Failed to load model capability settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await Promise.all([loadModels(), loadCapabilities(), loadModelCapabilitySettings()])
|
||||
}
|
||||
|
||||
// 从 PublicGlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
function getFirstTierPrice(model: PublicGlobalModel, type: 'input' | 'output'): number | null {
|
||||
const tiered = model.default_tiered_pricing
|
||||
if (!tiered?.tiers?.length) return null
|
||||
const firstTier = tiered.tiers[0]
|
||||
if (type === 'input') {
|
||||
return firstTier.input_price_per_1m || null
|
||||
}
|
||||
return firstTier.output_price_per_1m || null
|
||||
}
|
||||
|
||||
// 检测是否有阶梯计费(多于一个阶梯)
|
||||
function hasTieredPricing(model: PublicGlobalModel): boolean {
|
||||
const tiered = model.default_tiered_pricing
|
||||
return (tiered?.tiers?.length || 0) > 1
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
638
frontend/src/views/user/MyApiKeys.vue
Normal file
638
frontend/src/views/user/MyApiKeys.vue
Normal file
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- API Keys 表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">我的 API Keys</h3>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 新增 API Key 按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="showCreateDialog = true"
|
||||
title="创建新 API Key"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="loadApiKeys" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingState message="加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="apiKeys.length === 0" class="flex items-center justify-center py-12">
|
||||
<EmptyState
|
||||
title="暂无 API 密钥"
|
||||
description="创建你的第一个 API 密钥开始使用"
|
||||
:icon="Key"
|
||||
>
|
||||
<template #actions>
|
||||
<Button @click="showCreateDialog = true" size="lg" class="shadow-lg shadow-primary/20">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
创建新 API Key
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div v-else class="hidden md:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="min-w-[200px] h-12 font-semibold">密钥名称</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold">能力</TableHead>
|
||||
<TableHead class="min-w-[160px] h-12 font-semibold">密钥</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">费用(USD)</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">请求次数</TableHead>
|
||||
<TableHead class="min-w-[70px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">最后使用</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="apiKey in paginatedApiKeys"
|
||||
:key="apiKey.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<!-- 密钥名称 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold truncate" :title="apiKey.name">
|
||||
{{ apiKey.name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">
|
||||
创建于 {{ formatDate(apiKey.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 能力 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="userConfigurableCapabilities.length > 0">
|
||||
<button
|
||||
v-for="cap in userConfigurableCapabilities"
|
||||
:key="cap.name"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
isCapabilityEnabled(apiKey, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
|
||||
@click.stop="toggleCapability(apiKey, cap.name)"
|
||||
>
|
||||
<Check v-if="isCapabilityEnabled(apiKey, cap.name)" class="w-3 h-3" />
|
||||
<Plus v-else class="w-3 h-3" />
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 密钥显示 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
||||
{{ apiKey.key_display || 'sk-••••••••' }}
|
||||
</code>
|
||||
<Button
|
||||
@click="copyApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="复制完整密钥"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 费用 -->
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm font-semibold text-amber-600 dark:text-amber-500">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<!-- 请求次数 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Activity class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ formatNumber(apiKey.total_requests || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 状态 -->
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="font-medium px-3 py-1"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<!-- 最后使用时间 -->
|
||||
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
||||
</TableCell>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
@click="toggleApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="apiKey.is_active ? '禁用' : '启用'"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmDelete(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div v-if="!loading && apiKeys.length > 0" class="md:hidden space-y-3 p-4">
|
||||
<Card
|
||||
v-for="apiKey in paginatedApiKeys"
|
||||
:key="apiKey.id"
|
||||
variant="default"
|
||||
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div class="p-4">
|
||||
<!-- 第一行:名称、状态、操作 -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-foreground truncate">{{ apiKey.name }}</h3>
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs px-1.5 py-0"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
@click="copyApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="复制"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="toggleApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="apiKey.is_active ? '禁用' : '启用'"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmDelete(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:密钥、时间、统计 -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<code class="font-mono text-muted-foreground">{{ apiKey.key_display || 'sk-••••••••' }}</code>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-amber-600 dark:text-amber-500 font-semibold">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-foreground font-medium">
|
||||
{{ formatNumber(apiKey.total_requests || 0) }} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="apiKeys.length > 0"
|
||||
:current="currentPage"
|
||||
:total="apiKeys.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建 API 密钥对话框 -->
|
||||
<Dialog v-model="showCreateDialog">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Key class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建 API 密钥</h3>
|
||||
<p class="text-xs text-muted-foreground">创建一个新的密钥用于访问 API 服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="key-name" class="text-sm font-semibold">密钥名称</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
v-model="newKeyName"
|
||||
placeholder="例如:生产环境密钥"
|
||||
class="h-11 border-border/60"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">给密钥起一个有意义的名称方便识别</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" class="h-11 px-6" @click="showCreateDialog = false">取消</Button>
|
||||
<Button class="h-11 px-6 shadow-lg shadow-primary/20" @click="createApiKey" :disabled="creating">
|
||||
<Loader2 v-if="creating" class="animate-spin h-4 w-4 mr-2" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 新密钥创建成功对话框 -->
|
||||
<Dialog v-model="showKeyDialog" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
|
||||
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">API 密钥</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:value="newKeyValue"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||
@click="($event.target as HTMLInputElement)?.select()"
|
||||
/>
|
||||
<Button @click="copyTextToClipboard(newKeyValue)" class="h-11">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="showKeyDialog = false" class="h-10 px-5">确定</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showDeleteDialog"
|
||||
type="danger"
|
||||
title="确认删除"
|
||||
:description="`确定要删除密钥 "${keyToDelete?.name}" 吗?此操作不可恢复。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="deleteApiKey"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { meApi, type ApiKey } from '@/api/me'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { Dialog, Pagination } from '@/components/ui'
|
||||
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, Check } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const paginatedApiKeys = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return apiKeys.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showKeyDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
const newKeyName = ref('')
|
||||
const newKeyValue = ref('')
|
||||
const keyToDelete = ref<ApiKey | null>(null)
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "keyId:capName"
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (error) {
|
||||
console.error('Failed to load capabilities:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
apiKeys.value = await meApi.getApiKeys()
|
||||
} catch (error: any) {
|
||||
console.error('加载 API 密钥失败:', error)
|
||||
if (!error.response) {
|
||||
showError('无法连接到服务器,请检查后端服务是否运行')
|
||||
} else if (error.response.status === 401) {
|
||||
showError('认证失败,请重新登录')
|
||||
} else {
|
||||
showError('加载 API 密钥失败:' + (error.response?.data?.detail || error.message))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
showError('请输入密钥名称')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const newKey = await meApi.createApiKey(newKeyName.value)
|
||||
newKeyValue.value = newKey.key || ''
|
||||
showCreateDialog.value = false
|
||||
showKeyDialog.value = true
|
||||
newKeyName.value = ''
|
||||
await loadApiKeys()
|
||||
success('API 密钥创建成功')
|
||||
} catch (error) {
|
||||
console.error('创建 API 密钥失败:', error)
|
||||
showError('创建 API 密钥失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(apiKey: ApiKey) {
|
||||
keyToDelete.value = apiKey
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
if (!keyToDelete.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await meApi.deleteApiKey(keyToDelete.value.id)
|
||||
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value!.id)
|
||||
showDeleteDialog.value = false
|
||||
success('API 密钥已删除')
|
||||
} catch (error) {
|
||||
console.error('删除 API 密钥失败:', error)
|
||||
showError('删除 API 密钥失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
keyToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
const updated = await meApi.toggleApiKey(apiKey.id)
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].is_active = updated.is_active
|
||||
}
|
||||
success(updated.is_active ? '密钥已启用' : '密钥已禁用')
|
||||
} catch (error) {
|
||||
console.error('切换密钥状态失败:', error)
|
||||
showError('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(apiKey: ApiKey, capName: string): boolean {
|
||||
return apiKey.force_capabilities?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(apiKey: ApiKey, capName: string) {
|
||||
const capKey = `${apiKey.id}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(apiKey, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 构建新的能力配置
|
||||
const newCapabilities: Record<string, boolean> = { ...(apiKey.force_capabilities || {}) }
|
||||
|
||||
if (newEnabled) {
|
||||
newCapabilities[capName] = true
|
||||
} else {
|
||||
delete newCapabilities[capName]
|
||||
}
|
||||
|
||||
const capabilitiesData = Object.keys(newCapabilities).length > 0 ? newCapabilities : null
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateApiKeyCapabilities(apiKey.id, {
|
||||
force_capabilities: capabilitiesData
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].force_capabilities = capabilitiesData
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await meApi.getFullApiKey(apiKey.id)
|
||||
await copyTextToClipboard(response.key, false) // 不显示内部提示
|
||||
success('完整密钥已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制密钥失败:', error)
|
||||
showError('复制失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful && showToast) {
|
||||
success('已复制到剪贴板')
|
||||
} else if (!successful) {
|
||||
showError('复制失败,请手动复制')
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null) {
|
||||
return '0'
|
||||
}
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
|
||||
return formatDate(dateString)
|
||||
}
|
||||
|
||||
// 获取能力按钮的提示文字
|
||||
function getCapabilityTooltip(cap: CapabilityDefinition, isEnabled: boolean): string {
|
||||
if (isEnabled) {
|
||||
return `[已启用] 此密钥只能访问支持${cap.display_name}的模型`
|
||||
}
|
||||
return `${cap.description}`
|
||||
}
|
||||
|
||||
</script>
|
||||
457
frontend/src/views/user/Settings.vue
Normal file
457
frontend/src/views/user/Settings.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h2 class="text-2xl font-bold text-foreground mb-6">个人设置</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 左侧:个人信息和密码 -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">基本信息</h3>
|
||||
<form @submit.prevent="updateProfile" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="username">用户名</Label>
|
||||
<Input id="username" v-model="profileForm.username" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="email">邮箱</Label>
|
||||
<Input id="email" v-model="profileForm.email" type="email" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="bio">个人简介</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
v-model="preferencesForm.bio"
|
||||
rows="3"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="avatar">头像 URL</Label>
|
||||
<Input id="avatar" v-model="preferencesForm.avatar_url" type="url" class="mt-1" />
|
||||
<p class="mt-1 text-sm text-muted-foreground">输入头像图片的 URL 地址</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="savingProfile">
|
||||
{{ savingProfile ? '保存中...' : '保存修改' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">修改密码</h3>
|
||||
<form @submit.prevent="changePassword" class="space-y-4">
|
||||
<div>
|
||||
<Label for="old-password">当前密码</Label>
|
||||
<Input id="old-password" v-model="passwordForm.old_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="new-password">新密码</Label>
|
||||
<Input id="new-password" v-model="passwordForm.new_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="confirm-password">确认新密码</Label>
|
||||
<Input id="confirm-password" v-model="passwordForm.confirm_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<Button type="submit" :disabled="changingPassword">
|
||||
{{ changingPassword ? '修改中...' : '修改密码' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- 偏好设置 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">偏好设置</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="theme">主题</Label>
|
||||
<Select
|
||||
v-model="preferencesForm.theme"
|
||||
v-model:open="themeSelectOpen"
|
||||
@update:model-value="handleThemeChange"
|
||||
>
|
||||
<SelectTrigger id="theme" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">浅色</SelectItem>
|
||||
<SelectItem value="dark">深色</SelectItem>
|
||||
<SelectItem value="system">跟随系统</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="language">语言</Label>
|
||||
<Select
|
||||
v-model="preferencesForm.language"
|
||||
v-model:open="languageSelectOpen"
|
||||
@update:model-value="handleLanguageChange"
|
||||
>
|
||||
<SelectTrigger id="language" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="timezone">时区</Label>
|
||||
<Input id="timezone" v-model="preferencesForm.timezone" placeholder="Asia/Shanghai" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-foreground">通知设置</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
|
||||
<div class="flex-1">
|
||||
<Label for="email-notifications" class="text-sm font-medium cursor-pointer">
|
||||
邮件通知
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">接收系统重要通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="email-notifications"
|
||||
v-model="preferencesForm.notifications.email"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
|
||||
<div class="flex-1">
|
||||
<Label for="usage-alerts" class="text-sm font-medium cursor-pointer">
|
||||
使用提醒
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">当接近配额限制时提醒</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="usage-alerts"
|
||||
v-model="preferencesForm.notifications.usage_alerts"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex-1">
|
||||
<Label for="announcement-notifications" class="text-sm font-medium cursor-pointer">
|
||||
公告通知
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">接收系统公告</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="announcement-notifications"
|
||||
v-model="preferencesForm.notifications.announcements"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:账户信息和使用量 -->
|
||||
<div class="space-y-6">
|
||||
<!-- 账户信息 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">账户信息</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">角色</span>
|
||||
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
|
||||
{{ profile?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">账户状态</span>
|
||||
<span :class="profile?.is_active ? 'text-success' : 'text-destructive'">
|
||||
{{ profile?.is_active ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">注册时间</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatDate(profile?.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">最后登录</span>
|
||||
<span class="text-foreground">
|
||||
{{ profile?.last_login_at ? formatDate(profile.last_login_at) : '未记录' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 使用配额 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">使用配额</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-muted-foreground">配额使用(美元)</span>
|
||||
<span class="text-foreground">
|
||||
<template v-if="isUnlimitedQuota()">
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
<span class="text-warning">无限制</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
{{ formatCurrency(profile?.quota_usd || 0) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2.5">
|
||||
<div
|
||||
class="bg-success h-2.5 rounded-full"
|
||||
:style="`width: ${getUsagePercentage()}%`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi, type Profile } from '@/api/me'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
const profileForm = ref({
|
||||
email: '',
|
||||
username: ''
|
||||
})
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const preferencesForm = ref({
|
||||
avatar_url: '',
|
||||
bio: '',
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
notifications: {
|
||||
email: true,
|
||||
usage_alerts: true,
|
||||
announcements: true
|
||||
}
|
||||
})
|
||||
|
||||
const savingProfile = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const themeSelectOpen = ref(false)
|
||||
const languageSelectOpen = ref(false)
|
||||
|
||||
function handleThemeChange(value: string) {
|
||||
preferencesForm.value.theme = value
|
||||
themeSelectOpen.value = false
|
||||
updatePreferences()
|
||||
|
||||
// 应用主题
|
||||
if (value === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (value === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
// system: 跟随系统
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLanguageChange(value: string) {
|
||||
preferencesForm.value.language = value
|
||||
languageSelectOpen.value = false
|
||||
updatePreferences()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfile()
|
||||
await loadPreferences()
|
||||
})
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
profile.value = await meApi.getProfile()
|
||||
profileForm.value = {
|
||||
email: profile.value.email,
|
||||
username: profile.value.username
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载个人信息失败:', error)
|
||||
showError('加载个人信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
const prefs = await meApi.getPreferences()
|
||||
preferencesForm.value = {
|
||||
avatar_url: prefs.avatar_url || '',
|
||||
bio: prefs.bio || '',
|
||||
theme: prefs.theme || 'light',
|
||||
language: prefs.language || 'zh-CN',
|
||||
timezone: prefs.timezone || 'Asia/Shanghai',
|
||||
notifications: {
|
||||
email: prefs.notifications?.email ?? true,
|
||||
usage_alerts: prefs.notifications?.usage_alerts ?? true,
|
||||
announcements: prefs.notifications?.announcements ?? true
|
||||
}
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
if (preferencesForm.value.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (preferencesForm.value.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载偏好设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
savingProfile.value = true
|
||||
try {
|
||||
await meApi.updateProfile(profileForm.value)
|
||||
|
||||
// 同时更新偏好设置中的 avatar_url 和 bio
|
||||
await meApi.updatePreferences({
|
||||
avatar_url: preferencesForm.value.avatar_url || undefined,
|
||||
bio: preferencesForm.value.bio || undefined,
|
||||
theme: preferencesForm.value.theme,
|
||||
language: preferencesForm.value.language,
|
||||
timezone: preferencesForm.value.timezone || undefined,
|
||||
notifications: {
|
||||
email: preferencesForm.value.notifications.email,
|
||||
usage_alerts: preferencesForm.value.notifications.usage_alerts,
|
||||
announcements: preferencesForm.value.notifications.announcements
|
||||
}
|
||||
})
|
||||
|
||||
success('个人信息已更新')
|
||||
await loadProfile()
|
||||
authStore.fetchCurrentUser()
|
||||
} catch (error) {
|
||||
log.error('更新个人信息失败:', error)
|
||||
showError('更新个人信息失败')
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
showError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
return
|
||||
}
|
||||
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await meApi.changePassword({
|
||||
old_password: passwordForm.value.old_password,
|
||||
new_password: passwordForm.value.new_password
|
||||
})
|
||||
success('密码修改成功')
|
||||
passwordForm.value = {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('修改密码失败:', error)
|
||||
showError('修改密码失败,请检查当前密码是否正确')
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePreferences() {
|
||||
try {
|
||||
await meApi.updatePreferences({
|
||||
avatar_url: preferencesForm.value.avatar_url || undefined,
|
||||
bio: preferencesForm.value.bio || undefined,
|
||||
theme: preferencesForm.value.theme,
|
||||
language: preferencesForm.value.language,
|
||||
timezone: preferencesForm.value.timezone || undefined,
|
||||
notifications: {
|
||||
email: preferencesForm.value.notifications.email,
|
||||
usage_alerts: preferencesForm.value.notifications.usage_alerts,
|
||||
announcements: preferencesForm.value.notifications.announcements
|
||||
}
|
||||
})
|
||||
success('设置已保存')
|
||||
} catch (error) {
|
||||
log.error('更新偏好设置失败:', error)
|
||||
showError('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getUsagePercentage(): number {
|
||||
if (!profile.value) return 0
|
||||
|
||||
const quota = profile.value.quota_usd
|
||||
const used = profile.value.used_usd
|
||||
if (quota == null || quota === 0) return 0
|
||||
return Math.min(100, (used / quota) * 100)
|
||||
}
|
||||
|
||||
function isUnlimitedQuota(): boolean {
|
||||
return profile.value?.quota_usd == null
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return '未知'
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
395
frontend/src/views/user/components/UserModelDetailDrawer.vue
Normal file
395
frontend/src/views/user/components/UserModelDetailDrawer.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="open && model"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
|
||||
<!-- 标题栏 -->
|
||||
<div class="sticky top-0 z-10 bg-background border-b p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1 flex-1 min-w-0">
|
||||
<h3 class="text-xl font-bold truncate">{{ model.display_name || model.name }}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="model.is_active ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.is_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground font-mono">{{ model.name }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="复制模型 ID"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="model.description" class="text-xs text-muted-foreground">
|
||||
{{ model.description }}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- 模型能力 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型能力</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Zap class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Streaming</p>
|
||||
<p class="text-xs text-muted-foreground">流式输出</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<ImageIcon class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Image Generation</p>
|
||||
<p class="text-xs text-muted-foreground">图像生成</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Eye class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Vision</p>
|
||||
<p class="text-xs text-muted-foreground">视觉理解</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Wrench class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Tool Use</p>
|
||||
<p class="text-xs text-muted-foreground">工具调用</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Brain class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Extended Thinking</p>
|
||||
<p class="text-xs text-muted-foreground">深度思考</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型偏好 -->
|
||||
<div v-if="getModelUserConfigurableCapabilities().length > 0" class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型偏好</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="cap in getModelUserConfigurableCapabilities()"
|
||||
:key="cap.name"
|
||||
class="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{{ cap.display_name }}</p>
|
||||
<p v-if="cap.description" class="text-xs text-muted-foreground truncate">{{ cap.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isCapabilityEnabled(cap.name) ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="isCapabilityEnabled(cap.name)"
|
||||
@click="handleToggleCapability(cap.name)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out',
|
||||
isCapabilityEnabled(cap.name) ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">定价信息</h4>
|
||||
|
||||
<!-- 单阶梯(固定价格)展示 -->
|
||||
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输入价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'input_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输出价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'output_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存创建 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存读取 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_read_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 1h 缓存 -->
|
||||
<div v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
|
||||
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多阶梯计费展示 -->
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Layers class="w-4 h-4" />
|
||||
<span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} 档)</span>
|
||||
</div>
|
||||
|
||||
<!-- 阶梯价格表格 -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/30">
|
||||
<TableHead class="text-xs h-9">阶梯</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">输入 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">输出 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存创建</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存读取</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">1h 缓存</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(tier, index) in model.default_tiered_pricing?.tiers || []"
|
||||
:key="index"
|
||||
class="text-xs"
|
||||
>
|
||||
<TableCell class="py-2">
|
||||
<span v-if="tier.up_to === null" class="text-muted-foreground">
|
||||
{{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ index === 0 ? '0' : formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to) }} - {{ formatTierLimit(tier.up_to) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.input_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.output_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_creation_price_per_1m != null ? `$${tier.cache_creation_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_read_price_per_1m != null ? `$${tier.cache_read_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ get1hCachePrice(tier) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 按次计费(多阶梯时也显示) -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
X,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Zap,
|
||||
Copy,
|
||||
Layers,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
open: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
userConfigurableCapabilities?: CapabilityDefinition[]
|
||||
modelCapabilitySettings?: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'toggle-capability': [modelName: string, capName: string]
|
||||
}>()
|
||||
|
||||
// 根据能力名称获取显示名称
|
||||
function getCapabilityDisplayName(capName: string): string {
|
||||
const cap = props.capabilities?.find(c => c.name === capName)
|
||||
return cap?.display_name || capName
|
||||
}
|
||||
|
||||
// 获取模型支持的用户可配置能力
|
||||
function getModelUserConfigurableCapabilities(): CapabilityDefinition[] {
|
||||
if (!props.model?.supported_capabilities || !props.userConfigurableCapabilities) return []
|
||||
return props.userConfigurableCapabilities.filter(cap =>
|
||||
props.model!.supported_capabilities!.includes(cap.name)
|
||||
)
|
||||
}
|
||||
|
||||
// 检查能力是否已启用
|
||||
function isCapabilityEnabled(capName: string): boolean {
|
||||
if (!props.model) return false
|
||||
return props.modelCapabilitySettings?.[props.model.name]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力
|
||||
function handleToggleCapability(capName: string) {
|
||||
if (!props.model) return
|
||||
emit('toggle-capability', props.model.name, capName)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||
): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
const firstTier = tieredPricing.tiers[0]
|
||||
const value = firstTier[priceKey]
|
||||
if (value == null || value === 0) return '-'
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): number {
|
||||
return tieredPricing?.tiers?.length || 0
|
||||
}
|
||||
|
||||
function formatTierLimit(limit: number | null | undefined): string {
|
||||
if (limit == null) return ''
|
||||
if (limit >= 1000000) {
|
||||
return `${(limit / 1000000).toFixed(1)}M`
|
||||
} else if (limit >= 1000) {
|
||||
return `${(limit / 1000).toFixed(0)}K`
|
||||
}
|
||||
return limit.toString()
|
||||
}
|
||||
|
||||
function get1hCachePrice(tier: PricingTier): string {
|
||||
const ttl1h = tier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
||||
if (ttl1h) {
|
||||
return `$${ttl1h.cache_creation_price_per_1m.toFixed(2)}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | null): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active .relative,
|
||||
.drawer-leave-active .relative {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-leave-to .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-enter-to .relative,
|
||||
.drawer-leave-from .relative {
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user