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