Files
dy/frontend/src/views/Dashboard.vue
T
2026-07-23 17:56:25 +08:00

482 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import api from '../api'
import MessageBubble from '../components/MessageBubble.vue'
import {
UserOutlined,
MessageOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ArrowRightOutlined,
ThunderboltOutlined
} from '@ant-design/icons-vue'
const stats = ref({
totalAccounts: 0,
activeAccounts: 0,
totalMessages: 0,
repliedMessages: 0,
replyRate: '0%'
})
const recentLogs = ref([])
const loading = ref(true)
const fetchStats = async () => {
try {
const [accountsRes, statsRes] = await Promise.all([
api.get(`/accounts`),
api.get(`/logs/stats`)
])
const accounts = accountsRes.data
stats.value.totalAccounts = accounts.length
stats.value.activeAccounts = accounts.filter(a => a.status === 'online').length
// 后端全量统计所有消息,不受列表条数限制
stats.value.totalMessages = statsRes.data.total || 0
stats.value.repliedMessages = statsRes.data.replied || 0
if (stats.value.totalMessages > 0) {
stats.value.replyRate = ((stats.value.repliedMessages / stats.value.totalMessages) * 100).toFixed(1) + '%'
} else {
stats.value.replyRate = '0%'
}
} catch (error) {
console.error('获取仪表盘统计失败', error)
}
}
// 近期动态只在进入页面时加载一次,不自动刷新
const fetchRecentLogs = async () => {
try {
loading.value = true
const logsRes = await api.get(`/logs?limit=5`)
recentLogs.value = logsRes.data
} catch (error) {
console.error('获取近期动态失败', error)
} finally {
loading.value = false
}
}
let statsInterval = null
onMounted(() => {
fetchStats()
fetchRecentLogs()
// 统计卡片每10秒自动刷新一次
statsInterval = setInterval(fetchStats, 10000)
})
onUnmounted(() => {
if (statsInterval) clearInterval(statsInterval)
})
</script>
<template>
<div class="dashboard-container">
<!-- 头部欢迎卡片 -->
<div class="welcome-banner glass-card">
<div class="banner-content">
<h1 class="text-gradient" style="margin: 0 0 8px 0; font-size: 2rem;">欢迎使用抖音多账号客服系统</h1>
<p style="color: var(--text-secondary); font-size: 1rem;">
多账户自动回复RPA后台支持快捷扫码登录状态持久化保存以及自定义关键字规则精准答复
</p>
</div>
<div class="banner-icon">
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
</div>
</div>
<!-- 统计网格 -->
<a-row :gutter="[24, 24]" style="margin-top: 24px;">
<!-- 托管账号总数 -->
<a-col :xs="24" :sm="12" :lg="6">
<div class="glass-card stat-card border-purple">
<div class="stat-icon purple-glow">
<UserOutlined />
</div>
<div class="stat-info">
<span class="stat-label">托管账号</span>
<h2 class="stat-value">{{ stats.totalAccounts }}</h2>
</div>
</div>
</a-col>
<!-- 在线工作账号 -->
<a-col :xs="24" :sm="12" :lg="6">
<div class="glass-card stat-card border-green">
<div class="stat-icon green-glow">
<CheckCircleOutlined />
</div>
<div class="stat-info">
<span class="stat-label">在线运行</span>
<h2 class="stat-value text-green">{{ stats.activeAccounts }}</h2>
</div>
</div>
</a-col>
<!-- 处理消息数 -->
<a-col :xs="24" :sm="12" :lg="6">
<div class="glass-card stat-card border-blue">
<div class="stat-icon blue-glow">
<MessageOutlined />
</div>
<div class="stat-info">
<span class="stat-label">接收消息</span>
<h2 class="stat-value">{{ stats.totalMessages }}</h2>
</div>
</div>
</a-col>
<!-- 自动回复率 -->
<a-col :xs="24" :sm="12" :lg="6">
<div class="glass-card stat-card border-pink">
<div class="stat-icon pink-glow">
<ClockCircleOutlined />
</div>
<div class="stat-info">
<span class="stat-label">自动回复率</span>
<h2 class="stat-value text-pink">{{ stats.replyRate }}</h2>
</div>
</div>
</a-col>
</a-row>
<!-- 中部内容区 -->
<a-row :gutter="[24, 24]" style="margin-top: 24px;">
<!-- 最近动态 -->
<a-col :xs="24" :lg="16">
<div class="glass-card" style="height: 100%;">
<div class="card-header">
<h3>近期自动回复动态</h3>
<router-link to="/logs" class="view-all-link">
全部记录 <ArrowRightOutlined />
</router-link>
</div>
<a-list :loading="loading" :data-source="recentLogs" style="margin-top: 16px;">
<template #renderItem="{ item }">
<a-list-item class="log-item">
<div class="log-left">
<div class="log-dot" :class="item.status"></div>
<div class="log-details">
<div class="log-sender">
<strong>{{ item.sender_name }}</strong> 给账号 <strong>#{{ item.account_id }}</strong> 发送:
</div>
<div class="log-content">
<MessageBubble :content="item.message_content" compact />
</div>
<div v-if="item.reply_content" class="log-reply">
<span class="reply-tag">自动回复</span>
<MessageBubble :content="item.reply_content" compact />
</div>
</div>
</div>
<div class="log-right-info">
<span class="log-time">{{ new Date(item.created_at).toLocaleTimeString() }}</span>
<a-tag :color="item.status === 'replied' ? 'success' : item.status === 'ignored' ? 'default' : 'error'">
{{ item.status === 'replied' ? '已回复' : item.status === 'ignored' ? '已略过' : '失败' }}
</a-tag>
</div>
</a-list-item>
</template>
<template #empty>
<div class="empty-state">
<MessageOutlined style="font-size: 3rem; color: var(--text-muted); margin-bottom: 12px;" />
<p>暂无消息日志启动账号 RPA 并接收私信后会自动记录</p>
</div>
</template>
</a-list>
</div>
</a-col>
<!-- 快速导航与操作 -->
<a-col :xs="24" :lg="8">
<div class="glass-card" style="height: 100%;">
<h3>快捷导航</h3>
<div class="quick-actions-grid" style="margin-top: 20px;">
<router-link to="/accounts" class="quick-action-card">
<UserOutlined class="action-icon text-gradient" />
<span>账号配置</span>
<p>扫码登录并托管多个抖音账号</p>
</router-link>
<router-link to="/rules" class="quick-action-card">
<SettingOutlined class="action-icon text-gradient" />
<span>回复策略</span>
<p>自定义关键字匹配与自动回复模板</p>
</router-link>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<style scoped>
.welcome-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32px;
background: linear-gradient(135deg, rgba(20, 20, 30, 0.8) 0%, rgba(35, 20, 45, 0.8) 100%);
border-left: 4px solid var(--primary-color);
}
.stat-card {
display: flex;
align-items: center;
padding: 24px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-right: 20px;
}
.purple-glow {
background: rgba(170, 59, 255, 0.15);
color: #c084fc;
border: 1px solid rgba(170, 59, 255, 0.3);
}
.green-glow {
background: rgba(21, 200, 100, 0.15);
color: #22c55e;
border: 1px solid rgba(21, 200, 100, 0.3);
}
.blue-glow {
background: rgba(0, 170, 255, 0.15);
color: #38bdf8;
border: 1px solid rgba(0, 170, 255, 0.3);
}
.pink-glow {
background: rgba(236, 72, 153, 0.15);
color: #f472b6;
border: 1px solid rgba(236, 72, 153, 0.3);
}
.border-purple:hover { border-color: rgba(170, 59, 255, 0.5); }
.border-green:hover { border-color: rgba(21, 200, 100, 0.5); }
.border-blue:hover { border-color: rgba(0, 170, 255, 0.5); }
.border-pink:hover { border-color: rgba(236, 72, 153, 0.5); }
.stat-info {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.stat-value {
font-family: 'Outfit', sans-serif;
font-size: 1.75rem;
font-weight: 700;
margin: 4px 0 0 0;
color: #fff;
}
.text-green { color: var(--accent-green) !important; }
.text-pink { color: var(--accent-pink) !important; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-light);
padding-bottom: 16px;
}
.view-all-link {
font-size: 0.85rem;
color: var(--primary-color);
transition: var(--transition-smooth);
}
.view-all-link:hover {
color: #c084fc;
}
.log-item {
border-bottom: 1px solid var(--border-light) !important;
padding: 16px 0 !important;
}
.log-left {
display: flex;
align-items: flex-start;
}
.log-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
margin-right: 12px;
flex-shrink: 0;
}
.log-dot.replied { background: var(--accent-green); }
.log-dot.ignored { background: var(--text-muted); }
.log-dot.failed { background: var(--accent-red); }
.log-sender {
font-size: 0.85rem;
color: var(--text-secondary);
}
.log-content {
margin-top: 4px;
color: #fff;
font-size: 0.95rem;
}
.log-reply {
margin-top: 8px;
font-size: 0.9rem;
background: rgba(170, 59, 255, 0.08);
padding: 6px 12px;
border-radius: 8px;
border-left: 2px solid var(--primary-color);
display: inline-block;
}
.reply-tag {
color: #c084fc;
font-weight: 600;
margin-right: 6px;
}
.log-right-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.log-time {
font-size: 0.75rem;
color: var(--text-muted);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 0;
color: var(--text-secondary);
}
.quick-actions-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.quick-action-card {
display: block;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 16px;
text-decoration: none !important;
transition: var(--transition-smooth);
}
.quick-action-card:hover {
background: rgba(170, 59, 255, 0.05);
border-color: var(--border-glow);
transform: translateY(-2px);
}
.action-icon {
font-size: 24px;
margin-bottom: 8px;
}
.quick-action-card span {
display: block;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.quick-action-card p {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0;
}
@media (max-width: 768px) {
.welcome-banner {
flex-direction: column;
align-items: flex-start;
padding: 20px 16px;
gap: 12px;
}
.welcome-banner h1 {
font-size: 1.35rem !important;
}
.banner-icon {
display: none;
}
.stat-card {
padding: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
font-size: 20px;
margin-right: 14px;
}
.stat-value {
font-size: 1.45rem;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.log-item {
flex-direction: column !important;
align-items: flex-start !important;
gap: 10px;
}
.log-left {
width: 100%;
}
.log-right-info {
flex-direction: row;
align-items: center;
width: 100%;
}
.log-reply {
display: block;
max-width: 100%;
}
}
</style>