feat: 样式升级
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 淡入淡出动画 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</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 />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
63
frontend/app/web-gold/src/config/locale.js
Normal file
63
frontend/app/web-gold/src/config/locale.js
Normal 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
|
||||
}
|
||||
11
frontend/app/web-gold/src/env.d.ts
vendored
11
frontend/app/web-gold/src/env.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
63
frontend/app/web-gold/src/utils/confirmDialog.js
Normal file
63
frontend/app/web-gold/src/utils/confirmDialog.js
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">支持 MP4、MOV 格式(需 >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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user