feat: 样式升级
This commit is contained in:
@@ -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