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
+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>