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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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