更新
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 favicon(32×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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user