This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
DashboardOutlined,
UserOutlined,
SettingOutlined,
FileTextOutlined,
RobotOutlined,
ApiOutlined,
MessageOutlined,
BugOutlined,
TeamOutlined,
LogoutOutlined,
ControlOutlined,
PayCircleOutlined,
UnorderedListOutlined,
QuestionCircleOutlined,
RocketOutlined,
CloudDownloadOutlined,
InboxOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from './stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const selectedKeys = computed(() => [route.path])
const isLoginPage = computed(() => route.path === '/login')
const navigate = ({ key }) => {
router.push(key)
}
const handleLogout = () => {
auth.logout()
message.success('已退出登录')
router.push('/login')
}
</script>
<template>
<router-view v-if="isLoginPage" />
<a-layout v-else style="min-height: 100vh;">
<a-layout-sider width="240" collapsible breakpoint="lg" class="app-sider">
<div class="logo-container">
<RobotOutlined class="logo-icon" />
<span class="logo-text">抖音回复助手</span>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
theme="dark"
mode="inline"
@click="navigate"
class="custom-menu"
>
<a-menu-item key="/">
<template #icon><DashboardOutlined /></template>
<span>数据概览</span>
</a-menu-item>
<a-menu-item key="/accounts">
<template #icon><UserOutlined /></template>
<span>账号管理</span>
</a-menu-item>
<a-menu-item key="/messages">
<template #icon><MessageOutlined /></template>
<span>私信收发</span>
</a-menu-item>
<a-menu-item key="/rules">
<template #icon><SettingOutlined /></template>
<span>自动回复规则</span>
</a-menu-item>
<a-menu-item key="/logs">
<template #icon><FileTextOutlined /></template>
<span>回复日志面板</span>
</a-menu-item>
<a-menu-item key="/received-messages">
<template #icon><InboxOutlined /></template>
<span>接收消息日志</span>
</a-menu-item>
<a-menu-item key="/system-logs">
<template #icon><BugOutlined /></template>
<span>系统诊断日志</span>
</a-menu-item>
<a-menu-item key="/download">
<template #icon><CloudDownloadOutlined /></template>
<span>软件下载</span>
</a-menu-item>
<a-menu-item key="/help">
<template #icon><QuestionCircleOutlined /></template>
<span>帮助中心</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/users">
<template #icon><TeamOutlined /></template>
<span>用户与角色</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/settings">
<template #icon><ControlOutlined /></template>
<span>系统设置</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/desktop-update">
<template #icon><RocketOutlined /></template>
<span>桌面端升级</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/payment-settings">
<template #icon><PayCircleOutlined /></template>
<span>支付配置</span>
</a-menu-item>
<a-menu-item v-if="!auth.isAdmin" key="/payment-orders">
<template #icon><UnorderedListOutlined /></template>
<span>我的订单</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header class="app-header">
<div class="header-left">
<h2 class="header-title">
{{ route.name === 'Dashboard' ? '数据中心' : route.name === 'Accounts' ? '账号中心' : route.name === 'Messages' ? '私信中心' : route.name === 'Rules' ? '策略中心' : route.name === 'ReceivedMessages' ? '接收消息日志' : route.name === 'SystemLogs' ? '诊断中心' : route.name === 'Users' ? '权限中心' : route.name === 'Settings' ? '系统设置' : route.name === 'DesktopUpdate' ? '桌面端升级' : route.name === 'PaymentSettings' ? '支付配置' : route.name === 'MyPaymentOrders' ? '我的订单' : route.name === 'Download' ? '软件下载' : route.name === 'Help' ? '帮助中心' : '日志中心' }}
</h2>
</div>
<div class="header-right">
<a-space size="middle">
<span class="header-status">
<ApiOutlined style="margin-right: 6px; color: #aa3bff;" />
后端引擎已连接
</span>
<a-tag color="purple">{{ auth.roleLabel }}</a-tag>
<span class="user-name">{{ auth.user?.display_name || auth.user?.username }}</span>
<a-button type="text" class="logout-btn" @click="handleLogout">
<LogoutOutlined />
退出
</a-button>
</a-space>
</div>
</a-layout-header>
<a-layout-content class="main-content">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.logo-container {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.logo-icon {
font-size: 24px;
color: #c084fc;
margin-right: 12px;
}
.logo-text {
font-family: 'Outfit', sans-serif;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
background: linear-gradient(135deg, #aa3bff 0%, #c084fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 侧边栏折叠时只保留图标,避免标题文字竖排变形 */
.app-sider.ant-layout-sider-collapsed .logo-container {
justify-content: center;
padding: 0;
}
.app-sider.ant-layout-sider-collapsed .logo-icon {
margin-right: 0;
}
.app-sider.ant-layout-sider-collapsed .logo-text {
display: none;
}
.custom-menu {
background: transparent !important;
border-right: none !important;
margin-top: 16px;
}
.custom-menu :deep(.ant-menu-item) {
border-radius: 8px;
margin: 4px 12px !important;
width: calc(100% - 24px) !important;
height: 48px;
line-height: 48px;
transition: all 0.3s ease;
}
.custom-menu :deep(.ant-menu-item-selected) {
background: linear-gradient(135deg, rgba(170, 59, 255, 0.25) 0%, rgba(192, 132, 252, 0.1) 100%) !important;
border: 1px solid rgba(192, 132, 252, 0.35) !important;
color: #fff !important;
box-shadow: 0 0 12px rgba(170, 59, 255, 0.15);
}
.custom-menu :deep(.ant-menu-item:focus-visible) {
outline: none;
box-shadow: 0 0 0 2px rgba(192, 132, 252, 0.3);
}
.custom-menu :deep(.ant-menu-item-selected .ant-menu-item-icon) {
color: #c084fc !important;
}
.main-content {
padding: 24px;
overflow-y: auto;
max-height: calc(100vh - 64px);
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
}
.header-status {
font-size: 0.85rem;
color: #9ca3af;
background: rgba(170, 59, 255, 0.1);
padding: 6px 14px;
border-radius: 20px;
border: 1px solid rgba(170, 59, 255, 0.2);
}
.user-name {
color: #e5e7eb;
font-size: 0.9rem;
}
.logout-btn {
color: #9ca3af !important;
}
.logout-btn:hover {
color: #f87171 !important;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.header-title {
margin: 0;
color: #fff;
font-size: 1.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-left {
min-width: 0;
flex: 1;
}
.header-right {
flex-shrink: 0;
}
.app-header {
gap: 12px;
}
@media (max-width: 768px) {
.main-content {
padding: 12px;
}
.header-title {
font-size: 1rem;
}
.header-right :deep(.ant-space) {
gap: 8px !important;
}
.header-status,
.user-name {
display: none;
}
.logout-btn {
padding-inline: 8px !important;
}
.logout-btn :deep(span:not(.anticon)) {
display: none;
}
}
@media (max-width: 375px) {
.header-right :deep(.ant-tag) {
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
color: #e9d5ff !important;
background: rgba(147, 51, 234, 0.16) !important;
border-color: rgba(192, 132, 252, 0.42) !important;
}
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/api',
timeout: 30000
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('kefu_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('kefu_token')
localStorage.removeItem('kefu_user')
if (!window.location.hash.includes('/login')) {
window.location.hash = '#/login'
}
}
return Promise.reject(error)
}
)
export default api
+155
View File
@@ -0,0 +1,155 @@
<script setup>
import { ref } from 'vue'
import { SmileOutlined } from '@ant-design/icons-vue'
import { COMMON_EMOJIS, DOUYIN_EMOJI_PICKER, douyinEmojiUrl } from '../utils/messageContent'
const emit = defineEmits(['pick-emoji', 'pick-sticker'])
const open = ref(false)
const toggle = () => {
open.value = !open.value
}
const pickEmoji = (emoji) => {
emit('pick-emoji', emoji)
open.value = false
}
const pickDouyinEmoji = (name) => {
emit('pick-emoji', `[${name}]`)
open.value = false
}
</script>
<template>
<div class="emoji-picker-wrap">
<a-button size="small" @click="toggle">
<template #icon><SmileOutlined /></template>
表情
</a-button>
<div v-if="open" class="emoji-panel glass-card">
<div class="panel-section">
<div class="panel-title">Emoji</div>
<div class="emoji-grid">
<button
v-for="emoji in COMMON_EMOJIS"
:key="emoji"
type="button"
class="emoji-btn"
@click="pickEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
<div class="panel-section">
<div class="panel-title">抖音表情</div>
<div class="dy-emoji-grid">
<button
v-for="item in DOUYIN_EMOJI_PICKER"
:key="item.name"
type="button"
class="dy-emoji-btn"
:title="`[${item.name}]`"
@click="pickDouyinEmoji(item.name)"
>
<img
:src="douyinEmojiUrl(item.name)"
:alt="`[${item.name}]`"
class="dy-emoji-img"
loading="lazy"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.emoji-picker-wrap {
position: relative;
}
.emoji-picker-wrap :deep(.ant-btn) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.12));
color: var(--text-secondary, #cbd5e1);
}
.emoji-panel {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 20;
width: 280px;
padding: 12px;
border: 1px solid var(--border-light);
}
.panel-section + .panel-section {
margin-top: 12px;
}
.panel-title {
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: 8px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.emoji-btn,
.sticker-btn {
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
padding: 4px;
}
.emoji-btn:hover,
.sticker-btn:hover {
background: rgba(170, 59, 255, 0.15);
}
.emoji-btn {
font-size: 1.2rem;
line-height: 1.2;
}
.dy-emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
max-height: 180px;
overflow-y: auto;
}
.dy-emoji-btn {
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.dy-emoji-btn:hover {
background: rgba(170, 59, 255, 0.18);
}
.dy-emoji-img {
width: 24px;
height: 24px;
object-fit: contain;
pointer-events: none;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+269
View File
@@ -0,0 +1,269 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { PictureOutlined, SoundOutlined, PlayCircleOutlined, SmileOutlined } from '@ant-design/icons-vue'
import {
parseMessageContent,
parseMessageList,
resolveMediaUrl,
splitEmojiSegments,
MESSAGE_TYPE_LABELS
} from '../utils/messageContent'
const props = defineProps({
content: {
type: String,
default: ''
},
compact: {
type: Boolean,
default: false
}
})
const messageList = computed(() => parseMessageList(props.content))
const isMultiMessage = computed(() => messageList.value.length > 1)
const serializeItem = (item) => JSON.stringify(item)
const msg = computed(() => parseMessageContent(props.content))
// 文本按 [表情名] 切分,抖音表情渲染为图片
const textSegments = computed(() =>
msg.value.type === 'text' ? splitEmojiSegments(msg.value.text) : []
)
const mediaCandidates = computed(() => {
const parsed = msg.value
const urls = []
const add = (value) => {
const resolved = resolveMediaUrl(value, parsed)
if (resolved && !urls.includes(resolved)) urls.push(resolved)
}
add(parsed.url)
if (parsed.type === 'image') {
add(parsed.douyin_url)
if (Array.isArray(parsed.url_list)) {
for (const item of parsed.url_list) add(item)
}
}
return urls
})
const mediaIndex = ref(0)
const mediaUrl = computed(() => mediaCandidates.value[mediaIndex.value] || '')
const mediaBroken = computed(() => !mediaUrl.value || mediaIndex.value >= mediaCandidates.value.length)
const onMediaError = () => {
if (mediaIndex.value + 1 < mediaCandidates.value.length) {
mediaIndex.value += 1
}
}
watch(() => props.content, () => {
mediaIndex.value = 0
})
</script>
<template>
<div v-if="isMultiMessage" class="message-bubble-multi" :class="{ compact }">
<MessageBubble
v-for="(item, idx) in messageList"
:key="idx"
:content="serializeItem(item)"
:compact="compact"
/>
</div>
<div v-else class="message-bubble-content" :class="{ compact }">
<template v-if="msg.type === 'text'">
<span class="message-text">
<template v-for="(seg, idx) in textSegments" :key="idx">
<img
v-if="seg.type === 'emoji'"
:src="seg.url"
:alt="`[${seg.name}]`"
:title="`[${seg.name}]`"
class="inline-emoji"
loading="lazy"
/>
<template v-else>{{ seg.text }}</template>
</template>
</span>
</template>
<template v-else-if="msg.type === 'image'">
<div class="media-block">
<img
v-if="mediaUrl && !mediaBroken"
:src="mediaUrl"
class="message-image"
alt="图片消息"
loading="lazy"
@error="onMediaError"
@click.stop
/>
<div v-else class="media-fallback media-card">
<PictureOutlined class="media-card-icon" />
<span>{{ MESSAGE_TYPE_LABELS.image }}</span>
<small v-if="mediaCandidates.length">图片加载失败请重选或刷新后重试</small>
<small v-else>图片加载中</small>
</div>
</div>
</template>
<template v-else-if="msg.type === 'sticker'">
<div class="media-block sticker-block">
<img
v-if="mediaUrl && !mediaBroken"
:src="mediaUrl"
class="message-sticker"
:alt="msg.name || '表情'"
loading="lazy"
@error="onMediaError"
/>
<div v-else class="media-fallback media-card sticker-card">
<SmileOutlined class="media-card-icon" />
<span>{{ msg.name || MESSAGE_TYPE_LABELS.sticker }}</span>
</div>
</div>
</template>
<template v-else-if="msg.type === 'voice'">
<div class="media-block voice-block">
<SoundOutlined class="voice-icon" />
<audio v-if="mediaUrl" :src="mediaUrl" controls preload="none" class="voice-player" />
<span v-else class="media-fallback">{{ msg.text || MESSAGE_TYPE_LABELS.voice }}</span>
<span v-if="msg.duration" class="media-meta">{{ msg.duration }}s</span>
</div>
</template>
<template v-else-if="msg.type === 'video'">
<div class="media-block">
<video
v-if="mediaUrl"
:src="mediaUrl"
class="message-video"
controls
preload="metadata"
/>
<div v-else class="media-fallback">
<PlayCircleOutlined />
{{ msg.text || MESSAGE_TYPE_LABELS.video }}
</div>
</div>
</template>
<template v-else>
<span class="message-text">{{ msg.text || content }}</span>
</template>
</div>
</template>
<style scoped>
.message-bubble-content {
word-break: break-word;
white-space: pre-wrap;
}
.message-bubble-multi {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-bubble-content.compact .message-image,
.message-bubble-content.compact .message-video {
max-width: 160px;
max-height: 120px;
}
.message-text {
display: inline;
}
.inline-emoji {
width: 20px;
height: 20px;
object-fit: contain;
vertical-align: -4px;
margin: 0 1px;
}
.media-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.message-image,
.message-video {
max-width: 240px;
max-height: 240px;
border-radius: 8px;
object-fit: cover;
display: block;
}
.message-sticker {
width: 96px;
height: 96px;
object-fit: contain;
}
.sticker-block {
align-items: flex-start;
}
.voice-block {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.voice-icon {
color: #c084fc;
font-size: 18px;
}
.voice-player {
max-width: 220px;
height: 32px;
}
.media-fallback {
color: var(--text-secondary, #cbd5e1);
font-size: 0.92rem;
}
.media-card {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
min-width: 88px;
}
.media-card-icon {
font-size: 1.4rem;
color: #c084fc;
}
.media-card small {
font-size: 0.72rem;
opacity: 0.75;
text-align: center;
max-width: 140px;
}
.sticker-card {
min-height: 72px;
justify-content: center;
}
.media-meta {
font-size: 0.82rem;
color: var(--text-secondary, #94a3b8);
}
</style>
@@ -0,0 +1,646 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '../api'
import { useIsMobile } from '../composables/useIsMobile'
const props = defineProps({
showUserColumn: { type: Boolean, default: false },
manageable: { type: Boolean, default: false },
defaultStatus: { type: String, default: '' }
})
const loading = ref(false)
const isMobile = useIsMobile()
const statusModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
const orders = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const status = ref(props.defaultStatus)
const channel = ref(undefined)
const stats = ref({ paid_count: 0, paid_amount_yuan: 0 })
const statusModalVisible = ref(false)
const statusSaving = ref(false)
const editingOrder = ref(null)
const newStatus = ref('paid')
const statusOptions = [
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '支付成功' },
{ value: 'refunded', label: '已退款' },
{ value: 'expired', label: '已过期' },
{ value: 'cancelled', label: '已取消' }
]
const statusLabel = (value) => ({
paid: '支付成功',
pending: '待支付',
expired: '已过期',
cancelled: '已取消',
refunded: '已退款'
}[value] || value)
const statusColor = (value) => ({
paid: 'success',
pending: 'processing',
expired: 'default',
cancelled: 'error',
refunded: 'warning'
}[value] || 'default')
const channelLabel = (value) => (value === 'alipay' ? '支付宝' : '微信')
const formatTime = (value) => {
if (!value) return '-'
const d = new Date(value)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const tableScrollX = computed(() => {
let width = 920
if (props.showUserColumn) width += 120
if (props.manageable) width += 120
return width
})
const fetchOrders = async () => {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize.value }
if (status.value) params.status = status.value
if (channel.value) params.channel = channel.value
const res = await api.get('/payments/orders', { params })
orders.value = res.data.items
total.value = res.data.total
stats.value = {
paid_count: res.data.paid_count,
paid_amount_yuan: res.data.paid_amount_yuan
}
} catch (error) {
message.error(error.response?.data?.detail || '加载订单失败')
} finally {
loading.value = false
}
}
const onFilterChange = () => {
page.value = 1
fetchOrders()
}
const onTableChange = (pagination) => {
page.value = pagination.current
pageSize.value = pagination.pageSize
fetchOrders()
}
const openStatusModal = (record) => {
editingOrder.value = record
newStatus.value = record.status
statusModalVisible.value = true
}
const handleStatusSave = async () => {
if (!editingOrder.value) return
statusSaving.value = true
try {
await api.put(`/payments/orders/${editingOrder.value.order_no}/status`, {
status: newStatus.value
})
message.success('订单状态已更新')
statusModalVisible.value = false
fetchOrders()
} catch (error) {
message.error(error.response?.data?.detail || '更新失败')
} finally {
statusSaving.value = false
}
}
const handleDelete = (record) => {
Modal.confirm({
title: `确定删除订单「${record.order_no}」吗?`,
content: record.status === 'paid'
? '该订单已支付成功,删除后将自动扣回对应账号额度。'
: '删除后不可恢复。',
okType: 'danger',
okText: '删除',
cancelText: '取消',
onOk: async () => {
try {
await api.delete(`/payments/orders/${record.order_no}`)
message.success('订单已删除')
fetchOrders()
} catch (error) {
message.error(error.response?.data?.detail || '删除失败')
return Promise.reject(error)
}
}
})
}
onMounted(fetchOrders)
defineExpose({ refresh: fetchOrders })
</script>
<template>
<div class="order-list-panel">
<div class="order-stats">
<div class="stat-card">
<div class="stat-label">{{ showUserColumn ? '支付成功订单' : '我的成功订单' }}</div>
<div class="stat-value">{{ stats.paid_count }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ showUserColumn ? '成功支付总额' : '我的支付总额' }}</div>
<div class="stat-value accent">¥ {{ stats.paid_amount_yuan.toFixed(2) }}</div>
</div>
</div>
<div class="order-filters">
<a-select v-model:value="status" class="filter-select" @change="onFilterChange">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="paid">支付成功</a-select-option>
<a-select-option value="pending">待支付</a-select-option>
<a-select-option value="refunded">已退款</a-select-option>
<a-select-option value="expired">已过期</a-select-option>
<a-select-option value="cancelled">已取消</a-select-option>
</a-select>
<a-select
v-model:value="channel"
allow-clear
placeholder="全部支付方式"
class="filter-select"
@change="onFilterChange"
>
<a-select-option value="wechat">微信支付</a-select-option>
<a-select-option value="alipay">支付宝</a-select-option>
</a-select>
</div>
<div v-if="!isMobile" class="table-shell">
<a-table
:data-source="orders"
:loading="loading"
row-key="id"
size="middle"
:scroll="{ x: tableScrollX }"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`
}"
@change="onTableChange"
>
<a-table-column title="订单号" key="order_no" :width="220">
<template #default="{ record }">
<div class="cell-order">
<span class="order-no">{{ record.order_no }}</span>
<span v-if="record.trade_no" class="trade-no">{{ record.trade_no }}</span>
</div>
</template>
</a-table-column>
<a-table-column v-if="showUserColumn" title="用户" key="user" :width="110">
<template #default="{ record }">
<span class="cell-user">{{ record.display_name || record.username || `#${record.user_id}` }}</span>
</template>
</a-table-column>
<a-table-column title="购买内容" key="purchase" :width="130">
<template #default="{ record }">
<div class="cell-purchase">
<span>{{ channelLabel(record.channel) }} · {{ record.slots }} </span>
<span class="amount">¥ {{ record.amount_yuan.toFixed(2) }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="状态" key="status" :width="130">
<template #default="{ record }">
<div class="cell-status">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
<a-tag v-if="record.is_demo" color="warning">演示</a-tag>
</div>
</template>
</a-table-column>
<a-table-column title="支付时间" key="paid_at" :width="160">
<template #default="{ record }">
<span class="cell-time">{{ formatTime(record.paid_at) }}</span>
</template>
</a-table-column>
<a-table-column title="创建时间" key="created_at" :width="160">
<template #default="{ record }">
<span class="cell-time">{{ formatTime(record.created_at) }}</span>
</template>
</a-table-column>
<a-table-column v-if="manageable" title="操作" key="action" :width="120">
<template #default="{ record }">
<div class="cell-actions">
<a-button type="link" size="small" class="action-edit" @click="openStatusModal(record)">
<EditOutlined /> 改状态
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<DeleteOutlined /> 删除
</a-button>
</div>
</template>
</a-table-column>
</a-table>
</div>
<div v-else class="order-mobile-list">
<a-spin :spinning="loading">
<div v-if="orders.length" class="order-card-list">
<div v-for="record in orders" :key="record.id" class="order-card">
<div class="order-card-head">
<span class="order-no">{{ record.order_no }}</span>
<div class="cell-status">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
<a-tag v-if="record.is_demo" color="warning">演示</a-tag>
</div>
</div>
<div v-if="record.trade_no" class="trade-no">{{ record.trade_no }}</div>
<div v-if="showUserColumn" class="order-card-row">
<span class="order-card-label">用户</span>
<span class="cell-user">{{ record.display_name || record.username || `#${record.user_id}` }}</span>
</div>
<div class="order-card-row">
<span class="order-card-label">购买</span>
<span>{{ channelLabel(record.channel) }} · {{ record.slots }} </span>
</div>
<div class="order-card-row">
<span class="order-card-label">金额</span>
<span class="amount">¥ {{ record.amount_yuan.toFixed(2) }}</span>
</div>
<div class="order-card-row">
<span class="order-card-label">支付时间</span>
<span class="cell-time">{{ formatTime(record.paid_at) }}</span>
</div>
<div class="order-card-row">
<span class="order-card-label">创建时间</span>
<span class="cell-time">{{ formatTime(record.created_at) }}</span>
</div>
<div v-if="manageable" class="order-card-actions">
<a-button type="link" size="small" class="action-edit" @click="openStatusModal(record)">
<EditOutlined /> 改状态
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<DeleteOutlined /> 删除
</a-button>
</div>
</div>
</div>
<div v-else class="order-empty">暂无订单</div>
<div v-if="total > pageSize" class="order-mobile-pagination">
<a-pagination
v-model:current="page"
:total="total"
:page-size="pageSize"
size="small"
show-less-items
@change="(p) => { page = p; fetchOrders() }"
/>
</div>
</a-spin>
</div>
<a-modal
v-model:open="statusModalVisible"
title="修改订单状态"
ok-text="保存"
cancel-text="取消"
:confirm-loading="statusSaving"
:width="statusModalWidth"
@ok="handleStatusSave"
>
<div v-if="editingOrder" class="status-modal-body">
<p class="modal-order-no">订单号{{ editingOrder.order_no }}</p>
<p class="modal-hint">
设为支付成功将自动增加 {{ editingOrder.slots }} 个账号额度
设为已退款或从成功改为其他状态将自动扣回额度
</p>
<a-form-item label="订单状态" style="margin-bottom: 0;">
<a-select v-model:value="newStatus" style="width: 100%;" :options="statusOptions" />
</a-form-item>
</div>
</a-modal>
</div>
</template>
<style scoped>
.order-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
padding: 16px 20px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-light);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.stat-value.accent {
color: #c084fc;
}
.order-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.filter-select {
width: 150px;
}
.table-shell {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-light);
background: rgba(0, 0, 0, 0.15);
}
.cell-order {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.order-no {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
color: var(--text-primary);
word-break: break-all;
}
.trade-no {
font-size: 0.75rem;
color: var(--text-muted);
word-break: break-all;
}
.cell-user {
color: var(--text-secondary);
}
.cell-purchase {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.88rem;
color: var(--text-secondary);
}
.cell-purchase .amount {
color: #c084fc;
font-weight: 600;
}
.cell-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.cell-time {
font-size: 0.85rem;
color: var(--text-secondary);
white-space: nowrap;
}
.cell-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.action-edit {
color: #c084fc !important;
padding: 0 !important;
height: auto !important;
}
.cell-actions :deep(.ant-btn-link) {
padding: 0;
height: auto;
font-size: 0.85rem;
}
.status-modal-body {
margin-top: 8px;
}
.modal-order-no {
color: var(--text-secondary);
margin-bottom: 8px;
}
.modal-hint {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
line-height: 1.6;
}
/* 表格暗色主题 */
.order-list-panel :deep(.ant-table) {
background: transparent;
color: var(--text-primary);
}
.order-list-panel :deep(.ant-table-container) {
background: transparent;
}
.order-list-panel :deep(.ant-table-thead > tr > th) {
background: rgba(255, 255, 255, 0.04) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-light) !important;
font-weight: 500;
padding: 12px 16px !important;
}
.order-list-panel :deep(.ant-table-tbody > tr > td) {
background: transparent !important;
border-bottom: 1px solid var(--border-light) !important;
padding: 14px 16px !important;
vertical-align: middle;
}
.order-list-panel :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(170, 59, 255, 0.06) !important;
}
.order-list-panel :deep(.ant-table-tbody > tr:last-child > td) {
border-bottom: none !important;
}
.order-list-panel :deep(.ant-table-cell-fix-left),
.order-list-panel :deep(.ant-table-cell-fix-right) {
background: hsl(230, 20%, 11%) !important;
}
.order-list-panel :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-left),
.order-list-panel :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-right) {
background: hsl(230, 20%, 13%) !important;
}
.order-list-panel :deep(.ant-pagination) {
margin: 16px !important;
color: var(--text-secondary);
}
.order-list-panel :deep(.ant-pagination-item) {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
}
.order-list-panel :deep(.ant-pagination-item a) {
color: var(--text-secondary) !important;
}
.order-list-panel :deep(.ant-pagination-item-active) {
background: rgba(170, 59, 255, 0.2) !important;
border-color: rgba(170, 59, 255, 0.5) !important;
}
.order-list-panel :deep(.ant-pagination-item-active a) {
color: #c084fc !important;
}
.order-list-panel :deep(.ant-pagination-prev .ant-pagination-item-link),
.order-list-panel :deep(.ant-pagination-next .ant-pagination-item-link) {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
color: var(--text-secondary) !important;
}
.order-list-panel :deep(.ant-select-selector) {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
color: #fff !important;
}
.order-list-panel :deep(.ant-pagination-options .ant-select-selector) {
background: rgba(255, 255, 255, 0.05) !important;
}
.order-list-panel :deep(.ant-pagination-total-text) {
color: var(--text-muted) !important;
}
.order-list-panel :deep(.ant-empty-description) {
color: var(--text-muted);
}
.order-mobile-list {
margin-top: 4px;
}
.order-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.order-card {
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--border-light);
background: rgba(0, 0, 0, 0.15);
}
.order-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
.order-card-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
font-size: 0.88rem;
color: var(--text-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.order-card-row:last-of-type {
border-bottom: none;
}
.order-card-label {
flex-shrink: 0;
color: var(--text-muted);
}
.order-card-actions {
display: flex;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.order-mobile-pagination {
display: flex;
justify-content: center;
margin-top: 16px;
}
.order-empty {
text-align: center;
padding: 32px 16px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.order-stats {
grid-template-columns: 1fr;
}
.filter-select {
width: 100%;
}
}
</style>
@@ -0,0 +1,305 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { WechatOutlined, AlipayCircleOutlined } from '@ant-design/icons-vue'
import api from '../api'
const props = defineProps({
open: { type: Boolean, default: false }
})
const emit = defineEmits(['update:open', 'success'])
const visible = computed({
get: () => props.open,
set: (val) => emit('update:open', val)
})
const loading = ref(false)
const creating = ref(false)
const simulating = ref(false)
const config = ref(null)
const slots = ref(1)
const channel = ref('wechat')
const order = ref(null)
let pollTimer = null
const totalPrice = computed(() => {
if (!config.value) return 0
return Number((config.value.unit_price * slots.value).toFixed(2))
})
const channelOptions = computed(() => {
const opts = []
if (!config.value) return opts
if (config.value.wechat_available) {
opts.push({ value: 'wechat', label: '微信支付', icon: WechatOutlined })
}
if (config.value.alipay_available) {
opts.push({ value: 'alipay', label: '支付宝', icon: AlipayCircleOutlined })
}
return opts
})
const qrImageUrl = computed(() => {
if (!order.value?.qr_code || order.value.demo_mode) return ''
return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(order.value.qr_code)}`
})
const channelLabel = computed(() => (order.value?.channel === 'alipay' ? '支付宝' : '微信支付'))
const stopPoll = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const fetchConfig = async () => {
loading.value = true
try {
const res = await api.get('/payments/config')
config.value = res.data
slots.value = res.data.min_slots || 1
if (res.data.wechat_available) {
channel.value = 'wechat'
} else if (res.data.alipay_available) {
channel.value = 'alipay'
}
} catch (error) {
message.error(error.response?.data?.detail || '加载购买配置失败')
visible.value = false
} finally {
loading.value = false
}
}
const pollOrder = (orderNo) => {
stopPoll()
pollTimer = setInterval(async () => {
try {
const res = await api.get(`/payments/orders/${orderNo}`)
order.value = res.data
if (res.data.status === 'paid') {
stopPoll()
message.success('支付成功,账号额度已增加')
emit('success')
visible.value = false
} else if (res.data.status === 'expired') {
stopPoll()
message.warning('订单已过期,请重新下单')
}
} catch {
// ignore transient errors
}
}, 2500)
}
const createOrder = async () => {
if (!config.value) return
creating.value = true
try {
const res = await api.post('/payments/orders', {
slots: slots.value,
channel: channel.value
})
order.value = res.data
if (res.data.status === 'paid') {
message.success('支付成功')
emit('success')
visible.value = false
return
}
pollOrder(res.data.order_no)
} catch (error) {
message.error(error.response?.data?.detail || '创建订单失败')
} finally {
creating.value = false
}
}
const simulatePay = async () => {
if (!order.value?.order_no) return
simulating.value = true
try {
const res = await api.post(`/payments/orders/${order.value.order_no}/simulate`)
order.value = res.data
message.success('演示支付成功,账号额度已增加')
emit('success')
visible.value = false
} catch (error) {
message.error(error.response?.data?.detail || '演示支付失败')
} finally {
simulating.value = false
}
}
const resetState = () => {
stopPoll()
order.value = null
slots.value = config.value?.min_slots || 1
}
watch(
() => props.open,
(val) => {
if (val) {
resetState()
fetchConfig()
} else {
stopPoll()
}
}
)
onUnmounted(stopPoll)
</script>
<template>
<a-modal
v-model:open="visible"
title="购买抖音账号额度"
width="520px"
:footer="null"
destroy-on-close
>
<a-spin :spinning="loading">
<div v-if="config && !order" class="purchase-form">
<a-alert
type="info"
show-icon
message="额度不足时可在线购买,支付成功后立即生效"
style="margin-bottom: 16px;"
/>
<a-form layout="vertical">
<a-form-item label="购买数量(个)">
<a-input-number
v-model:value="slots"
:min="config.min_slots"
:max="config.max_slots"
style="width: 100%;"
/>
</a-form-item>
<a-form-item label="单价">
<span class="price-text">¥ {{ config.unit_price }} / </span>
</a-form-item>
<a-form-item label="应付金额">
<span class="total-price">¥ {{ totalPrice }}</span>
</a-form-item>
<a-form-item label="支付方式">
<a-radio-group v-model:value="channel" class="channel-group">
<a-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
<component :is="opt.icon" style="margin-right: 6px;" />
{{ opt.label }}
</a-radio-button>
</a-radio-group>
</a-form-item>
</a-form>
<a-button type="primary" block class="gradient-btn" :loading="creating" @click="createOrder">
确认下单
</a-button>
</div>
<div v-else-if="order" class="pay-panel">
<div v-if="order.status === 'pending'">
<div v-if="order.demo_mode" class="demo-panel">
<a-alert
type="warning"
show-icon
message="当前为演示模式"
description="支付渠道未配置或开启了演示支付,可点击下方按钮模拟支付成功。"
style="margin-bottom: 16px;"
/>
<p>订单号{{ order.order_no }}</p>
<p>购买数量{{ order.slots }} </p>
<p>应付金额<strong>¥ {{ order.amount_yuan }}</strong></p>
<a-button
type="primary"
block
class="gradient-btn"
:loading="simulating"
@click="simulatePay"
>
模拟支付成功
</a-button>
</div>
<div v-else class="qr-panel">
<p class="pay-tip">请使用{{ channelLabel }}扫描下方二维码完成支付</p>
<div class="qr-wrap">
<img v-if="qrImageUrl" :src="qrImageUrl" alt="支付二维码" class="qr-image" />
</div>
<p class="order-meta">
订单号 {{ order.order_no }} · ¥ {{ order.amount_yuan }} · {{ order.slots }} 个额度
</p>
<p class="poll-hint">支付完成后将自动刷新请勿关闭此窗口</p>
</div>
</div>
<div v-else-if="order.status === 'paid'" class="paid-panel">
<a-result status="success" title="支付成功" sub-title="账号额度已增加可以添加新账号了" />
</div>
<div v-else>
<a-result status="warning" title="订单已失效" sub-title="请关闭后重新下单" />
</div>
</div>
</a-spin>
</a-modal>
</template>
<style scoped>
.price-text {
color: var(--text-secondary);
}
.total-price {
font-size: 1.4rem;
font-weight: 700;
color: #c084fc;
}
.channel-group {
display: flex;
gap: 8px;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
margin-top: 8px;
}
.pay-tip {
text-align: center;
color: var(--text-secondary);
margin-bottom: 12px;
}
.qr-wrap {
display: flex;
justify-content: center;
padding: 12px;
background: #fff;
border-radius: 12px;
margin: 0 auto 12px;
width: fit-content;
}
.qr-image {
width: 240px;
height: 240px;
display: block;
}
.order-meta,
.poll-hint {
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.poll-hint {
margin-top: 8px;
}
</style>
+351
View File
@@ -0,0 +1,351 @@
<script setup>
import { computed, ref } from 'vue'
import {
PlusOutlined,
DeleteOutlined,
LinkOutlined,
FileTextOutlined,
PictureOutlined,
LoadingOutlined,
CopyOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { emptyReplyForm, replyTypeOptions } from '../utils/replyRules'
import api from '../api'
const props = defineProps({
replies: {
type: Array,
default: () => [emptyReplyForm()]
},
showHeader: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:replies'])
const replyItems = computed({
get: () => props.replies,
set: (value) => emit('update:replies', value)
})
const typeOptions = replyTypeOptions.map((opt) => ({
...opt,
icon: opt.value === 'link' ? LinkOutlined : opt.value === 'card' ? PictureOutlined : FileTextOutlined
}))
const cardUploading = ref({})
const addReplyItem = () => {
replyItems.value = [...replyItems.value, emptyReplyForm()]
}
const removeReplyItem = (index) => {
if (replyItems.value.length <= 1) {
message.warning('至少保留一条回复消息')
return
}
const next = [...replyItems.value]
next.splice(index, 1)
replyItems.value = next
}
const updateReplyField = (index, field, value) => {
const next = replyItems.value.map((item, i) =>
i === index ? { ...item, [field]: value } : item
)
replyItems.value = next
}
const updateReplyFields = (index, fields) => {
const next = replyItems.value.map((item, i) =>
i === index ? { ...item, ...fields } : item
)
replyItems.value = next
}
const uploadCardImage = async (index, options) => {
const { file, onSuccess, onError } = options
cardUploading.value = { ...cardUploading.value, [index]: true }
try {
const formData = new FormData()
formData.append('file', file)
const res = await api.post('/link-cards/upload-image', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
updateReplyFields(index, {
reply_card_image_path: res.data.image_path,
reply_card_cover_url: res.data.cover_url
})
message.success('封面图上传成功')
onSuccess?.(res.data, file)
} catch (error) {
message.error(error.response?.data?.detail || '封面图上传失败')
onError?.(error)
} finally {
cardUploading.value = { ...cardUploading.value, [index]: false }
}
}
const copyPageUrl = async (url) => {
if (!url) return
try {
await navigator.clipboard.writeText(url)
message.success('落地页链接已复制')
} catch {
message.info(url)
}
}
</script>
<template>
<div class="reply-rule-editor">
<div v-if="showHeader" class="reply-list-header">
<span class="reply-list-title">自动回复消息</span>
<a-button type="dashed" size="small" class="reply-add-btn" @click="addReplyItem">
<template #icon><PlusOutlined /></template>
添加一条消息
</a-button>
</div>
<div v-for="(reply, replyIndex) in replyItems" :key="replyIndex" class="reply-item-card">
<div class="reply-item-header">
<span>消息 {{ replyIndex + 1 }}</span>
<a-button
v-if="replyItems.length > 1"
type="text"
danger
size="small"
@click="removeReplyItem(replyIndex)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
<a-form-item label="回复类型">
<a-radio-group
:value="reply.reply_type"
button-style="solid"
class="reply-type-group"
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
>
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
<component :is="opt.icon" style="margin-right: 4px;" />
{{ opt.label }}
</a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="reply.reply_type === 'text'">
<a-form-item label="回复文本">
<a-textarea
:value="reply.reply_text"
placeholder="请输入匹配成功后发送的文本内容..."
:rows="3"
@update:value="(v) => updateReplyField(replyIndex, 'reply_text', v)"
/>
</a-form-item>
</template>
<template v-else-if="reply.reply_type === 'link'">
<a-form-item label="跳转网址" required>
<a-input
:value="reply.reply_link_url"
placeholder="https://example.com"
@update:value="(v) => updateReplyField(replyIndex, 'reply_link_url', v)"
/>
</a-form-item>
</template>
<template v-else-if="reply.reply_type === 'card'">
<div class="card-form-hint">
卡片会作为独立发送规则处理自动回复时先发送封面图片再发送标题内容和可点击链接
</div>
<a-form-item label="卡片标题" required>
<a-input
:value="reply.reply_card_title"
placeholder="卡片标题(同时作为页面 title"
@update:value="(v) => updateReplyField(replyIndex, 'reply_card_title', v)"
/>
</a-form-item>
<a-form-item label="卡片内容" required>
<a-textarea
:value="reply.reply_card_content"
placeholder="卡片描述内容(用于页面 description / keywords"
:rows="3"
@update:value="(v) => updateReplyField(replyIndex, 'reply_card_content', v)"
/>
</a-form-item>
<a-form-item label="跳转链接" required>
<a-input
:value="reply.reply_card_target_url"
placeholder="https://example.com(落地页打开后自动跳转)"
@update:value="(v) => updateReplyField(replyIndex, 'reply_card_target_url', v)"
/>
</a-form-item>
<a-form-item label="封面图片" required>
<div class="card-upload-row">
<a-upload
name="file"
list-type="picture-card"
:show-upload-list="false"
accept="image/*"
:custom-request="(opts) => uploadCardImage(replyIndex, opts)"
>
<div v-if="cardUploading[replyIndex]" class="card-upload-placeholder">
<LoadingOutlined />
<div>上传中</div>
</div>
<img
v-else-if="reply.reply_card_cover_url || reply.reply_card_image_path"
:src="reply.reply_card_cover_url || reply.reply_card_image_path"
alt="封面"
class="card-cover-preview"
/>
<div v-else class="card-upload-placeholder">
<PlusOutlined />
<div>上传封面</div>
</div>
</a-upload>
<span class="card-upload-tip">上传后自动转为 PNG favicon32×32)与卡片封面(256×256</span>
</div>
</a-form-item>
<a-form-item v-if="reply.reply_card_page_url" label="落地页链接">
<a-input :value="reply.reply_card_page_url" readonly>
<template #suffix>
<CopyOutlined
class="copy-icon"
title="复制链接"
@click="copyPageUrl(reply.reply_card_page_url)"
/>
</template>
</a-input>
</a-form-item>
</template>
</div>
</div>
</template>
<style scoped>
.reply-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 8px 0 12px;
}
.reply-list-title {
font-weight: 600;
color: var(--text-primary);
}
.reply-add-btn {
color: #c084fc !important;
border-color: rgba(192, 132, 252, 0.45) !important;
background: rgba(147, 51, 234, 0.08) !important;
}
.reply-item-card {
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
background: rgba(255, 255, 255, 0.03);
}
.reply-rule-editor :deep(.ant-form-item-label > label) {
color: var(--text-secondary) !important;
}
.reply-rule-editor :deep(.ant-input),
.reply-rule-editor :deep(textarea.ant-input) {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
color: var(--text-primary) !important;
}
.reply-rule-editor :deep(.ant-input::placeholder),
.reply-rule-editor :deep(textarea.ant-input::placeholder) {
color: var(--text-muted) !important;
}
.reply-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
color: #c084fc;
}
.reply-type-group :deep(.ant-radio-button-wrapper) {
min-width: 88px;
text-align: center;
color: var(--text-secondary, #cbd5e1);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.12);
}
.reply-type-group :deep(.ant-radio-button-wrapper-checked) {
color: #f3e8ff !important;
background: rgba(147, 51, 234, 0.25) !important;
border-color: rgba(192, 132, 252, 0.55) !important;
}
.card-form-hint {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.5;
color: #bae6fd;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.2);
}
.card-upload-row {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.card-upload-tip {
font-size: 0.8rem;
color: var(--text-muted);
max-width: 220px;
line-height: 1.5;
}
.card-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-muted);
}
.card-cover-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.copy-icon {
cursor: pointer;
color: #c084fc;
}
.copy-icon:hover {
color: #e9d5ff;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup>
import { ref } from 'vue'
import { UserOutlined } from '@ant-design/icons-vue'
const props = defineProps({
src: { type: String, default: '' },
name: { type: String, default: '' },
size: { type: Number, default: 42 },
variant: { type: String, default: 'default' },
})
const failed = ref(false)
const onError = () => {
failed.value = true
}
</script>
<template>
<div
class="user-avatar"
:class="`variant-${variant}`"
:style="{ width: `${size}px`, height: `${size}px` }"
:title="name || undefined"
>
<img
v-if="src && !failed"
:src="src"
:alt="name || 'avatar'"
referrerpolicy="no-referrer"
@error="onError"
/>
<UserOutlined v-else />
</div>
</template>
<style scoped>
.user-avatar {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
background: rgba(192, 132, 252, 0.2);
color: #c084fc;
}
.user-avatar.variant-default {
background: rgba(192, 132, 252, 0.2);
color: #c084fc;
}
.user-avatar.variant-account {
border-radius: 12px;
background: rgba(170, 59, 255, 0.1);
border: 1px solid rgba(170, 59, 255, 0.2);
}
.user-avatar.variant-system {
background: rgba(74, 222, 128, 0.15);
color: var(--accent-green);
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { computed } from 'vue'
import { Grid } from 'ant-design-vue'
/** @param {'sm'|'md'|'lg'|'xl'|'xxl'} breakpoint Ant Design breakpoint key */
export function useIsMobile(breakpoint = 'md') {
const screens = Grid.useBreakpoint()
return computed(() => !screens.value[breakpoint])
}
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import App from './App.vue'
import router from './router'
import 'ant-design-vue/dist/reset.css'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(Antd)
app.mount('#app')
+76
View File
@@ -0,0 +1,76 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import Accounts from '../views/Accounts.vue'
import Rules from '../views/Rules.vue'
import Logs from '../views/Logs.vue'
import Messages from '../views/Messages.vue'
import SystemLogs from '../views/SystemLogs.vue'
import ReceivedMessages from '../views/ReceivedMessages.vue'
import Login from '../views/Login.vue'
import Users from '../views/Users.vue'
import Settings from '../views/Settings.vue'
import DesktopUpdate from '../views/DesktopUpdate.vue'
import PaymentSettings from '../views/PaymentSettings.vue'
import MyPaymentOrders from '../views/MyPaymentOrders.vue'
import Help from '../views/Help.vue'
import Download from '../views/Download.vue'
import { useAuthStore } from '../stores/auth'
const routes = [
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
{ path: '/', component: Dashboard, name: 'Dashboard' },
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { write: true } },
{ path: '/messages', component: Messages, name: 'Messages', meta: { write: true } },
{ path: '/rules', component: Rules, name: 'Rules', meta: { write: true } },
{ path: '/logs', component: Logs, name: 'Logs' },
{ path: '/received-messages', component: ReceivedMessages, name: 'ReceivedMessages' },
{ path: '/system-logs', component: SystemLogs, name: 'SystemLogs' },
{ path: '/users', component: Users, name: 'Users', meta: { admin: true } },
{ path: '/settings', component: Settings, name: 'Settings', meta: { admin: true } },
{ path: '/desktop-update', component: DesktopUpdate, name: 'DesktopUpdate', meta: { admin: true } },
{ path: '/payment-settings', component: PaymentSettings, name: 'PaymentSettings', meta: { admin: true } },
{ path: '/payment-orders', component: MyPaymentOrders, name: 'MyPaymentOrders' },
{ path: '/help', component: Help, name: 'Help' },
{ path: '/download', component: Download, name: 'Download' }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) {
if (auth.isLoggedIn && to.path === '/login') {
return '/'
}
return true
}
if (!auth.isLoggedIn) {
return '/login'
}
if (!auth.user) {
try {
await auth.fetchMe()
} catch {
auth.clearSession()
return '/login'
}
}
if (to.meta.admin && !auth.isAdmin) {
return '/'
}
if (to.meta.write && auth.isViewer) {
return '/'
}
return true
})
export default router
+197
View File
@@ -0,0 +1,197 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('kefu_token') || '')
const user = ref(JSON.parse(localStorage.getItem('kefu_user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const canWrite = computed(() => ['admin', 'operator'].includes(user.value?.role))
const isViewer = computed(() => user.value?.role === 'viewer')
const roleLabel = computed(() => {
const map = { admin: '管理员', operator: '运营', viewer: '只读' }
return map[user.value?.role] || user.value?.role || ''
})
const setSession = (accessToken, userData) => {
token.value = accessToken
user.value = userData
localStorage.setItem('kefu_token', accessToken)
localStorage.setItem('kefu_user', JSON.stringify(userData))
}
const clearSession = () => {
token.value = ''
user.value = null
localStorage.removeItem('kefu_token')
localStorage.removeItem('kefu_user')
}
const login = async (username, password) => {
const res = await api.post('/auth/login', { username, password })
const accessToken = res.data.access_token
const me = await api.get('/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` }
})
setSession(accessToken, me.data)
return me.data
}
const register = async (payload) => {
const res = await api.post('/auth/register', payload)
return res.data
}
const verifyEmail = async (verifyToken) => {
const res = await api.post('/auth/verify-email', { token: verifyToken })
return res.data
}
const resendVerification = async (payload) => {
const res = await api.post('/auth/resend-verification', payload)
return res.data
}
const forgotPassword = async (payload) => {
const res = await api.post('/auth/forgot-password', payload)
return res.data
}
const resetPassword = async (token, password) => {
const res = await api.post('/auth/reset-password', { token, password })
return res.data
}
const fetchMe = async () => {
if (!token.value) return null
const res = await api.get('/auth/me')
user.value = res.data
localStorage.setItem('kefu_user', JSON.stringify(res.data))
return res.data
}
const logout = () => {
clearSession()
}
return {
token,
user,
isLoggedIn,
isAdmin,
canWrite,
isViewer,
roleLabel,
login,
register,
verifyEmail,
resendVerification,
forgotPassword,
resetPassword,
fetchMe,
logout,
clearSession
}
})
+408
View File
@@ -0,0 +1,408 @@
/* 导入字体 */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
:root {
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-heading: 'Outfit', var(--font-sans);
/* HSL 调和色彩系统 */
--bg-base: hsl(230, 20%, 8%);
--bg-card: hsla(230, 20%, 12%, 0.7);
--bg-card-hover: hsla(230, 20%, 15%, 0.85);
--bg-sidebar: hsla(230, 20%, 10%, 0.8);
--primary-color: hsl(270, 85%, 65%);
--primary-glow: hsla(270, 85%, 65%, 0.35);
--accent-blue: hsl(200, 85%, 60%);
--accent-pink: hsl(320, 85%, 60%);
--accent-green: hsl(150, 75%, 50%);
--accent-red: hsl(360, 75%, 60%);
--text-primary: hsl(0, 0%, 95%);
--text-secondary: hsl(230, 10%, 65%);
--text-muted: hsl(230, 10%, 45%);
--border-light: rgba(255, 255, 255, 0.06);
--border-glow: hsla(270, 85%, 65%, 0.2);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* 装饰性背景光晕 */
body::before {
content: '';
position: absolute;
top: -10%;
left: 20%;
width: 40vw;
height: 40vw;
background: radial-gradient(circle, var(--primary-glow) 0%, transparent 70%);
z-index: -2;
filter: blur(80px);
pointer-events: none;
}
body::after {
content: '';
position: absolute;
bottom: 10%;
right: 10%;
width: 50vw;
height: 50vw;
background: radial-gradient(circle, hsla(320, 85%, 60%, 0.15) 0%, transparent 70%);
z-index: -2;
filter: blur(100px);
pointer-events: none;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-base);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}
#app {
min-height: 100vh;
}
/* Ant Design Override for Premium Look */
.ant-layout {
background: transparent !important;
}
.ant-layout-sider {
background: var(--bg-sidebar) !important;
backdrop-filter: blur(16px);
border-right: 1px solid var(--border-light);
}
.ant-layout-header {
background: hsla(230, 20%, 8%, 0.5) !important;
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-light);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px !important;
}
/* Glassmorphism Cards */
.glass-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--border-light);
border-radius: 16px;
padding: 24px;
box-shadow: var(--glass-shadow);
transition: var(--transition-smooth);
}
.glass-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-glow);
box-shadow: 0 12px 40px 0 hsla(270, 85%, 65%, 0.1);
transform: translateY(-2px);
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
color: var(--text-primary);
}
.text-gradient {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Active status indicator animation */
@keyframes pulse-green {
0% {
box-shadow: 0 0 0 0 rgba(21, 200, 100, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(21, 200, 100, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(21, 200, 100, 0);
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-online {
background-color: var(--accent-green);
animation: pulse-green 2s infinite;
}
.status-offline {
background-color: var(--text-muted);
}
.status-logging_in {
background-color: var(--accent-blue);
animation: pulse-blue 1.5s infinite;
}
.status-error {
background-color: var(--accent-red);
}
@keyframes pulse-blue {
0% {
box-shadow: 0 0 0 0 rgba(0, 170, 255, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(0, 170, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 170, 255, 0);
}
}
/* QR Code scanning container */
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
border: 1px dashed var(--border-light);
position: relative;
overflow: hidden;
}
.qr-laser {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--primary-color);
box-shadow: 0 0 10px var(--primary-color);
animation: scanning 2s linear infinite;
}
@keyframes scanning {
0% {
top: 5%;
}
50% {
top: 95%;
}
100% {
top: 5%;
}
}
/* 全局 Modal 深色主题(与页面 glass 风格一致,避免白底 + 浅色字不可读) */
.ant-modal .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;
color: var(--text-primary);
}
.ant-modal .ant-modal-header {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 16px 20px !important;
}
.ant-modal .ant-modal-title {
color: var(--text-primary) !important;
font-family: var(--font-heading);
font-weight: 600;
}
.ant-modal .ant-modal-body {
padding: 16px 20px 20px !important;
background: transparent !important;
color: var(--text-primary);
}
.ant-modal .ant-modal-footer {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
}
.ant-modal .ant-modal-close,
.ant-modal .ant-modal-close-x {
color: var(--text-secondary) !important;
}
.ant-modal .ant-modal-close:hover,
.ant-modal .ant-modal-close-x:hover {
color: #fff !important;
}
.ant-modal .ant-form-item-label > label {
color: var(--text-secondary) !important;
}
.ant-modal .ant-input,
.ant-modal .ant-input-number,
.ant-modal .ant-input-number-input,
.ant-modal .ant-input-affix-wrapper,
.ant-modal .ant-input-password .ant-input,
.ant-modal textarea.ant-input,
.ant-modal .ant-select-selector {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
color: var(--text-primary) !important;
}
.ant-modal .ant-input::placeholder,
.ant-modal .ant-input-number-input::placeholder,
.ant-modal textarea.ant-input::placeholder {
color: var(--text-muted) !important;
}
.ant-modal .ant-radio-button-wrapper {
color: var(--text-secondary) !important;
background: rgba(255, 255, 255, 0.03) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.ant-modal .ant-radio-button-wrapper-checked {
color: #f3e8ff !important;
background: rgba(147, 51, 234, 0.25) !important;
border-color: rgba(192, 132, 252, 0.55) !important;
}
.ant-modal .ant-btn-default {
background: rgba(255, 255, 255, 0.06) !important;
border-color: var(--border-light) !important;
color: var(--text-primary) !important;
}
.ant-modal .ant-btn-default:hover {
background: rgba(170, 59, 255, 0.15) !important;
border-color: rgba(170, 59, 255, 0.35) !important;
color: #fff !important;
}
.ant-modal .ant-tag-default {
color: #cbd5e1 !important;
background: rgba(148, 163, 184, 0.2) !important;
border-color: rgba(203, 213, 225, 0.4) !important;
}
/* 暗色主题标签 — 半透明底 + 彩色边框/文字,避免 Ant 默认浅色底 */
.ant-tag {
border-radius: 6px;
font-weight: 500;
line-height: 1.4;
}
.ant-tag-purple,
.ant-tag-magenta,
.ant-tag-volcano {
color: #e9d5ff !important;
background: rgba(147, 51, 234, 0.16) !important;
border-color: rgba(192, 132, 252, 0.42) !important;
}
.ant-tag-blue,
.ant-tag-processing,
.ant-tag-geekblue,
.ant-tag-cyan {
color: #c4b5fd !important;
background: rgba(129, 140, 248, 0.14) !important;
border-color: rgba(167, 139, 250, 0.38) !important;
}
.ant-tag-green,
.ant-tag-success,
.ant-tag-lime {
color: #86efac !important;
background: rgba(34, 197, 94, 0.12) !important;
border-color: rgba(74, 222, 128, 0.32) !important;
}
.ant-tag-red,
.ant-tag-error {
color: #fca5a5 !important;
background: rgba(239, 68, 68, 0.12) !important;
border-color: rgba(248, 113, 113, 0.35) !important;
}
.ant-tag-orange,
.ant-tag-warning,
.ant-tag-gold,
.ant-tag-yellow {
color: #fcd34d !important;
background: rgba(245, 158, 11, 0.12) !important;
border-color: rgba(251, 191, 36, 0.35) !important;
}
.ant-tag-default {
color: #cbd5e1 !important;
background: rgba(148, 163, 184, 0.1) !important;
border-color: rgba(148, 163, 184, 0.28) !important;
}
@media (max-width: 768px) {
.glass-card {
padding: 16px;
border-radius: 12px;
}
.ant-layout-header {
padding: 0 12px !important;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.glass-card:hover {
transform: none;
}
}
+363
View File
@@ -0,0 +1,363 @@
export const MESSAGE_TYPE_LABELS = {
text: '文本',
image: '图片',
sticker: '表情',
voice: '语音',
video: '视频'
}
const PLACEHOLDER_MAP = {
'[图片]': 'image',
'[表情包]': 'sticker',
'[语音]': 'voice',
'[视频]': 'video'
}
const URI_HINT_RE = /tos-cn|aweme-|voice\/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.webp|\.jpe?g|\.png|\.gif/i
const AUDIO_URL_RE = /douyin-user-audio|\/audio\/|sc=audio|voice\/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.wav|\.ogg/i
const VIDEO_URL_RE = /sc=video|\/video\/|\.mp4|\.mov|\.webm|\.m3u8/i
const looksLikeAudioUrl = (value) => AUDIO_URL_RE.test(String(value || ''))
const looksLikeVideoUrl = (value) => VIDEO_URL_RE.test(String(value || ''))
const inferMediaTypeFromUrl = (url) => {
if (looksLikeAudioUrl(url)) return 'voice'
if (looksLikeVideoUrl(url)) return 'video'
return 'image'
}
const normalizeParsedMessage = (data) => {
if (!data || typeof data !== 'object') return data
const url = String(data.url || '').trim()
if (!url) return data
const inferred = inferMediaTypeFromUrl(url)
if (data.type === 'image' && (inferred === 'voice' || inferred === 'video')) {
return {
...data,
type: inferred,
text: inferred === 'voice' ? '[语音]' : '[视频]'
}
}
return data
}
const normalizeUriPath = (raw) => {
let path = String(raw || '').trim().replace(/^\/+/, '')
if (path.startsWith('obj/')) path = path.slice(4)
return path
}
export const uriToCdnUrls = (uri, { preferVoice = false } = {}) => {
const raw = String(uri || '').trim()
if (!raw) return []
if (raw.startsWith('//')) return [`https:${raw}`]
if (/^https?:\/\//i.test(raw)) return [raw]
const path = normalizeUriPath(raw)
if (!path) return []
const candidates = []
const seen = new Set()
const add = (url) => {
const value = String(url || '').trim()
if (value && !seen.has(value)) {
seen.add(value)
candidates.push(value)
}
}
const lower = path.toLowerCase()
const isVoice = preferVoice || lower.startsWith('voice/') || /\.(mp3|m4a|aac|mpeg)$/i.test(lower)
const isImage = !isVoice && (lower.includes('tos-cn-i') || lower.includes('aweme-') || /\.(jpe?g|png|webp|gif)$/i.test(lower))
if (isVoice) {
for (const host of ['sf6-cdn-tos.douyinstatic.com', 'sf3-cdn-tos.douyinstatic.com']) {
add(`https://${host}/obj/${path}`)
}
add(`https://p3.douyinpic.com/obj/${path}`)
}
if (isImage || lower.includes('tos-cn') || lower.includes('aweme')) {
add(`https://p3.douyinpic.com/obj/${path}`)
for (const size of ['720x720', '480x480', '300x300', '200x200', '100x100']) {
add(`https://p3.douyinpic.com/aweme/${size}/${path}`)
}
add(`https://p9-dy.byteimg.com/img/${path}`)
}
add(`https://p3.douyinpic.com/obj/${path}`)
return candidates
}
export const resolveMediaUri = (uri, options = {}) => {
const urls = uriToCdnUrls(uri, options)
return urls[0] || ''
}
const resolveParsedMessage = (data) => {
if (!data || typeof data !== 'object' || !data.type) return null
if (!data.url && data.uri) {
const preferVoice = data.type === 'voice'
data.url = resolveMediaUri(data.uri, { preferVoice })
}
if (data.type === 'image' && !data.url) {
const list = Array.isArray(data.url_list) ? data.url_list : []
const cdn = list.find((u) => String(u || '').startsWith('http'))
if (cdn) data.url = cdn
else if (data.douyin_url) data.url = data.douyin_url
}
return normalizeParsedMessage(data)
}
export const parseMessageContent = (raw) => {
const text = String(raw || '').trim()
if (!text) return { type: 'text', text: '' }
if (text.startsWith('{')) {
try {
const data = JSON.parse(text)
const resolved = resolveParsedMessage(data)
if (resolved) return resolved
} catch {
// fall through
}
}
if (PLACEHOLDER_MAP[text]) {
return { type: PLACEHOLDER_MAP[text], text }
}
return { type: 'text', text }
}
// 解析为消息列表:支持 {"messages":[...]} 形式的多条结构化回复(文本 + 图片等),
// 其余情况回退为单条消息,便于聊天气泡逐条渲染真实媒体。
export const parseMessageList = (raw) => {
const text = String(raw || '').trim()
if (text.startsWith('{')) {
try {
const data = JSON.parse(text)
if (data && Array.isArray(data.messages)) {
const list = data.messages
.map((item) => resolveParsedMessage(item))
.filter(Boolean)
if (list.length) return list
}
} catch {
// fall through
}
}
return [parseMessageContent(raw)]
}
const previewOne = (msg) => {
if (msg.type === 'text') return msg.text || ''
if (msg.type === 'sticker' && msg.name) return `[表情] ${msg.name}`
return msg.text || MESSAGE_TYPE_LABELS[msg.type] || '[消息]'
}
export const messagePreview = (raw) => {
const list = parseMessageList(raw)
if (list.length > 1) {
return list.map(previewOne).filter(Boolean).join(' | ')
}
return previewOne(list[0] || { type: 'text', text: '' })
}
export const resolveMediaUrl = (url, msg = null) => {
const preferVoice = msg?.type === 'voice' || looksLikeAudioUrl(url || msg?.url || msg?.uri)
const candidates = []
const add = (value) => {
const raw = String(value || '').trim()
if (raw && !candidates.includes(raw)) candidates.push(raw)
}
add(url)
add(msg?.url)
if (msg?.type === 'image') {
add(msg?.douyin_url)
if (Array.isArray(msg?.url_list)) {
for (const item of msg.url_list) add(item)
}
add(msg?.uri ? resolveMediaUri(msg.uri) : '')
} else {
add(msg?.douyin_url)
add(msg?.uri ? resolveMediaUri(msg.uri, { preferVoice }) : '')
}
for (const value of candidates) {
let resolved = String(value || '').trim()
if (!resolved) continue
if (!/^https?:\/\//i.test(resolved) && !resolved.startsWith('data:') && URI_HINT_RE.test(resolved)) {
resolved = resolveMediaUri(resolved, { preferVoice })
}
if (!resolved) continue
let absolute = resolved
if (resolved.startsWith('/')) {
absolute = `${window.location.origin}${resolved}`
} else if (resolved.startsWith('//')) {
absolute = `https:${resolved}`
} else if (!resolved.startsWith('http://') && !resolved.startsWith('https://') && !resolved.startsWith('data:')) {
continue
}
if (/douyinpic\.com|byteimg\.com|ibyteimg\.com|douyin\.com|douyinstatic\.com|amemv\.com|snssdk\.com/i.test(absolute)) {
return `/api/media/proxy?url=${encodeURIComponent(absolute)}`
}
return absolute
}
return ''
}
export const buildTextPayload = (text) => String(text || '').trim()
export const buildImagePayload = (url, width, height) =>
JSON.stringify({
type: 'image',
text: '[图片]',
url: resolveMediaUrl(url),
width: width || undefined,
height: height || undefined
})
export const buildStickerPayload = (url, stickerId = '', name = '') =>
JSON.stringify({
type: 'sticker',
text: '[表情包]',
url: resolveMediaUrl(url),
sticker_id: stickerId || undefined,
name: name || undefined
})
export const COMMON_EMOJIS = [
'😀', '😁', '😂', '🤣', '😊', '😍', '🥰', '😘', '😎', '🤔',
'😢', '😭', '😡', '👍', '👎', '🙏', '👏', '🎉', '❤️', '🔥',
'✨', '💯', '🌹', '🎁', '🤝', '💪', '🙌', '😅', '🥺', '😴'
]
// 抖音标准表情(与抖音网页版 EMOJI_LIST 同步)。
// 发送 [名称] 文本即可,收发两端都会渲染成表情图;别名(如 色/爱慕)共用同一张图。
const DOUYIN_EMOJI_CDN = 'https://sf3-cdn-tos.douyinstatic.com/obj/ies-douyin-opencn/emoji'
const DOUYIN_EMOJI_ITEMS = [
['微笑', 'weixiao'], ['色', 'aimu'], ['爱慕', 'aimu'], ['捂脸', 'wulian'],
['呲牙', 'ziya'], ['大笑', 'daxiao'], ['发怒', 'fanu'], ['灵机一动', 'lingguangyishan'],
['灵光一闪', 'lingguangyishan'], ['抠鼻', 'koubi'], ['害羞', 'haixiu'], ['调皮', 'keai'],
['可爱', 'keai'], ['吃瓜群众', 'chiguaqunzhong'], ['晕', 'yun'], ['闭嘴', 'bizui'],
['笑哭', 'xiaoku'], ['难过', 'nanguo'], ['亲亲', 'wen'], ['来看我', 'laikanwo'],
['偷笑', 'touxiao'], ['打脸', 'dalian'], ['翻白眼', 'fanbaiyan'], ['睡', 'hanshui'],
['鼾睡', 'hanshui'], ['奸笑', 'jianxiao'], ['送心', 'songxin'], ['大哭', 'daku'],
['抓狂', 'zhuakuang'], ['惊讶', 'jingya'], ['酷拽', 'kuye'], ['泣不成声', 'qibuchengsheng'],
['大金牙', 'dajinya'], ['疑问', 'what'], ['小鼓掌', 'xiaoguzhang'], ['吐', 'tu'],
['拥抱', 'qiubaobao'], ['惊恐', 'jingkong'], ['耶', 'ye'], ['醉了', 'zuile'],
['看', 'kan'], ['二哈', 'erha'], ['微笑袋鼠', 'weixiaodaishu'], ['冷漠', 'lengmo'],
['暗中观察', 'anzhongguancha'], ['凝视', 'ningshi'], ['握爪', 'wozhua'], ['锦鲤', 'jinli'],
['蜡烛', 'lazhu'], ['加一', 'jiayi'], ['我酸了', 'wosuanle'], ['加鸡腿', 'jiajitui'],
['我太南了', 'wotainanle'], ['扎心', 'zhaxin'], ['给跪了', 'geiguile'], ['赞', 'zan'],
['鼓掌', 'guzhang'], ['比心', 'bixin'], ['感谢', 'qidao'], ['祈祷', 'qidao'],
['胜利', 'shengli'], ['强壮', 'jiayou'], ['加油', 'jiayou'], ['OK', 'ok'],
['ok', 'ok'], ['弱', 'ruo'], ['抱拳', 'baoquan'], ['勾引', 'gouyin'],
['再见', 'zaijian'], ['握手', 'woshou'], ['玫瑰', 'meigui'], ['666', '666'],
['爱心', 'xin'], ['心', 'xin'], ['胡瓜', 'hugua'], ['嘴唇', 'kiss'],
['kiss', 'kiss'], ['给力', 'geili'], ['啤酒', 'pijiu'], ['派对', 'sahua'],
['撒花', 'sahua'], ['蛋糕', 'dangao'], ['红包', 'hongbao'], ['礼物', 'liwu'],
['发', 'fa'], ['咖啡', 'kafei'], ['太阳', 'taiyang'], ['月亮', 'yueliang'],
['心碎', 'shangxin'], ['伤心', 'shangxin'], ['便便', 'shi'], ['福', 'fu'],
['一起加油', 'yiqijiayou'], ['戴口罩', 'daikouzhao'], ['勤洗手', 'qinxishou'], ['不信谣言', 'buxinyaoyan'],
['情书', 'qingshu'], ['iloveyou', 'iloveyou'], ['巧克力', 'qiaokeli'], ['戒指', 'jiezhi'],
['流泪', 'liulei'], ['愉快', 'xiao'], ['笑', 'xiao'], ['发呆', 'liangdai'],
['惊呆', 'liangdai'], ['机智', 'jizhi'], ['快哭了', 'kuaikule'], ['击掌', 'jizhang'],
['黑脸', 'heilian'], ['飞吻', 'feiwen'], ['碰拳', 'pengquan'], ['舔屏', 'tianping'],
['憨笑', 'hanxiao'], ['我想静静', 'woxiangjingjing'], ['思考', 'sikao'], ['呆无辜', 'daiwugu'],
['尴尬', 'heixian'], ['黑线', 'heixian'], ['得意', 'deyi'], ['衰', 'shuai'],
['互粉', 'hufen'], ['吐血', 'tuxie'], ['可怜', 'kelian'], ['不看', 'bukan'],
['摸头', 'motou'], ['去污粉', 'quwufen'], ['钱', 'qian'], ['撇嘴', 'piezui'],
['震惊', 'zhenliang'], ['V5', 'V5'], ['菜刀', 'dao'], ['刀', 'dao'],
['做鬼脸', 'zuoguilian'], ['皱眉', 'zhoumei'], ['敲打', 'qiaoda'], ['尬笑', 'gaxiao'],
['恐惧', 'kongju'], ['惊喜', 'liangxi'], ['石化', 'shihua'], ['哈欠', 'haqian'],
['炸弹', 'zhadan'], ['嘘', 'xu'], ['吐舌', 'tushe'], ['委屈', 'weiqu'],
['吐彩虹', 'tucaihong'], ['奋斗', 'fendou'], ['生病', 'wumai'], ['雾霾', 'wumai'],
['擦汗', 'cahan'], ['如花', 'ruhua'], ['鄙视', 'bishi'], ['强', 'qiang'],
['紫薇别走', 'ziweibiezou'], ['红脸', 'honglian'], ['困', 'kun'], ['流汗', 'han'],
['汗', 'han'], ['绿帽子', 'lvmaozi'], ['左上', 'zuoshang'], ['熊吉', 'xiongji'],
['听歌', 'tingge'], ['骷髅', 'kulou'], ['18禁', '18jin'], ['西瓜', 'xigua'],
['斜眼', 'xieyan'], ['阴险', 'yinxian'], ['白眼', 'baiyan'], ['凋谢', 'diaoxie'],
['嘿哈', 'heiha'], ['坏笑', 'huaixiao'], ['加好友', 'jiahaoyou'], ['囧', 'jiong'],
['泪奔', 'leiben'], ['不失礼貌的微笑', 'masichundeweixiao'], ['拳头', 'quantou'], ['右边', 'youbian'],
['右哼哼', 'youhengheng'], ['悠闲', 'youxian'], ['绝望的凝视', 'zhoudongyudeningshi'], ['咒骂', 'zhouma'],
['猪头', 'zhutou'], ['左边', 'zuobian'], ['左哼哼', 'zuohengheng']
]
export const DOUYIN_EMOJIS = DOUYIN_EMOJI_ITEMS.map(([name, slug]) => ({
name,
img: `${DOUYIN_EMOJI_CDN}/${slug}-3x.png`
}))
const DOUYIN_EMOJI_MAP = new Map(DOUYIN_EMOJIS.map((e) => [e.name, e.img]))
// 表情图经媒体代理加载(douyinstatic CDN 域名),保证任何部署环境下都能显示
export const douyinEmojiUrl = (name) => {
const img = DOUYIN_EMOJI_MAP.get(String(name || ''))
return img ? resolveMediaUrl(img) : ''
}
// 选择面板用:别名去重,同一张表情图只显示一次
export const DOUYIN_EMOJI_PICKER = (() => {
const seen = new Set()
return DOUYIN_EMOJIS.filter((e) => {
if (seen.has(e.img)) return false
seen.add(e.img)
return true
})
})()
// 兼容旧引用:完整表情名列表
export const DOUYIN_EMOJI_NAMES = DOUYIN_EMOJIS.map((e) => e.name)
// 把文本按 [表情名] 切分成片段,供聊天气泡把表情渲染成图片
export const splitEmojiSegments = (text) => {
const value = String(text || '')
if (!value.includes('[')) return [{ type: 'text', text: value }]
const segments = []
const re = /\[([^\[\]]{1,10})\]/g
let last = 0
let match
while ((match = re.exec(value))) {
const img = DOUYIN_EMOJI_MAP.get(match[1])
if (!img) continue
if (match.index > last) {
segments.push({ type: 'text', text: value.slice(last, match.index) })
}
segments.push({ type: 'emoji', name: match[1], url: resolveMediaUrl(img) })
last = match.index + match[0].length
}
if (!segments.length) return [{ type: 'text', text: value }]
if (last < value.length) {
segments.push({ type: 'text', text: value.slice(last) })
}
return segments
}
// 旧占位(保留导出避免其它引用报错,UI 不再使用)
export const COMMON_STICKERS = []
export const formatSystemLogDetail = (detail) => {
const text = String(detail || '')
if (!text) return { text: '', messages: [] }
const messages = []
const patterns = [
/收到[:]\s*([^|]+)/,
/发送[:]\s*([^|]+)/,
/内容[:]\s*([^|]+)/
]
for (const pattern of patterns) {
const match = text.match(pattern)
if (match?.[1]) {
messages.push(parseMessageContent(match[1].trim()))
}
}
return { text, messages }
}
export const extractUrlsFromDetail = (detail) => {
const text = String(detail || '')
const matches = text.match(/https?:\/\/[^\s\]|)\"']+/g)
return matches || []
}
+196
View File
@@ -0,0 +1,196 @@
export const emptyReplyForm = () => ({
reply_type: 'text',
reply_text: '',
reply_link_url: '',
reply_card_id: null,
reply_card_title: '',
reply_card_content: '',
reply_card_target_url: '',
reply_card_image_path: '',
reply_card_cover_url: '',
reply_card_page_url: ''
})
export const extractLinkCardMediaPath = (url) => {
const value = (url || '').trim()
const idx = value.indexOf('/api/media/link-cards/')
return idx >= 0 ? value.slice(idx) : value
}
export const parseReplyContent = (raw) => {
const value = (raw || '').trim()
if (!value) return emptyReplyForm()
if (value.startsWith('{')) {
try {
const data = JSON.parse(value)
if (data?.type === 'link') {
return {
...emptyReplyForm(),
reply_type: 'link',
reply_link_url: data.url || ''
}
}
if (data?.type === 'card') {
return {
...emptyReplyForm(),
reply_type: 'card',
reply_card_id: data.card_id ?? null,
reply_card_title: data.title || '',
reply_card_content: data.desc || data.description || '',
reply_card_target_url: data.target_url || data.link_url || '',
reply_card_image_path: data.image_path || extractLinkCardMediaPath(data.cover_url) || '',
reply_card_cover_url: data.cover_url || '',
reply_card_page_url: data.url || ''
}
}
if (data?.type === 'text') {
return {
...emptyReplyForm(),
reply_type: 'text',
reply_text: data.text || ''
}
}
} catch {
// fall through
}
}
return {
...emptyReplyForm(),
reply_type: 'text',
reply_text: value
}
}
export const parseReplyMessages = (raw) => {
const value = (raw || '').trim()
if (!value) return [emptyReplyForm()]
if (value.startsWith('{') || value.startsWith('[')) {
try {
const data = JSON.parse(value)
if (Array.isArray(data?.messages) && data.messages.length) {
return data.messages.map((item) => parseReplyContent(JSON.stringify(item)))
}
if (Array.isArray(data) && data.length) {
return data.map((item) => parseReplyContent(JSON.stringify(item)))
}
} catch {
// fall through
}
}
return [parseReplyContent(raw)]
}
export const serializeReplyItem = (form) => {
if (form.reply_type === 'link') {
return {
type: 'link',
text: '',
url: form.reply_link_url.trim()
}
}
if (form.reply_type === 'card') {
return {
type: 'card',
card_id: form.reply_card_id ?? null,
title: form.reply_card_title.trim(),
desc: form.reply_card_content.trim(),
url: form.reply_card_page_url.trim(),
target_url: form.reply_card_target_url.trim(),
cover_url: form.reply_card_cover_url.trim() || form.reply_card_image_path.trim(),
image_path:
form.reply_card_image_path.trim() ||
extractLinkCardMediaPath(form.reply_card_cover_url)
}
}
return {
type: 'text',
text: form.reply_text.trim()
}
}
export const serializeReplyContent = (replies) => {
const messages = replies.map(serializeReplyItem)
if (messages.length === 1) {
return JSON.stringify(messages[0])
}
return JSON.stringify({ messages })
}
export const countReplyMessages = (raw) => parseReplyMessages(raw).length
export const formatReplyPreview = (raw) => {
const items = parseReplyMessages(raw)
const previews = items
.map((item) => {
if (item.reply_type === 'link') return item.reply_link_url.trim()
if (item.reply_type === 'card') {
const title = item.reply_card_title.trim()
const targetUrl = item.reply_card_target_url.trim() || item.reply_card_page_url.trim()
if (title && targetUrl) return `[卡片:图片+链接] ${title}${targetUrl}`
return title || targetUrl || '[卡片:图片+链接]'
}
return item.reply_text.trim()
})
.filter(Boolean)
if (!previews.length) return raw || '未设置'
if (previews.length === 1) return previews[0]
return previews.join(' | ')
}
export const validateReplies = (replies) => {
if (!replies?.length) return '请至少添加一条回复消息'
for (let i = 0; i < replies.length; i += 1) {
const form = replies[i]
const label = replies.length > 1 ? `${i + 1} 条消息` : '回复'
if (form.reply_type === 'text') {
if (!form.reply_text.trim()) return `请输入${label}文本`
} else if (form.reply_type === 'link') {
if (!form.reply_link_url.trim()) return `请输入${label}跳转网址`
} else if (form.reply_type === 'card') {
if (!form.reply_card_title.trim()) return `请输入${label}卡片标题`
if (!form.reply_card_content.trim()) return `请输入${label}卡片内容(用于页面 description`
if (!form.reply_card_target_url.trim()) return `请输入${label}卡片跳转链接`
const imagePath =
form.reply_card_image_path.trim() ||
extractLinkCardMediaPath(form.reply_card_cover_url)
if (!imagePath) return `请上传${label}卡片封面图`
}
}
return ''
}
export const ensureCardPagesForReplies = async (replies, api) => {
const next = []
for (const form of replies) {
if (form.reply_type !== 'card') {
next.push(form)
continue
}
const imagePath =
form.reply_card_image_path.trim() ||
extractLinkCardMediaPath(form.reply_card_cover_url)
const payload = {
id: form.reply_card_id || null,
title: form.reply_card_title.trim(),
content: form.reply_card_content.trim(),
target_url: form.reply_card_target_url.trim(),
image_path: imagePath
}
const res = await api.post('/link-cards', payload)
const card = res.data
next.push({
...form,
reply_card_id: card.id,
reply_card_page_url: card.page_url,
reply_card_cover_url: card.cover_url,
reply_card_image_path: card.image_path
})
}
return next
}
export const replyTypeOptions = [
{ value: 'text', label: '文本' },
{ value: 'link', label: '网址' },
{ value: 'card', label: '卡片' }
]
File diff suppressed because it is too large Load Diff
+481
View File
@@ -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>
+682
View File
@@ -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>
+480
View File
@@ -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>
+302
View File
@@ -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>
+679
View File
@@ -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
+424
View File
@@ -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>
+85
View File
@@ -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>
+391
View File
@@ -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>
+674
View File
@@ -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
+841
View File
@@ -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>
+766
View File
@@ -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>