feat: 样式升级

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { toast } from 'vue-sonner'
/**
* 任务列表通用逻辑 Composable
@@ -89,11 +89,11 @@ export function useTaskList(fetchApi, options = {}) {
total.value = res.data.total || 0
paginationConfig.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
toast.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载任务列表失败:', error)
message.error('加载失败,请重试')
toast.error('加载失败,请重试')
} finally {
loading.value = false
}

View File

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

View File

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

View File

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

View File

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