更新
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
<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>
|
||||
@@ -0,0 +1,682 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
SaveOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
RocketOutlined,
|
||||
InboxOutlined,
|
||||
CloudUploadOutlined,
|
||||
LinkOutlined,
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const savingUrl = ref(false)
|
||||
|
||||
const sourceMode = ref('upload') // 'upload' | 'url'
|
||||
|
||||
const form = ref({
|
||||
version: '',
|
||||
force: false,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const installer = ref({
|
||||
has_installer: false,
|
||||
package_ready: false,
|
||||
installer_name: '',
|
||||
installer_size: 0,
|
||||
installer_url: '',
|
||||
updated_at: '',
|
||||
download_url: ''
|
||||
})
|
||||
|
||||
const urlInput = ref('')
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let n = bytes
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024
|
||||
i++
|
||||
}
|
||||
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
const canPublish = computed(() => !!form.value.version && installer.value.package_ready)
|
||||
const usingExternalUrl = computed(() => !!installer.value.installer_url)
|
||||
const effectiveDownloadUrl = computed(
|
||||
() => installer.value.installer_url || installer.value.download_url
|
||||
)
|
||||
|
||||
const applyResponse = (data) => {
|
||||
form.value = {
|
||||
version: data.version || '',
|
||||
force: !!data.force,
|
||||
notes: data.notes || ''
|
||||
}
|
||||
installer.value = {
|
||||
has_installer: !!data.has_installer,
|
||||
package_ready: !!data.package_ready,
|
||||
installer_name: data.installer_name || '',
|
||||
installer_size: data.installer_size || 0,
|
||||
installer_url: data.installer_url || '',
|
||||
updated_at: data.updated_at || '',
|
||||
download_url: data.download_url || ''
|
||||
}
|
||||
urlInput.value = data.installer_url || ''
|
||||
sourceMode.value = data.installer_url ? 'url' : 'upload'
|
||||
}
|
||||
|
||||
const fetchRelease = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/desktop/release')
|
||||
applyResponse(res.data)
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '加载发布配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.version.trim()) {
|
||||
message.warning('请填写版本号,例如 1.0.1')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.put('/desktop/release', {
|
||||
version: form.value.version.trim(),
|
||||
force: form.value.force,
|
||||
notes: form.value.notes
|
||||
})
|
||||
applyResponse(res.data)
|
||||
message.success('发布配置已保存')
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveInstallerUrl = async () => {
|
||||
const url = urlInput.value.trim()
|
||||
if (url && !/^https?:\/\//i.test(url)) {
|
||||
message.warning('网址需以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
savingUrl.value = true
|
||||
try {
|
||||
const res = await api.put('/desktop/release', { installer_url: url })
|
||||
applyResponse(res.data)
|
||||
message.success(url ? '安装包网址已保存' : '安装包网址已清除')
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '保存网址失败')
|
||||
} finally {
|
||||
savingUrl.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isExe = file.name?.toLowerCase().endsWith('.exe')
|
||||
if (!isExe) {
|
||||
message.error('请上传 .exe 安装包')
|
||||
return false
|
||||
}
|
||||
uploadInstaller(file)
|
||||
return false
|
||||
}
|
||||
|
||||
const uploadInstaller = async (file) => {
|
||||
uploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await api.post('/desktop/release/installer', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 600000
|
||||
})
|
||||
applyResponse(res.data)
|
||||
message.success('安装包已上传')
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '上传失败(注意 Nginx client_max_body_size 需调大)')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteInstaller = () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除安装包?',
|
||||
content: '删除后若也未配置外部网址,客户端将停止检测到新版本。',
|
||||
okType: 'danger',
|
||||
okText: '删除',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await api.delete('/desktop/release/installer')
|
||||
applyResponse(res.data)
|
||||
message.success('安装包已删除')
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(fetchRelease)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-update-page">
|
||||
<div class="page-header glass-card">
|
||||
<div class="header-text">
|
||||
<h2>
|
||||
<RocketOutlined class="header-icon" />
|
||||
桌面端在线升级
|
||||
</h2>
|
||||
<p class="subtitle">发布桌面客户端新版本,支持强制升级或可跳过升级</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button class="ghost-btn" :loading="loading" @click="fetchRelease">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button class="gradient-btn" :loading="saving" @click="handleSave">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存发布
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<div class="glass-card panel">
|
||||
<h3 class="panel-title">版本发布</h3>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="最新版本号" required>
|
||||
<a-input
|
||||
v-model:value="form.version"
|
||||
placeholder="例如 1.0.1(需大于客户端当前版本才会提示升级)"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="升级方式">
|
||||
<a-radio-group v-model:value="form.force" button-style="solid">
|
||||
<a-radio-button :value="false">非强制(可稍后再说)</a-radio-button>
|
||||
<a-radio-button :value="true">强制升级(必须升级)</a-radio-button>
|
||||
</a-radio-group>
|
||||
<div class="hint">
|
||||
{{ form.force
|
||||
? '客户端只能点「立即升级」,升级完成前无法使用。'
|
||||
: '客户端可选择「稍后再说」直接进入主界面。' }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="更新说明">
|
||||
<a-textarea
|
||||
v-model:value="form.notes"
|
||||
:rows="6"
|
||||
placeholder="本次更新内容,将显示在客户端升级弹窗里。每行一条。"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="10">
|
||||
<div class="glass-card panel">
|
||||
<h3 class="panel-title">安装包</h3>
|
||||
|
||||
<a-radio-group v-model:value="sourceMode" button-style="solid" class="source-switch">
|
||||
<a-radio-button value="upload">
|
||||
<CloudUploadOutlined /> 上传安装包
|
||||
</a-radio-button>
|
||||
<a-radio-button value="url">
|
||||
<LinkOutlined /> 使用网址链接
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
|
||||
<template v-if="sourceMode === 'upload'">
|
||||
<a-upload-dragger
|
||||
name="file"
|
||||
class="dropzone"
|
||||
:multiple="false"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
accept=".exe"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<p class="drop-icon">
|
||||
<a-spin v-if="uploading" />
|
||||
<InboxOutlined v-else />
|
||||
</p>
|
||||
<p class="drop-title">
|
||||
{{ uploading ? '正在上传,请勿关闭页面…' : '点击或拖拽 .exe 安装包到此处' }}
|
||||
</p>
|
||||
<p class="drop-hint">上传后将作为客户端下载的安装包</p>
|
||||
</a-upload-dragger>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="url-box">
|
||||
<label class="url-label">安装包直链(.exe / 网盘直链)</label>
|
||||
<a-input
|
||||
v-model:value="urlInput"
|
||||
placeholder="https://example.com/DouyinDesktop-Setup.exe"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix><LinkOutlined class="url-prefix" /></template>
|
||||
</a-input>
|
||||
<p class="drop-hint">
|
||||
填写后客户端将直接从该网址下载,优先于本地上传的安装包;留空并保存即可清除。
|
||||
</p>
|
||||
<a-button
|
||||
class="gradient-btn url-save"
|
||||
:loading="savingUrl"
|
||||
@click="saveInstallerUrl"
|
||||
>
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存网址
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="status-list">
|
||||
<div class="status-row status-row--head">
|
||||
<span class="status-label">当前状态</span>
|
||||
<span class="status-badge" :class="installer.package_ready ? 'is-ready' : 'is-empty'">
|
||||
<CheckCircleFilled v-if="installer.package_ready" />
|
||||
<ExclamationCircleFilled v-else />
|
||||
{{ installer.package_ready ? '已就绪' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">来源</span>
|
||||
<span class="status-value">{{ usingExternalUrl ? '外部网址' : (installer.has_installer ? '本地上传' : '—') }}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">{{ usingExternalUrl ? '网址' : '文件名' }}</span>
|
||||
<span class="status-value truncate" :title="usingExternalUrl ? installer.installer_url : installer.installer_name">
|
||||
{{ usingExternalUrl ? installer.installer_url : (installer.installer_name || '—') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row" v-if="!usingExternalUrl">
|
||||
<span class="status-label">大小</span>
|
||||
<span class="status-value">{{ installer.has_installer ? formatSize(installer.installer_size) : '—' }}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">更新时间</span>
|
||||
<span class="status-value">{{ formatTime(installer.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button
|
||||
class="ghost-btn"
|
||||
:disabled="!installer.package_ready"
|
||||
:href="effectiveDownloadUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
下载验证
|
||||
</a-button>
|
||||
<a-button
|
||||
class="ghost-btn danger-btn"
|
||||
:disabled="!installer.has_installer"
|
||||
@click="handleDeleteInstaller"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除本地包
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="glass-card guide">
|
||||
<h3 class="panel-title">发布流程</h3>
|
||||
<ol class="guide-list">
|
||||
<li>本地打包出新版安装包(<code>version.py</code> 版本号需调高再打包)。</li>
|
||||
<li>在右侧「上传安装包」上传 .exe,或在「使用网址链接」填写已有的直链。</li>
|
||||
<li>左侧填写与安装包一致的版本号,选择升级方式,点「保存发布」。</li>
|
||||
<li>客户端下次启动会自动检测到新版本并按所选方式提示升级。</li>
|
||||
</ol>
|
||||
<p class="guide-note">
|
||||
客户端检查地址:<code>/api/desktop/latest</code>。上传大文件如失败,请把 Nginx
|
||||
<code>client_max_body_size</code> 调大(如 200m),或改用「网址链接」直链下载。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="!canPublish && (form.version || installer.package_ready)"
|
||||
class="notice"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="尚未生效"
|
||||
:description="!installer.package_ready ? '请先上传安装包或配置外部网址,客户端才会收到升级提示。' : '请先填写版本号并保存发布。'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-update-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-text h2 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.header-icon {
|
||||
color: #c084fc;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---------- Panels ---------- */
|
||||
.panel {
|
||||
height: 100%;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 1.05rem;
|
||||
color: #f3e8ff;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.panel-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, var(--primary-color), var(--accent-pink));
|
||||
}
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.gradient-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 6px 18px hsla(270, 85%, 65%, 0.35);
|
||||
}
|
||||
.ghost-btn {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ghost-btn:hover:not([disabled]) {
|
||||
background: rgba(170, 59, 255, 0.16) !important;
|
||||
border-color: rgba(170, 59, 255, 0.4) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.danger-btn:hover:not([disabled]) {
|
||||
background: rgba(239, 68, 68, 0.16) !important;
|
||||
border-color: rgba(248, 113, 113, 0.4) !important;
|
||||
color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
/* ---------- Source switch ---------- */
|
||||
.source-switch {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Dropzone ---------- */
|
||||
.dropzone {
|
||||
display: block;
|
||||
}
|
||||
.drop-icon {
|
||||
font-size: 38px;
|
||||
color: #c084fc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.drop-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.drop-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* ---------- URL box ---------- */
|
||||
.url-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border: 1px dashed rgba(170, 59, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
background: rgba(170, 59, 255, 0.05);
|
||||
}
|
||||
.url-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.url-prefix {
|
||||
color: #c084fc;
|
||||
}
|
||||
.url-save {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Status list ---------- */
|
||||
.status-list {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.status-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.status-row--head {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.status-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
.truncate {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.is-ready {
|
||||
color: #86efac;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
border: 1px solid rgba(74, 222, 128, 0.32);
|
||||
}
|
||||
.status-badge.is-empty {
|
||||
color: #fcd34d;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border: 1px solid rgba(251, 191, 36, 0.32);
|
||||
}
|
||||
|
||||
/* ---------- Actions ---------- */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------- Guide ---------- */
|
||||
.guide-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.9;
|
||||
font-size: 13px;
|
||||
}
|
||||
.guide-note {
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.guide code,
|
||||
.guide-note code {
|
||||
background: rgba(170, 59, 255, 0.14);
|
||||
color: #d8b4fe;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Dark-theme overrides for Ant components ---------- */
|
||||
.desktop-update-page :deep(.ant-form-item-label > label) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-input),
|
||||
.desktop-update-page :deep(.ant-input-affix-wrapper),
|
||||
.desktop-update-page :deep(textarea.ant-input) {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-input::placeholder),
|
||||
.desktop-update-page :deep(textarea.ant-input::placeholder) {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-input-affix-wrapper) {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-input-affix-wrapper .ant-input) {
|
||||
background: transparent !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-input-affix-wrapper:focus-within),
|
||||
.desktop-update-page :deep(.ant-input:focus),
|
||||
.desktop-update-page :deep(textarea.ant-input:focus) {
|
||||
border-color: rgba(170, 59, 255, 0.6) !important;
|
||||
box-shadow: 0 0 0 2px rgba(170, 59, 255, 0.18) !important;
|
||||
}
|
||||
|
||||
/* Radio (segmented) buttons → purple theme instead of default blue */
|
||||
.desktop-update-page :deep(.ant-radio-button-wrapper) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-radio-button-wrapper:hover) {
|
||||
color: #e9d5ff !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-radio-button-wrapper-checked) {
|
||||
background: rgba(147, 51, 234, 0.28) !important;
|
||||
border-color: rgba(192, 132, 252, 0.6) !important;
|
||||
color: #f3e8ff !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-radio-button-wrapper-checked::before) {
|
||||
background-color: rgba(192, 132, 252, 0.6) !important;
|
||||
}
|
||||
.desktop-update-page :deep(.ant-radio-button-wrapper-checked:hover) {
|
||||
background: rgba(147, 51, 234, 0.36) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Upload dragger dark surface */
|
||||
.desktop-update-page :deep(.ant-upload-wrapper .ant-upload-drag) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border: 1px dashed rgba(170, 59, 255, 0.3) !important;
|
||||
border-radius: 12px;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
.desktop-update-page :deep(.ant-upload-wrapper .ant-upload-drag:hover) {
|
||||
border-color: rgba(170, 59, 255, 0.6) !important;
|
||||
background: rgba(170, 59, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.truncate {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
DownloadOutlined,
|
||||
DesktopOutlined,
|
||||
WindowsOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloudDownloadOutlined,
|
||||
ToolOutlined,
|
||||
OrderedListOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const release = ref(null)
|
||||
|
||||
const toolDownloadUrl =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/vod/%E6%8A%96%E9%9F%B3%E8%87%AA%E5%8A%A8%E8%8E%B7%E5%8F%96%E5%87%AD%E8%AF%81%E5%B7%A5%E5%85%B7.zip'
|
||||
|
||||
const hasDesktopRelease = computed(() => !!release.value?.version && !!release.value?.url)
|
||||
|
||||
const releaseNotes = computed(() => {
|
||||
const notes = (release.value?.notes || '').trim()
|
||||
if (!notes) return []
|
||||
return notes.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
})
|
||||
|
||||
const fetchRelease = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/desktop/latest')
|
||||
const data = res.data
|
||||
release.value = data?.version ? data : null
|
||||
} catch (error) {
|
||||
release.value = null
|
||||
message.error(error.response?.data?.detail || '获取桌面版信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadDesktop = () => {
|
||||
if (!release.value?.url) {
|
||||
message.warning('桌面版暂未发布,请稍后再试')
|
||||
return
|
||||
}
|
||||
window.open(release.value.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const downloadTool = () => {
|
||||
window.open(toolDownloadUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const installSteps = [
|
||||
'下载并运行 Windows 安装包,按向导完成安装(无需管理员权限)。',
|
||||
'首次启动使用本系统账号登录,进入桌面客户端主界面。',
|
||||
'在「账号管理」中配置抖音凭证,开启托管后即可自动回复私信。',
|
||||
'客户端启动时会自动检查更新,有新版本时会提示升级。'
|
||||
]
|
||||
|
||||
const toolSteps = [
|
||||
'下载凭证工具 zip 并解压到任意文件夹。',
|
||||
'双击「一键采集」或「启动抖音一键采集器」,按提示扫码登录抖音。',
|
||||
'复制采集结果 JSON,粘贴到本系统「账号管理 → 添加账号」中即可。'
|
||||
]
|
||||
|
||||
onMounted(fetchRelease)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="download-page">
|
||||
<div class="page-header glass-card">
|
||||
<div class="header-text">
|
||||
<h2>
|
||||
<CloudDownloadOutlined class="header-icon" />
|
||||
软件下载
|
||||
</h2>
|
||||
<p class="subtitle">下载桌面客户端与凭证采集工具,快速接入抖音私信托管</p>
|
||||
</div>
|
||||
<a-button class="ghost-btn" :loading="loading" @click="fetchRelease">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<div class="glass-card product-card">
|
||||
<div class="product-head">
|
||||
<div class="product-icon desktop-icon">
|
||||
<DesktopOutlined />
|
||||
</div>
|
||||
<div class="product-meta">
|
||||
<div class="product-title-row">
|
||||
<h3>抖音托管客服桌面版</h3>
|
||||
<a-tag v-if="hasDesktopRelease" color="green" class="version-tag">
|
||||
v{{ release.version }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="default" class="version-tag">暂未发布</a-tag>
|
||||
</div>
|
||||
<p class="product-desc">
|
||||
Windows 桌面客户端,登录后即可管理账号、查看私信并运行自动回复,支持后台在线升级。
|
||||
</p>
|
||||
<div class="product-tags">
|
||||
<a-tag color="purple"><WindowsOutlined /> Windows 10+</a-tag>
|
||||
<a-tag color="purple">64 位</a-tag>
|
||||
<a-tag color="purple">一键安装</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="hasDesktopRelease && releaseNotes.length" class="notes-box">
|
||||
<h4 class="notes-title">更新说明</h4>
|
||||
<ul class="notes-list">
|
||||
<li v-for="(line, index) in releaseNotes" :key="index">{{ line }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a-empty
|
||||
v-else-if="!loading && !hasDesktopRelease"
|
||||
class="empty-state"
|
||||
description="桌面版安装包尚未发布,请联系管理员在「桌面端升级」中配置。"
|
||||
/>
|
||||
|
||||
<div class="product-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="gradient-btn download-btn"
|
||||
:disabled="!hasDesktopRelease"
|
||||
@click="downloadDesktop"
|
||||
>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
{{ hasDesktopRelease ? '下载桌面版' : '暂不可下载' }}
|
||||
</a-button>
|
||||
<span v-if="hasDesktopRelease" class="file-hint">安装包:DouyinHostedDesktop-Setup.exe</span>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="10">
|
||||
<div class="glass-card product-card compact-card">
|
||||
<div class="product-head">
|
||||
<div class="product-icon tool-icon">
|
||||
<ToolOutlined />
|
||||
</div>
|
||||
<div class="product-meta">
|
||||
<div class="product-title-row">
|
||||
<h3>抖音自动获取凭证工具</h3>
|
||||
<a-tag color="purple" class="version-tag">Windows</a-tag>
|
||||
</div>
|
||||
<p class="product-desc">
|
||||
本地采集工具,在无痕浏览器中登录抖音后自动获取 Cookie 与 IM 签名,适合首次配置账号。
|
||||
</p>
|
||||
<div class="product-tags">
|
||||
<a-tag color="purple">ZIP 解压即用</a-tag>
|
||||
<a-tag color="purple">一键采集</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-actions">
|
||||
<a-button type="primary" size="large" class="gradient-btn download-btn" @click="downloadTool">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
下载采集工具
|
||||
</a-button>
|
||||
<span class="file-hint">凭证工具.zip</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<div class="glass-card guide-card">
|
||||
<h3 class="guide-title">
|
||||
<OrderedListOutlined />
|
||||
桌面版安装步骤
|
||||
</h3>
|
||||
<ol class="guide-list">
|
||||
<li v-for="(step, index) in installSteps" :key="index">
|
||||
<CheckCircleOutlined class="step-icon" />
|
||||
<span>{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<div class="glass-card guide-card">
|
||||
<h3 class="guide-title">
|
||||
<OrderedListOutlined />
|
||||
凭证工具使用步骤
|
||||
</h3>
|
||||
<ol class="guide-list">
|
||||
<li v-for="(step, index) in toolSteps" :key="index">
|
||||
<CheckCircleOutlined class="step-icon" />
|
||||
<span>{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="glass-card tip-card">
|
||||
<p>
|
||||
更多凭证获取方式(含在线采集、视频教程)请前往
|
||||
<router-link to="/help" class="help-link">帮助中心</router-link>
|
||||
查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-text h2 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compact-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-icon {
|
||||
background: linear-gradient(135deg, rgba(170, 59, 255, 0.28), rgba(192, 132, 252, 0.12));
|
||||
color: #c084fc;
|
||||
border: 1px solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.22), rgba(96, 165, 250, 0.1));
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(96, 165, 250, 0.35);
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-title-row h3 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.product-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.notes-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.notes-title {
|
||||
margin: 0 0 10px;
|
||||
color: #f3e8ff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.85;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.compact-card .product-actions {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
min-width: 180px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.download-btn:hover:not([disabled]) {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 6px 18px hsla(270, 85%, 65%, 0.35);
|
||||
}
|
||||
|
||||
.file-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
padding: 22px 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.guide-title {
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #f3e8ff;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
color: #86efac;
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tip-card {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.tip-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
color: #c084fc;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-link:hover {
|
||||
color: #e9d5ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ghost-btn:hover:not([disabled]) {
|
||||
background: rgba(170, 59, 255, 0.16) !important;
|
||||
border-color: rgba(170, 59, 255, 0.4) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.download-page :deep(.ant-empty-description) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.guide-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.product-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
DownloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
KeyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const toolDownloadUrl =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/vod/%E6%8A%96%E9%9F%B3%E8%87%AA%E5%8A%A8%E8%8E%B7%E5%8F%96%E5%87%AD%E8%AF%81%E5%B7%A5%E5%85%B7.zip'
|
||||
|
||||
const tutorialVideoUrl =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/vod/%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E.mp4'
|
||||
|
||||
const videoVisible = ref(false)
|
||||
const isMobile = useIsMobile()
|
||||
const videoModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 860))
|
||||
|
||||
const openDownload = () => {
|
||||
window.open(toolDownloadUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const openVideo = () => {
|
||||
videoVisible.value = true
|
||||
}
|
||||
|
||||
const helpSections = [
|
||||
{
|
||||
key: 'method-1',
|
||||
badge: '方式 1',
|
||||
title: 'Windows 采集工具',
|
||||
intro:
|
||||
'推荐新手使用。下载本地工具后,在无痕浏览器中登录抖音,自动采集 Cookie / IM 签名,复制 JSON 粘贴到账号配置即可。',
|
||||
items: [
|
||||
{
|
||||
key: 'tool-download',
|
||||
title: '抖音自动获取凭证工具',
|
||||
description:
|
||||
'下载 Windows 版采集工具,解压后双击「一键采集」或「启动抖音一键采集器」,按提示扫码登录并复制凭证。',
|
||||
tags: ['Windows', '一键采集', 'Cookie'],
|
||||
url: toolDownloadUrl,
|
||||
actionLabel: '下载工具',
|
||||
icon: DownloadOutlined,
|
||||
action: openDownload
|
||||
},
|
||||
{
|
||||
key: 'tutorial-video',
|
||||
title: '使用说明',
|
||||
description:
|
||||
'观看视频教程,了解如何下载采集工具、扫码登录、自动采集凭证,以及如何将结果导入客服系统并启动托管。',
|
||||
tags: ['视频教程', '新手入门'],
|
||||
url: tutorialVideoUrl,
|
||||
actionLabel: '观看教程',
|
||||
icon: PlayCircleOutlined,
|
||||
action: openVideo
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'method-2',
|
||||
badge: '方式 2',
|
||||
title: '在线凭证采集',
|
||||
intro:
|
||||
'适用于无法下载客户端、或需要在本机浏览器中临时采集凭证的场景。打开在线工具页完成采集后粘贴 JSON。',
|
||||
items: [
|
||||
{
|
||||
key: 'credential-tool',
|
||||
title: '在线凭证采集',
|
||||
description:
|
||||
'在浏览器中打开服务端采集页,登录抖音并采集 Cookie、IM 签名与 frontier 连接信息。',
|
||||
tags: ['Cookie', 'IM 签名', 'storage_state'],
|
||||
url: '/api/help/credential-tool',
|
||||
actionLabel: '打开工具',
|
||||
icon: KeyOutlined,
|
||||
action: () => window.open('/api/help/credential-tool', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="help-page">
|
||||
<div class="page-header glass-card">
|
||||
<div>
|
||||
<h2 style="margin: 0;">
|
||||
<QuestionCircleOutlined style="margin-right: 8px;" />
|
||||
帮助中心
|
||||
</h2>
|
||||
<p class="subtitle">凭证获取方式与操作说明</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-for="section in helpSections"
|
||||
:key="section.key"
|
||||
class="help-section glass-card"
|
||||
>
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<a-tag color="purple" class="method-badge">{{ section.badge }}</a-tag>
|
||||
<h3 class="section-title">{{ section.title }}</h3>
|
||||
</div>
|
||||
<p class="section-intro">{{ section.intro }}</p>
|
||||
</div>
|
||||
|
||||
<div class="help-grid">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
class="help-card"
|
||||
>
|
||||
<div class="help-card-icon">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p class="help-desc">{{ item.description }}</p>
|
||||
<div class="help-tags">
|
||||
<a-tag v-for="tag in item.tags" :key="tag" color="purple">{{ tag }}</a-tag>
|
||||
</div>
|
||||
<a-button type="primary" class="gradient-btn" @click="item.action">
|
||||
<template #icon><component :is="item.icon" /></template>
|
||||
{{ item.actionLabel }}
|
||||
</a-button>
|
||||
<div v-if="item.url" class="help-link">{{ item.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="videoVisible"
|
||||
title="方式 1 · 使用说明"
|
||||
:footer="null"
|
||||
:width="videoModalWidth" destroy-on-close
|
||||
centered
|
||||
@cancel="videoVisible = false"
|
||||
>
|
||||
<video
|
||||
v-if="videoVisible"
|
||||
class="tutorial-video"
|
||||
:src="tutorialVideoUrl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
>
|
||||
您的浏览器不支持视频播放,请
|
||||
<a :href="tutorialVideoUrl" target="_blank" rel="noopener noreferrer">点击下载观看</a>
|
||||
</video>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section-intro {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.help-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-card {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.help-card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(170, 59, 255, 0.15);
|
||||
color: #c084fc;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.help-card h4 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.help-desc {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.help-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tutorial-video {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header,
|
||||
.help-section {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.help-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,679 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
RobotOutlined,
|
||||
MailOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeTab = ref('login')
|
||||
const registrationEnabled = ref(true)
|
||||
const emailVerificationRequired = ref(true)
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const regUsername = ref('')
|
||||
const regEmail = ref('')
|
||||
const regPassword = ref('')
|
||||
const regPassword2 = ref('')
|
||||
const regLoading = ref(false)
|
||||
|
||||
const verifyPanel = ref(false)
|
||||
const bindPanel = ref(false)
|
||||
const pendingEmail = ref('')
|
||||
const pendingUsername = ref('')
|
||||
const resendLoading = ref(false)
|
||||
const devVerifyUrl = ref('')
|
||||
|
||||
const forgotModalVisible = ref(false)
|
||||
const forgotLoading = ref(false)
|
||||
const forgotAccount = ref('')
|
||||
const devResetUrl = ref('')
|
||||
|
||||
const resetToken = ref('')
|
||||
const resetPassword = ref('')
|
||||
const resetPassword2 = ref('')
|
||||
const resetLoading = ref(false)
|
||||
|
||||
const resetMode = computed(() => !!resetToken.value)
|
||||
|
||||
const pageSubtitle = computed(() => {
|
||||
if (resetMode.value) return '请设置新的登录密码'
|
||||
if (activeTab.value === 'register') return '创建新账号'
|
||||
return '请登录以继续'
|
||||
})
|
||||
|
||||
const parseErrorDetail = (error) => {
|
||||
const detail = error.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || '操作失败'
|
||||
return '操作失败'
|
||||
}
|
||||
|
||||
const parseEmailNotVerified = (error) => {
|
||||
const detail = error.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.code === 'email_not_verified') {
|
||||
return detail
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const parseEmailNotBound = (error) => {
|
||||
const detail = error.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.code === 'email_not_bound') {
|
||||
return detail
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchPublicSettings = async () => {
|
||||
try {
|
||||
const res = await api.get('/settings/public')
|
||||
registrationEnabled.value = !!res.data.registration_enabled
|
||||
emailVerificationRequired.value = res.data.email_verification_required !== false
|
||||
if (!registrationEnabled.value && activeTab.value === 'register') {
|
||||
activeTab.value = 'login'
|
||||
}
|
||||
} catch {
|
||||
registrationEnabled.value = true
|
||||
emailVerificationRequired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value.trim() || !password.value) {
|
||||
message.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
verifyPanel.value = false
|
||||
bindPanel.value = false
|
||||
try {
|
||||
await auth.login(username.value.trim(), password.value)
|
||||
message.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
const notBound = parseEmailNotBound(error)
|
||||
const unverified = parseEmailNotVerified(error)
|
||||
if (notBound) {
|
||||
bindPanel.value = true
|
||||
message.warning(notBound.message || '请先绑定邮箱')
|
||||
} else if (unverified) {
|
||||
verifyPanel.value = true
|
||||
pendingEmail.value = unverified.email || ''
|
||||
pendingUsername.value = username.value.trim()
|
||||
message.warning(unverified.message || '请先验证邮箱')
|
||||
} else {
|
||||
message.error(parseErrorDetail(error))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
const name = regUsername.value.trim()
|
||||
const email = regEmail.value.trim()
|
||||
const pwd = regPassword.value
|
||||
const pwd2 = regPassword2.value
|
||||
|
||||
if (!name || !email || !pwd) {
|
||||
message.warning('请填写完整注册信息')
|
||||
return
|
||||
}
|
||||
if (pwd.length < 6) {
|
||||
message.warning('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
if (pwd !== pwd2) {
|
||||
message.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
regLoading.value = true
|
||||
try {
|
||||
const res = await auth.register({ username: name, email, password: pwd })
|
||||
activeTab.value = 'login'
|
||||
username.value = name
|
||||
password.value = ''
|
||||
|
||||
if (res.verification_required === false) {
|
||||
verifyPanel.value = false
|
||||
devVerifyUrl.value = ''
|
||||
message.success(res.message || '注册成功,可直接登录')
|
||||
return
|
||||
}
|
||||
|
||||
verifyPanel.value = true
|
||||
pendingEmail.value = res.email || email
|
||||
pendingUsername.value = name
|
||||
devVerifyUrl.value = res.dev_verify_url || ''
|
||||
message.success(res.message || '注册成功,请验证邮箱')
|
||||
} catch (error) {
|
||||
message.error(parseErrorDetail(error))
|
||||
} finally {
|
||||
regLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!pendingUsername.value) {
|
||||
message.warning('请先输入用户名或完成注册')
|
||||
return
|
||||
}
|
||||
resendLoading.value = true
|
||||
try {
|
||||
const res = await auth.resendVerification({ username: pendingUsername.value.trim() })
|
||||
devVerifyUrl.value = res.dev_verify_url || ''
|
||||
pendingEmail.value = res.email || pendingEmail.value
|
||||
if (res.verification_sent === false && res.dev_verify_url) {
|
||||
message.warning(res.message || '邮件未发出,请使用下方验证链接')
|
||||
} else if (res.verification_sent === false) {
|
||||
message.error(res.message || '邮件发送失败,请检查 SMTP 配置')
|
||||
} else {
|
||||
message.success(res.message || '验证邮件已发送')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(parseErrorDetail(error))
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyToken = async (token) => {
|
||||
if (!token) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await auth.verifyEmail(token)
|
||||
verifyPanel.value = false
|
||||
devVerifyUrl.value = ''
|
||||
message.success(res.message || '邮箱验证成功')
|
||||
router.replace({ path: '/login', query: {} })
|
||||
} catch (error) {
|
||||
message.error(parseErrorDetail(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openForgotModal = () => {
|
||||
forgotAccount.value = username.value.trim()
|
||||
devResetUrl.value = ''
|
||||
forgotModalVisible.value = true
|
||||
}
|
||||
|
||||
const buildForgotPayload = () => {
|
||||
const value = forgotAccount.value.trim()
|
||||
if (!value) return null
|
||||
if (value.includes('@')) {
|
||||
return { email: value }
|
||||
}
|
||||
return { username: value }
|
||||
}
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
const payload = buildForgotPayload()
|
||||
if (!payload) {
|
||||
message.warning('请输入用户名或注册邮箱')
|
||||
return
|
||||
}
|
||||
forgotLoading.value = true
|
||||
try {
|
||||
const res = await auth.forgotPassword(payload)
|
||||
devResetUrl.value = res.dev_reset_url || ''
|
||||
message.success(res.message || '重置邮件已发送')
|
||||
forgotModalVisible.value = false
|
||||
} catch (error) {
|
||||
message.error(parseErrorDetail(error))
|
||||
} finally {
|
||||
forgotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
const pwd = resetPassword.value
|
||||
const pwd2 = resetPassword2.value
|
||||
if (!resetToken.value) {
|
||||
message.error('重置链接无效')
|
||||
return
|
||||
}
|
||||
if (!pwd || pwd.length < 6) {
|
||||
message.warning('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
if (pwd !== pwd2) {
|
||||
message.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const res = await auth.resetPassword(resetToken.value, pwd)
|
||||
message.success(res.message || '密码已重置')
|
||||
resetToken.value = ''
|
||||
resetPassword.value = ''
|
||||
resetPassword2.value = ''
|
||||
activeTab.value = 'login'
|
||||
router.replace({ path: '/login', query: {} })
|
||||
} catch (error) {
|
||||
message.error(parseErrorDetail(error))
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const backToLogin = () => {
|
||||
resetToken.value = ''
|
||||
resetPassword.value = ''
|
||||
resetPassword2.value = ''
|
||||
router.replace({ path: '/login', query: {} })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPublicSettings()
|
||||
const verifyToken = route.query.verify_token
|
||||
if (typeof verifyToken === 'string' && verifyToken) {
|
||||
handleVerifyToken(verifyToken)
|
||||
return
|
||||
}
|
||||
const token = route.query.reset_token
|
||||
if (typeof token === 'string' && token) {
|
||||
resetToken.value = token
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card glass-card">
|
||||
<div class="login-header">
|
||||
<RobotOutlined class="login-logo" />
|
||||
<h1>抖音回复助手</h1>
|
||||
<p>{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="resetMode">
|
||||
<a-form layout="vertical" @finish="handleResetPassword">
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password
|
||||
v-model:value="resetPassword"
|
||||
size="large"
|
||||
placeholder="至少 6 位"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="确认新密码">
|
||||
<a-input-password
|
||||
v-model:value="resetPassword2"
|
||||
size="large"
|
||||
placeholder="再次输入新密码"
|
||||
@pressEnter="handleResetPassword"
|
||||
>
|
||||
<template #prefix><SafetyCertificateOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
class="login-btn"
|
||||
:loading="resetLoading"
|
||||
@click="handleResetPassword"
|
||||
>
|
||||
确认重置密码
|
||||
</a-button>
|
||||
|
||||
<a-button type="link" block class="back-login-btn" @click="backToLogin">
|
||||
返回登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-alert
|
||||
v-if="verifyPanel && emailVerificationRequired"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="verify-alert"
|
||||
message="邮箱尚未验证"
|
||||
:description="`验证邮件已发送至 ${pendingEmail || '您的邮箱'},请点击邮件中的链接完成验证后再登录。`"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" :loading="resendLoading" @click="handleResend">
|
||||
重新发送
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-alert
|
||||
v-if="bindPanel"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="verify-alert"
|
||||
message="账号未绑定邮箱"
|
||||
description="当前系统要求登录前必须绑定邮箱,请联系管理员在用户管理中为您绑定邮箱后再登录。"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-if="devVerifyUrl"
|
||||
type="info"
|
||||
show-icon
|
||||
class="verify-alert"
|
||||
message="验证链接(管理员已在系统设置中开启)"
|
||||
:description="devVerifyUrl"
|
||||
/>
|
||||
|
||||
<a-tabs v-if="registrationEnabled" v-model:activeKey="activeTab" centered class="login-tabs">
|
||||
<a-tab-pane key="login" tab="登录">
|
||||
<a-form layout="vertical" @finish="handleLogin">
|
||||
<a-form-item label="用户名">
|
||||
<a-input
|
||||
v-model:value="username"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
@pressEnter="handleLogin"
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
@pressEnter="handleLogin"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<div class="login-extra-row">
|
||||
<a-button type="link" class="forgot-link" @click="openForgotModal">
|
||||
忘记密码?
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="register" tab="注册">
|
||||
<a-form layout="vertical" @finish="handleRegister">
|
||||
<a-form-item label="用户名">
|
||||
<a-input v-model:value="regUsername" size="large" placeholder="2-50 个字符">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="邮箱">
|
||||
<a-input
|
||||
v-model:value="regEmail"
|
||||
size="large"
|
||||
:placeholder="emailVerificationRequired ? '用于接收验证邮件' : '用于账号绑定与找回'"
|
||||
>
|
||||
<template #prefix><MailOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码">
|
||||
<a-input-password v-model:value="regPassword" size="large" placeholder="至少 6 位">
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="确认密码">
|
||||
<a-input-password
|
||||
v-model:value="regPassword2"
|
||||
size="large"
|
||||
placeholder="再次输入密码"
|
||||
@pressEnter="handleRegister"
|
||||
>
|
||||
<template #prefix><SafetyCertificateOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
class="login-btn"
|
||||
:loading="regLoading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
{{ emailVerificationRequired ? '注册并发送验证邮件' : '注册' }}
|
||||
</a-button>
|
||||
<p v-if="!emailVerificationRequired" class="login-hint" style="margin-top: 12px;">
|
||||
当前系统未开启邮箱验证,注册后可直接登录
|
||||
</p>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-form v-else layout="vertical" @finish="handleLogin">
|
||||
<a-form-item label="用户名">
|
||||
<a-input
|
||||
v-model:value="username"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
@pressEnter="handleLogin"
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
@pressEnter="handleLogin"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<div class="login-extra-row">
|
||||
<a-button type="link" class="forgot-link" @click="openForgotModal">
|
||||
忘记密码?
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<p class="login-hint"></p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="forgotModalVisible"
|
||||
title="找回密码"
|
||||
ok-text="发送重置邮件"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="forgotLoading"
|
||||
@ok="handleForgotPassword"
|
||||
>
|
||||
<p class="forgot-desc">
|
||||
请输入注册时的用户名或邮箱。若账号已绑定邮箱,我们将发送密码重置链接。
|
||||
</p>
|
||||
<a-input
|
||||
v-model:value="forgotAccount"
|
||||
size="large"
|
||||
placeholder="用户名或邮箱"
|
||||
@pressEnter="handleForgotPassword"
|
||||
>
|
||||
<template #prefix><MailOutlined /></template>
|
||||
</a-input>
|
||||
<a-alert
|
||||
v-if="devResetUrl"
|
||||
type="info"
|
||||
show-icon
|
||||
class="verify-alert"
|
||||
style="margin-top: 12px;"
|
||||
message="重置链接(开发模式)"
|
||||
:description="devResetUrl"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(circle at top, rgba(170, 59, 255, 0.15), transparent 45%),
|
||||
var(--bg-primary, #0f0f14);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 36px 32px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 42px;
|
||||
color: #c084fc;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-tab) {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-tab-active .ant-tabs-tab-btn) {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-ink-bar) {
|
||||
background: #aa3bff;
|
||||
}
|
||||
|
||||
.verify-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-extra-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: -4px 0 4px;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, var(--primary-color, #aa3bff) 0%, #c084fc 100%) !important;
|
||||
border: none !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-login-btn {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary, #9ca3af) !important;
|
||||
}
|
||||
|
||||
.forgot-desc {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
margin: 20px 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
padding: 16px;
|
||||
align-items: flex-start;
|
||||
padding-top: max(16px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 24px 20px;
|
||||
margin-top: 8vh;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.verify-alert :deep(.ant-alert-action) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined, SendOutlined, MessageOutlined } from '@ant-design/icons-vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import MessageBubble from '../components/MessageBubble.vue'
|
||||
import EmojiPicker from '../components/EmojiPicker.vue'
|
||||
import {
|
||||
messagePreview,
|
||||
buildStickerPayload,
|
||||
parseMessageContent
|
||||
} from '../utils/messageContent'
|
||||
|
||||
const accounts = ref([])
|
||||
const selectedAccount = ref(undefined)
|
||||
const conversations = ref([])
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const selectedConv = ref(null)
|
||||
const sendContent = ref('')
|
||||
const pendingPayload = ref('')
|
||||
|
||||
const accountSelectOptions = computed(() =>
|
||||
accounts.value.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: acc.username || acc.phone || `账号 #${acc.id}`
|
||||
}))
|
||||
)
|
||||
|
||||
const formatPeerId = (conv) => {
|
||||
const raw = String(conv?.sender_id || conv?.peer_uid || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^\d+$/.test(raw)) return raw
|
||||
if (/^0:1:\d+:\d+$/.test(raw)) {
|
||||
return raw.split(':')[3] || ''
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
const res = await api.get(`/accounts`)
|
||||
accounts.value = res.data.filter(a => a.has_cookie)
|
||||
if (selectedAccount.value) {
|
||||
const current = accounts.value.find(a => a.id === selectedAccount.value)
|
||||
if (current && current.status !== 'online') {
|
||||
message.warning('当前账号未启动托管,私信发送可能失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConversations = async () => {
|
||||
if (!selectedAccount.value) return
|
||||
loading.value = true
|
||||
selectedConv.value = null
|
||||
try {
|
||||
const res = await api.get(`/accounts/${selectedAccount.value}/conversations`)
|
||||
conversations.value = res.data
|
||||
if (!res.data.length) {
|
||||
const hosting = accounts.value.find(a => a.id === selectedAccount.value)?.status === 'online'
|
||||
if (hosting) {
|
||||
message.info('暂无会话记录,收到私信后会自动出现在列表中')
|
||||
} else {
|
||||
message.info('暂无会话,请先启动托管并收到私信')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
conversations.value = []
|
||||
message.error(error.response?.data?.detail || '拉取会话失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccountChange = () => {
|
||||
conversations.value = []
|
||||
selectedConv.value = null
|
||||
if (selectedAccount.value) {
|
||||
fetchConversations()
|
||||
}
|
||||
}
|
||||
|
||||
const selectConversation = (conv) => {
|
||||
selectedConv.value = conv
|
||||
pendingPayload.value = ''
|
||||
}
|
||||
|
||||
const onPickEmoji = (emoji) => {
|
||||
sendContent.value = `${sendContent.value}${emoji}`
|
||||
}
|
||||
|
||||
const onPickSticker = (item) => {
|
||||
pendingPayload.value = buildStickerPayload(item.url, item.id, item.name)
|
||||
}
|
||||
|
||||
const previewContent = (raw) => messagePreview(raw)
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!selectedAccount.value || !selectedConv.value) {
|
||||
message.warning('请选择账号和会话')
|
||||
return
|
||||
}
|
||||
const acc = accounts.value.find(a => a.id === selectedAccount.value)
|
||||
if (acc && acc.status !== 'online') {
|
||||
message.warning('请先在账号管理启动托管,再发送私信')
|
||||
return
|
||||
}
|
||||
const content = pendingPayload.value || sendContent.value.trim()
|
||||
if (!content) {
|
||||
message.warning('请输入消息内容或选择表情')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
const body = { conversation_id: selectedConv.value.conversation_id, content }
|
||||
const parsed = parseMessageContent(content)
|
||||
if (parsed.type === 'sticker') {
|
||||
body.message_type = 'sticker'
|
||||
body.sticker_url = parsed.url
|
||||
body.sticker_id = parsed.sticker_id
|
||||
}
|
||||
const res = await api.post(`/accounts/${selectedAccount.value}/messages/send`, body)
|
||||
if (res.data.success) {
|
||||
message.success('发送成功')
|
||||
sendContent.value = ''
|
||||
pendingPayload.value = ''
|
||||
fetchConversations()
|
||||
} else if (res.data.need_browser_login) {
|
||||
message.error(res.data.message || '缺少 IM 签名密钥,请到账号管理用浏览器登录补全')
|
||||
} else {
|
||||
message.error(res.data.message || '发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '发送失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAccounts)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="page-header glass-card">
|
||||
<div class="header-info">
|
||||
<h2 style="margin: 0;">私信收发</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 4px; font-size: 0.9rem;">
|
||||
需先在账号管理启动托管;发送依赖 IM 签名密钥(浏览器登录后自动采集)。
|
||||
</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="selectedAccount"
|
||||
placeholder="选择账号"
|
||||
style="width: 200px;"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="accountSelectOptions"
|
||||
@change="handleAccountChange"
|
||||
/>
|
||||
<a-button type="primary" class="gradient-btn" :disabled="!selectedAccount" @click="fetchConversations">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新会话
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="24" style="margin-top: 24px;">
|
||||
<a-col :xs="24" :md="10">
|
||||
<div class="glass-card panel">
|
||||
<h3>会话列表</h3>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!conversations.length" class="empty-tip">
|
||||
<MessageOutlined style="font-size: 2rem; margin-bottom: 8px;" />
|
||||
<p>选择账号后加载会话</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.conversation_id || conv.sender_name"
|
||||
class="conv-item"
|
||||
:class="{ active: selectedConv?.conversation_id === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<UserAvatar
|
||||
:src="conv.sender_avatar"
|
||||
:name="conv.sender_name"
|
||||
:size="42"
|
||||
/>
|
||||
<div class="conv-body">
|
||||
<div class="conv-name">
|
||||
<span class="conv-title">{{ conv.sender_name }}</span>
|
||||
<a-badge v-if="conv.unread_count > 0" :count="conv.unread_count" />
|
||||
</div>
|
||||
<div v-if="formatPeerId(conv)" class="conv-id">ID: {{ formatPeerId(conv) }}</div>
|
||||
<div class="conv-preview">{{ previewContent(conv.content) || '暂无预览' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="14">
|
||||
<div class="glass-card panel">
|
||||
<h3>发送私信</h3>
|
||||
<div v-if="selectedConv" class="send-target">
|
||||
<UserAvatar
|
||||
:src="selectedConv.sender_avatar"
|
||||
:name="selectedConv.sender_name"
|
||||
:size="40"
|
||||
/>
|
||||
<div class="send-target-meta">
|
||||
<strong>{{ selectedConv.sender_name }}</strong>
|
||||
<span v-if="formatPeerId(selectedConv)" class="send-target-id">
|
||||
抖音 ID: {{ formatPeerId(selectedConv) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="send-target muted">请从左侧选择一个会话</div>
|
||||
<div v-if="pendingPayload" class="pending-media glass-card">
|
||||
<span class="pending-label">待发送:</span>
|
||||
<MessageBubble :content="pendingPayload" compact />
|
||||
<a-button type="link" size="small" @click="pendingPayload = ''">取消</a-button>
|
||||
</div>
|
||||
<div class="compose-toolbar">
|
||||
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
|
||||
</div>
|
||||
<a-textarea
|
||||
v-model:value="sendContent"
|
||||
:rows="6"
|
||||
placeholder="输入文字,或使用上方按钮发送表情..."
|
||||
:disabled="!selectedConv"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="gradient-btn send-btn"
|
||||
:loading="sending"
|
||||
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
发送
|
||||
</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.conv-item:hover,
|
||||
.conv-item.active {
|
||||
background: rgba(170, 59, 255, 0.12);
|
||||
border-color: rgba(170, 59, 255, 0.35);
|
||||
}
|
||||
|
||||
.conv-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-target {
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.send-target-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.send-target-meta strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.send-target-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-target.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.compose-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pending-media {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.pending-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header :deep(.ant-space) {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header :deep(.ant-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.messages-container :deep(.ant-row) {
|
||||
margin-top: 16px !important;
|
||||
}
|
||||
|
||||
.pending-media {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import PaymentOrderListPanel from '../components/PaymentOrderListPanel.vue'
|
||||
|
||||
const orderListRef = ref(null)
|
||||
|
||||
const refreshOrders = () => {
|
||||
orderListRef.value?.refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-payment-orders-page">
|
||||
<div class="page-header glass-card">
|
||||
<div>
|
||||
<h2 style="margin: 0;">
|
||||
<UnorderedListOutlined style="margin-right: 8px;" />
|
||||
我的支付订单
|
||||
</h2>
|
||||
<p class="subtitle">查看账号额度购买的支付状态与历史记录</p>
|
||||
</div>
|
||||
<a-button class="gradient-btn" @click="refreshOrders">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<PaymentOrderListPanel ref="orderListRef" :show-user-column="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SaveOutlined,
|
||||
PayCircleOutlined,
|
||||
WechatOutlined,
|
||||
AlipayCircleOutlined,
|
||||
UnorderedListOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '../api'
|
||||
import PaymentOrderListPanel from '../components/PaymentOrderListPanel.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('general')
|
||||
const orderListRef = ref(null)
|
||||
|
||||
const form = ref({
|
||||
payment_enabled: false,
|
||||
payment_demo_mode: true,
|
||||
wechat_pay_enabled: false,
|
||||
alipay_pay_enabled: false,
|
||||
account_slot_unit_price: 9.9,
|
||||
account_slot_purchase_min: 1,
|
||||
account_slot_purchase_max: 20,
|
||||
app_url: 'http://localhost:8800',
|
||||
wechat_app_id: '',
|
||||
wechat_mch_id: '',
|
||||
wechat_api_v3_key: '',
|
||||
wechat_cert_serial: '',
|
||||
wechat_private_key: '',
|
||||
alipay_app_id: '',
|
||||
alipay_private_key: '',
|
||||
alipay_public_key: '',
|
||||
alipay_sandbox: false
|
||||
})
|
||||
|
||||
const wechatKeyConfigured = ref(false)
|
||||
const wechatPrivateConfigured = ref(false)
|
||||
const alipayPrivateConfigured = ref(false)
|
||||
const wechatPayConfigured = ref(false)
|
||||
const alipayConfigured = ref(false)
|
||||
|
||||
const applyResponse = (data) => {
|
||||
form.value = {
|
||||
...form.value,
|
||||
...data,
|
||||
wechat_api_v3_key: data.wechat_api_v3_key || '',
|
||||
wechat_private_key: data.wechat_private_key || '',
|
||||
alipay_private_key: data.alipay_private_key || ''
|
||||
}
|
||||
wechatKeyConfigured.value = !!data.wechat_api_v3_key_configured
|
||||
wechatPrivateConfigured.value = !!data.wechat_private_key_configured
|
||||
alipayPrivateConfigured.value = !!data.alipay_private_key_configured
|
||||
wechatPayConfigured.value = !!data.wechat_pay_configured
|
||||
alipayConfigured.value = !!data.alipay_configured
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/settings/payment')
|
||||
applyResponse(res.data)
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '加载支付配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload = { ...form.value }
|
||||
delete payload.app_url
|
||||
for (const key of ['wechat_api_v3_key', 'wechat_private_key', 'alipay_private_key']) {
|
||||
if (!payload[key] || payload[key] === '******') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.put('/settings/payment', buildPayload())
|
||||
applyResponse(res.data)
|
||||
message.success('支付配置已保存')
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshOrders = () => {
|
||||
orderListRef.value?.refresh()
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'orders') {
|
||||
refreshOrders()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(fetchSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="payment-settings-page">
|
||||
<div class="page-header glass-card">
|
||||
<div>
|
||||
<h2 style="margin: 0;">
|
||||
<PayCircleOutlined style="margin-right: 8px;" />
|
||||
支付配置
|
||||
</h2>
|
||||
<p class="subtitle">管理账号额度购买、微信支付与支付宝支付</p>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="activeTab !== 'orders'"
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存配置
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
class="gradient-btn"
|
||||
@click="refreshOrders"
|
||||
>
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新订单
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="glass-card panel">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="general" tab="基础设置">
|
||||
<a-form layout="vertical" class="settings-form">
|
||||
<a-form-item label="开启在线购买账号额度">
|
||||
<a-switch v-model:checked="form.payment_enabled" />
|
||||
</a-form-item>
|
||||
<a-form-item label="演示支付模式">
|
||||
<a-switch v-model:checked="form.payment_demo_mode" />
|
||||
<div class="field-hint">未配置真实支付时可模拟支付成功,便于开发测试</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="每个账号额度单价(元)">
|
||||
<a-input-number
|
||||
v-model:value="form.account_slot_unit_price"
|
||||
:min="0.01"
|
||||
:max="99999"
|
||||
:step="0.1"
|
||||
style="width: 100%; max-width: 280px;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="单次购买数量范围">
|
||||
<a-space>
|
||||
<a-input-number v-model:value="form.account_slot_purchase_min" :min="1" :max="100" />
|
||||
<span class="range-sep">至</span>
|
||||
<a-input-number v-model:value="form.account_slot_purchase_max" :min="1" :max="100" />
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="支付回调站点地址">
|
||||
<a-input :value="form.app_url" disabled />
|
||||
<div class="field-hint">在「系统设置」中修改站点访问地址,用于支付回调 URL</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="wechat" tab="微信支付">
|
||||
<template #tab>
|
||||
<span><WechatOutlined /> 微信支付</span>
|
||||
</template>
|
||||
<a-form layout="vertical" class="settings-form">
|
||||
<a-form-item label="启用微信支付">
|
||||
<a-switch v-model:checked="form.wechat_pay_enabled" />
|
||||
<a-tag v-if="wechatPayConfigured" color="green" style="margin-left: 12px;">已配置</a-tag>
|
||||
<a-tag v-else-if="form.wechat_pay_enabled" color="orange" style="margin-left: 12px;">待完善配置</a-tag>
|
||||
</a-form-item>
|
||||
<template v-if="form.wechat_pay_enabled">
|
||||
<a-form-item label="AppID">
|
||||
<a-input v-model:value="form.wechat_app_id" placeholder="wx..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="商户号 MchID">
|
||||
<a-input v-model:value="form.wechat_mch_id" placeholder="16xxxxxxx" />
|
||||
</a-form-item>
|
||||
<a-form-item label="APIv3 密钥">
|
||||
<a-input-password
|
||||
v-model:value="form.wechat_api_v3_key"
|
||||
:placeholder="wechatKeyConfigured ? '留空则不修改' : '32 位 APIv3 密钥'"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="商户证书序列号">
|
||||
<a-input v-model:value="form.wechat_cert_serial" placeholder="证书 serial_no" />
|
||||
</a-form-item>
|
||||
<a-form-item label="商户私钥(PEM)">
|
||||
<a-textarea
|
||||
v-model:value="form.wechat_private_key"
|
||||
:rows="5"
|
||||
:placeholder="wechatPrivateConfigured ? '留空则不修改' : '-----BEGIN PRIVATE KEY-----...'"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="field-hint callback-hint">
|
||||
回调地址:{{ form.app_url }}/api/payments/notify/wechat
|
||||
</div>
|
||||
</template>
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
message="微信支付已关闭"
|
||||
description="开启后可配置微信 Native 扫码支付,用户购买额度时可选择微信支付。"
|
||||
/>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="alipay" tab="支付宝">
|
||||
<template #tab>
|
||||
<span><AlipayCircleOutlined /> 支付宝</span>
|
||||
</template>
|
||||
<a-form layout="vertical" class="settings-form">
|
||||
<a-form-item label="启用支付宝">
|
||||
<a-switch v-model:checked="form.alipay_pay_enabled" />
|
||||
<a-tag v-if="alipayConfigured" color="green" style="margin-left: 12px;">已配置</a-tag>
|
||||
<a-tag v-else-if="form.alipay_pay_enabled" color="orange" style="margin-left: 12px;">待完善配置</a-tag>
|
||||
</a-form-item>
|
||||
<template v-if="form.alipay_pay_enabled">
|
||||
<a-form-item label="AppID">
|
||||
<a-input v-model:value="form.alipay_app_id" placeholder="2021..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="应用私钥(RSA2 PEM)">
|
||||
<a-textarea
|
||||
v-model:value="form.alipay_private_key"
|
||||
:rows="5"
|
||||
:placeholder="alipayPrivateConfigured ? '留空则不修改' : '-----BEGIN RSA PRIVATE KEY-----...'"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="支付宝公钥(PEM)">
|
||||
<a-textarea
|
||||
v-model:value="form.alipay_public_key"
|
||||
:rows="5"
|
||||
placeholder="-----BEGIN PUBLIC KEY-----..."
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="沙箱环境">
|
||||
<a-switch v-model:checked="form.alipay_sandbox" />
|
||||
</a-form-item>
|
||||
<div class="field-hint callback-hint">
|
||||
回调地址:{{ form.app_url }}/api/payments/notify/alipay
|
||||
</div>
|
||||
</template>
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
message="支付宝已关闭"
|
||||
description="开启后可配置支付宝当面付扫码,用户购买额度时可选择支付宝。"
|
||||
/>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="orders" tab="订单记录">
|
||||
<template #tab>
|
||||
<span><UnorderedListOutlined /> 订单记录</span>
|
||||
</template>
|
||||
<PaymentOrderListPanel
|
||||
ref="orderListRef"
|
||||
:show-user-column="true"
|
||||
:manageable="true"
|
||||
default-status="paid"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.callback-hint {
|
||||
padding: 10px 12px;
|
||||
background: rgba(170, 59, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(170, 59, 255, 0.15);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.range-sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-tabs-tab) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-tabs-tab-active .ant-tabs-tab-btn) {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-tabs-ink-bar) {
|
||||
background: #aa3bff;
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-input),
|
||||
.payment-settings-page :deep(.ant-input-number),
|
||||
.payment-settings-page :deep(.ant-input-number-input),
|
||||
.payment-settings-page :deep(.ant-input-affix-wrapper),
|
||||
.payment-settings-page :deep(.ant-input-password .ant-input),
|
||||
.payment-settings-page :deep(textarea.ant-input) {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-input-disabled) {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-form-item-label > label) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 640px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.payment-settings-page :deep(.ant-input-number) {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,674 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
FilterOutlined,
|
||||
InboxOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import MessageBubble from '../components/MessageBubble.vue'
|
||||
import { parseMessageContent } from '../utils/messageContent'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const logs = ref([])
|
||||
const accounts = ref([])
|
||||
const loading = ref(false)
|
||||
const filterAccount = ref(undefined)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const expandedIds = ref(new Set())
|
||||
const detailModalVisible = ref(false)
|
||||
const detailModalText = ref('')
|
||||
const detailModalTitle = ref('')
|
||||
|
||||
const accountMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
accounts.value.map((acc) => [acc.id, acc.username || acc.phone || `账号 #${acc.id}`])
|
||||
)
|
||||
)
|
||||
|
||||
const accountSelectOptions = computed(() =>
|
||||
accounts.value.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: accountMap.value[acc.id] || `账号 #${acc.id}`
|
||||
}))
|
||||
)
|
||||
|
||||
const paginatedLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return logs.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '--'
|
||||
const normalized = /[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value}Z`
|
||||
const d = new Date(normalized)
|
||||
return isNaN(d.getTime()) ? value : d.toLocaleString()
|
||||
}
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = new URLSearchParams({ limit: '300' })
|
||||
if (filterAccount.value) params.append('account_id', filterAccount.value)
|
||||
const res = await api.get(`/received-messages?${params.toString()}`)
|
||||
logs.value = res.data
|
||||
currentPage.value = 1
|
||||
expandedIds.value = new Set()
|
||||
} catch (error) {
|
||||
message.error('获取接收消息日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const res = await api.get('/accounts')
|
||||
accounts.value = res.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getAccountName = (accountId) =>
|
||||
accountMap.value[accountId] || (accountId ? `账号 #${accountId}` : '未知')
|
||||
|
||||
const formatRawContent = (raw) => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return ''
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const isJsonContent = (raw) => {
|
||||
const text = String(raw || '').trim()
|
||||
return text.startsWith('{') || text.startsWith('[')
|
||||
}
|
||||
|
||||
const previewText = (raw, maxLen = 120) => {
|
||||
const formatted = formatRawContent(raw)
|
||||
if (formatted.length <= maxLen) return formatted
|
||||
return `${formatted.slice(0, maxLen)}…`
|
||||
}
|
||||
|
||||
const isExpanded = (id) => expandedIds.value.has(id)
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
const next = new Set(expandedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedIds.value = next
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
detailModalTitle.value = `${record.sender_name || '未知用户'} · ${formatTime(record.created_at)}`
|
||||
detailModalText.value = formatRawContent(record.raw_content)
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
const copyText = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('已复制')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const messageTypeLabel = (type) => {
|
||||
const map = { 1: '文本', 2: '图片', 3: '语音', 4: '视频', 5: '表情' }
|
||||
return map[type] || (type != null ? String(type) : '-')
|
||||
}
|
||||
|
||||
const messageTypeColor = (type) => {
|
||||
const map = { 1: 'blue', 2: 'cyan', 3: 'purple', 4: 'geekblue', 5: 'magenta' }
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const tryParseBubble = (raw) => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
const parsed = parseMessageContent(text)
|
||||
if (parsed.type !== 'text' || parsed.text !== text) return parsed
|
||||
return null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAccounts()
|
||||
await fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="received-page">
|
||||
<div class="page-header glass-card">
|
||||
<div class="header-info">
|
||||
<h2 style="margin: 0;">
|
||||
<InboxOutlined style="margin-right: 8px; color: #38bdf8;" />
|
||||
接收消息日志
|
||||
</h2>
|
||||
<p class="header-desc">
|
||||
仅记录收到的消息,内容为通道原样保存,不含自动回复记录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a-space size="middle" wrap>
|
||||
<a-select
|
||||
v-model:value="filterAccount"
|
||||
placeholder="全部账号"
|
||||
style="width: 180px;"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="accountSelectOptions"
|
||||
@change="fetchLogs"
|
||||
>
|
||||
<template #suffixIcon><FilterOutlined /></template>
|
||||
</a-select>
|
||||
|
||||
<a-button type="primary" class="gradient-btn" :loading="loading" @click="fetchLogs">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">记录总数</span>
|
||||
<span class="stat-value">{{ logs.length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">当前页</span>
|
||||
<span class="stat-value">{{ paginatedLogs.length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">筛选账号</span>
|
||||
<span class="stat-value stat-value--text">
|
||||
{{ filterAccount ? getAccountName(filterAccount) : '全部' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card log-panel">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="paginatedLogs.length" class="log-feed">
|
||||
<div v-for="record in paginatedLogs" :key="record.id" class="log-item">
|
||||
<div class="log-item-main">
|
||||
<div class="log-item-top">
|
||||
<div class="log-tags">
|
||||
<a-tag :color="messageTypeColor(record.message_type)">
|
||||
{{ messageTypeLabel(record.message_type) }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.server_message_id" color="default">
|
||||
ID {{ record.server_message_id }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span class="log-meta-item">
|
||||
<ClockCircleOutlined />
|
||||
{{ formatTime(record.created_at) }}
|
||||
</span>
|
||||
<span class="log-meta-item">
|
||||
<UserOutlined />
|
||||
{{ getAccountName(record.account_id) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-row">
|
||||
<UserAvatar
|
||||
:name="record.sender_name"
|
||||
:src="record.sender_avatar"
|
||||
:size="36"
|
||||
/>
|
||||
<div class="sender-info">
|
||||
<div class="sender-name">{{ record.sender_name || '未知用户' }}</div>
|
||||
<div v-if="record.sender_id" class="sender-id">ID: {{ record.sender_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tryParseBubble(record.raw_content)" class="bubble-preview">
|
||||
<MessageBubble :content="record.raw_content" compact />
|
||||
</div>
|
||||
|
||||
<div class="raw-wrap">
|
||||
<pre
|
||||
class="raw-content"
|
||||
:class="{
|
||||
'raw-content--json': isJsonContent(record.raw_content),
|
||||
'raw-content--collapsed': !isExpanded(record.id)
|
||||
}"
|
||||
>{{ isExpanded(record.id) ? formatRawContent(record.raw_content) : previewText(record.raw_content) }}</pre>
|
||||
|
||||
<div class="raw-actions">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="detail-action-btn"
|
||||
@click="toggleExpand(record.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<UpOutlined v-if="isExpanded(record.id)" />
|
||||
<DownOutlined v-else />
|
||||
</template>
|
||||
{{ isExpanded(record.id) ? '收起' : '展开' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="detail-action-btn"
|
||||
@click="openDetail(record)"
|
||||
>
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="detail-action-btn"
|
||||
@click="copyText(formatRawContent(record.raw_content))"
|
||||
>
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<InboxOutlined class="empty-icon" />
|
||||
<p>暂无接收消息记录</p>
|
||||
<span class="empty-hint">启动账号托管并收到粉丝私信后,原始消息会记录在此</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length" class="log-pagination">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="logs.length"
|
||||
:page-size-options="['15', '30', '50']"
|
||||
show-size-changer
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="detailModalVisible"
|
||||
:title="detailModalTitle"
|
||||
:width="isMobile ? 'calc(100vw - 32px)' : 820"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
wrap-class-name="received-detail-modal-wrap"
|
||||
class="received-detail-modal"
|
||||
>
|
||||
<div class="detail-modal-toolbar">
|
||||
<span class="detail-modal-meta">{{ detailModalText.length }} 字符</span>
|
||||
<a-button size="small" class="detail-copy-btn" @click="copyText(detailModalText)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制全文
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="detail-code-view">
|
||||
<pre class="detail-code-text">{{ detailModalText }}</pre>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.received-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-value--text {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.log-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.log-item-main {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.log-item-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.log-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sender-id {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bubble-preview {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.raw-wrap {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.raw-content {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: hsl(230, 22%, 7%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #cbd5e1;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.raw-content--json {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.raw-content--collapsed {
|
||||
max-height: 4.8em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.raw-content--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1.6em;
|
||||
background: linear-gradient(transparent, hsl(230, 22%, 7%));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.raw-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.detail-action-btn {
|
||||
color: #c084fc !important;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 56px 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-modal-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-modal-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-copy-btn {
|
||||
background: rgba(192, 132, 252, 0.12) !important;
|
||||
border: 1px solid rgba(192, 132, 252, 0.35) !important;
|
||||
color: #e9d5ff !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.detail-code-view {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: hsl(230, 22%, 7%);
|
||||
}
|
||||
|
||||
.detail-code-text {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #e2e8f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selector) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selection-placeholder) {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selection-item) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.ant-tag-default) {
|
||||
color: #e2e8f0 !important;
|
||||
background: rgba(148, 163, 184, 0.2) !important;
|
||||
border-color: rgba(203, 213, 225, 0.35) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item),
|
||||
.log-pagination :deep(.ant-pagination-item-link) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item-active) {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item-active a) {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions :deep(.ant-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.header-actions .gradient-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
padding: 14px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.log-item-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
FilterOutlined,
|
||||
DeleteOutlined,
|
||||
BugOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined,
|
||||
CopyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import MessageBubble from '../components/MessageBubble.vue'
|
||||
import {
|
||||
parseMessageContent,
|
||||
extractUrlsFromDetail
|
||||
} from '../utils/messageContent'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isMobile = useIsMobile()
|
||||
const detailModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 820))
|
||||
const logs = ref([])
|
||||
const accounts = ref([])
|
||||
const loading = ref(false)
|
||||
const filterAccount = ref(undefined)
|
||||
const filterLevel = ref(undefined)
|
||||
const filterCategory = ref(undefined)
|
||||
const expandedIds = ref(new Set())
|
||||
const detailModalVisible = ref(false)
|
||||
const detailModalText = ref('')
|
||||
const detailModalLevel = ref('info')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(15)
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '--'
|
||||
const normalized = /[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value}Z`
|
||||
const d = new Date(normalized)
|
||||
return isNaN(d.getTime()) ? value : d.toLocaleString()
|
||||
}
|
||||
|
||||
const levelOptions = [
|
||||
{ value: 'error', label: '错误' },
|
||||
{ value: 'warning', label: '警告' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'info', label: '信息' }
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'send', label: '发送' },
|
||||
{ value: 'recv', label: '接收' },
|
||||
{ value: 'ws', label: '实时连接' },
|
||||
{ value: 'poll', label: '会话轮询' },
|
||||
{ value: 'auth', label: '鉴权/凭证' },
|
||||
{ value: 'system', label: '系统' }
|
||||
]
|
||||
|
||||
const paginatedLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return logs.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = new URLSearchParams({ limit: '300' })
|
||||
if (filterAccount.value) params.append('account_id', filterAccount.value)
|
||||
if (filterLevel.value) params.append('level', filterLevel.value)
|
||||
if (filterCategory.value) params.append('category', filterCategory.value)
|
||||
const res = await api.get(`/system-logs?${params.toString()}`)
|
||||
logs.value = res.data
|
||||
currentPage.value = 1
|
||||
expandedIds.value = new Set()
|
||||
} catch (error) {
|
||||
message.error('获取系统诊断日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const res = await api.get(`/accounts`)
|
||||
accounts.value = res.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空系统诊断日志?',
|
||||
content: '将同时清除内存缓冲区与数据库中的历史诊断记录,此操作不可恢复。',
|
||||
okText: '清空',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await api.delete(`/system-logs`)
|
||||
message.success('已清空诊断日志')
|
||||
fetchLogs()
|
||||
} catch (error) {
|
||||
message.error('清空失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const levelColor = (level) => {
|
||||
const map = { error: 'error', warning: 'warning', success: 'success', info: 'processing' }
|
||||
return map[level] || 'default'
|
||||
}
|
||||
|
||||
const levelLabel = (level) => {
|
||||
const map = { error: '错误', warning: '警告', success: '成功', info: '信息' }
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
const categoryLabel = (category) => {
|
||||
const found = categoryOptions.find(c => c.value === category)
|
||||
return found ? found.label : category
|
||||
}
|
||||
|
||||
const categoryColor = (category) => {
|
||||
const map = {
|
||||
send: 'purple',
|
||||
recv: 'cyan',
|
||||
ws: 'geekblue',
|
||||
poll: 'blue',
|
||||
auth: 'gold',
|
||||
system: 'default'
|
||||
}
|
||||
return map[category] || 'default'
|
||||
}
|
||||
|
||||
const getAccountName = (accountId) => {
|
||||
if (!accountId) return '全局'
|
||||
const acc = accounts.value.find(a => a.id === accountId)
|
||||
return acc ? (acc.username || `账号 #${acc.id}`) : `账号 #${accountId}`
|
||||
}
|
||||
|
||||
const accountSelectOptions = computed(() =>
|
||||
accounts.value.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: acc.username || acc.phone || `账号 #${acc.id}`
|
||||
}))
|
||||
)
|
||||
|
||||
const isLongDetail = (detail) => (detail || '').length > 160
|
||||
|
||||
const isExpanded = (id) => expandedIds.value.has(id)
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
const next = new Set(expandedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedIds.value = next
|
||||
}
|
||||
|
||||
const openDetailModal = (detail, level = 'info') => {
|
||||
detailModalText.value = detail || ''
|
||||
detailModalLevel.value = level || 'info'
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
const formatDetailLines = (detail) => {
|
||||
const text = (detail || '').trim()
|
||||
if (!text) return []
|
||||
const byNewline = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
||||
if (byNewline.length > 1) return byNewline
|
||||
const bySemicolon = text.split(/;\s+(?=[A-Za-z_[\u4e00-\u9fa5])/).map(s => s.trim()).filter(Boolean)
|
||||
if (bySemicolon.length > 1) return bySemicolon
|
||||
const byPipe = text.split(/\s\|\s+/).map(s => s.trim()).filter(Boolean)
|
||||
if (byPipe.length > 1) return byPipe
|
||||
if (text.length > 180) {
|
||||
return text.match(/.{1,120}(\s|$)/g)?.map(s => s.trim()).filter(Boolean) || [text]
|
||||
}
|
||||
return [text]
|
||||
}
|
||||
|
||||
const detailModalLines = computed(() => formatDetailLines(detailModalText.value))
|
||||
|
||||
const copyDetail = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '')
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const extractMessageFromDetail = (detail) => {
|
||||
const text = String(detail || '')
|
||||
if (!text) return null
|
||||
const patterns = [
|
||||
/收到[::]\s*([^((|\n]+)/,
|
||||
/发送[::]\s*([^((|\n]+)/,
|
||||
/内容[::]\s*([^((|\n]+)/,
|
||||
/会话\s+[^::]+[::]\s*(.+)/
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
if (match?.[1]) {
|
||||
const candidate = match[1].trim()
|
||||
if (candidate) return parseMessageContent(candidate)
|
||||
}
|
||||
}
|
||||
if (text.trim().startsWith('{')) {
|
||||
return parseMessageContent(text.trim())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const detailMessageContent = (detail) => {
|
||||
const msg = extractMessageFromDetail(detail)
|
||||
if (!msg) return ''
|
||||
if (msg.type === 'text') return msg.text || ''
|
||||
return JSON.stringify(msg)
|
||||
}
|
||||
|
||||
const isRichDetailMessage = (detail) => {
|
||||
const msg = extractMessageFromDetail(detail)
|
||||
return msg && msg.type !== 'text'
|
||||
}
|
||||
|
||||
const mediaUrlsInDetail = (detail) => extractUrlsFromDetail(detail).slice(0, 3)
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccounts()
|
||||
fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="syslogs-container">
|
||||
<div class="page-header glass-card">
|
||||
<div class="header-info">
|
||||
<h2 style="margin: 0;">
|
||||
<BugOutlined style="margin-right: 8px; color: #c084fc;" />
|
||||
系统诊断日志
|
||||
</h2>
|
||||
<p class="header-desc">
|
||||
追踪每个账号私信收发、实时连接、鉴权等链路事件;收发失败时这里会记录具体原因。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a-space size="middle" wrap>
|
||||
<a-select
|
||||
v-model:value="filterAccount"
|
||||
placeholder="账号"
|
||||
style="width: 180px;"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="accountSelectOptions"
|
||||
@change="fetchLogs"
|
||||
>
|
||||
<template #suffixIcon><FilterOutlined /></template>
|
||||
</a-select>
|
||||
|
||||
<a-select
|
||||
v-model:value="filterLevel"
|
||||
placeholder="级别"
|
||||
style="width: 120px;"
|
||||
allow-clear
|
||||
@change="fetchLogs"
|
||||
>
|
||||
<a-select-option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-select
|
||||
v-model:value="filterCategory"
|
||||
placeholder="类型"
|
||||
style="width: 130px;"
|
||||
allow-clear
|
||||
@change="fetchLogs"
|
||||
>
|
||||
<a-select-option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button type="primary" class="gradient-btn" @click="fetchLogs">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<a-button v-if="auth.isAdmin" danger @click="clearLogs">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
清空
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card log-panel">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="logs.length" class="log-feed">
|
||||
<div
|
||||
v-for="log in paginatedLogs"
|
||||
:key="log.id"
|
||||
class="log-item"
|
||||
:class="`log-item--${log.level || 'info'}`"
|
||||
>
|
||||
<div class="log-item-main">
|
||||
<div class="log-item-top">
|
||||
<div class="log-tags">
|
||||
<a-tag :color="levelColor(log.level)">{{ levelLabel(log.level) }}</a-tag>
|
||||
<a-tag :color="categoryColor(log.category)">{{ categoryLabel(log.category) }}</a-tag>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span class="log-meta-item">
|
||||
<ClockCircleOutlined />
|
||||
{{ formatTime(log.created_at) }}
|
||||
</span>
|
||||
<span class="log-meta-item">
|
||||
<UserOutlined />
|
||||
{{ getAccountName(log.account_id) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-event">{{ log.event }}</div>
|
||||
|
||||
<div v-if="log.detail" class="log-detail-wrap">
|
||||
<div
|
||||
v-if="(log.category === 'recv' || log.category === 'send') && isRichDetailMessage(log.detail)"
|
||||
class="log-media-preview"
|
||||
>
|
||||
<MessageBubble
|
||||
:content="detailMessageContent(log.detail)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mediaUrlsInDetail(log.detail).length"
|
||||
class="log-media-thumbs"
|
||||
>
|
||||
<img
|
||||
v-for="(url, idx) in mediaUrlsInDetail(log.detail)"
|
||||
:key="`${log.id}-media-${idx}`"
|
||||
:src="url"
|
||||
alt="消息媒体"
|
||||
class="log-media-thumb"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
class="log-detail"
|
||||
:class="{
|
||||
'log-detail--error': log.level === 'error',
|
||||
'log-detail--collapsed': isLongDetail(log.detail) && !isExpanded(log.id)
|
||||
}"
|
||||
>{{ log.detail }}</pre>
|
||||
|
||||
<div v-if="isLongDetail(log.detail)" class="log-detail-actions">
|
||||
<a-button type="link" size="small" class="detail-action-btn" @click="toggleExpand(log.id)">
|
||||
<template #icon>
|
||||
<UpOutlined v-if="isExpanded(log.id)" />
|
||||
<DownOutlined v-else />
|
||||
</template>
|
||||
{{ isExpanded(log.id) ? '收起' : '展开' }}
|
||||
</a-button>
|
||||
<a-button type="link" size="small" class="detail-action-btn" @click="openDetailModal(log.detail, log.level)">
|
||||
查看全文
|
||||
</a-button>
|
||||
<a-button type="link" size="small" class="detail-action-btn" @click="copyDetail(log.detail)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<BugOutlined class="empty-icon" />
|
||||
<p>暂无诊断日志,启动托管后将实时记录收发链路事件</p>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length > pageSize" class="log-pagination">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
:total="logs.length"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="false"
|
||||
show-less-items
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="detailModalVisible"
|
||||
:footer="null"
|
||||
:width="detailModalWidth"
|
||||
destroyOnClose
|
||||
wrap-class-name="log-detail-modal-wrap"
|
||||
class="log-detail-modal"
|
||||
>
|
||||
<template #title>
|
||||
<div class="detail-modal-title">
|
||||
<span>日志详情</span>
|
||||
<a-tag :color="levelColor(detailModalLevel)">{{ levelLabel(detailModalLevel) }}</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="detail-modal-toolbar">
|
||||
<span class="detail-modal-meta">共 {{ detailModalLines.length }} 段 · {{ detailModalText.length }} 字符</span>
|
||||
<a-button size="small" class="detail-copy-btn" @click="copyDetail(detailModalText)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制全文
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="detail-code-view" :class="`detail-code-view--${detailModalLevel}`">
|
||||
<div
|
||||
v-for="(line, index) in detailModalLines"
|
||||
:key="index"
|
||||
class="detail-line"
|
||||
>
|
||||
<span class="line-no">{{ index + 1 }}</span>
|
||||
<code class="line-text">{{ line }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.log-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.log-item--error {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.log-item--warning {
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.log-item--success {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.log-item--info {
|
||||
border-left: 3px solid #6366f1;
|
||||
}
|
||||
|
||||
.log-item-main {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.log-item-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.log-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-event {
|
||||
color: #f3f4f6;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-detail-wrap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: hsla(230, 20%, 6%, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #cbd5e1;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-detail--error {
|
||||
color: #fecaca;
|
||||
background: hsla(360, 60%, 8%, 0.9);
|
||||
border-color: rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
|
||||
.log-detail--collapsed {
|
||||
max-height: 4.8em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.log-detail--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1.6em;
|
||||
background: linear-gradient(transparent, hsla(230, 20%, 8%, 0.98));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.log-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.log-detail-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.log-media-preview,
|
||||
.log-media-thumbs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-media-thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-media-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.detail-action-btn {
|
||||
color: #c084fc !important;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 56px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-modal-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-modal-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-copy-btn {
|
||||
background: rgba(192, 132, 252, 0.12) !important;
|
||||
border: 1px solid rgba(192, 132, 252, 0.35) !important;
|
||||
color: #e9d5ff !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.detail-code-view {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: hsl(230, 22%, 7%);
|
||||
}
|
||||
|
||||
.detail-code-view--error {
|
||||
border-color: rgba(248, 113, 113, 0.25);
|
||||
box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.06);
|
||||
}
|
||||
|
||||
.detail-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.detail-line:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-line:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.line-no {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
.line-text {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #e2e8f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-code-view--error .line-text {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selector) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selection-placeholder) {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
:deep(.ant-select-selection-item) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.ant-tag-default) {
|
||||
color: #e2e8f0 !important;
|
||||
background: rgba(148, 163, 184, 0.2) !important;
|
||||
border-color: rgba(203, 213, 225, 0.35) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item),
|
||||
.log-pagination :deep(.ant-pagination-item-link) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item-active) {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.log-pagination :deep(.ant-pagination-item-active a) {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions :deep(.ant-space) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions :deep(.ant-select) {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.header-actions :deep(.ant-space-item) {
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.log-item-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-modal-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-copy-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.log-detail-modal-wrap .ant-modal-content {
|
||||
background: hsl(230, 20%, 11%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-detail-modal-wrap .ant-modal-header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||
padding: 16px 20px !important;
|
||||
}
|
||||
|
||||
.log-detail-modal-wrap .ant-modal-body {
|
||||
padding: 16px 20px 20px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.log-detail-modal-wrap .ant-modal-close {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.log-detail-modal-wrap .ant-modal-close:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,766 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import api from '../api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
|
||||
|
||||
const pageCurrent = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const paginatedUsers = computed(() => {
|
||||
const start = (pageCurrent.value - 1) * pageSize.value
|
||||
return users.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const paginationConfig = computed(() => ({
|
||||
current: pageCurrent.value,
|
||||
pageSize: pageSize.value,
|
||||
total: users.value.length,
|
||||
showSizeChanger: !isMobile.value,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
size: isMobile.value ? 'small' : 'default',
|
||||
onChange: (page, size) => {
|
||||
pageCurrent.value = page
|
||||
pageSize.value = size
|
||||
}
|
||||
}))
|
||||
|
||||
const auth = useAuthStore()
|
||||
const users = ref([])
|
||||
const roles = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('新增用户')
|
||||
const editingId = ref(null)
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
email_verified: true,
|
||||
role: 'operator',
|
||||
is_active: true,
|
||||
max_accounts: 3
|
||||
})
|
||||
|
||||
const defaultRegisterMaxAccounts = ref(3)
|
||||
const emailVerificationRequired = ref(true)
|
||||
const emailBindingRequired = ref(false)
|
||||
|
||||
const isAdminRole = computed(() => userForm.value.role === 'admin')
|
||||
const emailRequiredForRole = computed(
|
||||
() => emailBindingRequired.value && !isAdminRole.value
|
||||
)
|
||||
|
||||
const roleOptions = ref([
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'operator', label: '运营' },
|
||||
{ value: 'viewer', label: '只读' }
|
||||
])
|
||||
|
||||
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/users')
|
||||
users.value = res.data
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/roles')
|
||||
roles.value = res.data.roles
|
||||
if (roles.value.length) {
|
||||
roleOptions.value = roles.value
|
||||
}
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDefaultMaxAccounts = async () => {
|
||||
try {
|
||||
const res = await api.get('/settings')
|
||||
defaultRegisterMaxAccounts.value = res.data.default_register_max_accounts ?? 3
|
||||
emailVerificationRequired.value = res.data.email_verification_required !== false
|
||||
emailBindingRequired.value = !!res.data.email_binding_required
|
||||
} catch {
|
||||
defaultRegisterMaxAccounts.value = 3
|
||||
emailVerificationRequired.value = true
|
||||
emailBindingRequired.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAdd = () => {
|
||||
editingId.value = null
|
||||
modalTitle.value = '新增用户'
|
||||
userForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
email_verified: true,
|
||||
role: 'operator',
|
||||
is_active: true,
|
||||
max_accounts: defaultRegisterMaxAccounts.value
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = (record) => {
|
||||
editingId.value = record.id
|
||||
modalTitle.value = '编辑用户'
|
||||
userForm.value = {
|
||||
username: record.username,
|
||||
password: '',
|
||||
display_name: record.display_name || record.username,
|
||||
email: record.email || '',
|
||||
email_verified: !!record.email_verified,
|
||||
role: record.role,
|
||||
is_active: record.is_active,
|
||||
max_accounts: record.role === 'admin' ? defaultRegisterMaxAccounts.value : resolveAccountLimit(record)
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const onEmailChange = () => {
|
||||
if (!userForm.value.email?.trim()) {
|
||||
userForm.value.email_verified = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const email = userForm.value.email?.trim() || null
|
||||
|
||||
if (emailRequiredForRole.value && !email) {
|
||||
message.warning('当前系统要求非管理员用户必须绑定邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
if (!editingId.value) {
|
||||
if (!userForm.value.username.trim() || !userForm.value.password) {
|
||||
message.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.post('/users', {
|
||||
username: userForm.value.username.trim(),
|
||||
password: userForm.value.password,
|
||||
display_name: userForm.value.display_name || userForm.value.username,
|
||||
role: userForm.value.role,
|
||||
email: email || undefined,
|
||||
email_verified: email ? userForm.value.email_verified : true,
|
||||
max_accounts: userForm.value.max_accounts
|
||||
})
|
||||
message.success('用户创建成功')
|
||||
modalVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '创建失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
display_name: userForm.value.display_name,
|
||||
role: userForm.value.role,
|
||||
is_active: userForm.value.is_active,
|
||||
email: email
|
||||
}
|
||||
if (email) {
|
||||
payload.email_verified = userForm.value.email_verified
|
||||
}
|
||||
if (userForm.value.password) {
|
||||
payload.password = userForm.value.password
|
||||
}
|
||||
if (!isAdminRole.value) {
|
||||
payload.max_accounts = userForm.value.max_accounts
|
||||
}
|
||||
try {
|
||||
await api.put(`/users/${editingId.value}`, payload)
|
||||
message.success('用户更新成功')
|
||||
modalVisible.value = false
|
||||
fetchUsers()
|
||||
if (auth.user?.id === editingId.value) {
|
||||
await auth.fetchMe()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.detail || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: `确定删除用户「${record.username}」吗?`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
await api.delete(`/users/${record.id}`)
|
||||
message.success('已删除')
|
||||
fetchUsers()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getRoleLabel = (role) => roleOptions.value.find(r => r.value === role)?.label || role
|
||||
|
||||
const roleTagColor = (role) => {
|
||||
if (role === 'admin') return 'purple'
|
||||
if (role === 'operator') return 'geekblue'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const emailVerifyLabel = (record) => {
|
||||
if (!record.email) {
|
||||
if (record.role === 'admin') return '无需验证'
|
||||
if (emailBindingRequired.value) return '需绑定邮箱'
|
||||
return '无需验证'
|
||||
}
|
||||
if (!emailVerificationRequired.value) {
|
||||
return record.email_verified ? '已验证' : '未验证(可登录)'
|
||||
}
|
||||
return record.email_verified ? '已验证' : '未验证'
|
||||
}
|
||||
|
||||
const emailVerifyColor = (record) => {
|
||||
if (!record.email) {
|
||||
if (record.role === 'admin') return 'default'
|
||||
if (emailBindingRequired.value) return 'gold'
|
||||
return 'default'
|
||||
}
|
||||
if (!emailVerificationRequired.value && !record.email_verified) return 'geekblue'
|
||||
return record.email_verified ? 'green' : 'gold'
|
||||
}
|
||||
|
||||
const isUnlimitedQuota = (record) => record.role === 'admin'
|
||||
|
||||
const resolveAccountLimit = (record) => {
|
||||
if (isUnlimitedQuota(record)) return null
|
||||
const raw = record.max_accounts
|
||||
if (raw == null || raw < 0) return 3
|
||||
return raw
|
||||
}
|
||||
|
||||
const accountQuotaLabel = (record) => {
|
||||
const total = record.account_count ?? 0
|
||||
const active = record.active_account_count ?? total
|
||||
const disabled = record.disabled_account_count ?? Math.max(0, total - active)
|
||||
|
||||
if (isUnlimitedQuota(record)) {
|
||||
return total > 0 ? `${total} 个 / 不限` : '0 个 / 不限'
|
||||
}
|
||||
|
||||
const limit = resolveAccountLimit(record)
|
||||
if (disabled > 0) {
|
||||
return `${active}/${limit}(停用 ${disabled})`
|
||||
}
|
||||
return `${total}/${limit}`
|
||||
}
|
||||
|
||||
watch(users, (list) => {
|
||||
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||
if (pageCurrent.value > maxPage) {
|
||||
pageCurrent.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
fetchDefaultMaxAccounts()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<div class="page-header glass-card">
|
||||
<div class="page-header-main">
|
||||
<h2 class="page-title">
|
||||
<TeamOutlined class="page-title-icon" />
|
||||
用户与角色管理
|
||||
</h2>
|
||||
<p class="subtitle">管理员可创建用户并分配角色,实现数据隔离与权限控制</p>
|
||||
</div>
|
||||
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增用户
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格 -->
|
||||
<div v-if="!isMobile" class="glass-card table-card">
|
||||
<a-table
|
||||
:data-source="paginatedUsers"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:pagination="paginationConfig"
|
||||
:scroll="{ x: 960 }"
|
||||
>
|
||||
<a-table-column title="ID" data-index="id" key="id" :width="72" />
|
||||
<a-table-column title="用户名" data-index="username" key="username" />
|
||||
<a-table-column title="邮箱" key="email" :width="220">
|
||||
<template #default="{ record }">
|
||||
<span :class="{ 'text-muted': !record.email }">{{ record.email || '未绑定' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="邮箱验证" key="email_verified" :width="110">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="emailVerifyColor(record)">
|
||||
{{ emailVerifyLabel(record) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="显示名" data-index="display_name" key="display_name" />
|
||||
<a-table-column title="角色" key="role">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="roleTagColor(record.role)">
|
||||
{{ getRoleLabel(record.role) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="托管额度" key="max_accounts" :width="148">
|
||||
<template #default="{ record }">
|
||||
{{ accountQuotaLabel(record) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="is_active">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="record.is_active ? 'green' : 'red'">
|
||||
{{ record.is_active ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" key="created_at">
|
||||
<template #default="{ record }">
|
||||
{{ new Date(record.created_at).toLocaleString() }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" key="action" width="180px">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" style="color: #c084fc;" @click="openEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.id !== auth.user?.id"
|
||||
type="text"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 手机端:卡片列表 -->
|
||||
<div v-else class="users-mobile-list">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="paginatedUsers.length" class="user-card-list">
|
||||
<div v-for="record in paginatedUsers" :key="record.id" class="user-card glass-card">
|
||||
<div class="user-card-head">
|
||||
<div>
|
||||
<div class="user-card-name">
|
||||
{{ record.username }}
|
||||
<span class="user-card-id">#{{ record.id }}</span>
|
||||
</div>
|
||||
<div class="user-card-display">{{ record.display_name || record.username }}</div>
|
||||
</div>
|
||||
<a-tag :color="roleTagColor(record.role)">
|
||||
{{ getRoleLabel(record.role) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="user-card-meta">
|
||||
<div class="user-card-row">
|
||||
<span class="user-card-label">邮箱</span>
|
||||
<span :class="{ 'text-muted': !record.email }" class="user-card-value">
|
||||
{{ record.email || '未绑定' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-card-row">
|
||||
<span class="user-card-label">邮箱验证</span>
|
||||
<a-tag :color="emailVerifyColor(record)">
|
||||
{{ emailVerifyLabel(record) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="user-card-row">
|
||||
<span class="user-card-label">托管额度</span>
|
||||
<span class="user-card-value">{{ accountQuotaLabel(record) }}</span>
|
||||
</div>
|
||||
<div class="user-card-row">
|
||||
<span class="user-card-label">状态</span>
|
||||
<a-tag :color="record.is_active ? 'green' : 'red'">
|
||||
{{ record.is_active ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="user-card-row">
|
||||
<span class="user-card-label">创建时间</span>
|
||||
<span class="user-card-value user-card-time">
|
||||
{{ new Date(record.created_at).toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-card-actions">
|
||||
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.id !== auth.user?.id"
|
||||
type="text"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无用户" />
|
||||
</a-spin>
|
||||
|
||||
<div v-if="users.length" class="users-mobile-pagination">
|
||||
<a-pagination
|
||||
v-model:current="pageCurrent"
|
||||
v-model:page-size="pageSize"
|
||||
:total="users.length"
|
||||
:show-size-changer="false"
|
||||
size="small"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="modalVisible"
|
||||
:title="modalTitle"
|
||||
:width="modalWidth"
|
||||
@ok="handleSave"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
|
||||
<a-form-item v-if="!editingId" label="用户名" required>
|
||||
<a-input v-model:value="userForm.username" placeholder="登录用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
|
||||
<a-input-password v-model:value="userForm.password" placeholder="至少 6 位" />
|
||||
</a-form-item>
|
||||
<a-form-item label="显示名称">
|
||||
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="emailRequiredForRole ? '邮箱(必填)' : '邮箱'">
|
||||
<a-input
|
||||
v-model:value="userForm.email"
|
||||
:placeholder="emailRequiredForRole ? '非管理员用户必须绑定邮箱' : '选填,绑定后可用于邮箱验证登录'"
|
||||
@change="onEmailChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="hasEmail" label="邮箱验证状态">
|
||||
<a-switch
|
||||
v-model:checked="userForm.email_verified"
|
||||
checked-children="已验证"
|
||||
un-checked-children="未验证"
|
||||
/>
|
||||
<div class="field-hint">
|
||||
<template v-if="emailVerificationRequired">
|
||||
设为「已验证」后,用户可直接登录;设为「未验证」则需完成邮箱验证
|
||||
</template>
|
||||
<template v-else>
|
||||
系统已关闭全局邮箱验证,用户未验证也可登录;此处可手动调整验证状态
|
||||
</template>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="userForm.role" :options="roleOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
||||
<a-input-number
|
||||
v-model:value="userForm.max_accounts"
|
||||
:min="0"
|
||||
:max="999"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<div class="field-hint">该用户最多可添加的抖音托管账号数量</div>
|
||||
</a-form-item>
|
||||
<a-form-item v-else label="可添加抖音账号数">
|
||||
<span class="text-muted">管理员不限制</span>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editingId" label="账号状态">
|
||||
<a-switch v-model:checked="userForm.is_active" checked-children="启用" un-checked-children="禁用" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<div class="glass-card role-help">
|
||||
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
|
||||
<ul class="role-list">
|
||||
<li><strong>管理员</strong>:管理所有抖音账号、用户、全局规则与系统日志</li>
|
||||
<li><strong>运营</strong>:管理自己创建的抖音账号、规则与私信(不可见他人数据)</li>
|
||||
<li><strong>只读</strong>:仅查看自己账号的数据,不可修改或发送</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.page-title-icon {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gradient-btn {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||
border: none !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.table-card :deep(.ant-table-wrapper) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: hsla(230, 20%, 12%, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
transform: none;
|
||||
background: hsla(230, 20%, 14%, 0.9);
|
||||
border-color: rgba(192, 132, 252, 0.2);
|
||||
}
|
||||
|
||||
.users-page :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.users-page :deep(.ant-table) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.users-page :deep(.ant-table-thead > tr > th) {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
.users-page :deep(.ant-table-tbody > tr > td) {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.users-page :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: rgba(170, 59, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.user-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-card-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-card-id {
|
||||
margin-left: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.user-card-display {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.user-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-card-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.user-card-value {
|
||||
text-align: right;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-card-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.users-mobile-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.user-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.add-user-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-help {
|
||||
margin-top: 16px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
padding-left: 18px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user