refactor: replace ant-design components with shadcn/ui and update toast notifications

This commit migrates from Ant Design Vue components to Shadcn/Vue components across multiple files in the web-gold frontend application. Key changes include:

- Replaced ant-design-vue imports with shadcn/ui components (Dialog, Button, Input, etc.)
- Swapped ant-design-vue message/toast system for vue-sonner toast notifications
- Updated icon usage from ant-design icons to lucide icons via @iconify/vue
- Removed unused token refresh logic that was incorrectly implemented client-side
- Applied consistent styling updates to match new component library

The token refresh functionality was removed since it should be handled server-side through axios interceptors rather than client-side intervals.
This commit is contained in:
2026-03-16 02:41:26 +08:00
parent 52c3b5489d
commit 110fe62404
8 changed files with 632 additions and 736 deletions

View File

@@ -2,8 +2,6 @@
import { RouterView } from 'vue-router'
import { ref, watchEffect } from 'vue'
import SvgSprite from '@/components/icons/SvgSprite.vue'
import { useUserStore } from '@/stores/user'
import tokenManager from '@gold/utils/token-manager'
// ========================================
// 主题配置 - 使用 CSS 变量,无需 Ant Design
@@ -47,21 +45,9 @@ watchEffect((onCleanup) => {
onCleanup(() => media.removeEventListener('change', handler))
})
// 用户状态
const userStore = useUserStore()
// 自动刷新 token
watchEffect((onCleanup) => {
let interval
if (userStore.isLoggedIn) {
interval = setInterval(() => {
tokenManager.refreshToken()
}, 5 * 60 * 1000)
}
onCleanup(() => {
if (interval) clearInterval(interval)
})
})
// 注意token 刷新逻辑需要通过后端 API 实现
// TokenManager 只是本地存储管理器,不具备刷新能力
// 如需自动刷新,应在 axios 拦截器中处理 401 响应
// 暴露给模板使用
defineExpose({ toggleTheme, isDark })

View File

