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:
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user