feat: 样式升级

This commit is contained in:
2026-03-16 23:54:01 +08:00
parent 110fe62404
commit 4a5fdd3961
42 changed files with 1931 additions and 1404 deletions

View File

@@ -1,7 +1,27 @@
<script setup>
import { RouterView } from 'vue-router'
import { ref, watchEffect } from 'vue'
import { ref, watchEffect, provide } from 'vue'
import { Toaster, toast } from 'vue-sonner'
import SvgSprite from '@/components/icons/SvgSprite.vue'
import { DEFAULT_LOCALE, getStoredLocale, setStoredLocale, initDayjsLocale } from '@/config/locale'
// ========================================
// 国际化配置
// ========================================
const locale = ref(getStoredLocale())
// 提供全局 locale供 shadcn-vue 组件使用)
provide('locale', locale)
// 初始化 dayjs locale
initDayjsLocale(locale.value)
// 切换 locale
const changeLocale = (newLocale) => {
locale.value = newLocale
setStoredLocale(newLocale)
initDayjsLocale(newLocale)
}
// ========================================
// 主题配置 - 使用 CSS 变量,无需 Ant Design
@@ -50,12 +70,23 @@ watchEffect((onCleanup) => {
// 如需自动刷新,应在 axios 拦截器中处理 401 响应
// 暴露给模板使用
defineExpose({ toggleTheme, isDark })
defineExpose({ toggleTheme, isDark, changeLocale, locale })
</script>
<template>
<div class="app-container min-h-screen bg-background text-foreground antialiased">
<SvgSprite />
<!-- vue-sonner Toaster 全局配置 -->
<Toaster
position="top-center"
:rich-colors="true"
:close-button="true"
:duration="4000"
:expand="false"
dir="ltr"
/>
<keep-alive>
<RouterView />
</keep-alive>

View File

@@ -115,13 +115,13 @@ onUnmounted(() => {
.spinner-ring:nth-child(1) {
animation-delay: -0.45s;
border-top-color: var(--color-primary, #3B82F6);
border-top-color: var(--primary);
opacity: 1;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
border-top-color: rgba(59, 130, 246, 0.8);
border-top-color: oklch(from var(--primary) l c h / 0.8);
opacity: 0.8;
width: 80%;
height: 80%;
@@ -131,7 +131,7 @@ onUnmounted(() => {
.spinner-ring:nth-child(3) {
animation-delay: -0.15s;
border-top-color: rgba(59, 130, 246, 0.6);
border-top-color: oklch(from var(--primary) l c h / 0.6);
opacity: 0.6;
width: 60%;
height: 60%;
@@ -141,7 +141,7 @@ onUnmounted(() => {
.spinner-ring:nth-child(4) {
animation-delay: 0s;
border-top-color: rgba(59, 130, 246, 0.4);
border-top-color: oklch(from var(--primary) l c h / 0.4);
opacity: 0.4;
width: 40%;
height: 40%;
@@ -159,11 +159,11 @@ onUnmounted(() => {
}
.loading-text {
color: var(--color-text, #F2F2F2);
font-size: 14px;
color: var(--foreground);
font-size: var(--font-size-base);
font-weight: 500;
text-align: center;
margin-top: 8px;
margin-top: var(--space-2);
}
/* 淡入淡出动画 */

View File

@@ -5,7 +5,7 @@
@click="handleClick"
>
<span v-if="loading" class="gradient-button__loading">
<a-spin size="small" />
<span class="gradient-button__spinner"></span>
<span v-if="loadingText" class="gradient-button__loading-text">{{ loadingText }}</span>
</span>
<span v-else class="gradient-button__content">
@@ -99,7 +99,7 @@ const buttonClass = computed(() => {
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
font-weight: 500;
color: #ffffff;
color: var(--primary-foreground);
cursor: pointer;
transition: all var(--duration-fast);
background: var(--color-gray-900);
@@ -189,6 +189,22 @@ const buttonClass = computed(() => {
transform: none;
}
/* 加载动画 */
.gradient-button__spinner {
width: 14px;
height: 14px;
border: 2px solid oklch(1 0 0 / 0.3);
border-top-color: var(--primary-foreground);
border-radius: 50%;
animation: gradient-button-spin 0.6s linear infinite;
}
@keyframes gradient-button-spin {
to {
transform: rotate(360deg);
}
}
/* 响应式调整 */
@media (max-width: 768px) {
.gradient-button--large {

View File

@@ -62,31 +62,31 @@ const displayText = computed(() => {
.points-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
gap: var(--space-1);
border-radius: var(--radius-tag);
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
white-space: nowrap;
&--small {
padding: 1px 6px;
font-size: 11px;
font-size: var(--font-size-xs);
}
&--default {
padding: 2px 8px;
font-size: 12px;
padding: 2px var(--space-2);
font-size: var(--font-size-sm);
}
&--large {
padding: 4px 12px;
font-size: 14px;
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-base);
}
&--free {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
background: var(--success);
color: white;
}
&__icon {

View File

@@ -9,7 +9,7 @@
<h3>生成的数字人视频</h3>
<video :src="previewVideoUrl" controls class="generated-video"></video>
<div class="video-actions">
<a-button type="primary" @click="downloadVideo">下载视频</a-button>
<Button @click="downloadVideo">下载视频</Button>
</div>
</div>
</div>
@@ -18,7 +18,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { getDigitalHumanTask } from '@/api/digitalHuman'
const props = defineProps({
@@ -52,7 +53,7 @@ const loadLastTask = async () => {
}
const downloadVideo = () => {
if (!previewVideoUrl.value) return message.warning('没有可下载的视频')
if (!previewVideoUrl.value) return toast.warning('没有可下载的视频')
const link = document.createElement('a')
link.href = previewVideoUrl.value
link.download = `数字人视频_${Date.now()}.mp4`
@@ -71,9 +72,9 @@ onMounted(async () => {
<style scoped>
.result-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
background: var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
.result-placeholder {
@@ -81,31 +82,31 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
color: var(--muted-foreground);
}
.result-content {
color: #fff;
color: var(--foreground);
}
.result-section {
margin-bottom: 24px;
margin-bottom: var(--space-6);
}
.result-section h3 {
margin-bottom: 12px;
font-size: 18px;
margin-bottom: var(--space-3);
font-size: var(--font-size-lg);
}
.generated-video {
width: 100%;
max-height: 400px;
border-radius: 8px;
margin-top: 12px;
border-radius: var(--radius);
margin-top: var(--space-3);
}
.video-actions {
margin-top: 16px;
margin-top: var(--space-4);
display: flex;
justify-content: center;
}

View File

@@ -130,7 +130,7 @@ defineExpose({
.option-content {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
.option-avatar {
@@ -148,18 +148,18 @@ defineExpose({
}
.option-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
font-size: var(--font-size-xs);
padding: 1px var(--space-1-5);
border-radius: var(--radius);
background: oklch(from var(--primary) l c h / 0.1);
color: var(--primary);
flex-shrink: 0;
}
.empty-text {
padding: 8px 12px;
color: var(--color-gray-400);
font-size: 13px;
padding: var(--space-2) var(--space-3);
color: var(--muted-foreground);
font-size: var(--font-size-sm);
font-style: italic;
text-align: center;
}

View File

@@ -16,14 +16,15 @@ const shouldShowUser = computed(() => {
<template>
<header
class="fixed top-0 left-0 right-0 z-[100]
h-[70px] flex items-center px-[30px]
bg-gray-900 text-white
border-b border-border"
flex items-center px-[30px]
bg-sidebar border-b border-sidebar-border
text-sidebar-foreground"
:style="{ height: 'var(--header-height)' }"
>
<div class="flex items-center gap-3 flex-1">
<div class="flex items-center gap-md flex-1">
<BrandLogo :size="40" />
</div>
<div class="flex items-center gap-4 pr-1">
<div class="flex items-center gap-md pr-1">
<UserDropdown v-if="shouldShowUser" />
</div>
</header>

View File

@@ -50,12 +50,12 @@ async function handleLogout() {
<template>
<DropdownMenu>
<DropdownMenuTrigger
class="group flex items-center gap-3 rounded-full px-3 py-1.5 pl-1.5
bg-white/5 border border-white/10
class="group flex items-center gap-md rounded-full px-md py-1.5 pl-1.5
bg-muted border border-border
cursor-pointer outline-none
transition-all duration-250 ease-out
hover:bg-white/10 hover:border-white/20 hover:-translate-y-0.5
data-[state=open]:bg-white/10 data-[state=open]:border-white/20"
hover:bg-accent hover:border-border hover:-translate-y-0.5
data-[state=open]:bg-accent data-[state=open]:border-border"
>
<!-- 头像容器 -->
<div class="relative w-9 h-9">
@@ -66,13 +66,13 @@ async function handleLogout() {
style="background: conic-gradient(from 0deg, rgba(59, 130, 246, 0.8), rgba(99, 102, 241, 0.8), rgba(139, 92, 246, 0.8), rgba(59, 130, 246, 0.8))"
/>
<!-- 头像背景遮罩 -->
<div class="absolute inset-0 rounded-full bg-gray-900 -z-5" />
<div class="absolute inset-0 rounded-full bg-background -z-5" />
<!-- 头像 -->
<Avatar class="w-9 h-9 relative z-10">
<AvatarImage v-if="userStore.displayAvatar" :src="userStore.displayAvatar" alt="avatar" />
<AvatarFallback
class="flex items-center justify-center text-white font-bold text-[15px]"
class="flex items-center justify-center text-primary-foreground font-bold text-[15px]"
:style="{ background: avatarGradient }"
>
{{ userStore.displayName?.charAt(0)?.toUpperCase() || 'U' }}
@@ -81,21 +81,21 @@ async function handleLogout() {
<!-- 在线状态点 -->
<div
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500
border-2 border-gray-900 z-20
shadow-[0_0_0_2px_rgba(34,197,94,0.3)]"
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-success
border-2 border-background z-20
shadow-[0_0_0_2px_oklch(from_var(--success)_l_c_h_/_0.3)]"
/>
</div>
<!-- 用户名 -->
<span class="text-sm font-medium text-white truncate max-w-[100px]">
<span class="text-sm font-medium text-foreground truncate max-w-[100px]">
{{ userStore.displayName || '用户' }}
</span>
<!-- 下拉箭头 -->
<Icon
icon="lucide:chevron-down"
class="w-4 h-4 text-white/50 shrink-0 transition-transform duration-250
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform duration-250
group-hover:rotate-180 group-data-[state=open]:rotate-180"
/>
</DropdownMenuTrigger>

View File

@@ -61,12 +61,12 @@ const handleClose = () => {
.video-preview-dialog {
max-width: 90vw;
max-height: 90vh;
border-radius: 12px;
border-radius: var(--radius-lg);
overflow: hidden;
}
.video-container {
background: #000;
background: oklch(0 0 0);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -372,25 +372,25 @@ watch(() => props.open, (isOpen) => {
/* 视频卡片样式 */
.video-card {
background: white;
background: var(--card);
border: 2px solid transparent;
border-radius: 8px;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: all var(--duration-fast);
box-shadow: var(--shadow-sm);
text-align: left;
padding: 0;
}
.video-card:hover {
border-color: var(--color-primary-400);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.video-card.selected {
border-color: var(--color-primary-500);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
border-color: var(--primary);
box-shadow: 0 0 0 2px oklch(from var(--primary) l c h / 0.25);
}
/* 缩略图样式 */
@@ -410,24 +410,24 @@ watch(() => props.open, (isOpen) => {
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
bottom: var(--space-2);
right: var(--space-2);
background: oklch(0 0 0 / 0.8);
color: var(--primary-foreground);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
}
.video-selected-mark {
position: absolute;
top: 8px;
right: 8px;
top: var(--space-2);
right: var(--space-2);
width: 24px;
height: 24px;
background: var(--color-primary-500);
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
border-radius: 50%;
display: flex;
align-items: center;
@@ -441,16 +441,16 @@ watch(() => props.open, (isOpen) => {
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.2s;
transition: opacity var(--duration-fast);
cursor: pointer;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.5);
background: oklch(0 0 0 / 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
color: var(--primary-foreground);
}
.video-thumbnail:hover .video-play-btn {
@@ -459,14 +459,14 @@ watch(() => props.open, (isOpen) => {
/* 信息区域样式 */
.video-info {
padding: 14px;
padding: var(--space-3-5);
}
.video-title {
font-size: 14px;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-gray-900);
margin-bottom: 10px;
color: var(--foreground);
margin-bottom: var(--space-2-5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -474,15 +474,15 @@ watch(() => props.open, (isOpen) => {
.video-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--color-gray-500);
gap: var(--space-3);
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
gap: var(--space-1);
}
/* 空状态样式 */
@@ -533,20 +533,20 @@ watch(() => props.open, (isOpen) => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-top: 1px solid var(--color-gray-200);
padding: var(--space-4) 0;
border-top: 1px solid var(--border);
}
/* 底部操作栏样式 */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--color-gray-200);
background: var(--color-gray-50);
border-radius: 8px;
margin-top: 8px;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border);
background: var(--muted);
border-radius: var(--radius);
margin-top: var(--space-2);
}
/* 骨架屏样式 */

View File

@@ -337,14 +337,14 @@ onBeforeUnmount(() => {
/* 空状态 */
.empty-voices {
padding: 40px 24px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px dashed #e2e8f0;
border-radius: 16px;
padding: var(--space-10) var(--space-6);
background: var(--muted);
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
text-align: center;
.empty-icon {
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
}
@@ -352,7 +352,7 @@ onBeforeUnmount(() => {
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--space-4);
}
/* 标题栏 */
@@ -360,49 +360,49 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
gap: var(--space-2-5);
}
.header-title {
font-size: 15px;
font-size: var(--font-size-md);
font-weight: 600;
color: #1e293b;
color: var(--foreground);
}
.voice-count {
font-size: 12px;
color: #94a3b8;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 10px;
font-size: var(--font-size-xs);
color: var(--muted-foreground);
background: var(--muted);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius);
}
.synthesize-btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
padding: 0 var(--space-3-5);
border-radius: var(--radius);
border: none;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
background: var(--primary);
color: var(--primary-foreground);
font-weight: 500;
font-size: 13px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
transition: all 0.2s ease;
font-size: var(--font-size-sm);
box-shadow: var(--shadow-sm);
transition: all var(--duration-fast);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
box-shadow: var(--shadow-md);
}
&:disabled {
background: #94a3b8;
background: var(--muted-foreground);
box-shadow: none;
cursor: not-allowed;
transform: none;
@@ -437,25 +437,25 @@ onBeforeUnmount(() => {
/* 音色卡片 */
.voice-card {
position: relative;
background: white;
border: 2px solid #f1f5f9;
border-radius: 14px;
padding: 16px 12px;
background: var(--card);
border: 2px solid var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-3);
cursor: pointer;
transition: all 0.25s ease;
transition: all var(--duration-base);
overflow: hidden;
text-align: left;
&:hover {
border-color: #e2e8f0;
border-color: var(--border);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
box-shadow: var(--shadow-lg);
}
&.selected {
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.15);
border-color: var(--primary);
background: oklch(from var(--primary) l c h / 0.1);
box-shadow: var(--shadow-md);
}
}
@@ -464,17 +464,17 @@ onBeforeUnmount(() => {
position: relative;
width: 48px;
height: 48px;
margin: 0 auto 10px;
margin: 0 auto var(--space-2-5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar-ring {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
background: oklch(from var(--primary) l c h / 0.2);
}
.avatar-icon {
color: #3b82f6;
color: var(--primary);
}
}
@@ -500,15 +500,15 @@ onBeforeUnmount(() => {
right: -4px;
width: 20px;
height: 20px;
background: #3b82f6;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.4);
animation: scaleIn 0.2s ease;
color: var(--primary-foreground);
font-size: var(--font-size-xs);
box-shadow: var(--shadow-sm);
animation: scaleIn var(--duration-fast);
}
@keyframes scaleIn {
@@ -526,18 +526,18 @@ onBeforeUnmount(() => {
}
.voice-name {
font-size: 13px;
font-size: var(--font-size-sm);
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
color: var(--foreground);
margin-bottom: var(--space-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voice-desc {
font-size: 11px;
color: #94a3b8;
font-size: var(--font-size-xs);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -545,29 +545,29 @@ onBeforeUnmount(() => {
/* 播放器区域 */
.player-section {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 14px;
padding: 14px;
border: 1px solid #e2e8f0;
background: var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-3-5);
border: 1px solid var(--border);
}
.player-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
.player-info {
display: flex;
align-items: center;
gap: 10px;
gap: var(--space-2-5);
}
.player-icon {
width: 36px;
height: 36px;
color: #3b82f6;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
@@ -575,49 +575,49 @@ onBeforeUnmount(() => {
.player-meta {
.player-title {
font-size: 14px;
font-size: var(--font-size-base);
font-weight: 600;
color: #1e293b;
color: var(--foreground);
}
.player-label {
font-size: 11px;
color: #94a3b8;
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
}
.download-btn {
color: #64748b;
font-size: 12px;
padding: 4px 10px;
color: var(--muted-foreground);
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2-5);
height: auto;
border-radius: 6px;
transition: all 0.2s ease;
border-radius: var(--radius-sm);
transition: all var(--duration-fast);
&:hover {
color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
color: var(--primary);
background: oklch(from var(--primary) l c h / 0.08);
}
}
.aplayer-container {
:deep(.aplayer) {
border-radius: 10px;
border-radius: var(--radius);
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.04);
border: 1px solid var(--border);
.aplayer-body {
border-radius: 10px;
border-radius: var(--radius);
}
}
}
/* 动画 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
transition: all var(--duration-base) ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s ease-in;
transition: all var(--duration-fast) ease-in;
}
.slide-fade-enter-from {

View File

@@ -11,7 +11,7 @@
<div class="header-left">
<div class="agent-avatar">
<img v-if="agent?.avatar" :src="agent?.avatar" :alt="agent?.name" />
<RobotOutlined v-else class="avatar-fallback" />
<Icon v-else icon="lucide:bot" class="avatar-fallback" />
</div>
<div class="agent-info">
<h3 class="agent-name">{{ agent?.name || 'AI 助手' }}</h3>
@@ -20,10 +20,10 @@
</div>
<div class="header-actions">
<button class="history-btn" @click="openHistory" title="历史记录">
<HistoryOutlined />
<Icon icon="lucide:history" />
</button>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
<Icon icon="lucide:x" />
</button>
</div>
</header>
@@ -37,18 +37,18 @@
<div class="visual-ring delay-1"></div>
<div class="visual-ring delay-2"></div>
<div class="visual-core">
<RobotOutlined class="core-icon" />
<Icon icon="lucide:bot" class="core-icon" />
</div>
</div>
<h3 class="empty-title">准备好为你生成内容</h3>
<p class="empty-desc">在下方输入框描述你的需求AI 将立即开始创作</p>
<div class="empty-features">
<div class="feature-item">
<ThunderboltFilled class="feature-icon" />
<Icon icon="lucide:zap" class="feature-icon" />
<span>深度模式支持复杂任务</span>
</div>
<div class="feature-item">
<EditOutlined class="feature-icon" />
<Icon icon="lucide:pencil" class="feature-icon" />
<span>可随时重新生成调整</span>
</div>
</div>
@@ -60,7 +60,7 @@
<div class="prompt-display">
<p class="prompt-text">{{ currentInput }}</p>
<button v-if="generatedContent" class="prompt-edit" @click="resetTask">
<EditOutlined />
<Icon icon="lucide:pencil" />
</button>
</div>
@@ -84,10 +84,10 @@
<!-- Action Bar -->
<div v-if="generatedContent && !isGenerating" class="action-bar">
<button class="action-btn primary" @click="handleCopy(generatedContent)">
<CopyOutlined /> 复制
<Icon icon="lucide:copy" /> 复制
</button>
<button class="action-btn" @click="handleRegenerate">
<ReloadOutlined /> 重新生成
<Icon icon="lucide:refresh-cw" /> 重新生成
</button>
</div>
</div>
@@ -111,7 +111,7 @@
:class="{ active: modelMode === 'pro' }"
@click="modelMode = 'pro'"
>
<ThunderboltFilled />
<Icon icon="lucide:zap" />
深度
<PointsTag :points="50" size="small" />
</button>
@@ -136,8 +136,8 @@
:disabled="!inputText.trim() || isGenerating"
@click="handleGenerate"
>
<LoadingOutlined v-if="isGenerating" class="spin" />
<SendOutlined v-else />
<Icon v-if="isGenerating" icon="lucide:loader-2" class="spin" />
<Icon v-else icon="lucide:send" />
</button>
</div>
@@ -151,6 +151,22 @@
:agent-id="agent?.id"
@close="closeHistory"
/>
<!-- Close Confirm Dialog -->
<AlertDialog :open="showCloseConfirm" @update:open="showCloseConfirm = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>关闭将丢失生成的内容</AlertDialogTitle>
<AlertDialogDescription>
建议先复制内容确定要关闭吗
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="showCloseConfirm = false">取消</AlertDialogCancel>
<AlertDialogAction @click="confirmClose">关闭</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</transition>
</teleport>
@@ -158,18 +174,18 @@
<script setup>
import { ref, watch, nextTick, onMounted } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import {
CloseOutlined,
RobotOutlined,
CopyOutlined,
ReloadOutlined,
ThunderboltFilled,
EditOutlined,
SendOutlined,
LoadingOutlined,
HistoryOutlined
} from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { sendChatStream } from '@/api/agent'
import { copyToClipboard } from '@/utils/clipboard'
import HistoryPanel from './HistoryPanel.vue'
@@ -205,29 +221,29 @@ const abortController = ref(null)
// History State
const historyVisible = ref(false)
// Confirm Close Dialog State
const showCloseConfirm = ref(false)
// Methods
const handleClose = (force = false) => {
// 正在生成时,不允许关闭
if (isGenerating.value) {
message.warning('正在生成内容,请稍候...')
toast.warning('正在生成内容,请稍候...')
return
}
// 有生成内容时,提示用户
if (!force && generatedContent.value) {
Modal.confirm({
title: '关闭将丢失生成的内容',
content: '建议先复制内容,确定要关闭吗?',
okText: '关闭',
cancelText: '取消',
onOk: () => {
doClose()
}
})
showCloseConfirm.value = true
return
}
doClose()
}
const confirmClose = () => {
showCloseConfirm.value = false
doClose()
}
const doClose = () => {
if (abortController.value) {
abortController.value.abort()
@@ -273,7 +289,7 @@ const handleKeyDown = (e) => {
const handleCopy = async (content) => {
const success = await copyToClipboard(content)
success ? message.success('已复制') : message.error('复制失败')
success ? toast.success('已复制') : toast.error('复制失败')
}
// 执行流式请求
@@ -291,11 +307,11 @@ const executeStreamRequest = async (prompt) => {
generatedContent.value += result.content
scrollToBottom()
} else if (result.event === 'error') {
message.error(result.errorMessage || '生成出错')
toast.error(result.errorMessage || '生成出错')
}
},
onError: () => {
message.error('生成失败')
toast.error('生成失败')
if (!generatedContent.value) isGenerating.value = false
},
onClose: () => {
@@ -304,7 +320,7 @@ const executeStreamRequest = async (prompt) => {
}
})
} catch (error) {
if (error.name !== 'AbortError') message.error('生成失败')
if (error.name !== 'AbortError') toast.error('生成失败')
isGenerating.value = false
}
}

View File

@@ -1,149 +1,147 @@
<template>
<a-modal
:visible="visible"
title="上传素材"
:width="600"
:footer="false"
:closable="!uploading"
:maskClosable="!uploading"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<div class="upload-modal-content">
<!-- 文件上传区域 -->
<div class="upload-area" v-if="!uploading">
<a-upload-dragger
v-model:file-list="fileList"
name="file"
:multiple="true"
:accept="acceptTypes"
action=""
:before-upload="handleBeforeUpload"
:show-upload-list="false"
@change="handleFileChange"
>
<p class="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此处上传</p>
<p class="ant-upload-hint">
支持多文件上传单文件最大 1GB
<br />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</p>
</a-upload-dragger>
</div>
<Dialog :open="props.visible" @update:open="handleVisibleChange">
<DialogContent class="max-w-[600px]">
<DialogHeader>
<DialogTitle>上传素材</DialogTitle>
</DialogHeader>
<!-- 已选文件列表 -->
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
<div class="upload-file-list-title">
已选择 {{ fileList.length }} 个文件总大小{{ getTotalSize() }}
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" style="color: #ff4d4f; margin-left: 8px;">
已超出 1GB 限制
</span>
</div>
<div class="upload-file-items">
<div class="upload-modal-content">
<!-- 文件上传区域 -->
<div class="upload-area" v-if="!uploading">
<div
v-for="(fileItem, index) in fileList"
:key="fileItem.uid || index"
class="upload-file-item"
class="upload-dragger"
:class="{ 'drag-over': isDragOver }"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
>
<FileOutlined class="file-icon" />
<span class="file-name">{{ getFileName(fileItem) }}</span>
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<a-button
type="text"
status="danger"
size="small"
@click="handleRemove(fileItem)"
>
删除
</a-button>
</div>
</div>
</div>
<!-- 上传进度区域 -->
<div v-if="uploading" class="upload-progress-area">
<div class="upload-progress-header">
<LoadingOutlined spin class="upload-spinner" />
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
</div>
<!-- 总体进度 -->
<div class="upload-total-progress">
<div class="progress-info">
<span>总体进度</span>
<span>{{ totalProgress }}%</span>
</div>
<a-progress
:percent="totalProgress"
:show-info="false"
:stroke-color="{
'0%': '#3B82F6',
'100%': '#10B981'
}"
/>
</div>
<!-- 当前文件进度 -->
<div class="upload-current-file">
<div class="current-file-name">
<FileOutlined />
<span>{{ currentFileName }}</span>
</div>
<div class="current-file-progress">
<div class="progress-info">
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
<span>{{ currentFileProgress }}%</span>
</div>
<a-progress
:percent="currentFileProgress"
:show-info="false"
size="small"
<input
ref="fileInputRef"
type="file"
multiple
:accept="acceptTypes"
class="hidden"
@change="handleFileSelect"
/>
<div class="upload-icon">
<Icon icon="lucide:cloud-upload" class="text-4xl text-blue-500" />
</div>
<p class="upload-text">点击或拖拽文件到此处上传</p>
<p class="upload-hint">
支持多文件上传单文件最大 1GB
<br />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</p>
</div>
</div>
<!-- 完成文件列表 -->
<div v-if="completedFiles.length > 0" class="completed-files">
<div class="completed-header">
<CheckCircleOutlined style="color: #10B981" />
<span>已完成 {{ completedFiles.length }} 个文件</span>
<!-- 文件列表 -->
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
<div class="upload-file-list-title">
已选择 {{ fileList.length }} 个文件总大小{{ getTotalSize() }}
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" class="text-red-500 ml-2">
已超出 1GB 限制
</span>
</div>
<div class="completed-list">
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
<CheckOutlined style="color: #10B981; font-size: 12px" />
<span>{{ file.name }}</span>
<div class="upload-file-items">
<div
v-for="(fileItem, index) in fileList"
:key="fileItem.uid || index"
class="upload-file-item"
>
<Icon icon="lucide:file" class="file-icon" />
<span class="file-name">{{ getFileName(fileItem) }}</span>
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<Button
variant="ghost"
size="sm"
class="text-red-500 hover:text-red-600"
@click="handleRemove(fileItem)"
>
删除
</Button>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<a-space>
<a-button v-if="!uploading" @click="handleCancel">取消</a-button>
<a-button
v-if="!uploading"
type="primary"
:loading="uploading"
:disabled="fileList.length === 0"
@click="handleConfirm"
>
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
</a-button>
<span v-else class="upload-tip">上传中请勿关闭窗口...</span>
</a-space>
<!-- 上传进度区域 -->
<div v-if="uploading" class="upload-progress-area">
<div class="upload-progress-header">
<span class="custom-spinner"></span>
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
</div>
<!-- 总体进度 -->
<div class="upload-total-progress">
<div class="progress-info">
<span>总体进度</span>
<span>{{ totalProgress }}%</span>
</div>
<Progress :value="totalProgress" class="h-2" />
</div>
<!-- 当前文件进度 -->
<div class="upload-current-file">
<div class="current-file-name">
<Icon icon="lucide:file" />
<span>{{ currentFileName }}</span>
</div>
<div class="current-file-progress">
<div class="progress-info">
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
<span>{{ currentFileProgress }}%</span>
</div>
<Progress :value="currentFileProgress" class="h-1.5" />
</div>
</div>
<!-- 已完成文件列表 -->
<div v-if="completedFiles.length > 0" class="completed-files">
<div class="completed-header">
<Icon icon="lucide:check-circle" class="text-green-500" />
<span>已完成 {{ completedFiles.length }} 个文件</span>
</div>
<div class="completed-list">
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
<Icon icon="lucide:check" class="text-green-500 text-xs" />
<span>{{ file.name }}</span>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<div class="flex gap-2 justify-end">
<Button v-if="!uploading" variant="outline" @click="handleCancel">取消</Button>
<Button
v-if="!uploading"
:disabled="fileList.length === 0"
@click="handleConfirm"
>
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
</Button>
<span v-if="uploading" class="upload-tip">上传中请勿关闭窗口...</span>
</div>
</div>
</div>
</div>
</a-modal>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
import { useUpload } from '@/composables/useUpload'
@@ -170,6 +168,8 @@ const { upload } = useUpload()
// 数据
const fileList = ref([])
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
const isDragOver = ref(false)
const fileInputRef = ref(null)
// 常量
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
@@ -205,6 +205,69 @@ const getTotalSize = () => {
return formatFileSize(getTotalSizeBytes())
}
// 触发文件选择
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileSelect = (e) => {
const files = e.target.files
if (files) {
addFiles(Array.from(files))
}
// 重置 input 以便可以再次选择相同文件
e.target.value = ''
}
// 处理拖放
const handleDrop = (e) => {
isDragOver.value = false
const files = e.dataTransfer?.files
if (files) {
addFiles(Array.from(files))
}
}
// 添加文件
const addFiles = (files) => {
files.forEach(file => {
// 检查单个文件大小
if (file.size > MAX_SINGLE_FILE_SIZE) {
toast.warning(`文件 ${file.name} 超过 1GB已跳过`)
return
}
// 计算当前文件列表的总大小(包括新文件)
const currentTotalSize = getTotalSizeBytes()
const newTotalSize = currentTotalSize + file.size
// 检查总大小
if (newTotalSize > MAX_TOTAL_SIZE) {
toast.warning(`文件总大小超过 1GB当前${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
return
}
// 检查是否已存在相同文件
const exists = fileList.value.some(item => {
const itemName = getFileName(item)
const itemSize = getFileSize(item)
return itemName === file.name && itemSize === file.size
})
if (exists) {
toast.warning(`文件 ${file.name} 已存在,已跳过`)
return
}
fileList.value.push({
uid: `${Date.now()}-${Math.random()}`,
file,
name: file.name,
size: file.size
})
})
}
// 监听 visible 变化,重置文件列表
watch(() => props.visible, (newVal) => {
if (!newVal) {
@@ -225,54 +288,6 @@ const resetUploadState = () => {
completedFiles.value = []
}
// 上传前处理
const handleBeforeUpload = (file) => {
// 检查单个文件大小
if (file.size > MAX_SINGLE_FILE_SIZE) {
message.warning(`文件 ${file.name} 超过 1GB已跳过`)
return false
}
// 计算当前文件列表的总大小(包括新文件)
const currentTotalSize = getTotalSizeBytes()
const newTotalSize = currentTotalSize + file.size
// 检查总大小
if (newTotalSize > MAX_TOTAL_SIZE) {
message.warning(`文件总大小超过 1GB当前${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
return false
}
// 检查是否已存在相同文件
const exists = fileList.value.some(item => {
const itemName = getFileName(item)
const itemSize = getFileSize(item)
return itemName === file.name && itemSize === file.size
})
if (exists) {
message.warning(`文件 ${file.name} 已存在,已跳过`)
return false
}
return false // 阻止自动上传
}
// 文件列表变化
const handleFileChange = (info) => {
const { file, fileList: newFileList } = info
if (file && file.status !== 'uploading') {
fileList.value = newFileList
.map(item => {
if (!item.file && item.originFileObj) {
item.file = item.originFileObj
}
return item
})
.filter(item => item.status !== 'removed')
}
}
// 移除文件
const handleRemove = (fileItem) => {
const index = fileList.value.findIndex(item =>
@@ -287,13 +302,13 @@ const handleRemove = (fileItem) => {
// 确认上传
const handleConfirm = async () => {
if (fileList.value.length === 0) {
message.warning('请选择文件')
toast.warning('请选择文件')
return
}
// 检查总大小
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) {
message.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
toast.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
return
}
@@ -307,7 +322,7 @@ const handleConfirm = async () => {
.filter(item => item !== null)
if (files.length === 0) {
message.error('无法获取文件对象,请重新选择文件')
toast.error('无法获取文件对象,请重新选择文件')
return
}
@@ -374,11 +389,11 @@ const performUpload = async (files) => {
// 显示结果
if (successCount > 0 && failCount === 0) {
message.success(`成功上传 ${successCount} 个文件`)
toast.success(`成功上传 ${successCount} 个文件`)
} else if (successCount > 0 && failCount > 0) {
message.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
toast.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
} else {
message.error('上传失败,请重试')
toast.error('上传失败,请重试')
}
// 通知父组件上传完成
@@ -390,6 +405,7 @@ const performUpload = async (files) => {
// 处理 visible 变化
const handleVisibleChange = (value) => {
if (!value && uploading.value) return
emit('update:visible', value)
}
@@ -401,7 +417,7 @@ const handleCancel = () => {
}
</script>
<style scoped>
<style scoped lang="less">
.upload-modal-content {
padding: 8px 0;
}
@@ -410,6 +426,46 @@ const handleCancel = () => {
margin-bottom: 24px;
}
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
cursor: pointer;
transition: all 0.2s ease;
&:hover, &.drag-over {
border-color: #3B82F6;
background: rgba(59, 130, 246, 0.05);
}
.upload-icon {
margin-bottom: 12px;
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
margin-bottom: 8px;
}
.upload-hint {
font-size: 12px;
color: var(--color-text-3);
text-align: center;
line-height: 1.6;
}
}
.hidden {
display: none;
}
.upload-file-list {
margin-bottom: 24px;
padding: 16px;
@@ -492,9 +548,17 @@ const handleCancel = () => {
color: var(--color-text);
}
.upload-spinner {
font-size: 20px;
color: #3B82F6;
.custom-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(59, 130, 246, 0.2);
border-top-color: #3B82F6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.upload-total-progress {

View File

@@ -3,7 +3,7 @@
* 支持多个供应商CosyVoice, SiliconFlow, Azure, AWS等
*/
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { VoiceService } from '@/api/voice'
import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig'
@@ -83,7 +83,7 @@ export function useTTS(options = {}) {
*/
function playAudioPreview(url, opts = {}) {
if (!url) {
message.warning('暂无可试听的音频')
toast.warning('暂无可试听的音频')
return
}
@@ -103,9 +103,9 @@ export function useTTS(options = {}) {
.then(() => {
previewAudio = audio
audio.onended = cleanup
audio.onerror = () => { cleanup(); message.error('播放失败') }
audio.onerror = () => { cleanup(); toast.error('播放失败') }
})
.catch(() => { cleanup(); message.error('播放失败') })
.catch(() => { cleanup(); toast.error('播放失败') })
}
/**
@@ -157,7 +157,7 @@ export function useTTS(options = {}) {
function buildPreviewParams(voice) {
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.error('配音配置无效')
toast.error('配音配置无效')
return null
}
return {
@@ -231,7 +231,7 @@ export function useTTS(options = {}) {
const res = await VoiceService.synthesize(params)
if (res.code !== 0) {
message.error(res.msg || '试听失败')
toast.error(res.msg || '试听失败')
resetPreviewState()
onError?.(new Error(res.msg || '试听失败'))
return
@@ -258,11 +258,11 @@ export function useTTS(options = {}) {
return
}
message.error('试听失败')
toast.error('试听失败')
resetPreviewState()
onError?.(new Error('未收到音频数据'))
} catch (error) {
message.error('试听失败')
toast.error('试听失败')
resetPreviewState()
onError?.(error)
}

View File

@@ -4,7 +4,7 @@
*/
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { MaterialService } from '@/api/material'
import { useUserStore } from '@/stores/user'
import { OSS_ORIGINAL, OSS_DOMAIN, isDev } from '@gold/config/api'

View File

@@ -0,0 +1,63 @@
/**
* 国际化配置
* 为 shadcn-vue 组件和其他库提供中文 locale 支持
*/
import dayjs from 'dayjs'
// 默认 locale
export const DEFAULT_LOCALE = 'zh-CN'
// 支持的 locale 列表
export const SUPPORTED_LOCALES = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'en-US', name: 'English' }
]
// dayjs locale 映射
const DAYJS_LOCALE_MAP = {
'zh-CN': 'zh-cn',
'en-US': 'en'
}
/**
* 获取存储的 locale 或默认 locale
*/
export function getStoredLocale() {
return localStorage.getItem('locale') || DEFAULT_LOCALE
}
/**
* 存储 locale
*/
export function setStoredLocale(locale) {
localStorage.setItem('locale', locale)
}
/**
* 初始化 dayjs locale
* @param {string} locale - 应用 locale (zh-CN, en-US)
*/
export async function initDayjsLocale(locale = DEFAULT_LOCALE) {
const dayjsLocale = DAYJS_LOCALE_MAP[locale] || 'en'
try {
// 动态加载 dayjs locale
if (dayjsLocale === 'zh-cn') {
await import('dayjs/locale/zh-cn')
}
dayjs.locale(dayjsLocale)
} catch (error) {
console.warn('Failed to load dayjs locale:', error)
dayjs.locale('en')
}
}
/**
* 获取 shadcn-vue Calendar 组件使用的 locale
* @param {string} locale - 应用 locale
* @returns {string} - BCP 47 locale tag
*/
export function getCalendarLocale(locale = DEFAULT_LOCALE) {
return locale
}

View File

@@ -16,14 +16,3 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}
// 声明 ant-design-vue 的类型
declare module 'ant-design-vue' {
export const message: {
success: (content: string) => void
error: (content: string) => void
warning: (content: string) => void
info: (content: string) => void
}
export const Modal: any
}

View File

@@ -1,13 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'normalize.css'
import 'ant-design-vue/dist/reset.css'
import 'aplayer/dist/APlayer.min.css'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import 'dayjs/locale/zh-cn';
import App from './App.vue'
import router from './router'
import './style.css'
@@ -18,6 +15,5 @@ const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(Antd)
app.mount('#app')

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { VoiceService } from '@/api/voice'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
export const useVoiceCopyStore = defineStore('voiceCopy', {
state: () => ({
@@ -43,11 +43,11 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
}
this.loaded = true
} else {
message.error(res.msg || '加载失败')
toast.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载配音列表失败:', error)
message.error('加载失败,请稍后重试')
toast.error('加载失败,请稍后重试')
} finally {
this.loading = false
}
@@ -77,7 +77,7 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
await this.load() // 重新加载以获取完整数据
return newProfile
} else {
message.error(res.msg || '创建失败')
toast.error(res.msg || '创建失败')
throw new Error(res.msg || '创建失败')
}
} catch (error) {
@@ -113,7 +113,7 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
}
return profile
} else {
message.error(res.msg || '更新失败')
toast.error(res.msg || '更新失败')
throw new Error(res.msg || '更新失败')
}
} catch (error) {
@@ -147,7 +147,7 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
this.activeId = this.profiles[0]?.id || ''
}
} else {
message.error(res.msg || '删除失败')
toast.error(res.msg || '删除失败')
throw new Error(res.msg || '删除失败')
}
} catch (error) {

View File

@@ -1,243 +1,215 @@
/* ================================
Yudao Cloud B端设计系统
Design System v1.0
SionRUI 业务样式
主题变量已移至 theme.css
================================ */
/* ================================
1. 设计令牌 (Design Tokens)
================================ */
:root {
/* ========================================
shadcn 兼容变量
======================================== */
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.55 0.18 254.604);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0.001 264.695);
--secondary-foreground: oklch(0.205 0.015 264.695);
--muted: oklch(0.97 0.001 264.695);
--muted-foreground: oklch(0.45 0.02 264.695);
--accent: oklch(0.97 0.001 264.695);
--accent-foreground: oklch(0.205 0.015 264.695);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0.004 264.695);
--input: oklch(0.922 0.004 264.695);
--ring: oklch(0.55 0.18 254.604);
/* ========================================
品牌主色 - 科技蓝 (oklch)
======================================== */
--color-primary-50: oklch(0.97 0.014 254.604);
--color-primary-100: oklch(0.93 0.028 254.604);
--color-primary-200: oklch(0.87 0.056 254.604);
--color-primary-300: oklch(0.78 0.11 254.604);
--color-primary-400: oklch(0.68 0.16 254.604);
--color-primary-500: oklch(0.55 0.18 254.604);
--color-primary-600: oklch(0.45 0.16 254.604);
--color-primary-700: oklch(0.37 0.13 254.604);
/* ========================================
中性灰 - 偏蓝冷色调
======================================== */
--color-gray-50: oklch(0.985 0.001 264.695);
--color-gray-100: oklch(0.971 0.002 264.695);
--color-gray-200: oklch(0.938 0.004 264.695);
--color-gray-300: oklch(0.876 0.01 264.695);
--color-gray-400: oklch(0.718 0.018 264.695);
--color-gray-500: oklch(0.552 0.024 264.695);
--color-gray-600: oklch(0.446 0.03 264.695);
--color-gray-700: oklch(0.369 0.034 264.695);
--color-gray-800: oklch(0.271 0.036 264.695);
--color-gray-900: oklch(0.195 0.038 264.695);
/* ========================================
功能色
======================================== */
--color-success-50: oklch(0.95 0.05 142.495);
--color-success-500: oklch(0.72 0.19 142.495);
--color-success-600: oklch(0.62 0.17 142.495);
--color-warning-50: oklch(0.95 0.08 90.278);
--color-warning-500: oklch(0.75 0.183 55.278);
--color-warning-600: oklch(0.65 0.16 55.278);
--color-error-50: oklch(0.95 0.08 25.331);
--color-error-500: oklch(0.63 0.237 25.331);
--color-error-600: oklch(0.55 0.21 25.331);
--color-info-50: oklch(0.95 0.04 254.604);
--color-info-500: oklch(0.62 0.17 254.604);
--color-info-600: oklch(0.52 0.15 254.604);
/* ========================================
语义化颜色 (兼容旧变量)
======================================== */
--color-bg: oklch(0.985 0.001 264.695);
--color-bg-page: oklch(0.985 0.001 264.695);
--color-bg-card: oklch(1 0 0);
--color-surface: oklch(1 0 0);
--color-text: oklch(0.195 0.038 264.695);
--color-text-primary: oklch(0.195 0.038 264.695);
--color-text-secondary: oklch(0.446 0.03 264.695);
--color-text-muted: oklch(0.552 0.024 264.695);
--color-text-disabled: oklch(0.718 0.018 264.695);
--color-text-inverse: oklch(0.985 0 0);
--color-border: oklch(0.922 0.004 264.695);
--color-border-focus: oklch(0.55 0.18 254.604);
--color-primary-hover: var(--color-primary-400);
--color-success: var(--color-success-500);
--color-success-bg: var(--color-success-50);
--color-warning: var(--color-warning-500);
--color-warning-bg: var(--color-warning-50);
--color-error: var(--color-error-500);
--color-error-bg: var(--color-error-50);
/* ========================================
字体系统
======================================== */
--font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--line-height-tight: 1.25;
--line-height-base: 1.5;
/* ========================================
间距系统 (4px 基准)
======================================== */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* ========================================
圆角系统
======================================== */
--radius-card: 12px;
--radius-button: 6px;
--radius-tag: 4px;
/* ========================================
阴影系统
======================================== */
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-base: 0 1px 3px 0 oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.1), 0 4px 6px -4px oklch(0 0 0 / 0.1);
--shadow-blue: 0 0 0 3px oklch(0.55 0.18 254.604 / 0.15);
--shadow-inset-card: inset 0 1px 0 0 oklch(1 0 0 / 0.05);
--glow-primary: 0 0 0 3px oklch(0.55 0.18 254.604 / 0.15);
/* ========================================
布局系统
======================================== */
--sidebar-width: 240px;
--header-height: 56px;
/* ========================================
动效
======================================== */
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-slow: 300ms;
}
/* ========================================
深色模式
======================================== */
[data-theme="dark"],
.dark {
--background: oklch(0.145 0.004 264.695);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.006 264.695);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.006 264.695);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0.007 264.695);
--secondary: oklch(0.25 0.01 264.695);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.25 0.01 264.695);
--muted-foreground: oklch(0.65 0.015 264.695);
--accent: oklch(0.30 0.015 264.695);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.65 0.2 25);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.28 0.01 264.695);
--input: oklch(0.28 0.01 264.695);
--ring: oklch(0.55 0.15 254.604);
/* 背景层级 */
--color-bg: oklch(0.12 0.005 264.695);
--color-bg-page: oklch(0.12 0.005 264.695);
--color-bg-card: oklch(0.18 0.006 264.695);
--color-surface: oklch(0.18 0.006 264.695);
/* 中性灰 - 深色模式调整 */
--color-gray-50: oklch(0.22 0.01 264.695);
--color-gray-100: oklch(0.28 0.01 264.695);
--color-gray-200: oklch(0.35 0.012 264.695);
--color-gray-300: oklch(0.45 0.015 264.695);
--color-gray-400: oklch(0.58 0.015 264.695);
--color-gray-500: oklch(0.70 0.012 264.695);
--color-gray-600: oklch(0.80 0.008 264.695);
--color-gray-700: oklch(0.88 0.005 264.695);
--color-gray-800: oklch(0.94 0.003 264.695);
--color-gray-900: oklch(0.98 0 0);
/* 文字 */
--color-text: oklch(0.94 0.003 264.695);
--color-text-primary: oklch(0.94 0.003 264.695);
--color-text-secondary: oklch(0.70 0.012 264.695);
--color-text-muted: oklch(0.50 0.015 264.695);
--color-text-disabled: oklch(0.35 0.012 264.695);
/* 边框 */
--color-border: oklch(0.28 0.01 264.695);
/* 主色 - 深色模式提亮 */
--color-primary-hover: oklch(0.98 0.005 264.695);
--color-primary-500: oklch(0.68 0.16 254.604);
--color-primary-400: oklch(0.78 0.11 254.604);
--color-primary-50: oklch(0.22 0.03 254.604);
}
/* ================================
2. 全局基础样式 (Global Base)
全局基础样式
================================ */
html, body, #app {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
font-size: 16px;
line-height: 1.5;
}
body { scrollbar-gutter: stable both-edges; }
body {
scrollbar-gutter: stable both-edges;
}
/* ================================
滚动条样式
================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-400);
}
.dark ::-webkit-scrollbar-thumb {
background: var(--color-gray-600);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}
/* ================================
文本选中和焦点
================================ */
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* ================================
链接样式
================================ */
a {
color: var(--primary);
text-decoration: none;
transition: color var(--duration-fast) ease;
}
a:hover {
color: var(--primary-hover);
}
/* ================================
按钮基础
================================ */
button {
cursor: pointer;
font-family: inherit;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* ================================
输入框基础
================================ */
input, textarea, select {
font-family: inherit;
font-size: inherit;
}
/* ================================
通用工具类
================================ */
/* 文本截断 */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 多行截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 玻璃态效果 */
.glass {
background: oklch(1 0 0 / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.dark .glass {
background: oklch(0.18 0.010 265 / 0.8);
}
/* 渐变文字 */
.gradient-text {
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 发光边框 */
.glow-border {
box-shadow: var(--shadow-glow);
}
.glow-border:hover {
box-shadow: var(--shadow-glow-lg);
}
/* 悬浮卡片 */
.hover-card {
transition: transform var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.hover-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* 加载动画 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
animation: spin 0.8s linear infinite;
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--duration-base) ease-out;
}
/* 滑入动画 */
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-up {
animation: slideUp var(--duration-base) var(--ease-out);
}
/* 脉冲动画 */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

View File

@@ -1,141 +1,295 @@
/* ================================
SionRUI 现代设计系统 v2.0
极简 · 稳重 · 专业
================================ */
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ================================
1. 设计令牌 - 亮色模式
================================ */
:root {
/* ========================================
基础颜色 - 与 style.css 保持一致
基础颜色 - 纯净白底
======================================== */
--background: oklch(0.985 0.001 264.695);
--foreground: oklch(0.195 0.038 264.695);
--background: oklch(0.995 0 0);
--foreground: oklch(0.15 0.005 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.195 0.038 264.695);
--card-foreground: oklch(0.15 0.005 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.195 0.038 264.695);
--popover-foreground: oklch(0.15 0.005 260);
/* ========================================
主色 - 科技蓝品牌色
主色 - 沉稳深蓝 (低调专业)
======================================== */
--primary: oklch(0.45 0.16 254.604);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(0.35 0.06 255);
--primary-foreground: oklch(0.99 0 0);
--primary-hover: oklch(0.28 0.05 255);
/* ========================================
次要色 - 偏蓝冷灰
次要色 - 冷灰
======================================== */
--secondary: oklch(0.97 0.001 264.695);
--secondary-foreground: oklch(0.205 0.015 264.695);
--secondary: oklch(0.96 0.003 260);
--secondary-foreground: oklch(0.22 0.005 260);
/* ========================================
静音色 - 柔和灰
静音色
======================================== */
--muted: oklch(0.97 0.001 264.695);
--muted-foreground: oklch(0.45 0.02 264.695);
--muted: oklch(0.96 0.002 260);
--muted-foreground: oklch(0.50 0.008 260);
/* ========================================
强调色
强调色 - 低饱和蓝灰
======================================== */
--accent: oklch(0.97 0.001 264.695);
--accent-foreground: oklch(0.205 0.015 264.695);
--accent: oklch(0.94 0.008 255);
--accent-foreground: oklch(0.22 0.010 255);
/* ========================================
功能色
功能色 - 柔和
======================================== */
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.99 0 0);
--success: oklch(0.55 0.12 145);
--warning: oklch(0.65 0.12 80);
--info: oklch(0.50 0.10 255);
/* ========================================
边框与输入
======================================== */
--border: oklch(0.922 0.004 264.695);
--input: oklch(0.922 0.004 264.695);
--ring: oklch(0.55 0.18 254.604);
--border: oklch(0.92 0.002 260);
--input: oklch(0.92 0.002 260);
--ring: oklch(0.35 0.06 255);
/* ========================================
图表色 - 蓝色系渐变
图表色 - 低调渐变
======================================== */
--chart-1: oklch(0.68 0.16 254.604);
--chart-2: oklch(0.55 0.18 254.604);
--chart-3: oklch(0.45 0.16 254.604);
--chart-4: oklch(0.37 0.13 254.604);
--chart-5: oklch(0.30 0.10 254.604);
--chart-1: oklch(0.50 0.08 255);
--chart-2: oklch(0.55 0.06 200);
--chart-3: oklch(0.55 0.05 150);
--chart-4: oklch(0.60 0.04 90);
--chart-5: oklch(0.55 0.08 25);
/* ========================================
侧边栏主题 - 品牌蓝高亮
侧边栏
======================================== */
--sidebar: oklch(0.985 0.001 264.695);
--sidebar-foreground: oklch(0.195 0.038 264.695);
--sidebar-primary: oklch(0.45 0.16 254.604);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0.001 264.695);
--sidebar-accent-foreground: oklch(0.205 0.015 264.695);
--sidebar-border: oklch(0.922 0.004 264.695);
--sidebar-ring: oklch(0.55 0.18 254.604);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0.15 0.005 260);
--sidebar-primary: oklch(0.35 0.06 255);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.008 255);
--sidebar-accent-foreground: oklch(0.22 0.010 255);
--sidebar-border: oklch(0.92 0.002 260);
--sidebar-ring: oklch(0.35 0.06 255);
/* ========================================
品牌色阶 - 深蓝渐变
======================================== */
--color-primary-50: oklch(0.97 0.003 255);
--color-primary-100: oklch(0.93 0.005 255);
--color-primary-200: oklch(0.86 0.008 255);
--color-primary-300: oklch(0.72 0.020 255);
--color-primary-400: oklch(0.55 0.035 255);
--color-primary-500: oklch(0.40 0.055 255);
--color-primary-600: oklch(0.35 0.060 255);
--color-primary-700: oklch(0.28 0.055 255);
/* ========================================
中性灰 - 偏蓝冷色
======================================== */
--color-gray-50: oklch(0.98 0.001 260);
--color-gray-100: oklch(0.96 0.002 260);
--color-gray-200: oklch(0.92 0.003 260);
--color-gray-300: oklch(0.85 0.004 260);
--color-gray-400: oklch(0.68 0.005 260);
--color-gray-500: oklch(0.50 0.006 260);
--color-gray-600: oklch(0.40 0.007 260);
--color-gray-700: oklch(0.32 0.006 260);
--color-gray-800: oklch(0.24 0.005 260);
--color-gray-900: oklch(0.15 0.004 260);
/* ========================================
语义化颜色 (兼容旧变量)
======================================== */
--color-bg: oklch(0.985 0.001 260);
--color-bg-page: oklch(0.975 0.001 260);
--color-bg-card: oklch(1 0 0);
--color-surface: oklch(1 0 0);
--color-text: oklch(0.15 0.005 260);
--color-text-primary: oklch(0.15 0.005 260);
--color-text-secondary: oklch(0.42 0.006 260);
--color-text-muted: oklch(0.55 0.005 260);
--color-text-disabled: oklch(0.72 0.003 260);
--color-border: oklch(0.92 0.002 260);
--color-primary-hover: var(--color-primary-400);
/* ========================================
字体系统
======================================== */
--font-sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, monospace;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--font-family: var(--font-sans);
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--line-height-tight: 1.25;
--line-height-base: 1.5;
/* ========================================
间距系统
======================================== */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ========================================
圆角系统
======================================== */
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius: 0.5rem;
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) + 8px);
--radius-card: 8px;
--radius-button: 6px;
--radius-tag: 4px;
/* ========================================
阴影系统
阴影系统 - 极简
======================================== */
--shadow-2xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 oklch(0 0 0 / 0.10), 0 1px 2px -1px oklch(0 0 0 / 0.10);
--shadow: 0 1px 3px 0 oklch(0 0 0 / 0.10), 0 1px 2px -1px oklch(0 0 0 / 0.10);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.10);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.10), 0 4px 6px -4px oklch(0 0 0 / 0.10);
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.10), 0 8px 10px -6px oklch(0 0 0 / 0.10);
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.25);
--shadow-2xs: 0 1px 2px 0 oklch(0 0 0 / 0.02);
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.03);
--shadow-sm: 0 1px 3px 0 oklch(0 0 0 / 0.04), 0 1px 2px -1px oklch(0 0 0 / 0.04);
--shadow: 0 1px 3px 0 oklch(0 0 0 / 0.05), 0 1px 2px -1px oklch(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.05), 0 2px 4px -2px oklch(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.05), 0 4px 6px -4px oklch(0 0 0 / 0.05);
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.06), 0 8px 10px -6px oklch(0 0 0 / 0.06);
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.10);
/* ========================================
布局系统
======================================== */
--sidebar-width: 240px;
--header-height: 56px;
/* ========================================
动效
======================================== */
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
.dark {
--background: oklch(0.145 0.004 264.695);
--foreground: oklch(0.94 0.003 264.695);
--card: oklch(0.18 0.006 264.695);
--card-foreground: oklch(0.94 0.003 264.695);
--popover: oklch(0.18 0.006 264.695);
--popover-foreground: oklch(0.94 0.003 264.695);
--primary: oklch(0.68 0.16 254.604);
--primary-foreground: oklch(0.145 0.004 264.695);
--secondary: oklch(0.25 0.01 264.695);
--secondary-foreground: oklch(0.94 0.003 264.695);
--muted: oklch(0.25 0.01 264.695);
--muted-foreground: oklch(0.65 0.015 264.695);
--accent: oklch(0.30 0.015 264.695);
--accent-foreground: oklch(0.94 0.003 264.695);
--destructive: oklch(0.65 0.2 25);
--destructive-foreground: oklch(0.94 0.003 264.695);
--border: oklch(0.28 0.01 264.695);
--input: oklch(0.28 0.01 264.695);
--ring: oklch(0.55 0.15 254.604);
--chart-1: oklch(0.78 0.11 254.604);
--chart-2: oklch(0.68 0.16 254.604);
--chart-3: oklch(0.55 0.18 254.604);
--chart-4: oklch(0.45 0.16 254.604);
--chart-5: oklch(0.37 0.13 254.604);
--sidebar: oklch(0.18 0.006 264.695);
--sidebar-foreground: oklch(0.94 0.003 264.695);
--sidebar-primary: oklch(0.68 0.16 254.604);
--sidebar-primary-foreground: oklch(0.145 0.004 264.695);
--sidebar-accent: oklch(0.25 0.01 264.695);
--sidebar-accent-foreground: oklch(0.94 0.003 264.695);
--sidebar-border: oklch(0.28 0.01 264.695);
--sidebar-ring: oklch(0.55 0.15 254.604);
/* ================================
2. 深色模式
================================ */
.dark,
[data-theme="dark"] {
/* 基础颜色 */
--background: oklch(0.12 0.004 260);
--foreground: oklch(0.94 0.002 260);
--card: oklch(0.15 0.005 260);
--card-foreground: oklch(0.94 0.002 260);
--popover: oklch(0.15 0.005 260);
--popover-foreground: oklch(0.94 0.002 260);
/* 主色 - 深色模式提亮 */
--primary: oklch(0.65 0.08 255);
--primary-foreground: oklch(0.12 0.004 260);
--primary-hover: oklch(0.72 0.07 255);
/* 次要色 */
--secondary: oklch(0.22 0.006 260);
--secondary-foreground: oklch(0.94 0.002 260);
/* 静音色 */
--muted: oklch(0.22 0.006 260);
--muted-foreground: oklch(0.62 0.008 260);
/* 强调色 */
--accent: oklch(0.25 0.012 255);
--accent-foreground: oklch(0.94 0.002 260);
/* 功能色 */
--destructive: oklch(0.60 0.16 25);
--destructive-foreground: oklch(0.94 0.002 260);
--success: oklch(0.58 0.10 145);
--warning: oklch(0.68 0.10 80);
--info: oklch(0.55 0.08 255);
/* 边框与输入 */
--border: oklch(0.26 0.006 260);
--input: oklch(0.26 0.006 260);
--ring: oklch(0.65 0.08 255);
/* 图表色 */
--chart-1: oklch(0.62 0.08 255);
--chart-2: oklch(0.60 0.06 200);
--chart-3: oklch(0.58 0.05 150);
--chart-4: oklch(0.65 0.04 90);
--chart-5: oklch(0.58 0.08 25);
/* 侧边栏 */
--sidebar: oklch(0.12 0.004 260);
--sidebar-foreground: oklch(0.94 0.002 260);
--sidebar-primary: oklch(0.65 0.08 255);
--sidebar-primary-foreground: oklch(0.12 0.004 260);
--sidebar-accent: oklch(0.25 0.012 255);
--sidebar-accent-foreground: oklch(0.94 0.002 260);
--sidebar-border: oklch(0.26 0.006 260);
--sidebar-ring: oklch(0.65 0.08 255);
/* 灰色系 */
--color-gray-50: oklch(0.22 0.006 260);
--color-gray-100: oklch(0.28 0.008 260);
--color-gray-200: oklch(0.36 0.008 260);
--color-gray-300: oklch(0.48 0.008 260);
--color-gray-400: oklch(0.62 0.006 260);
--color-gray-500: oklch(0.76 0.004 260);
--color-gray-600: oklch(0.84 0.003 260);
--color-gray-700: oklch(0.90 0.002 260);
--color-gray-800: oklch(0.95 0.001 260);
--color-gray-900: oklch(0.98 0 0);
/* 语义化颜色 */
--color-bg: oklch(0.10 0.004 260);
--color-bg-page: oklch(0.10 0.004 260);
--color-bg-card: oklch(0.15 0.005 260);
--color-surface: oklch(0.15 0.005 260);
--color-text: oklch(0.94 0.002 260);
--color-text-primary: oklch(0.94 0.002 260);
--color-text-secondary: oklch(0.68 0.006 260);
--color-text-muted: oklch(0.50 0.006 260);
--color-text-disabled: oklch(0.36 0.006 260);
--color-border: oklch(0.26 0.006 260);
/* 主色阶 */
--color-primary-50: oklch(0.20 0.015 255);
--color-primary-100: oklch(0.28 0.020 255);
--color-primary-400: oklch(0.60 0.08 255);
--color-primary-500: oklch(0.65 0.08 255);
--color-primary-hover: oklch(0.72 0.07 255);
}
/* ================================
3. Tailwind v4 @theme Bridge
================================ */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -153,6 +307,9 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -169,13 +326,16 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
@@ -186,73 +346,51 @@
--shadow-2xl: var(--shadow-2xl);
}
/* ================================
4. 基础样式
================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
/* ================================
5. 全局样式
================================ */
html, body, #app {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
font-size: 16px;
line-height: 1.5;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
body {
scrollbar-gutter: stable both-edges;
}
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 选中文本样式 */
::selection {
background: oklch(0.35 0.06 255 / 0.15);
color: inherit;
}
.dark ::selection {
background: oklch(0.65 0.08 255 / 0.25);
}
/* 焦点样式 */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}

View File

@@ -0,0 +1,63 @@
import { toast } from 'vue-sonner'
/**
* 确认对话框工具
* 用于替代 ant-design-vue 的 Modal.confirm
*/
/**
* 显示确认对话框
* @param {Object} options - 配置选项
* @param {string} options.title - 标题
* @param {string} options.content - 内容
* @param {string} options.okText - 确认按钮文字
* @param {string} options.cancelText - 取消按钮文字
* @param {string} options.okType - 确认按钮类型 (primary/danger)
* @param {Function} options.onOk - 确认回调
* @param {Function} options.onCancel - 取消回调
* @returns {Promise<boolean>}
*/
export function confirmDialog(options = {}) {
const {
title = '确认',
content = '',
okText = '确认',
cancelText = '取消',
okType = 'primary',
onOk,
onCancel
} = options
return new Promise((resolve) => {
const confirmed = window.confirm(`${title}\n\n${content}`)
if (confirmed) {
if (onOk) {
Promise.resolve(onOk())
.then(() => resolve(true))
.catch((error) => {
toast.error(error?.message || '操作失败')
resolve(false)
})
} else {
resolve(true)
}
} else {
if (onCancel) {
Promise.resolve(onCancel())
}
resolve(false)
}
})
}
/**
* 显示确认对话框(简化版,直接返回 Promise
* @param {string} message - 确认消息
* @returns {Promise<boolean>}
*/
export function confirm(message) {
return Promise.resolve(window.confirm(message))
}
export default confirmDialog

View File

@@ -1,5 +1,5 @@
import { ChatMessageApi } from '@/api/chat'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
/**
* 打字机效果管理器
@@ -184,7 +184,7 @@ export async function streamChat(options = {}) {
if (typeof onError === 'function') {
onError(error)
} else {
message.error('请求超时,请稍后重试')
toast.error('请求超时,请稍后重试')
}
reject(error)
}
@@ -207,7 +207,7 @@ export async function streamChat(options = {}) {
try {
const { code, data: responseData, msg } = JSON.parse(dataStr)
if (code !== 0) {
message.warning(`对话异常! ${msg}`)
toast.warning(`对话异常! ${msg}`)
return
}
@@ -256,7 +256,7 @@ export async function streamChat(options = {}) {
if (typeof onError === 'function') {
onError(err)
} else {
message.error(errorMsg)
toast.error(errorMsg)
}
reject(new Error(errorMsg))

View File

@@ -13,7 +13,7 @@
<!-- 搜索区域 - 核心焦点 -->
<div class="search-hero">
<div class="search-input-group">
<SearchOutlined class="search-icon" />
<Icon icon="lucide:search" class="search-icon" />
<input
v-model="searchKeyword"
type="text"
@@ -22,14 +22,14 @@
/>
<transition name="fade">
<button v-if="searchKeyword" class="search-clear" @click="clearSearch">
<CloseOutlined />
<Icon icon="lucide:x" />
</button>
</transition>
</div>
<!-- 我创建的 -->
<button class="favorites-btn" @click="showFavoritesModal = true">
<StarFilled class="favorites-btn-icon" />
<Icon icon="lucide:star" class="favorites-btn-icon" />
<span class="favorites-btn-text">我创建的</span>
</button>
</div>
@@ -60,7 +60,7 @@
@click="toggleCategoryPanel"
>
<span class="expand-icon">
<AppstoreOutlined />
<Icon icon="lucide:layout-grid" />
</span>
</button>
</div>
@@ -80,7 +80,7 @@
<div class="panel-header">
<span class="panel-title">快速选择</span>
<button class="panel-close" @click="showCategoryPanel = false">
<CloseOutlined />
<Icon icon="lucide:x" />
</button>
</div>
<div class="panel-grid">
@@ -101,10 +101,10 @@
<!-- 主内容区域 -->
<main class="agents-main">
<a-spin :spinning="loading" class="loading-spinner">
<template #indicator>
<div class="loading-wrapper" :class="{ 'is-loading': loading }">
<div v-if="loading" class="loading-overlay">
<div class="custom-spinner"></div>
</template>
</div>
<template v-if="filteredAgentList.length > 0">
<div class="agents-grid">
@@ -121,8 +121,8 @@
:class="{ 'favorite-icon--active': agent.isFavorite }"
@click.stop="handleFavorite(agent)"
>
<StarFilled v-if="agent.isFavorite" />
<StarOutlined v-else />
<Icon icon="lucide:star" v-if="agent.isFavorite" />
<Icon icon="lucide:star" v-else />
</button>
<!-- 卡片内容 -->
@@ -132,7 +132,7 @@
<div class="avatar-ring" :class="`ring--${agent.tagColor}`"></div>
<div class="avatar-inner">
<img v-if="agent.avatar" :src="agent.avatar" :alt="agent.name" />
<RobotOutlined v-else class="avatar-icon" />
<Icon icon="lucide:bot" v-else class="avatar-icon" />
</div>
</div>
@@ -152,11 +152,11 @@
<div class="card-footer">
<div class="footer-actions">
<button class="action-btn" @click.stop="handleHistory(agent)">
<HistoryOutlined class="action-btn-icon" />
<Icon icon="lucide:history" class="action-btn-icon" />
<span>历史</span>
</button>
<button class="action-btn action-btn--primary" @click.stop="handleChat(agent)">
<MessageOutlined class="action-btn-icon" />
<Icon icon="lucide:message-circle" class="action-btn-icon" />
<span>对话</span>
</button>
</div>
@@ -167,12 +167,12 @@
<div v-else class="empty-state">
<div class="empty-visual">
<SearchOutlined />
<Icon icon="lucide:search" />
</div>
<p class="empty-text">没有找到匹配的智能体</p>
<button class="empty-action" @click="resetFilters">清除筛选</button>
</div>
</a-spin>
</div>
</main>
</div>
@@ -200,17 +200,8 @@
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import {
SearchOutlined,
RobotOutlined,
CloseOutlined,
MessageOutlined,
AppstoreOutlined,
StarOutlined,
StarFilled,
HistoryOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
import ChatDrawer from '@/components/agents/ChatDrawer.vue'
import HistoryPanel from '@/components/agents/HistoryPanel.vue'
@@ -301,7 +292,7 @@ const fetchAgentList = async () => {
}
} catch (error) {
console.error('获取智能体列表失败:', error)
message.error('获取智能体列表失败')
toast.error('获取智能体列表失败')
} finally {
loading.value = false
}
@@ -395,7 +386,7 @@ const handleFavorite = async (agent) => {
}
} catch {
agent.isFavorite = !newStatus // 失败时回滚
message.error('操作失败')
toast.error('操作失败')
}
}
@@ -702,6 +693,22 @@ onMounted(() => {
width: 100%;
}
.loading-wrapper {
width: 100%;
position: relative;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 48px;
background: rgba(255, 255, 255, 0.7);
z-index: 10;
}
.custom-spinner {
width: 32px;
height: 32px;
@@ -709,7 +716,6 @@ onMounted(() => {
border-top-color: var(--color-primary-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {

View File

@@ -101,80 +101,86 @@
</div>
<!-- 登录表单 -->
<a-form
:model="smsForm"
:rules="smsRules"
ref="smsFormRef"
layout="vertical"
class="login-form"
>
<a-form-item name="mobile" label="手机号码">
<form class="login-form" @submit.prevent="handleSmsLogin">
<!-- 手机号 -->
<div class="form-item" :class="{ 'has-error': errors.mobile }">
<label class="form-label">手机号码</label>
<div class="input-wrapper">
<a-input
v-model:value="smsForm.mobile"
size="large"
<div class="input-prefix">
<Icon icon="lucide:smartphone" class="input-icon" />
</div>
<input
v-model="smsForm.mobile"
type="tel"
class="custom-input"
:class="{ 'input-error': errors.mobile }"
placeholder="请输入手机号"
:maxlength="11"
maxlength="11"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
<template #prefix>
<PhoneOutlined class="input-icon" />
</template>
</a-input>
@blur="validateMobile"
/>
<div class="input-glow"></div>
</div>
</a-form-item>
<span v-if="errors.mobile" class="error-message">{{ errors.mobile }}</span>
</div>
<a-form-item name="code" label="验证码">
<!-- 验证码 -->
<div class="form-item" :class="{ 'has-error': errors.code }">
<label class="form-label">验证码</label>
<div class="code-row">
<div class="input-wrapper code-input">
<a-input
v-model:value="smsForm.code"
size="large"
<div class="input-prefix">
<Icon icon="lucide:shield-check" class="input-icon" />
</div>
<input
v-model="smsForm.code"
type="text"
class="custom-input"
:class="{ 'input-error': errors.code }"
placeholder="请输入验证码"
:maxlength="4"
maxlength="4"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
<template #prefix>
<SafetyOutlined class="input-icon" />
</template>
</a-input>
@blur="validateCode"
/>
<div class="input-glow"></div>
</div>
<a-button
type="primary"
<Button
type="button"
variant="outline"
:disabled="codeCountdown > 0"
:loading="sendingCode"
@click="sendSmsCode"
class="code-btn"
@click="sendSmsCode"
>
<span v-if="sendingCode" class="custom-spinner small"></span>
<span v-if="codeCountdown > 0">{{ codeCountdown }}s</span>
<span v-else>获取验证码</span>
</a-button>
</Button>
</div>
</a-form-item>
<span v-if="errors.code" class="error-message">{{ errors.code }}</span>
</div>
<a-form-item class="submit-item">
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loggingIn"
@click="handleSmsLogin"
class="submit-btn"
<!-- 提交按钮 -->
<div class="submit-item">
<Button
type="submit"
size="lg"
class="submit-btn w-full"
:disabled="loggingIn"
>
<span class="btn-text">立即登录</span>
<span class="btn-arrow"></span>
</a-button>
</a-form-item>
</a-form>
<span v-if="loggingIn" class="custom-spinner"></span>
<template v-else>
<span class="btn-text">立即登录</span>
<span class="btn-arrow"></span>
</template>
</Button>
</div>
</form>
<!-- 底部装饰 -->
<div class="card-footer">
@@ -192,11 +198,9 @@
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
PhoneOutlined,
SafetyOutlined
} from '@ant-design/icons-vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import authApi, { SMS_SCENE } from '@/api/auth'
import { useUserStore } from '@/stores/user'
@@ -208,7 +212,12 @@ const smsForm = reactive({
mobile: '',
code: ''
})
const smsFormRef = ref()
// 错误信息
const errors = reactive({
mobile: '',
code: ''
})
// 状态
const sendingCode = ref(false)
@@ -218,6 +227,39 @@ const codeCountdown = ref(0)
// 验证码倒计时
let countdownTimer = null
// 表单验证
const validateMobile = () => {
if (!smsForm.mobile) {
errors.mobile = '请输入手机号'
return false
}
if (!/^1[3-9]\d{9}$/.test(smsForm.mobile)) {
errors.mobile = '请输入正确的手机号'
return false
}
errors.mobile = ''
return true
}
const validateCode = () => {
if (!smsForm.code) {
errors.code = '请输入验证码'
return false
}
if (!/^\d{4}$/.test(smsForm.code)) {
errors.code = '验证码为4位数字'
return false
}
errors.code = ''
return true
}
const validateForm = () => {
const mobileValid = validateMobile()
const codeValid = validateCode()
return mobileValid && codeValid
}
// ========== 粒子系统 ==========
const particleCanvas = ref(null)
let particles = []
@@ -361,13 +403,17 @@ const smsRules = {
// 发送验证码
async function sendSmsCode() {
// 验证手机号
if (!validateMobile()) {
return
}
try {
await smsFormRef.value.validateFields(['mobile'])
sendingCode.value = true
await authApi.sendSmsCode(smsForm.mobile, SMS_SCENE.MEMBER_LOGIN)
message.success('验证码已发送')
toast.success('验证码已发送')
// 开始倒计时
codeCountdown.value = 60
@@ -379,11 +425,11 @@ async function sendSmsCode() {
}, 1000)
} catch (error) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
toast.error(error.response.data.message)
} else if (error.message) {
message.error(error.message)
toast.error(error.message)
} else {
message.error('发送验证码失败')
toast.error('发送验证码失败')
}
} finally {
sendingCode.value = false
@@ -392,8 +438,12 @@ async function sendSmsCode() {
// 短信登录
async function handleSmsLogin() {
// 验证表单
if (!validateForm()) {
return
}
try {
await smsFormRef.value.validateFields()
loggingIn.value = true
const info = await authApi.loginBySms(smsForm.mobile, smsForm.code)
@@ -412,14 +462,14 @@ async function handleSmsLogin() {
// 获取完整的用户信息
await userStore.fetchUserInfo()
message.success('登录成功')
toast.success('登录成功')
router.push({ name: '对标分析' })
} catch (error) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
toast.error(error.response.data.message)
} else {
message.error('登录失败,请检查输入信息')
toast.error('登录失败,请检查输入信息')
}
} finally {
loggingIn.value = false
@@ -825,79 +875,92 @@ async function handleSmsLogin() {
/* ========== 表单样式 ========== */
.login-form {
:deep(.ant-form-item) {
.form-item {
margin-bottom: 24px;
}
:deep(.ant-form-item-label > label) {
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: @text-secondary;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 8px;
}
&::after {
display: none;
.error-message {
display: block;
color: #E5484D;
font-size: 12px;
margin-top: 6px;
}
&.has-error {
.custom-input {
border-color: #E5484D !important;
background: rgba(229, 72, 77, 0.05) !important;
}
}
}
.input-wrapper {
position: relative;
}
/* 输入框样式 */
:deep(.ant-input-affix-wrapper) {
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid @border-subtle !important;
border-radius: 12px !important;
height: 52px !important;
box-shadow: none !important;
transition: all 0.3s ease !important;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid @border-subtle;
border-radius: 12px;
height: 52px;
transition: all 0.3s ease;
&:hover {
border-color: @border-active !important;
background: rgba(255, 255, 255, 0.05) !important;
border-color: @border-active;
background: rgba(255, 255, 255, 0.05);
}
&.ant-input-affix-wrapper-focused {
border-color: @primary-gold !important;
background: rgba(255, 255, 255, 0.05) !important;
box-shadow: 0 0 0 3px rgba(212, 168, 83, 0.1) !important;
&:focus-within {
border-color: @primary-gold;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 0 3px rgba(212, 168, 83, 0.1);
.input-glow {
opacity: 1;
}
}
}
:deep(.ant-input) {
color: @text-primary !important;
font-size: 15px !important;
background: transparent !important;
&::placeholder {
color: @text-muted !important;
}
}
:deep(.ant-input-prefix) {
color: @text-muted !important;
margin-right: 12px !important;
font-size: 16px;
}
:deep(.ant-form-item-has-error .ant-input-affix-wrapper) {
border-color: #E5484D !important;
background: rgba(229, 72, 77, 0.05) !important;
}
:deep(.ant-form-item-explain-error) {
color: #E5484D;
font-size: 12px;
margin-top: 6px;
.input-prefix {
display: flex;
align-items: center;
justify-content: center;
padding-left: 16px;
color: @text-muted;
}
.input-icon {
font-size: 18px;
}
.custom-input {
flex: 1;
height: 100%;
padding: 0 16px 0 12px;
border: none;
background: transparent;
color: @text-primary;
font-size: 15px;
outline: none;
&::placeholder {
color: @text-muted;
}
&.input-error {
border-color: #E5484D;
}
}
.input-glow {
position: absolute;
inset: 0;
@@ -908,10 +971,6 @@ async function handleSmsLogin() {
box-shadow: 0 0 20px rgba(212, 168, 83, 0.15);
}
.input-wrapper:focus-within .input-glow {
opacity: 1;
}
/* 验证码行 */
.code-row {
display: flex;
@@ -974,7 +1033,7 @@ async function handleSmsLogin() {
transition: opacity 0.3s ease;
}
&:hover {
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(212, 168, 83, 0.3);
@@ -991,6 +1050,11 @@ async function handleSmsLogin() {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-text,
.btn-arrow {
position: relative;
@@ -1003,6 +1067,25 @@ async function handleSmsLogin() {
}
}
/* 自定义加载动画 */
.custom-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(10, 11, 13, 0.2);
border-top-color: @deep-black;
border-radius: 50%;
animation: spin 0.8s linear infinite;
&.small {
width: 14px;
height: 14px;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 卡片底部 */
.card-footer {
display: flex;
@@ -1237,8 +1320,8 @@ async function handleSmsLogin() {
font-size: 13px;
}
:deep(.ant-input-affix-wrapper) {
height: 48px !important;
.input-wrapper {
height: 48px;
}
.code-btn,

View File

@@ -1,8 +1,17 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import ChatMessageRendererV2 from '@/components/ChatMessageRendererV2.vue'
import { benchmarkAnalyzeStream } from '@/api/dify'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
const props = withDefaults(defineProps<{
visible: boolean
@@ -66,7 +75,7 @@ async function generateBatchPrompt() {
},
onError: (error: Error) => {
console.error('对标分析错误:', error)
message.error('AI生成失败请稍后重试')
toast.error('AI生成失败请稍后重试')
},
onComplete: (fullText: string) => {
console.log('[BatchAnalyzeModal] onComplete - fullText length:', fullText?.length)
@@ -74,12 +83,12 @@ async function generateBatchPrompt() {
batchPrompt.value = fullText
}
console.log('[BatchAnalyzeModal] onComplete - batchPrompt.value length:', batchPrompt.value?.length)
message.success(`批量分析完成:已基于 ${props.textCount} 个视频的文案生成综合提示词`)
toast.success(`批量分析完成:已基于 ${props.textCount} 个视频的文案生成综合提示词`)
}
})
} catch (error) {
console.error('AI生成失败:', error)
message.error('AI生成失败请稍后重试')
toast.error('AI生成失败请稍后重试')
} finally {
batchPromptGenerating.value = false
// 注意:不要重置 hasGenerated保持生成状态直到弹窗关闭
@@ -110,53 +119,52 @@ function handleUse() {
</script>
<template>
<a-modal
:open="props.visible"
title="综合分析结果"
:width="800"
:maskClosable="false"
:keyboard="false"
@cancel="handleClose"
>
<div class="batch-prompt-modal">
<!-- 内容显示模式 -->
<div v-if="!batchPromptEditMode" class="batch-prompt-display">
<ChatMessageRendererV2
:content="batchPrompt"
:is-streaming="batchPromptGenerating"
<Dialog :open="props.visible" @update:open="handleClose">
<DialogContent class="max-w-3xl">
<DialogHeader>
<DialogTitle>综合分析结果</DialogTitle>
</DialogHeader>
<div class="batch-prompt-modal">
<!-- 内容显示模式 -->
<div v-if="!batchPromptEditMode" class="batch-prompt-display">
<ChatMessageRendererV2
:content="batchPrompt"
:is-streaming="batchPromptGenerating"
/>
</div>
<!-- 编辑模式 -->
<Textarea
v-else
v-model="batchPrompt"
:rows="15"
placeholder="内容将在这里显示..."
class="edit-textarea"
/>
</div>
<!-- 编辑模式 -->
<a-textarea
v-else
v-model:value="batchPrompt"
:rows="15"
placeholder="内容将在这里显示..."
/>
</div>
<template #footer>
<div class="footer-actions">
<DialogFooter class="footer-actions">
<div class="left-actions">
<a-button type="text" @click="toggleEditMode">
<Button variant="ghost" size="sm" @click="toggleEditMode">
{{ batchPromptEditMode ? '取消编辑' : '编辑' }}
</a-button>
<a-button type="text" @click="handleCopy">复制</a-button>
<a-button
type="text"
</Button>
<Button variant="ghost" size="sm" @click="handleCopy">复制</Button>
<Button
variant="ghost"
size="sm"
@click="handleSave"
:disabled="!batchPrompt.trim()"
>
保存提示词
</a-button>
</Button>
</div>
<div class="right-actions">
<a-button @click="handleClose">取消</a-button>
<Button variant="outline" @click="handleClose">取消</Button>
</div>
</div>
</template>
</a-modal>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped lang="less">
@@ -172,6 +180,10 @@ function handleUse() {
border-radius: 8px;
background: var(--color-surface);
}
.edit-textarea {
min-height: 300px;
}
}
.footer-actions {
@@ -179,10 +191,12 @@ function handleUse() {
justify-content: space-between;
align-items: center;
width: 100%;
flex-wrap: wrap;
gap: 8px;
.left-actions {
display: flex;
gap: 8px;
gap: 4px;
}
.right-actions {
@@ -191,4 +205,3 @@ function handleUse() {
}
}
</style>

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { ChatMessageApi } from '@/api/chat'
import useVoiceText from '@/hooks/web/useVoiceText'
import { streamChat } from '@/utils/streamChat'
@@ -18,17 +18,17 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
row._analyzing = true
message.info('正在获取音频转写...')
toast.info('正在获取音频转写...')
const transcriptions = await getVoiceText([row])
row.transcriptions = transcriptions.find(item => item.audio_url === row.audio_url)?.value
if (!row.transcriptions?.trim()) {
message.warning('未提取到语音内容,请检查音频文件或稍后重试')
toast.warning('未提取到语音内容,请检查音频文件或稍后重试')
row._analyzing = false
return false
}
message.info('正在创建对话...')
toast.info('正在创建对话...')
const createPayload = { roleId: 20, role_id: 20 }
console.debug('createChatConversationMy payload:', createPayload)
const conversationResp = await ChatMessageApi.createChatConversationMy(createPayload)
@@ -41,7 +41,7 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
throw new Error('创建对话失败:未获取到 conversationId')
}
message.info('正在生成提示词...')
toast.info('正在生成提示词...')
const content = buildPromptFromTranscription(row.transcriptions)
const index = data.value.findIndex(item => item.id === row.id)
const aiContent = await streamChat({
@@ -65,11 +65,11 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
await saveTableDataToSession()
message.success('分析完成')
toast.success('分析完成')
return true
} catch (error) {
console.error('分析视频失败:', error)
message.error('分析失败,请稍后重试')
toast.error('分析失败,请稍后重试')
return false
} finally {
row._analyzing = false
@@ -78,7 +78,7 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
async function batchAnalyze(selectedRowKeys, onBatchComplete) {
if (!selectedRowKeys.value.length) {
message.warning('请先选择要分析的视频')
toast.warning('请先选择要分析的视频')
return
}
@@ -104,7 +104,7 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
}
if (!allTexts.length) {
message.warning('未提取到任何语音内容,请检查音频文件或稍后重试')
toast.warning('未提取到任何语音内容,请检查音频文件或稍后重试')
batchAnalyzeLoading.value = false
globalLoading.value = false
globalLoadingText.value = ''
@@ -119,7 +119,7 @@ export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSessi
}
} catch (error) {
console.error('批量分析失败:', error)
message.error('批量分析失败,请稍后重试')
toast.error('批量分析失败,请稍后重试')
} finally {
batchAnalyzeLoading.value = false
globalLoading.value = false

View File

@@ -95,19 +95,19 @@
<!-- 识别状态 -->
<div v-if="store.videoStep !== 'idle'" class="process-status" :class="store.videoStep">
<div v-if="store.videoStep === 'uploading'" class="status-row">
<a-spin size="small" />
<span class="custom-spinner"></span>
<span>正在上传视频...</span>
</div>
<div v-else-if="store.videoStep === 'recognizing'" class="status-row">
<a-spin size="small" />
<span class="custom-spinner"></span>
<span>正在识别人脸...</span>
</div>
<div v-else-if="store.videoStep === 'recognized'" class="status-row success">
<CheckCircleOutlined />
<Icon icon="lucide:check-circle" />
<span>识别成功 · 人脸时长 {{ formatDurationMs(store.faceDurationMs) }}</span>
</div>
<div v-else-if="store.videoStep === 'error'" class="status-row error">
<ExclamationCircleOutlined />
<Icon icon="lucide:alert-circle" />
<span>{{ store.error }}</span>
<button class="link-btn" @click="store.retry">重试</button>
</div>
@@ -168,18 +168,16 @@
<div class="label-row">
<label class="input-label">播报文案</label>
<button class="generate-text-btn" @click="openTextGeneratePopup">
<EditOutlined />
<Icon icon="lucide:edit-3" />
<span>AI 生成</span>
</button>
</div>
<a-textarea
v-model:value="store.text"
<Textarea
v-model="store.text"
:placeholder="placeholder"
:rows="6"
:maxlength="4000"
:show-count="true"
class="notion-textarea"
:bordered="false"
/>
<div class="input-footer">
<span>{{ store.text?.length || 0 }} </span>
@@ -207,12 +205,11 @@
<div class="input-section">
<label class="input-label">语速调节</label>
<div class="rate-control">
<a-slider
v-model:value="store.speechRate"
<Slider
v-model="store.speechRate"
:min="0.5"
:max="2.0"
:step="0.1"
:marks="rateMarks"
class="rate-slider"
/>
<span class="rate-value">{{ store.speechRate.toFixed(1) }}x</span>
@@ -241,23 +238,14 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Icon } from '@iconify/vue'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
import VideoSelector from '@/components/VideoSelector.vue'
import VoiceSelector from '@/components/VoiceSelector.vue'
import TimelinePanel from './components/TimelinePanel.vue'
import TextGeneratePopup from './components/TextGeneratePopup.vue'
import {
CloudUploadOutlined,
FolderOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
PlayCircleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
EditOutlined,
} from '@ant-design/icons-vue'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { useDigitalHumanStore } from './stores/useDigitalHumanStore'
import { useUserStore } from '@/stores/user'
import { usePointsConfigStore } from '@/stores/pointsConfig'
@@ -707,6 +695,21 @@ onMounted(async () => {
}
}
.custom-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(59, 130, 246, 0.2);
border-top-color: @accent-blue;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.link-btn {
margin-left: auto;
padding: 0;

View File

@@ -8,7 +8,7 @@
<!-- 生成摘要 -->
<div class="generate-summary">
<div class="summary-item">
<VideoCameraOutlined class="summary-icon" />
<Icon icon="lucide:video" class="summary-icon" />
<div class="summary-content">
<span class="summary-label">视频素材</span>
<span class="summary-value">{{ videoName }}</span>
@@ -16,7 +16,7 @@
</div>
<div class="summary-item">
<SoundOutlined class="summary-icon" />
<Icon icon="lucide:volume-2" class="summary-icon" />
<div class="summary-content">
<span class="summary-label">配音音色</span>
<span class="summary-value">{{ store.voice?.name || '未选择' }}</span>
@@ -24,7 +24,7 @@
</div>
<div class="summary-item">
<FileTextOutlined class="summary-icon" />
<Icon icon="lucide:file-text" class="summary-icon" />
<div class="summary-content">
<span class="summary-label">文案字数</span>
<span class="summary-value">{{ store.text?.length || 0 }} </span>
@@ -32,7 +32,7 @@
</div>
<div class="summary-item">
<ClockCircleOutlined class="summary-icon" />
<Icon icon="lucide:clock" class="summary-icon" />
<div class="summary-content">
<span class="summary-label">人脸时长</span>
<span class="summary-value">{{ formatDurationMs(store.faceDurationMs) }}</span>
@@ -40,7 +40,7 @@
</div>
<div class="summary-item">
<AudioOutlined class="summary-icon" />
<Icon icon="lucide:music" class="summary-icon" />
<div class="summary-content">
<span class="summary-label">音频时长</span>
<span class="summary-value">{{ formatDurationMs(store.audioDurationMs) }}</span>
@@ -72,64 +72,55 @@
<!-- 生成按钮 -->
<div class="action-section">
<a-button
<Button
v-if="!store.isDone"
type="primary"
size="large"
size="lg"
:disabled="!store.canGenerate"
:loading="store.createStep === 'creating'"
block
class="action-btn w-full"
@click="store.createTask"
class="action-btn"
>
<template v-if="store.createStep === 'creating'">
<Icon icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
正在创建任务...
</template>
<template v-else>
<PlayCircleOutlined /> 生成数字人视频
<Icon icon="lucide:play-circle" class="mr-2 h-4 w-4" />
生成数字人视频
</template>
</a-button>
</Button>
<!-- 成功状态 -->
<div v-if="store.isDone" class="success-result">
<CheckCircleFilled class="success-icon" />
<Icon icon="lucide:check-circle-2" class="success-icon" />
<h4>任务已提交成功</h4>
<p>请在任务中心查看生成进度</p>
<a-button type="primary" @click="store.reset">
<Button @click="store.reset">
重新生成
</a-button>
</Button>
</div>
<!-- 错误状态 -->
<div v-if="store.createStep === 'error'" class="error-result">
<ExclamationCircleFilled class="error-icon" />
<Icon icon="lucide:alert-circle" class="error-icon" />
<span>{{ store.error }}</span>
<a-button type="link" @click="store.retry">重试</a-button>
<Button variant="link" @click="store.retry">重试</Button>
</div>
</div>
<!-- 导航按钮 -->
<div v-if="!store.isDone" class="nav-buttons">
<a-button size="large" @click="store.goPrevPhase">
<LeftOutlined /> 上一步
</a-button>
<Button variant="outline" size="lg" @click="store.goPrevPhase">
<Icon icon="lucide:chevron-left" class="mr-1 h-4 w-4" />
上一步
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
VideoCameraOutlined,
SoundOutlined,
FileTextOutlined,
ClockCircleOutlined,
AudioOutlined,
PlayCircleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
LeftOutlined,
} from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import TimelinePanel from './TimelinePanel.vue'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
import { useUserStore } from '@/stores/user'

View File

@@ -12,7 +12,7 @@
@click="handleNavigate(s.key)"
>
<div class="step-number">
<CheckOutlined v-if="isStepDone(s.key)" />
<Icon v-if="isStepDone(s.key)" icon="lucide:check" />
<span v-else>{{ idx + 1 }}</span>
</div>
<span class="step-label">{{ s.label }}</span>
@@ -21,7 +21,7 @@
</template>
<script setup lang="ts">
import { CheckOutlined } from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import type { PipelinePhase } from '../types/identify-face'
interface Props {

View File

@@ -16,7 +16,7 @@
<div class="popover-header">
<span class="popover-title">AI 文案生成</span>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
<Icon icon="lucide:x" class="w-3.5 h-3.5" />
</button>
</div>
@@ -25,25 +25,23 @@
<!-- 智能体选择 -->
<div class="form-item">
<label class="form-label">选择智能体</label>
<a-select
v-model:value="selectedAgentId"
:loading="loadingAgents"
placeholder="请选择智能体"
class="agent-select"
:bordered="false"
size="small"
>
<a-select-option
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</a-select-option>
</a-select>
<Select v-model="selectedAgentId" :disabled="loadingAgents" class="agent-select">
<SelectTrigger class="agent-select-trigger">
<SelectValue :placeholder="loadingAgents ? '加载中...' : '请选择智能体'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 主题输入 -->
@@ -88,8 +86,15 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAgentList, sendChatStream } from '@/api/agent'
// Props
@@ -176,7 +181,7 @@ const handleGenerate = async () => {
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value)
if (!selectedAgent) {
message.warning('请选择智能体')
toast.warning('请选择智能体')
return
}
@@ -200,12 +205,12 @@ const handleGenerate = async () => {
if (result.event === 'message' && result.content) {
generatedText.value += result.content
} else if (result.event === 'error') {
message.error(result.errorMessage || '生成出错')
toast.error(result.errorMessage || '生成出错')
isGenerating.value = false
}
},
onError: () => {
message.error('生成失败,请重试')
toast.error('生成失败,请重试')
isGenerating.value = false
},
onClose: () => {
@@ -218,7 +223,7 @@ const handleGenerate = async () => {
})
} catch (error: any) {
if (error.name !== 'AbortError') {
message.error('生成失败')
toast.error('生成失败')
}
isGenerating.value = false
}

View File

@@ -74,21 +74,21 @@
<!-- 校验错误提示 -->
<div v-if="validationError" class="timeline-diff error">
<CloseCircleOutlined class="diff-icon" />
<Icon icon="lucide:x-circle" class="diff-icon" />
<span>{{ validationError }}</span>
</div>
<!-- 时长差异提示 -->
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<template v-if="diffStatus === 'match'">
<CheckCircleOutlined class="diff-icon" />
<Icon icon="lucide:check-circle" class="diff-icon" />
<span>时长匹配良好可以生成</span>
</template>
<template v-else-if="diffStatus === 'exceed'">
<ExclamationCircleOutlined class="diff-icon" />
<Icon icon="lucide:alert-circle" class="diff-icon" />
<span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span>
</template>
<template v-else-if="diffStatus === 'short'">
<InfoCircleOutlined class="diff-icon" />
<Icon icon="lucide:info" class="diff-icon" />
<span>音频较短可适当增加文案</span>
</template>
</div>
@@ -97,7 +97,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { formatDurationMs } from '../utils/format'
interface Props {

View File

@@ -13,7 +13,7 @@
@click="store.selectUploadMode"
>
<div class="option-icon">
<CloudUploadOutlined />
<Icon icon="lucide:cloud-upload" />
</div>
<div class="option-content">
<h4>上传新视频</h4>
@@ -27,7 +27,7 @@
@click="store.selectLibraryMode"
>
<div class="option-icon">
<FolderOutlined />
<Icon icon="lucide:folder" />
</div>
<div class="option-content">
<h4>从素材库选择</h4>
@@ -54,7 +54,7 @@
/>
<div v-if="!store.videoPreviewUrl" class="upload-placeholder" @click="triggerFileSelect">
<CloudUploadOutlined class="upload-icon" />
<Icon icon="lucide:cloud-upload" class="upload-icon" />
<span class="upload-text">点击上传新视频</span>
<span class="upload-hint">支持 MP4MOV 格式 >3</span>
<button class="select-file-btn" @click.stop="triggerFileSelect">选择文件</button>
@@ -97,43 +97,38 @@
<!-- 识别状态 -->
<div v-if="store.videoStep !== 'idle'" class="recognize-status" :class="store.videoStep">
<div v-if="store.videoStep === 'uploading'" class="status-content loading">
<a-spin size="small" />
<span class="custom-spinner"></span>
<span>正在上传视频...</span>
</div>
<div v-else-if="store.videoStep === 'recognizing'" class="status-content loading">
<a-spin size="small" />
<span class="custom-spinner"></span>
<span>正在识别人脸...</span>
</div>
<div v-else-if="store.videoStep === 'recognized'" class="status-content success">
<CheckCircleOutlined />
<Icon icon="lucide:check-circle" />
<span>识别成功人脸时长: {{ formatDurationMs(store.faceDurationMs) }}</span>
</div>
<div v-else-if="store.videoStep === 'error'" class="status-content error">
<ExclamationCircleOutlined />
<Icon icon="lucide:alert-circle" />
<span>{{ store.error }}</span>
<a-button size="small" type="link" @click="store.retry">重试</a-button>
<Button variant="link" size="sm" @click="store.retry">重试</Button>
</div>
</div>
<!-- 下一步按钮 -->
<div v-if="store.isVideoReady" class="step-actions">
<a-button type="primary" size="large" block @click="store.goNextPhase">
<Button size="lg" class="w-full" @click="store.goNextPhase">
下一步添加配音
<RightOutlined />
</a-button>
<Icon icon="lucide:chevron-right" class="ml-1 h-4 w-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
CloudUploadOutlined,
FolderOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
import { formatDuration, formatDurationMs, formatFileSize } from '../utils/format'

View File

@@ -8,14 +8,12 @@
<!-- 文案输入 -->
<div class="text-section">
<label class="section-label">播报文案</label>
<a-textarea
v-model:value="store.text"
<Textarea
v-model="store.text"
:placeholder="placeholder"
:rows="4"
:maxlength="4000"
:show-count="true"
class="text-input"
:bordered="false"
/>
<div class="text-meta">
<span>当前字数{{ store.text?.length || 0 }} </span>
@@ -37,12 +35,11 @@
<div class="rate-section">
<label class="section-label">语速调节</label>
<div class="rate-control">
<a-slider
v-model:value="store.speechRate"
<Slider
v-model="store.speechRate"
:min="0.5"
:max="2.0"
:step="0.1"
:marks="rateMarks"
class="rate-slider"
/>
<span class="rate-value">{{ store.speechRate.toFixed(1) }}x</span>
@@ -61,62 +58,62 @@
<!-- 生成音频按钮 -->
<div class="action-section">
<a-button
type="primary"
size="large"
:loading="store.audioStep === 'generating'"
<Button
size="lg"
:disabled="!canGenerateAudio"
block
class="action-btn w-full"
@click="store.generateAudio"
class="action-btn"
>
<template v-if="store.audioStep === 'generated'">
<ReloadOutlined /> 重新生成音频
<template v-if="store.audioStep === 'generating'">
<Icon icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
正在生成...
</template>
<template v-else-if="store.audioStep === 'generated'">
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
重新生成音频
</template>
<template v-else>
<SoundOutlined /> 生成音频
<Icon icon="lucide:volume-2" class="mr-2 h-4 w-4" />
生成音频
</template>
</a-button>
</Button>
<div v-if="store.audioStep === 'generated'" class="audio-result">
<CheckCircleOutlined class="success-icon" />
<Icon icon="lucide:check-circle-2" class="success-icon" />
<span>音频已生成时长: {{ formatDurationMs(store.audioDurationMs) }}</span>
</div>
<div v-if="store.audioStep === 'error'" class="audio-error">
<ExclamationCircleOutlined class="error-icon" />
<Icon icon="lucide:alert-circle" class="error-icon" />
<span>{{ store.error }}</span>
<a-button size="small" type="link" @click="store.retry">重试</a-button>
<Button variant="link" size="sm" @click="store.retry">重试</Button>
</div>
</div>
<!-- 导航按钮 -->
<div v-if="store.isAudioReady" class="nav-buttons">
<a-button size="large" @click="store.goPrevPhase">
<LeftOutlined /> 上一步
</a-button>
<a-button
type="primary"
size="large"
<Button variant="outline" size="lg" @click="store.goPrevPhase">
<Icon icon="lucide:chevron-left" class="mr-1 h-4 w-4" />
上一步
</Button>
<Button
size="lg"
:disabled="!store.canGenerate"
@click="store.goNextPhase"
>
下一步生成视频 <RightOutlined />
</a-button>
下一步生成视频
<Icon icon="lucide:chevron-right" class="ml-1 h-4 w-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
SoundOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import VoiceSelector from '@/components/VoiceSelector.vue'
import TimelinePanel from './TimelinePanel.vue'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'

View File

@@ -9,7 +9,7 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { VoiceService } from '@/api/voice'
import { identifyFace, createLipSyncTask } from '@/api/kling'
import { MaterialService } from '@/api/material'
@@ -227,7 +227,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 处理文件上传(上传后自动识别) */
async function handleFileUpload(file: File) {
if (!file.name.match(/\.(mp4|mov)$/i)) {
message.error('仅支持 MP4 和 MOV 格式')
toast.error('仅支持 MP4 和 MOV 格式')
return
}
@@ -262,7 +262,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
} catch (err: any) {
videoStep.value = 'error'
error.value = err.message || '获取播放链接失败'
message.error(error.value)
toast.error(error.value)
return
}
@@ -302,12 +302,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
faceEndTime: result.faceEndTime,
}
message.success('人脸识别成功')
toast.success('人脸识别成功')
} catch (err: any) {
videoStep.value = 'error'
error.value = err.message || '识别失败'
message.error(error.value)
toast.error(error.value)
}
}
@@ -359,15 +359,15 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 生成音频步骤2核心方法 */
async function generateAudio() {
if (!text.value.trim()) {
message.warning('请输入文案内容')
toast.warning('请输入文案内容')
return
}
if (!voice.value) {
message.warning('请选择音色')
toast.warning('请选择音色')
return
}
if (!isVideoReady.value) {
message.warning('请先完成视频识别')
toast.warning('请先完成视频识别')
return
}
@@ -412,15 +412,15 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
// 检查时长是否匹配
if (durationMs > faceDurationMs.value) {
message.warning(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长,请缩短文案`)
toast.warning(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长,请缩短文案`)
} else {
message.success('音频生成成功')
toast.success('音频生成成功')
}
} catch (err: any) {
audioStep.value = 'error'
error.value = err.message || '音频生成失败'
message.error(error.value)
toast.error(error.value)
}
}
@@ -429,17 +429,17 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 创建任务步骤3核心方法 */
async function createTask() {
if (!identifyData.value) {
message.warning('请先完成视频识别')
toast.warning('请先完成视频识别')
return
}
if (!audioData.value) {
message.warning('请先生成音频')
toast.warning('请先生成音频')
return
}
// 时长校验
if (audioData.value.durationMs > faceDurationMs.value) {
message.error('音频时长超过人脸时长,请缩短文案后重试')
toast.error('音频时长超过人脸时长,请缩短文案后重试')
return
}
@@ -475,12 +475,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
}
createStep.value = 'done'
message.success('任务已提交,请在任务中心查看生成进度')
toast.success('任务已提交,请在任务中心查看生成进度')
} catch (err: any) {
createStep.value = 'error'
error.value = err.message || '任务创建失败'
message.error(error.value)
toast.error(error.value)
}
}

View File

@@ -1,5 +1,5 @@
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
/**
* 任务列表通用逻辑 Composable
@@ -89,11 +89,11 @@ export function useTaskList(fetchApi, options = {}) {
total.value = res.data.total || 0
paginationConfig.total = 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
}

View File

@@ -1,4 +1,5 @@
import { message, Modal } from 'ant-design-vue'
import { toast } from 'vue-sonner'
import { confirmDialog } from '@/utils/confirmDialog'
// 配置常量
const API_SUCCESS_CODE = 0
@@ -44,78 +45,95 @@ export function useTaskOperations(apiHandlers, onSuccess) {
getSignedUrlsApi
} = apiHandlers
// 通用模态框确认
function confirmModal({ title, content, okType = 'primary', onOk }) {
Modal.confirm({
title,
content,
okText: '确认',
cancelText: '取消',
okType,
onOk: async () => {
try {
await onOk()
onSuccess && onSuccess()
} catch (error) {
message.error('操作失败')
}
}
})
}
// 执行 API 操作并显示成功消息
async function executeApiOperation(apiFn, successMessage) {
await apiFn()
message.success(successMessage)
toast.success(successMessage)
}
// 删除任务
function deleteTask(id) {
confirmModal({
async function deleteTask(id) {
const confirmed = await confirmDialog({
title: '确认删除',
content: '确定删除这个任务吗?删除后无法恢复。',
okType: 'danger',
onOk: () => executeApiOperation(() => deleteApi(id), '删除成功')
okText: '确认',
cancelText: '取消'
})
if (confirmed) {
try {
await executeApiOperation(() => deleteApi(id), '删除成功')
onSuccess && onSuccess()
} catch (error) {
toast.error('操作失败')
}
}
}
// 取消任务
function cancelTask(id) {
confirmModal({
async function cancelTask(id) {
const confirmed = await confirmDialog({
title: '确认取消',
content: '确定要取消这个任务吗?',
onOk: () => executeApiOperation(() => cancelApi(id), '已取消任务')
okText: '确认',
cancelText: '取消'
})
if (confirmed) {
try {
await executeApiOperation(() => cancelApi(id), '已取消任务')
onSuccess && onSuccess()
} catch (error) {
toast.error('操作失败')
}
}
}
// 重试任务
function retryTask(id) {
confirmModal({
async function retryTask(id) {
const confirmed = await confirmDialog({
title: '确认重试',
content: '确定要重新生成这个任务吗?',
onOk: () => executeApiOperation(() => retryApi(id), '已重新提交任务')
okText: '确认',
cancelText: '取消'
})
if (confirmed) {
try {
await executeApiOperation(() => retryApi(id), '已重新提交任务')
onSuccess && onSuccess()
} catch (error) {
toast.error('操作失败')
}
}
}
// 批量删除
async function batchDeleteTasks(ids, deleteApiFn) {
if (!ids || ids.length === 0) {
message.warning('请选择要删除的任务')
toast.warning('请选择要删除的任务')
return
}
confirmModal({
const confirmed = await confirmDialog({
title: '确认批量删除',
content: `确定要删除选中的 ${ids.length} 个任务吗?删除后无法恢复。`,
okType: 'danger',
onOk: async () => {
okText: '确认',
cancelText: '取消'
})
if (confirmed) {
try {
const deleteFn = deleteApiFn || deleteApi
for (const id of ids) {
await deleteFn(id)
}
message.success(`成功删除 ${ids.length} 个任务`)
toast.success(`成功删除 ${ids.length} 个任务`)
onSuccess && onSuccess()
} catch (error) {
toast.error('操作失败')
}
})
}
}
// 下载单个文件(使用 fetch + blob 强制下载)
@@ -151,7 +169,8 @@ export function useTaskOperations(apiHandlers, onSuccess) {
// 批量下载
async function batchDownload(urls, getSignedUrlsApi, taskId) {
try {
message.loading('正在准备下载...', 0)
toast.loading('正在准备下载...')
let downloadUrls = urls
// 如果需要获取签名URL
@@ -161,12 +180,11 @@ export function useTaskOperations(apiHandlers, onSuccess) {
// 检查下载链接
if (!downloadUrls || downloadUrls.length === 0) {
message.warning('没有可下载的文件')
toast.warning('没有可下载的文件')
return
}
message.destroy()
message.loading(`正在下载 ${downloadUrls.length} 个文件...`, 0)
toast.loading(`正在下载 ${downloadUrls.length} 个文件...`)
// 逐个下载文件
for (let i = 0; i < downloadUrls.length; i++) {
@@ -179,11 +197,9 @@ export function useTaskOperations(apiHandlers, onSuccess) {
}
}
message.destroy()
message.success(`成功下载 ${downloadUrls.length} 个文件`)
toast.success(`成功下载 ${downloadUrls.length} 个文件`)
} catch (error) {
message.destroy()
message.error('下载失败,请稍后重试')
toast.error('下载失败,请稍后重试')
}
}
@@ -194,10 +210,10 @@ export function useTaskOperations(apiHandlers, onSuccess) {
if (res.code === API_SUCCESS_CODE && res.data && res.data[index]) {
return res.data[index]
}
message.warning('获取预览链接失败')
toast.warning('获取预览链接失败')
return null
} catch (error) {
message.error('获取预览链接失败')
toast.error('获取预览链接失败')
return null
}
}
@@ -205,16 +221,14 @@ export function useTaskOperations(apiHandlers, onSuccess) {
// 预览文件
async function previewFile(getSignedUrlsApi, taskId, index) {
try {
message.loading('正在获取预览链接...', 0)
toast.loading('正在获取预览链接...')
const url = await getSignedUrl(getSignedUrlsApi, taskId, index)
message.destroy()
if (url) {
window.open(url, '_blank')
}
} catch (error) {
message.destroy()
message.error('预览失败')
toast.error('预览失败')
}
}

View File

@@ -1,20 +1,22 @@
<template>
<div class="task-layout">
<!-- 顶部Tab栏 -->
<!-- 顶部Tab栏 - 现代化设计 -->
<div class="task-layout__header">
<Tabs v-model:model-value="currentType" class="w-full">
<TabsList class="h-12 bg-transparent p-0 gap-1">
<TabsTrigger
v-for="item in NAV_ITEMS"
:key="item.type"
:value="item.type"
class="h-10 px-4 gap-2 rounded-lg transition-all data-[state=active]:bg-foreground data-[state=active]:!text-white data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=inactive]:hover:bg-muted"
>
<Icon :icon="item.icon" class="size-4" />
<span>{{ item.label }}</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div class="flex items-center justify-between">
<Tabs v-model:model-value="currentType" class="w-auto">
<TabsList class="h-11 bg-muted/50 p-1 gap-1">
<TabsTrigger
v-for="item in NAV_ITEMS"
:key="item.type"
:value="item.type"
class="h-9 px-4 gap-2 rounded-md transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground"
>
<Icon :icon="item.icon" class="size-4" />
<span class="font-medium">{{ item.label }}</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<!-- 内容区 -->
@@ -71,21 +73,23 @@ const currentComponent = computed(() => {
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
gap: var(--space-4);
background: transparent;
overflow: hidden;
}
.task-layout__header {
flex-shrink: 0;
padding: 0 var(--space-4);
background: var(--color-bg-card);
padding: var(--space-4) var(--space-6);
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.task-layout__content {
flex: 1;
overflow: auto;
min-height: 0;
}
.fade-enter-active,

View File

@@ -620,7 +620,7 @@ onMounted(fetchList)
<style scoped lang="less">
.task-page {
padding: var(--space-4);
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
@@ -628,29 +628,35 @@ onMounted(fetchList)
}
.task-page__filters {
padding: var(--space-4);
background: var(--color-bg-card);
flex-shrink: 0;
padding: var(--space-5);
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.task-page__content {
flex: 1;
overflow: hidden;
background: var(--color-bg-card);
background: var(--card);
border-radius: var(--radius-lg);
padding: var(--space-4);
border: 1px solid var(--border);
padding: var(--space-5);
display: flex;
flex-direction: column;
}
.batch-toolbar {
flex-shrink: 0;
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-4);
}
.expanded-content {
padding: var(--space-4);
background: var(--color-gray-50);
border-radius: var(--radius-md);
padding: var(--space-5);
background: var(--muted);
border-radius: var(--radius);
margin: var(--space-2);
}
@@ -659,10 +665,10 @@ onMounted(fetchList)
p {
margin: var(--space-2) 0 0;
padding: var(--space-3);
background: var(--color-gray-100);
border-radius: var(--radius-md);
line-height: 1.5;
padding: var(--space-4);
background: var(--muted);
border-radius: var(--radius);
line-height: 1.6;
}
}
@@ -673,19 +679,19 @@ onMounted(fetchList)
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
margin-bottom: var(--space-3);
}
.result-count {
font-size: var(--font-size-xs);
color: var(--color-gray-500);
color: var(--muted-foreground);
}
.result-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
gap: var(--space-3);
margin-top: var(--space-3);
}
.result-item {
@@ -693,12 +699,14 @@ onMounted(fetchList)
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-gray-100);
border-radius: var(--radius-md);
transition: box-shadow var(--duration-fast) ease;
background: var(--muted);
border-radius: var(--radius);
border: 1px solid var(--border);
transition: all var(--duration-fast);
&:hover {
box-shadow: var(--shadow-sm);
border-color: var(--primary);
}
}
}

View File

@@ -1,17 +1,10 @@
<script setup>
import { computed, onMounted, ref, reactive } from 'vue'
import { Icon } from '@iconify/vue'
import { useUserStore } from '@/stores/user'
import { getPointRecordPage } from '@/api/pointRecord'
import {
UserOutlined,
DatabaseOutlined,
WalletOutlined,
PayCircleOutlined,
ClockCircleOutlined,
SafetyCertificateOutlined,
PlusOutlined,
MinusOutlined
} from '@ant-design/icons-vue'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
const userStore = useUserStore()
@@ -87,12 +80,16 @@ async function fetchPointRecords() {
}
// 分页变化
function handleTableChange(page, pageSize) {
function handlePageChange(page) {
recordsPagination.current = page
recordsPagination.pageSize = pageSize
fetchPointRecords()
}
// 计算总页数
const totalPages = computed(() => {
return Math.ceil(recordsPagination.total / recordsPagination.pageSize)
})
// 格式化积分记录时间
function formatRecordTime(dateStr) {
if (!dateStr) return ''
@@ -154,17 +151,17 @@ onMounted(async () => {
<p class="page-subtitle">管理您的账户信息和资源使用情况</p>
</div>
<a-row :gutter="[24, 24]">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- 左侧用户信息卡片 -->
<a-col :xs="24" :lg="8">
<a-card class="user-card" :bordered="false">
<div class="lg:col-span-4">
<div class="user-card">
<div class="user-info-header">
<div class="avatar-wrapper">
<img
v-if="userStore.displayAvatar"
class="user-avatar"
:src="userStore.displayAvatar"
alt="avatar"
<img
v-if="userStore.displayAvatar"
class="user-avatar"
:src="userStore.displayAvatar"
alt="avatar"
/>
<div v-else class="user-avatar-placeholder">
{{ userStore.displayName?.charAt(0) || 'U' }}
@@ -173,9 +170,9 @@ onMounted(async () => {
<h2 class="user-name">{{ userStore.displayName }}</h2>
<div class="user-role-badge">普通用户</div>
</div>
<a-divider />
<div class="divider"></div>
<div class="user-details">
<div class="detail-item">
<span class="detail-label">注册时间</span>
@@ -186,117 +183,117 @@ onMounted(async () => {
<span class="detail-value">{{ maskMobile(userStore.mobile || userStore.profile?.mobile) }}</span>
</div>
</div>
</a-card>
</a-col>
</div>
</div>
<!-- 右侧资源统计与活动 -->
<a-col :xs="24" :lg="16">
<div class="lg:col-span-8">
<!-- 资源概览卡片 -->
<a-row :gutter="[24, 24]">
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper blue">
<DatabaseOutlined />
</div>
<div class="stat-content">
<div class="stat-label">存储空间</div>
<div class="stat-value">{{ formatStorage(usedStorage) }} <span class="stat-unit">/ {{ formatStorage(totalStorage) }}</span></div>
<a-progress
:percent="storagePercent"
:show-info="false"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
size="small"
class="stat-progress"
/>
</div>
</a-card>
</a-col>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<!-- 存储空间 -->
<div class="stat-card">
<div class="stat-icon-wrapper blue">
<Icon icon="lucide:database" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">存储空间</div>
<div class="stat-value">{{ formatStorage(usedStorage) }} <span class="stat-unit">/ {{ formatStorage(totalStorage) }}</span></div>
<Progress :value="storagePercent" class="stat-progress h-2" />
</div>
</div>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper purple">
<WalletOutlined />
</div>
<div class="stat-content">
<div class="stat-label">剩余积分</div>
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
<div class="stat-desc">用于生成消耗</div>
</div>
</a-card>
</a-col>
<!-- 剩余积分 -->
<div class="stat-card">
<div class="stat-icon-wrapper purple">
<Icon icon="lucide:wallet" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">剩余积分</div>
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
<div class="stat-desc">用于生成消耗</div>
</div>
</div>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper orange">
<PayCircleOutlined />
</div>
<div class="stat-content">
<div class="stat-label">账户余额</div>
<div class="stat-value">¥{{ formatMoney(userStore.totalRecharge) }}</div>
<div class="stat-desc">累计充值金额</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 账户余额 -->
<div class="stat-card">
<div class="stat-icon-wrapper orange">
<Icon icon="lucide:coins" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">账户余额</div>
<div class="stat-value">¥{{ formatMoney(userStore.totalRecharge) }}</div>
<div class="stat-desc">累计充值金额</div>
</div>
</div>
</div>
<!-- 积分记录 -->
<a-card title="积分记录" :bordered="false" class="activity-card mt-6">
<template #extra>
<div class="activity-card mt-6">
<div class="activity-header">
<h3 class="activity-title">积分记录</h3>
<span class="record-count"> {{ recordsPagination.total }} 条记录</span>
</template>
<a-spin :spinning="recordsLoading">
<a-list
v-if="pointRecords.length > 0"
item-layout="horizontal"
:data-source="pointRecords"
class="point-record-list"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<div :class="['record-icon', item.type === 'increase' ? 'increase' : 'decrease']">
<PlusOutlined v-if="item.type === 'increase'" />
<MinusOutlined v-else />
</div>
</template>
<template #title>
<div class="record-title">
<span class="record-reason">{{ getBizTypeName(item.bizType) }}</span>
</div>
</template>
<template #description>
<div class="record-desc">
<span>{{ formatRecordTime(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<div :class="['record-amount', item.type === 'increase' ? 'increase' : 'decrease']">
{{ item.type === 'increase' ? '+' : '-' }}{{ Math.abs(item.pointAmount) }}
</div>
</a-list-item>
</template>
</a-list>
<a-pagination
v-if="recordsPagination.total > recordsPagination.pageSize"
v-model:current="recordsPagination.current"
v-model:page-size="recordsPagination.pageSize"
:total="recordsPagination.total"
:show-size-changer="false"
:show-total="total => `${total}`"
size="small"
class="record-pagination"
@change="handleTableChange"
/>
<div v-if="pointRecords.length === 0 && !recordsLoading" class="empty-state">
<ClockCircleOutlined class="empty-icon" />
<p>暂无积分记录</p>
</div>
</a-spin>
</a-card>
</div>
</a-col>
</a-row>
<!-- 加载状态 -->
<div v-if="recordsLoading" class="loading-state">
<span class="custom-spinner"></span>
<span>加载中...</span>
</div>
<!-- 积分记录列表 -->
<div v-else-if="pointRecords.length > 0" class="point-record-list">
<div
v-for="item in pointRecords"
:key="item.id"
class="record-item"
>
<div class="record-left">
<div :class="['record-icon', item.type === 'increase' ? 'increase' : 'decrease']">
<Icon v-if="item.type === 'increase'" icon="lucide:plus" />
<Icon v-else icon="lucide:minus" />
</div>
<div class="record-info">
<div class="record-reason">{{ getBizTypeName(item.bizType) }}</div>
<div class="record-time">{{ formatRecordTime(item.createTime) }}</div>
</div>
</div>
<div :class="['record-amount', item.type === 'increase' ? 'increase' : 'decrease']">
{{ item.type === 'increase' ? '+' : '-' }}{{ Math.abs(item.pointAmount) }}
</div>
</div>
<!-- 分页 -->
<div v-if="recordsPagination.total > recordsPagination.pageSize" class="record-pagination">
<Button
variant="outline"
size="sm"
:disabled="recordsPagination.current === 1"
@click="handlePageChange(recordsPagination.current - 1)"
>
上一页
</Button>
<span class="page-info">
{{ recordsPagination.current }} / {{ totalPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="recordsPagination.current >= totalPages"
@click="handlePageChange(recordsPagination.current + 1)"
>
下一页
</Button>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<Icon icon="lucide:clock" class="empty-icon" />
<p>暂无积分记录</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -330,6 +327,7 @@ onMounted(async () => {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
background: var(--color-bg-container, #fff);
height: 100%;
padding: 24px;
}
.user-info-header {
@@ -383,6 +381,12 @@ onMounted(async () => {
font-weight: 500;
}
.divider {
height: 1px;
background: var(--color-border-secondary, #f0f0f0);
margin: 16px 0;
}
.user-details {
padding: 16px 0;
}
@@ -411,7 +415,9 @@ onMounted(async () => {
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
transition: transform 0.2s;
background: var(--color-bg-container, #fff);
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
@@ -426,7 +432,6 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
@@ -477,6 +482,29 @@ onMounted(async () => {
margin-top: 24px;
}
/* Loading State */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 0;
color: var(--color-text-secondary);
}
.custom-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(24, 144, 255, 0.2);
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
@@ -490,16 +518,26 @@ onMounted(async () => {
opacity: 0.5;
}
.security-icon {
font-size: 24px;
color: #52c41a;
margin-right: 12px;
}
/* Point Record List */
.activity-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
background: var(--color-bg-container, #fff);
padding: 20px;
}
.activity-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.activity-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.record-count {
@@ -512,6 +550,24 @@ onMounted(async () => {
overflow-y: auto;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--color-border-secondary, #f0f0f0);
}
.record-item:last-child {
border-bottom: none;
}
.record-left {
display: flex;
align-items: center;
gap: 12px;
}
.record-icon {
width: 36px;
height: 36px;
@@ -532,30 +588,23 @@ onMounted(async () => {
color: #ff4d4f;
}
.record-title {
.record-info {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
gap: 2px;
}
.record-reason {
font-weight: 500;
color: var(--color-text);
font-size: 14px;
}
.record-desc {
display: flex;
gap: 12px;
.record-time {
font-size: 12px;
color: var(--color-text-secondary);
}
.record-biz-type {
padding: 1px 6px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
}
.record-amount {
font-size: 18px;
font-weight: 600;
@@ -570,7 +619,17 @@ onMounted(async () => {
}
.record-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
text-align: center;
padding-top: 16px;
border-top: 1px solid var(--color-border-secondary, #f0f0f0);
}
.page-info {
font-size: 14px;
color: var(--color-text-secondary);
}
</style>