@@ -1,9 +1,18 @@
<script setup>
import { ref, watchEffect, computed } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { UserPromptApi } from '@/api/userPrompt'
import { useUserStore } from '@/stores/user'
import { Dialog, DialogContent,
DialogHeader,
DialogTitle,
DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
const props = defineProps({
visible: {
type: Boolean,
@@ -66,18 +75,18 @@ watchEffect(() => {
async function handleSave() {
if (!savePromptForm.value.name.trim()) {
message.warning('请输入提示词名称')
toast.warning('请输入提示词名称')
return
}
if (!savePromptForm.value.content.trim()) {
message.warning('提示词内容不能为空')
toast.warning('提示词内容不能为空')
return
}
const userId = Number(userStore.userId)
if (!userId) {
message.error('无法获取用户ID请先登录')
toast.error('无法获取用户ID请先登录')
return
}
@@ -85,7 +94,7 @@ async function handleSave() {
try {
const content = savePromptForm.value.content?.trim() || ''
if (!content) {
message.error('提示词内容不能为空')
toast.error('提示词内容不能为空')
return
}
@@ -118,7 +127,7 @@ async function handleSave() {
}
if (response && (response.code === 0 || response.code === 200)) {
message.success(isEditMode.value ? '提示词更新成功' : '提示词保存成功')
toast.success(isEditMode.value ? '提示词更新成功' : '提示词保存成功')
emit('update:visible', false)
emit('success')
// 重置表单
@@ -133,7 +142,7 @@ async function handleSave() {
}
} catch (error) {
console.error('保存提示词失败:', error)
message.error(error?.message || '保存失败,请稍后重试')
toast.error(error?.message || '保存失败,请稍后重试')
} finally {
savingPrompt.value = false
}
@@ -145,43 +154,51 @@ function handleCancel() {
</script>
<template>
<a-modal
:open="visible"
:title="modalTitle"
:width="600"
:maskClosable="false"
@cancel="handleCancel">
<a-form :model="savePromptForm" layout="vertical">
<a-form-item label="提示词名称" required>
<a-input
v-model:value="savePromptForm.name"
placeholder="请输入提示词名称"
:maxlength="50"
show-count />
</a-form-item>
<a-form-item label="分类/标签">
<a-input
v-model:value="savePromptForm.category"
placeholder="可选:输入分类或标签"
:maxlength="20" />
</a-form-item>
<a-form-item label="提示词内容">
<a-textarea
v-model:value="savePromptForm.content"
:rows="8"
placeholder="提示词内容" />
</a-form-item>
</a-form>
<Dialog :open="visible" @update:open="$emit('update:visible', $event)">
<DialogContent class="sm:max-w-[600px] max-h-[80vh] flex flex-col p-0">
<DialogHeader class="px-6 pt-6 pb-2 flex-shrink-0">
<DialogTitle>{{ modalTitle }}</DialogTitle>
</DialogHeader>
<template #footer>
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
<div class="space-y-4 px-6 py-4 overflow-y-auto flex-1">
<div class="space-y-2">
<Label>提示词名称 <span class="text-destructive">*</span></Label>
<Input
v-model="savePromptForm.name"
placeholder="请输入提示词名称"
:maxlength="50"
/>
</div>
<div class="space-y-2">
<Label>分类/标签</Label>
<Input
v-model="savePromptForm.category"
placeholder="可选:输入分类或标签"
:maxlength="20"
/>
</div>
<div class="space-y-2">
<Label>提示词内容</Label>
<Textarea
v-model="savePromptForm.content"
:rows="8"
placeholder="提示词内容"
/>
</div>
</div>
<DialogFooter class="px-6 pb-6 pt-2 flex-shrink-0 border-t">
<Button variant="outline" @click="handleCancel">取消</Button>
<Button
:loading="savingPrompt"
:disabled="!savePromptForm.name.trim()"
@click="handleSave">{{ isEditMode ? '更新' : '保存' }}</a-button>
</a-space>
</template>
</a-modal>
@click="handleSave"
>
{{ isEditMode ? '更新' : '保存' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -1,32 +1,27 @@
<template>
<a-select
v-model:value="selectedId"
:placeholder="placeholder"
:loading="loading"
style="width: 100%"
show-search
:filter-option="filterOption"
@change="handleChange"
>
<a-select-option
v-for="item in allList"
:key="item.id"
:value="item.id"
>
<div class="option-content">
<img v-if="item.icon" :src="item.icon" class="option-avatar" />
<span class="option-name">{{ item.name }}</span>
<span class="option-tag">{{ item.category || '其他' }}</span>
</div>
</a-select-option>
<Select v-model="selectedId" @update:model-value="handleChange">
<SelectTrigger class="w-full">
<SelectValue :placeholder="placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in allList"
:key="item.id"
:value="item.id"
>
<div class="option-content">
<img v-if="item.icon" :src="item.icon" class="option-avatar" />
<span class="option-name">{{ item.name }}</span>
<span class="option-tag">{{ item.category || '其他' }}</span>
</div>
</SelectItem>
<!-- 空状态 -->
<template v-if="!loading && allList.length === 0">
<a-select-option disabled value="__empty__">
<span class="empty-text">暂无可选项</span>
</a-select-option>
</template>
</a-select>
<!-- 空状态 -->
<div v-if="!loading && allList.length === 0" class="empty-text">
暂无可选项
</div>
</SelectContent>
</Select>
</template>
<script setup>
@@ -34,6 +29,8 @@ import { ref, computed, watch, onMounted } from 'vue'
import { usePromptStore } from '@/stores/prompt'
import { getJSON, setJSON } from '@/utils/storage'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
const props = defineProps({
modelValue: {
type: [String, Number],
@@ -59,15 +56,9 @@ const selectedId = ref(null)
// 全部列表(来自 /ai/user-prompt/my-list
const allList = computed(() => promptStore.promptList || [])
// 过滤选项
function filterOption(input, option) {
const item = allList.value.find(p => p.id === option.value)
return item?.name?.toLowerCase().includes(input.toLowerCase())
}
// 处理选择变化
function handleChange(value) {
if (!value || value === '__empty__') {
if (!value) {
emit('update:modelValue', null)
emit('change', null)
return
@@ -160,13 +151,16 @@ defineExpose({
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
flex-shrink: 0;
}
.empty-text {
color: var(--color-text-secondary);
padding: 8px 12px;
color: var(--color-gray-400);
font-size: 13px;
font-style: italic;
text-align: center;
}
</style>

View File

@@ -1,30 +1,28 @@
<template>
<a-modal
v-model:open="visible"
:title="title"
:footer="null"
width="auto"
centered
@cancel="handleClose"
class="video-preview-modal"
>
<div class="video-container">
<video
v-if="visible && videoUrl"
ref="videoRef"
:src="videoUrl"
controls
autoplay
class="preview-video"
>
您的浏览器不支持视频播放
</video>
</div>
</a-modal>
<Dialog v-model:open="visible" @update:open="$emit('update:open', $event)">
<DialogContent class="video-preview-dialog">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
</DialogHeader>
<div class="video-container">
<video
v-if="visible && videoUrl"
ref="videoRef"
:src="videoUrl"
controls
autoplay
class="preview-video"
>
您的浏览器不支持视频播放
</video>
</div>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const props = defineProps({
open: {
@@ -33,7 +31,7 @@ const props = defineProps({
},
videoUrl: {
type: String,
default: ''
default: '',
},
title: {
type: String,
@@ -60,27 +58,11 @@ const handleClose = () => {
</script>
<style scoped lang="less">
.video-preview-modal {
:deep(.ant-modal-content) {
max-width: 90vw;
max-height: 90vh;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.ant-modal-header) {
padding: 12px 20px;
border-bottom: 1px solid var(--color-gray-200);
flex-shrink: 0;
}
:deep(.ant-modal-body) {
padding: 0;
flex: 1;
overflow: hidden;
}
.video-preview-dialog {
max-width: 90vw;
max-height: 90vh;
border-radius: 12px;
overflow: hidden;
}
.video-container {
@@ -88,6 +70,7 @@ const handleClose = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.preview-video {
@@ -100,11 +83,9 @@ const handleClose = () => {
// 响应式:小屏幕
@media (max-width: 768px) {
.video-preview-modal {
:deep(.ant-modal-content) {
max-width: 95vw;
max-height: 85vh;
}
.video-preview-dialog {
max-width: 95vw;
max-height: 85vh;
}
.preview-video {

View File

@@ -1,136 +1,144 @@
<template>
<a-modal
v-model:open="visible"
:title="modalTitle"
width="900px"
:footer="null"
:maskClosable="false"
class="video-selector-modal"
>
<div class="video-selector">
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-input-wrapper">
<SearchOutlined class="search-icon" />
<a-input-search
v-model:value="searchKeyword"
placeholder="输入视频名称进行搜索..."
allow-clear
@search="handleSearch"
class="search-input"
/>
</div>
</div>
<Dialog v-model:open="visible" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-[900px] p-0">
<DialogHeader>
<DialogTitle>{{ modalTitle }}</DialogTitle>
</DialogHeader>
<!-- 视频网格 -->
<div class="video-grid">
<!-- 骨架屏加载状态 -->
<a-skeleton
v-if="loading"
v-for="n in 6"
:key="n"
:loading="true"
:active="true"
:avatar="false"
:title="false"
class="video-card-skeleton"
>
<template #default>
<div class="skeleton-thumbnail"></div>
<div class="skeleton-content">
<a-skeleton-title class="skeleton-title" />
<a-skeleton-paragraph class="skeleton-meta" :rows="1" />
<div class="video-selector">
<!-- 搜索栏 -->
<div class="search-bar">
<div class="relative">
<Icon icon="lucide:search" class="search-icon" />
<Input
v-model="searchKeyword"
placeholder="输入视频名称进行搜索..."
class="search-input"
@keydown.enter="handleSearch"
/>
<Button
v-if="searchKeyword"
variant="ghost"
size="icon-sm"
class="absolute right-1 top-1/2 -translate-y-1/2"
@click="clearSearch"
>
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
</div>
<!-- 视频网格 -->
<div class="video-grid">
<!-- 骨架屏加载状态 -->
<template v-if="loading">
<div v-for="n in 6" :key="n" class="video-card-skeleton">
<div class="skeleton-thumbnail"></div>
<div class="skeleton-content">
<div class="skeleton-title"></div>
<div class="skeleton-meta"></div>
</div>
</div>
</template>
</a-skeleton>
<!-- 视频列表 -->
<div
v-for="video in videoList"
:key="video.id"
class="video-card"
:class="{ selected: selectedVideoId === video.id }"
@click="selectVideo(video)"
role="button"
:aria-label="`选择视频: ${video.fileName}`"
>
<div class="video-thumbnail">
<img
:src="getVideoPreviewUrl(video) || defaultCover"
:alt="video.fileName"
@error="handleImageError"
loading="lazy"
/>
<div class="video-duration">{{ formatDuration(video.duration) }}</div>
<!-- hover 播放按钮 -->
<div class="video-play-btn" @click.stop="handlePreview(video)">
<PlayCircleOutlined />
<!-- 视频列表 -->
<button
v-for="video in videoList"
:key="video.id"
class="video-card"
:class="{ selected: selectedVideoId === video.id }"
@click="selectVideo(video)"
>
<div class="video-thumbnail">
<img
:src="getVideoPreviewUrl(video) || defaultCover"
:alt="video.fileName"
@error="handleImageError"
loading="lazy"
/>
<div class="video-duration">{{ formatDuration(video.duration) }}</div>
<!-- hover 播放按钮 -->
<div class="video-play-btn" @click.stop="handlePreview(video)">
<Icon icon="lucide:play-circle" class="size-10" />
</div>
<div class="video-selected-mark" v-if="selectedVideoId === video.id">
<Icon icon="lucide:check" class="size-4" />
</div>
</div>
<div class="video-selected-mark" v-if="selectedVideoId === video.id">
<CheckOutlined />
<div class="video-info">
<div class="video-title" :title="video.fileName">
<span class="title-text">{{ video.fileName }}</span>
</div>
<div class="video-meta">
<span class="meta-item">
<Icon icon="lucide:video" class="size-3" />
{{ formatFileSize(video.fileSize) }}
</span>
<span class="meta-item">
<Icon icon="lucide:clock" class="size-3" />
{{ formatDuration(video.duration) }}
</span>
</div>
</div>
</div>
<div class="video-info">
<div class="video-title" :title="video.fileName">
<span class="title-text">{{ video.fileName }}</span>
</div>
<div class="video-meta">
<span class="meta-item">
<VideoCameraOutlined />
{{ formatFileSize(video.fileSize) }}
</span>
<span class="meta-item">
<ClockCircleOutlined />
{{ formatDuration(video.duration) }}
</span>
</button>
<!-- 空状态 -->
<div v-if="!loading && videoList.length === 0" class="empty-state">
<div class="empty-illustration">
<div class="empty-icon-wrapper">
<Icon icon="lucide:image" class="size-9 text-muted-foreground" />
</div>
<div class="empty-text">
<h3 class="empty-title">
{{ searchKeyword ? '未找到匹配的视频' : '暂无视频' }}
</h3>
<p class="empty-description">
{{ searchKeyword ? '尝试使用不同的关键词搜索' : '请先上传视频文件' }}
</p>
<Button v-if="searchKeyword" variant="link" size="sm" @click="clearSearch">
<Icon icon="lucide:x" class="size-4" />
清除搜索条件
</Button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && videoList.length === 0" class="empty-state">
<div class="empty-illustration">
<div class="empty-icon-wrapper">
<PictureOutlined class="empty-icon" />
</div>
<div class="empty-text">
<h3 class="empty-title">
{{ searchKeyword ? '未找到匹配的视频' : '暂无视频' }}
</h3>
<p class="empty-description">
{{ searchKeyword ? '尝试使用不同的关键词搜索' : '请先上传视频文件' }}
</p>
<a-button v-if="searchKeyword" type="link" @click="clearSearch" class="clear-search-btn">
<CloseOutlined /> 清除搜索条件
</a-button>
</div>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > pageSize">
<span class="text-sm text-muted-foreground">
{{ total }}
</span>
<div class="flex gap-1">
<Button
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
上一页
</Button>
<Button
variant="outline"
size="sm"
:disabled="currentPage * pageSize >= total"
@click="handlePageChange(currentPage + 1)"
>
下一页
</Button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > pageSize">
<a-pagination
v-model:current="currentPage"
v-model:page-size="pageSize"
:total="total"
show-size-changer
show-quick-jumper
:show-total="(total, range) => `${range[0]}-${range[1]} 条,共 ${total}`"
@change="handlePageChange"
@show-size-change="handlePageSizeChange"
/>
<!-- 底部操作栏 -->
<div class="modal-footer">
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="!selectedVideoId" @click="handleConfirm">
确认选择
</Button>
</div>
</div>
<!-- 底部操作栏 -->
<div class="modal-footer">
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" @click="handleConfirm" :disabled="!selectedVideoId">
确认选择
</a-button>
</div>
</div>
</a-modal>
</DialogContent>
</Dialog>
<!-- 视频预览弹窗 -->
<VideoPreviewModal
@@ -142,19 +150,15 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
CheckOutlined,
PictureOutlined,
VideoCameraOutlined,
ClockCircleOutlined,
SearchOutlined,
CloseOutlined,
PlayCircleOutlined
} from '@ant-design/icons-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { MaterialService } from '@/api/material'
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps({
open: {
type: Boolean,
@@ -203,11 +207,11 @@ const fetchVideoList = async () => {
videoList.value = res.data.list || []
total.value = res.data.total || 0
} else {
message.error(res.msg || '获取视频列表失败')
toast.error(res.msg || '获取视频列表失败')
}
} catch (error) {
console.error('获取视频列表失败:', error)
message.error('获取视频列表失败')
toast.error('获取视频列表失败')
} finally {
loading.value = false
}
@@ -224,17 +228,8 @@ const clearSearch = () => {
fetchVideoList()
}
const handlePageChange = (page, size) => {
const handlePageChange = (page) => {
currentPage.value = page
if (size) {
pageSize.value = size
}
fetchVideoList()
}
const handlePageSizeChange = (_current, size) => {
currentPage.value = 1
pageSize.value = size
fetchVideoList()
}
@@ -246,12 +241,10 @@ const selectVideo = (video) => {
// 预览视频
const handlePreview = async (video) => {
if (!video.id) {
message.warning('视频信息不完整')
toast.warning('视频信息不完整')
return
}
// 显示加载提示
try {
// 调用后端API获取带签名的视频播放URL
const res = await MaterialService.getVideoPlayUrl(video.id)
@@ -261,11 +254,11 @@ const handlePreview = async (video) => {
previewUrl.value = res.data
previewVisible.value = true
} else {
message.error(res.msg || '获取视频地址失败')
toast.error(res.msg || '获取视频地址失败')
}
} catch (error) {
console.error('获取视频播放URL失败:', error)
message.error('获取视频地址失败,请重试')
toast.error('获取视频地址失败,请重试')
}
}
@@ -306,7 +299,7 @@ const handleCancel = () => {
const handleConfirm = () => {
if (!selectedVideo.value) {
message.warning('请选择一个视频')
toast.warning('请选择一个视频')
return
}
@@ -336,52 +329,21 @@ watch(() => props.open, (isOpen) => {
/* 搜索栏样式 */
.search-bar {
padding: 16px;
background: #f9fafb;
background: var(--color-gray-50);
border-radius: 8px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
z-index: 100;
color: #9ca3af;
top: 50%;
transform: translateY(-50%);
color: var(--color-gray-400);
font-size: 16px;
}
.search-input :deep(.ant-input) {
.search-input {
padding-left: 36px;
padding-right: 12px;
height: 40px;
background: #ffffff;
border-radius: 6px;
color: #111827;
font-size: 14px;
}
.search-input :deep(.ant-input-group),
.search-input :deep(.ant-input-group-wrapper),
.search-input :deep(.ant-input-group-addon) {
display: block;
}
.search-input :deep(.ant-input-group-addon) {
display: none;
}
.search-input :deep(.ant-input):focus,
.search-input :deep(.ant-input-affix-wrapper-focused) {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.search-input :deep(.ant-input::placeholder) {
color: #9ca3af;
}
/* 视频网格样式 */
@@ -399,52 +361,45 @@ watch(() => props.open, (isOpen) => {
}
.video-grid::-webkit-scrollbar-track {
background: #f3f4f6;
background: var(--color-gray-100);
border-radius: 3px;
}
.video-grid::-webkit-scrollbar-thumb {
background: #d1d5db;
background: var(--color-gray-300);
border-radius: 3px;
}
.video-grid::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 视频卡片样式 */
.video-card {
background: #ffffff;
border: 3px solid transparent;
background: white;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
text-align: left;
padding: 0;
}
.video-card:hover {
border-color: #3b82f6;
border-color: var(--color-primary-400);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
.video-card.selected {
border-color: #3B82F6;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
}
.video-card:focus-visible {
border-color: #3B82F6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 缩略图样式 */
.video-thumbnail {
position: relative;
width: 100%;
height: 112px;
overflow: hidden;
background: #f3f4f6;
background: var(--color-gray-100);
}
.video-thumbnail img {
@@ -471,13 +426,12 @@ watch(() => props.open, (isOpen) => {
right: 8px;
width: 24px;
height: 24px;
background: #3B82F6;
background: var(--color-primary-500);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
/* hover 播放按钮 */
@@ -489,12 +443,14 @@ watch(() => props.open, (isOpen) => {
opacity: 0;
transition: opacity 0.2s;
cursor: pointer;
}
.video-play-btn .anticon {
font-size: 40px;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.video-thumbnail:hover .video-play-btn {
@@ -509,7 +465,7 @@ watch(() => props.open, (isOpen) => {
.video-title {
font-size: 14px;
font-weight: 600;
color: #111827;
color: var(--color-gray-900);
margin-bottom: 10px;
overflow: hidden;
text-overflow: ellipsis;
@@ -520,7 +476,7 @@ watch(() => props.open, (isOpen) => {
display: flex;
gap: 12px;
font-size: 12px;
color: #6b7280;
color: var(--color-gray-500);
}
.meta-item {
@@ -537,7 +493,7 @@ watch(() => props.open, (isOpen) => {
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #6b7280;
color: var(--color-gray-500);
}
.empty-illustration {
@@ -548,18 +504,13 @@ watch(() => props.open, (isOpen) => {
width: 80px;
height: 80px;
margin: 0 auto 16px;
background: #f3f4f6;
background: var(--color-gray-100);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-icon {
font-size: 36px;
color: #9ca3af;
}
.empty-text {
max-width: 400px;
}
@@ -567,35 +518,23 @@ watch(() => props.open, (isOpen) => {
.empty-title {
font-size: 16px;
font-weight: 600;
color: #111827;
color: var(--color-gray-900);
margin: 0 0 8px 0;
}
.empty-description {
font-size: 14px;
color: #6b7280;
color: var(--color-gray-500);
margin: 0;
}
.clear-search-btn {
margin-top: 12px;
color: #3b82f6;
font-weight: 500;
}
.clear-search-btn:hover {
color: #60a5fa;
}
/* 分页样式 */
.pagination-wrapper {
display: flex;
justify-content: center;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px;
margin-top: 8px;
border-top: 1px solid var(--color-gray-200);
}
/* 底部操作栏样式 */
@@ -604,12 +543,60 @@ watch(() => props.open, (isOpen) => {
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
border-top: 1px solid var(--color-gray-200);
background: var(--color-gray-50);
border-radius: 8px;
margin-top: 8px;
}
/* 骨架屏样式 */
.video-card-skeleton {
background: white;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
}
.skeleton-thumbnail {
width: 100%;
height: 112px;
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-content {
padding: 14px;
}
.skeleton-title {
height: 16px;
width: 70%;
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 10px;
}
.skeleton-meta {
height: 12px;
width: 40%;
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.video-grid {
@@ -656,66 +643,5 @@ watch(() => props.open, (isOpen) => {
.video-info {
padding: 8px;
}
.search-input :deep(.ant-input) {
height: 36px;
font-size: 13px;
}
.modal-footer .ant-btn {
height: 32px;
padding: 0 12px;
font-size: 13px;
}
}
/* 骨架屏样式 */
.video-card-skeleton {
background: #ffffff;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
}
.skeleton-thumbnail {
width: 100%;
height: 112px;
background: #f3f4f6;
}
.skeleton-content {
padding: 14px;
}
.skeleton-title {
margin-bottom: 10px;
}
.skeleton-meta :deep(.ant-skeleton-paragraph > li) {
height: 16px;
width: 60px;
background: #f3f4f6;
border-radius: 4px;
}
@media (max-width: 768px) {
.skeleton-thumbnail {
height: 100px;
}
.skeleton-content {
padding: 10px;
}
}
@media (max-width: 480px) {
.skeleton-thumbnail {
height: 90px;
}
.skeleton-content {
padding: 8px;
}
.skeleton-meta :deep(.ant-skeleton-paragraph > li) {
width: 50px;
}
}
</style>

View File

@@ -2,20 +2,13 @@
<div class="voice-selector">
<!-- 空状态 -->
<div v-if="userVoiceCards.length === 0" class="empty-voices">
<a-empty :image="simpleImage" description="还没有配音">
<template #image>
<div class="empty-icon">
<svg viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="28" fill="#f1f5f9" stroke="#e2e8f0" stroke-width="2"/>
<path d="M32 18C36.4183 18 40 21.5817 40 26V38C40 42.4183 36.4183 46 32 46C27.5817 46 24 42.4183 24 38V26C24 21.5817 27.5817 18 32 18Z" fill="#cbd5e1"/>
<path d="M32 14V18M32 46V50M18 32H22M42 32H46" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</template>
<a-button type="primary" size="small" @click="$router.push('/voice-copy')">
去创建配音
</a-button>
</a-empty>
<div class="empty-icon">
<Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" />
</div>
<p class="text-muted-foreground mb-4">还没有配音</p>
<Button @click="$router.push('/voice-copy')">
去创建配音
</Button>
</div>
<div v-else class="voice-selector-wrapper">
@@ -25,23 +18,21 @@
<span class="header-title">选择音色</span>
<span class="voice-count">{{ userVoiceCards.length }} 个配音</span>
</div>
<a-button
<Button
v-if="selectedVoiceId"
class="synthesize-btn"
:disabled="isPlayerInitializing"
:loading="previewLoadingVoiceId === selectedVoiceId"
@click="handleSynthesize"
>
<template #icon>
<SoundOutlined />
</template>
<Icon icon="lucide:volume-2" class="size-4" />
合成试听
</a-button>
</Button>
</div>
<!-- 卡片网格 -->
<div class="voice-grid" :class="{ 'has-many': userVoiceCards.length > 4 }">
<div
<button
v-for="voice in userVoiceCards"
:key="voice.id"
class="voice-card"
@@ -52,15 +43,11 @@
<div class="card-avatar">
<div class="avatar-ring"></div>
<div class="avatar-icon">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" fill="currentColor"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
</svg>
<Icon icon="lucide:audio-waveform" class="size-6" />
</div>
<!-- 选中指示器 -->
<div v-if="selectedVoiceId === voice.id" class="selected-indicator">
<CheckOutlined />
<Icon icon="lucide:check" class="size-3" />
</div>
</div>
@@ -69,7 +56,7 @@
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description || '我的配音' }}</div>
</div>
</div>
</button>
</div>
<!-- 播放器区域 -->
@@ -78,20 +65,17 @@
<div class="player-header">
<div class="player-info">
<div class="player-icon">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.1"/>
<path d="M10 8L16 12L10 16V8Z" fill="currentColor"/>
</svg>
<Icon icon="lucide:play-circle" class="size-8" />
</div>
<div class="player-meta">
<div class="player-title">{{ currentVoiceName }}</div>
<div class="player-label">合成预览</div>
</div>
</div>
<a-button type="text" size="small" @click="downloadAudio" class="download-btn">
<template #icon><DownloadOutlined /></template>
<Button variant="ghost" size="sm" @click="downloadAudio" class="download-btn">
<Icon icon="lucide:download" class="size-4" />
下载
</a-button>
</Button>
</div>
<div ref="playerContainer" class="aplayer-container"></div>
</div>
@@ -102,25 +86,25 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Empty, message } from 'ant-design-vue'
import { SoundOutlined, DownloadOutlined, CheckOutlined } from '@ant-design/icons-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
import APlayer from 'aplayer'
import { Button } from '@/components/ui/button'
const props = defineProps({
synthText: {
type: String,
default: ''
default: '',
},
speechRate: {
type: Number,
default: 1.0
}
default: 1.0,
},
})
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const voiceStore = useVoiceCopyStore()
const emit = defineEmits(['select', 'audioGenerated'])
@@ -171,15 +155,15 @@ const {
previewLoadingVoiceId,
playVoiceSample,
setText,
setSpeechRate
setSpeechRate,
} = useTTS({
provider: TTS_PROVIDERS.SILICONFLOW
provider: TTS_PROVIDERS.SILICONFLOW,
})
const selectedVoiceId = ref('')
const userVoiceCards = computed(() =>
(voiceStore.profiles || []).map(profile => ({
(voiceStore.profiles || []).map((profile) => ({
id: `user-${profile.id}`,
rawId: profile.id,
name: profile.name || '未命名',
@@ -189,8 +173,8 @@ const userVoiceCards = computed(() =>
fileUrl: profile.fileUrl,
transcription: profile.transcription || '',
source: 'user',
voiceId: profile.voiceId
}))
voiceId: profile.voiceId,
})),
)
const handleVoiceSelect = (voice) => {
@@ -202,7 +186,7 @@ const handleVoiceSelect = (voice) => {
const handleSynthesize = () => {
if (!selectedVoiceId.value || isPlayerInitializing.value) return
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
const voice = userVoiceCards.value.find((v) => v.id === selectedVoiceId.value)
if (!voice) return
currentVoiceName.value = voice.name
@@ -210,13 +194,21 @@ const handleSynthesize = () => {
}
// 监听 prop 变化,更新 TTS 参数
watch(() => props.synthText, (newText) => {
setText(newText || '')
}, { immediate: true })
watch(
() => props.synthText,
(newText) => {
setText(newText || '')
},
{ immediate: true },
)
watch(() => props.speechRate, (newRate) => {
setSpeechRate(newRate)
}, { immediate: true })
watch(
() => props.speechRate,
(newRate) => {
setSpeechRate(newRate)
},
{ immediate: true },
)
const handlePlayVoiceSample = (voice) => {
currentVoiceName.value = voice.name
@@ -227,8 +219,10 @@ const handlePlayVoiceSample = (voice) => {
if (!url) return
initPlayer(url)
},
() => { /* 错误已在 useTTS 中处理 */ },
{ autoPlay: false }
() => {
/* 错误已在 useTTS 中处理 */
},
{ autoPlay: false },
)
}
@@ -249,7 +243,7 @@ const initPlayer = (url) => {
try {
// 检查容器是否存在
if (!playerContainer.value) {
message.error('播放器容器未就绪')
toast.error('播放器容器未就绪')
isPlayerInitializing.value = false
audioUrl.value = ''
return
@@ -261,12 +255,14 @@ const initPlayer = (url) => {
theme: '#3b82f6',
volume: 0.7,
loop: 'none',
audio: [{
name: currentVoiceName.value || '语音合成',
artist: '合成',
url: url,
cover: defaultCover
}]
audio: [
{
name: currentVoiceName.value || '语音合成',
artist: '合成',
url: url,
cover: defaultCover,
},
],
})
player.on('ended', () => {
@@ -284,13 +280,13 @@ const initPlayer = (url) => {
if (durationMs > 0) {
emit('audioGenerated', {
durationMs,
audioUrl: audioUrl.value // 使用 URL性能优化
audioUrl: audioUrl.value, // 使用 URL性能优化
})
}
})
} catch (e) {
console.error('APlayer 初始化失败:', e)
message.error('播放器初始化失败,请重试')
toast.error('播放器初始化失败,请重试')
isPlayerInitializing.value = false
audioUrl.value = ''
}
@@ -349,10 +345,6 @@ onBeforeUnmount(() => {
.empty-icon {
margin-bottom: 12px;
svg {
width: 64px;
height: 64px;
}
}
}
@@ -452,6 +444,7 @@ onBeforeUnmount(() => {
cursor: pointer;
transition: all 0.25s ease;
overflow: hidden;
text-align: left;
&:hover {
border-color: #e2e8f0;
@@ -496,11 +489,9 @@ onBeforeUnmount(() => {
z-index: 1;
width: 28px;
height: 28px;
svg {
width: 100%;
height: 100%;
}
display: flex;
align-items: center;
justify-content: center;
}
.selected-indicator {
@@ -577,11 +568,9 @@ onBeforeUnmount(() => {
width: 36px;
height: 36px;
color: #3b82f6;
svg {
width: 100%;
height: 100%;
}
display: flex;
align-items: center;
justify-content: center;
}
.player-meta {

View File

@@ -12,13 +12,13 @@
<div class="header-content">
<div v-if="selectedConversation" class="header-nav">
<button class="nav-back" @click="backToList">
<LeftOutlined />
<Icon icon="lucide:arrow-left" class="size-4" />
<span>返回列表</span>
</button>
</div>
<div class="header-title-group">
<h2 class="modal-title">
<HistoryOutlined class="title-icon" />
<Icon icon="lucide:history" class="title-icon" />
{{ selectedConversation ? '对话详情' : '历史记录' }}
</h2>
<p v-if="!selectedConversation" class="modal-subtitle">
@@ -27,7 +27,7 @@
</div>
</div>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
<Icon icon="lucide:x" class="size-4" />
</button>
</header>
@@ -35,107 +35,113 @@
<div class="modal-body">
<!-- Conversation List View -->
<div v-if="!selectedConversation" class="list-view">
<a-spin :spinning="loading">
<!-- Empty State -->
<div v-if="groupedConversations.length === 0 && !loading" class="empty-state">
<div class="empty-illustration">
<div class="illustration-ring"></div>
<div class="illustration-ring delay-1"></div>
<HistoryOutlined class="illustration-icon" />
</div>
<h3 class="empty-title">暂无历史记录</h3>
<p class="empty-desc">开始对话后记录将保存在这里</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-primary" />
</div>
<!-- Grouped Conversations -->
<div v-else class="conversation-groups">
<div
v-for="(group, groupIndex) in groupedConversations"
:key="group.label"
class="conversation-group"
:style="{ '--group-index': groupIndex }"
>
<div class="group-header">
<span class="group-dot"></span>
<span class="group-label">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="group-items">
<button
v-for="(item, itemIndex) in group.items"
:key="item.id"
class="conversation-item"
:style="{ '--item-index': itemIndex }"
@click="selectConversation(item)"
>
<div class="item-indicator"></div>
<div class="item-content">
<h4 class="item-title">{{ item.name || '未命名会话' }}</h4>
<p class="item-preview">{{ item.preview || '暂无预览内容' }}</p>
<!-- Empty State -->
<div v-else-if="groupedConversations.length === 0" class="empty-state">
<div class="empty-illustration">
<div class="illustration-ring"></div>
<div class="illustration-ring delay-1"></div>
<Icon icon="lucide:history" class="illustration-icon" />
</div>
<h3 class="empty-title">暂无历史记录</h3>
<p class="empty-desc">开始对话后记录将保存在这里</p>
</div>
<!-- Grouped Conversations -->
<div v-else class="conversation-groups">
<div
v-for="(group, groupIndex) in groupedConversations"
:key="group.label"
class="conversation-group"
:style="{ '--group-index': groupIndex }"
>
<div class="group-header">
<span class="group-dot"></span>
<span class="group-label">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="group-items">
<button
v-for="(item, itemIndex) in group.items"
:key="item.id"
class="conversation-item"
:style="{ '--item-index': itemIndex }"
@click="selectConversation(item)"
>
<div class="item-indicator"></div>
<div class="item-content">
<h4 class="item-title">{{ item.name || '未命名会话' }}</h4>
<p class="item-preview">{{ item.preview || '暂无预览内容' }}</p>
</div>
<div class="item-meta">
<span class="meta-time">
<Icon icon="lucide:clock" class="size-3" />
{{ formatTime(item.updatedAt || item.createdAt) }}
</span>
<div class="meta-arrow">
<Icon icon="lucide:chevron-right" class="size-4" />
</div>
<div class="item-meta">
<span class="meta-time">
<ClockCircleOutlined />
{{ formatTime(item.updatedAt || item.createdAt) }}
</span>
<div class="meta-arrow">
<RightOutlined />
</div>
</div>
</button>
</div>
</div>
</button>
</div>
</div>
</a-spin>
</div>
</div>
<!-- Message Detail View -->
<div v-else class="detail-view">
<a-spin :spinning="messageLoading">
<div v-if="messageList.length === 0 && !messageLoading" class="empty-state">
<CommentOutlined class="empty-icon-single" />
<p>暂无消息记录</p>
</div>
<!-- Loading State -->
<div v-if="messageLoading" class="loading-state">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-primary" />
</div>
<div v-else class="message-timeline">
<div
v-for="(msg, index) in messageList"
:key="msg.id"
class="timeline-item"
:style="{ '--msg-index': index }"
>
<!-- User Message -->
<div class="message-block message-block--user">
<div class="message-avatar">
<UserOutlined />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author"></span>
<span class="message-time">{{ formatTime(msg.createdAt) }}</span>
</div>
<div class="message-content">{{ msg.query }}</div>
</div>
<div v-else-if="messageList.length === 0" class="empty-state">
<Icon icon="lucide:message-circle" class="empty-icon-single" />
<p>暂无消息记录</p>
</div>
<div v-else class="message-timeline">
<div
v-for="(msg, index) in messageList"
:key="msg.id"
class="timeline-item"
:style="{ '--msg-index': index }"
>
<!-- User Message -->
<div class="message-block message-block--user">
<div class="message-avatar">
<Icon icon="lucide:user" class="size-4" />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author"></span>
<span class="message-time">{{ formatTime(msg.createdAt) }}</span>
</div>
<div class="message-content">{{ msg.query }}</div>
</div>
</div>
<!-- AI Response -->
<div class="message-block message-block--ai">
<div class="message-avatar">
<RobotOutlined />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author">AI 助手</span>
<button class="copy-btn" @click="copyContent(msg.answer)" title="复制">
<CopyOutlined />
</button>
</div>
<div class="message-content">{{ msg.answer }}</div>
<!-- AI Response -->
<div class="message-block message-block--ai">
<div class="message-avatar">
<Icon icon="lucide:bot" class="size-4" />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author">AI 助手</span>
<button class="copy-btn" @click="copyContent(msg.answer)" title="复制">
<Icon icon="lucide:copy" class="size-3" />
</button>
</div>
<div class="message-content">{{ msg.answer }}</div>
</div>
</div>
</div>
</a-spin>
</div>
</div>
</div>
</div>
@@ -146,18 +152,8 @@
<script setup>
import { ref, computed, watch } from 'vue'
import {
CloseOutlined,
LeftOutlined,
RightOutlined,
HistoryOutlined,
ClockCircleOutlined,
CopyOutlined,
UserOutlined,
RobotOutlined,
CommentOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import dayjs from 'dayjs'
import { getConversations, getMessages } from '@/api/agent'
import { copyToClipboard } from '@/utils/clipboard'
@@ -253,7 +249,7 @@ const handleClose = () => {
const copyContent = async (content) => {
const success = await copyToClipboard(content)
success ? message.success('已复制到剪贴板') : message.error('复制失败')
success ? toast.success('已复制到剪贴板') : toast.error('复制失败')
}
const formatTime = (timestamp) => {
@@ -444,6 +440,16 @@ watch(() => props.visible, (val) => {
}
}
// ========================================
// Loading State
// ========================================
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
// ========================================
// Empty State
// ========================================

View File

@@ -1,15 +1,24 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
StarFilled,
CloseOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined
} from '@ant-design/icons-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { UserPromptApi } from '@/api/userPrompt'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import SavePromptModal from '@/components/SavePromptModal.vue'
// Props
@@ -63,7 +72,7 @@ async function loadList() {
}
} catch (error) {
console.error('加载风格列表失败:', error)
message.error('加载风格列表失败')
toast.error('加载风格列表失败')
} finally {
loading.value = false
}
@@ -91,11 +100,11 @@ async function handleDelete(id) {
if (index !== -1) {
promptList.value.splice(index, 1)
}
message.success('删除成功')
toast.success('删除成功')
emit('refresh')
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
toast.error('删除失败')
}
}
@@ -107,96 +116,100 @@ function handleSaveSuccess() {
</script>
<template>
<a-modal
:open="visible"
title="我创建的"
:width="700"
:footer="null"
@cancel="handleClose"
>
<!-- 操作栏 -->
<div class="action-bar">
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建风格
</a-button>
<a-button @click="loadList" :loading="loading">
刷新
</a-button>
</div>
<Dialog :open="visible" @update:open="$emit('update:visible', $event)">
<DialogContent class="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>我创建的</DialogTitle>
</DialogHeader>
<!-- 搜索框 -->
<div class="search-bar">
<a-input
v-model:value="searchKeyword"
placeholder="搜索风格..."
allow-clear
/>
</div>
<!-- 操作栏 -->
<div class="flex gap-3 mb-4">
<Button @click="handleCreate">
<Icon icon="lucide:plus" class="size-4" />
新建风格
</Button>
<Button variant="outline" @click="loadList" :disabled="loading">
<Icon v-if="loading" icon="lucide:loader-2" class="size-4 animate-spin" />
<Icon v-else icon="lucide:refresh-cw" class="size-4" />
刷新
</Button>
</div>
<!-- 列表 -->
<div class="prompt-list" v-if="!loading && filteredList.length > 0">
<div
v-for="item in filteredList"
:key="item.id"
class="prompt-card"
>
<div class="card-header">
<span class="card-name">{{ item.name }}</span>
<div class="card-actions">
<a-button type="link" size="small" @click="handleEdit(item)">
<EditOutlined /> 编辑
</a-button>
<a-popconfirm
title="确定删除此风格?"
@confirm="handleDelete(item.id)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
<!-- 搜索框 -->
<div class="mb-4">
<Input
v-model="searchKeyword"
placeholder="搜索风格..."
/>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-primary" />
</div>
<!-- 列表 -->
<div class="prompt-list" v-else-if="filteredList.length > 0">
<div
v-for="item in filteredList"
:key="item.id"
class="prompt-card"
>
<div class="card-header">
<span class="card-name">{{ item.name }}</span>
<div class="flex gap-2">
<Button variant="link" size="sm" class="h-auto p-0" @click="handleEdit(item)">
<Icon icon="lucide:pencil" class="size-4" />
编辑
</Button>
<AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="link" size="sm" class="h-auto p-0 text-destructive">
<Icon icon="lucide:trash-2" class="size-4" />
删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除此风格吗此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDelete(item.id)">确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div class="card-content">{{ item.content }}</div>
<div class="card-footer">
<span class="card-category" v-if="item.category">{{ item.category }}</span>
<span class="text-xs text-muted-foreground">使用 {{ item.useCount || 0 }} </span>
</div>
</div>
<div class="card-content">{{ item.content }}</div>
<div class="card-footer">
<span class="card-category" v-if="item.category">{{ item.category }}</span>
<span class="card-use-count">使用 {{ item.useCount || 0 }} </span>
</div>
</div>
</div>
<!-- 空状态 -->
<div class="empty-state" v-else-if="!loading">
<StarFilled class="empty-icon" />
<p class="empty-title">暂无收藏的风格</p>
<p class="empty-desc">点击"新建风格"创建您的第一个风格</p>
</div>
<!-- 空状态 -->
<div class="empty-state" v-else>
<Icon icon="lucide:star" class="empty-icon" />
<p class="empty-title">暂无收藏的风格</p>
<p class="empty-desc">点击"新建风格"创建您的第一个风格</p>
</div>
<!-- 加载状态 -->
<div class="loading-state" v-if="loading">
<a-spin />
</div>
<!-- 保存弹窗 -->
<SavePromptModal
v-model:visible="showSaveModal"
:prompt="editingPrompt"
@success="handleSaveSuccess"
/>
</a-modal>
<!-- 保存弹窗 -->
<SavePromptModal
v-model:visible="showSaveModal"
:prompt="editingPrompt"
@success="handleSaveSuccess"
/>
</DialogContent>
</Dialog>
</template>
<style scoped lang="less">
.action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.search-bar {
margin-bottom: 16px;
}
.prompt-list {
display: flex;
flex-direction: column;
@@ -207,13 +220,13 @@ function handleSaveSuccess() {
.prompt-card {
padding: 16px;
border: 1px solid var(--color-border);
border: 1px solid var(--color-gray-200);
border-radius: 8px;
background: var(--color-surface);
background: var(--color-bg-card);
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary);
border-color: var(--color-primary-300);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
@@ -228,17 +241,12 @@ function handleSaveSuccess() {
.card-name {
font-weight: 600;
font-size: 14px;
color: var(--color-text);
}
.card-actions {
display: flex;
gap: 8px;
color: var(--color-gray-900);
}
.card-content {
font-size: 13px;
color: var(--color-text-secondary);
color: var(--color-gray-600);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -256,15 +264,11 @@ function handleSaveSuccess() {
.card-category {
padding: 2px 8px;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border-radius: 10px;
}
.card-use-count {
color: var(--color-text-tertiary);
}
.empty-state {
display: flex;
flex-direction: column;
@@ -284,20 +288,13 @@ function handleSaveSuccess() {
.empty-title {
font-size: 15px;
font-weight: 500;
color: var(--color-text);
color: var(--color-gray-900);
margin: 0 0 8px 0;
}
.empty-desc {
font-size: 13px;
color: var(--color-text-secondary);
color: var(--color-gray-500);
margin: 0;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
}
</style>