551 lines
15 KiB
Vue
551 lines
15 KiB
Vue
<template>
|
||
<div class="space-y-4">
|
||
<a-page-header title="已购产品" sub-title="订阅与授权信息">
|
||
<template #extra>
|
||
<a-space>
|
||
<a-input
|
||
v-model:value="keywords"
|
||
allow-clear
|
||
placeholder="搜索产品名称"
|
||
class="w-56"
|
||
@press-enter="doSearch"
|
||
/>
|
||
<a-button :loading="loading" @click="refresh">刷新</a-button>
|
||
</a-space>
|
||
</template>
|
||
</a-page-header>
|
||
|
||
<!-- 统计卡片 -->
|
||
<a-row :gutter="[16, 16]">
|
||
<a-col :xs="8" :md="4" v-for="stat in stats" :key="stat.label">
|
||
<div class="mini-stat" :class="stat.color">
|
||
<div class="mini-stat-value">{{ stat.value }}</div>
|
||
<div class="mini-stat-label">{{ stat.label }}</div>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-card :bordered="false" class="card">
|
||
<!-- 筛选 -->
|
||
<a-space class="mb-4" wrap>
|
||
<a-segmented
|
||
v-model:value="statusFilter"
|
||
:options="statusOptions"
|
||
@change="doSearch"
|
||
/>
|
||
<a-select
|
||
v-model:value="productType"
|
||
allow-clear
|
||
placeholder="产品类型"
|
||
style="min-width: 140px"
|
||
@change="doSearch"
|
||
>
|
||
<a-select-option value="website">企业官网</a-select-option>
|
||
<a-select-option value="shop">电商系统</a-select-option>
|
||
<a-select-option value="mp">小程序/公众号</a-select-option>
|
||
</a-select>
|
||
</a-space>
|
||
|
||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||
|
||
<!-- 产品卡片列表 -->
|
||
<div v-if="loading" class="product-loading">
|
||
<a-spin tip="加载中..." />
|
||
</div>
|
||
|
||
<div v-else-if="productList.length === 0" class="product-empty">
|
||
<a-empty description="暂无已购产品">
|
||
<a-button type="primary" @click="navigateTo('/market')">
|
||
前往应用商店
|
||
</a-button>
|
||
</a-empty>
|
||
</div>
|
||
|
||
<div v-else class="product-grid">
|
||
<div
|
||
v-for="product in productList"
|
||
:key="product.id"
|
||
class="product-card"
|
||
>
|
||
<div class="product-card-header">
|
||
<div class="product-icon" :class="product.typeClass">{{ product.icon }}</div>
|
||
<a-tag
|
||
:color="product.isActive ? 'green' : 'default'"
|
||
class="product-status"
|
||
>
|
||
{{ product.isActive ? '生效中' : '已过期' }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="product-card-body">
|
||
<div class="product-name">{{ product.name }}</div>
|
||
<div class="product-desc">{{ product.desc }}</div>
|
||
<div class="product-meta">
|
||
<span v-if="product.tenantName" class="meta-item">
|
||
<span class="meta-dot" /> {{ product.tenantName }}
|
||
</span>
|
||
<span v-if="product.domain" class="meta-item">
|
||
<span class="meta-dot" /> {{ product.domain }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<a-divider style="margin: 0" />
|
||
<div class="product-card-footer">
|
||
<div class="product-price">
|
||
<span class="price-amount">¥{{ product.price }}</span>
|
||
<span class="price-period">/ {{ product.period }}</span>
|
||
</div>
|
||
<a-space>
|
||
<a-button
|
||
v-if="product.adminUrl"
|
||
size="small"
|
||
type="primary"
|
||
@click="goAdmin(product)"
|
||
>
|
||
进入后台
|
||
</a-button>
|
||
<a-button size="small" @click="openDetail(product)">详情</a-button>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div v-if="total > pageSize" class="mt-4 flex justify-end">
|
||
<a-pagination
|
||
:current="page"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
size="small"
|
||
@change="onPageChange"
|
||
/>
|
||
</div>
|
||
</a-card>
|
||
|
||
<!-- 详情弹窗 -->
|
||
<a-modal
|
||
v-model:open="detailOpen"
|
||
:title="selectedProduct?.name || '产品详情'"
|
||
:width="640"
|
||
:footer="null"
|
||
>
|
||
<template v-if="selectedProduct">
|
||
<a-descriptions :column="1" size="small" bordered>
|
||
<a-descriptions-item label="产品名称">{{ selectedProduct.name }}</a-descriptions-item>
|
||
<a-descriptions-item label="产品类型">{{ selectedProduct.typeName }}</a-descriptions-item>
|
||
<a-descriptions-item label="授权状态">
|
||
<a-tag :color="selectedProduct.isActive ? 'green' : 'default'">
|
||
{{ selectedProduct.isActive ? '生效中' : '已过期' }}
|
||
</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="购买金额">¥{{ selectedProduct.price }}</a-descriptions-item>
|
||
<a-descriptions-item label="订阅周期">{{ selectedProduct.period }}</a-descriptions-item>
|
||
<a-descriptions-item label="关联租户">{{ selectedProduct.tenantName || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="绑定域名">
|
||
<a-typography-text v-if="selectedProduct.domain" :copyable="{ text: selectedProduct.domain }">
|
||
{{ selectedProduct.domain }}
|
||
</a-typography-text>
|
||
<span v-else>-</span>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="订单号">
|
||
<a-typography-text v-if="selectedProduct.orderNo" :copyable="{ text: selectedProduct.orderNo }">
|
||
{{ selectedProduct.orderNo }}
|
||
</a-typography-text>
|
||
<span v-else>-</span>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="购买时间">{{ selectedProduct.createTime || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="到期时间">
|
||
<span :class="{ 'text-red-500': !selectedProduct.isActive }">
|
||
{{ selectedProduct.expirationTime || '-' }}
|
||
</span>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
<div v-if="selectedProduct.adminUrl" class="mt-4 text-right">
|
||
<a-button type="primary" @click="goAdmin(selectedProduct!)">进入管理后台</a-button>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { getUserInfo } from '@/api/layout'
|
||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||
|
||
definePageMeta({ layout: 'console' })
|
||
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const list = ref<ShopOrder[]>([])
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const keywords = ref('')
|
||
const statusFilter = ref('active')
|
||
const productType = ref<string | undefined>(undefined)
|
||
|
||
const currentUserId = ref<number | null>(null)
|
||
|
||
const statusOptions = [
|
||
{ label: '生效中', value: 'active' },
|
||
{ label: '已过期', value: 'expired' },
|
||
{ label: '全部', value: 'all' },
|
||
]
|
||
|
||
interface ProductItem {
|
||
id: string
|
||
name: string
|
||
desc: string
|
||
icon: string
|
||
typeClass: string
|
||
typeName: string
|
||
type: string
|
||
isActive: boolean
|
||
price: string
|
||
period: string
|
||
tenantName: string
|
||
domain: string
|
||
adminUrl: string
|
||
orderNo: string
|
||
createTime: string
|
||
expirationTime: string
|
||
raw: ShopOrder
|
||
}
|
||
|
||
function safeJsonParse(value: string): unknown {
|
||
try { return JSON.parse(value) }
|
||
catch { return undefined }
|
||
}
|
||
|
||
function pickFirstRemark(order?: ShopOrder | null) {
|
||
if (!order) return ''
|
||
const record = order as unknown as Record<string, unknown>
|
||
const keys = ['buyerRemarks', 'merchantRemarks', 'description']
|
||
for (const key of keys) {
|
||
const v = record[key]
|
||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function parseExtra(order?: ShopOrder | null): Record<string, unknown> | null {
|
||
const raw = pickFirstRemark(order)
|
||
if (!raw) return null
|
||
const parsed = safeJsonParse(raw)
|
||
if (!parsed || typeof parsed !== 'object') return null
|
||
return parsed as Record<string, unknown>
|
||
}
|
||
|
||
const productCatalog: Record<string, { name: string; icon: string; typeClass: string; desc: string }> = {
|
||
website: { name: '企业官网', icon: '🌐', typeClass: 'blue', desc: '响应式企业建站系统' },
|
||
shop: { name: '电商系统', icon: '🛒', typeClass: 'green', desc: '多端电商解决方案' },
|
||
mp: { name: '小程序/公众号', icon: '📱', typeClass: 'purple', desc: '微信生态应用' },
|
||
}
|
||
|
||
function toProductItem(order: ShopOrder): ProductItem {
|
||
const extra = parseExtra(order)
|
||
const code = typeof extra?.product === 'string' ? extra.product.trim() : ''
|
||
const catalog = code && productCatalog[code] ? productCatalog[code] : { name: code || '其他产品', icon: '📦', typeClass: 'gray', desc: '' }
|
||
const months = extra?.months
|
||
const tenantName = typeof extra?.tenantName === 'string' ? extra.tenantName.trim() : ''
|
||
const domain = typeof extra?.domain === 'string' ? extra.domain.trim() : ''
|
||
const payPrice = order.payPrice || order.totalPrice || '0'
|
||
|
||
// 判断是否过期
|
||
const expirationTime = order.expirationTime || ''
|
||
let isActive = true
|
||
if (expirationTime) {
|
||
isActive = new Date(expirationTime) > new Date()
|
||
}
|
||
if (Number(order.orderStatus) === 2 || Number(order.orderStatus) === 3 || Number(order.orderStatus) === 6) {
|
||
isActive = false
|
||
}
|
||
|
||
return {
|
||
id: `${order.orderId}-${code}`,
|
||
name: catalog.name,
|
||
desc: catalog.desc,
|
||
icon: catalog.icon,
|
||
typeClass: catalog.typeClass,
|
||
typeName: catalog.name,
|
||
type: code,
|
||
isActive,
|
||
price: typeof months === 'number' || typeof months === 'string' ? payPrice : payPrice,
|
||
period: typeof months === 'number' || typeof months === 'string' ? `${months}个月` : '永久',
|
||
tenantName,
|
||
domain,
|
||
adminUrl: isActive && tenantName ? `https://${tenantName}` : '',
|
||
orderNo: order.orderNo || '',
|
||
createTime: order.createTime || '',
|
||
expirationTime,
|
||
raw: order,
|
||
}
|
||
}
|
||
|
||
const productList = computed(() => {
|
||
const result: ProductItem[] = []
|
||
const seen = new Set<string>()
|
||
|
||
for (const order of list.value) {
|
||
const product = toProductItem(order)
|
||
|
||
// 状态筛选
|
||
if (statusFilter.value === 'active' && !product.isActive) continue
|
||
if (statusFilter.value === 'expired' && product.isActive) continue
|
||
|
||
// 类型筛选
|
||
if (productType.value && product.type !== productType.value) continue
|
||
|
||
// 关键词筛选
|
||
if (keywords.value?.trim()) {
|
||
const kw = keywords.value.trim().toLowerCase()
|
||
if (!product.name.toLowerCase().includes(kw) && !product.tenantName.toLowerCase().includes(kw)) continue
|
||
}
|
||
|
||
// 去重(按 product + tenantName)
|
||
const dedupeKey = `${product.type}-${product.tenantName}`
|
||
if (seen.has(dedupeKey)) {
|
||
// 更新已有项为生效中的
|
||
const existing = result.find(p => `${p.type}-${p.tenantName}` === dedupeKey)
|
||
if (existing && product.isActive) {
|
||
Object.assign(existing, product)
|
||
}
|
||
continue
|
||
}
|
||
seen.add(dedupeKey)
|
||
result.push(product)
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const stats = computed(() => {
|
||
const allProducts = list.value.map(toProductItem)
|
||
const activeCount = allProducts.filter(p => p.isActive).length
|
||
const expiredCount = allProducts.filter(p => !p.isActive).length
|
||
const totalSpent = list.value.reduce((sum, o) => sum + Number(o.payPrice || o.totalPrice || 0), 0)
|
||
|
||
return [
|
||
{ label: '总产品', value: list.value.length, color: 'blue' },
|
||
{ label: '生效中', value: activeCount, color: 'green' },
|
||
{ label: '已过期', value: expiredCount, color: 'orange' },
|
||
{ label: '累计消费', value: `¥${totalSpent.toFixed(0)}`, color: 'purple' },
|
||
]
|
||
})
|
||
|
||
const detailOpen = ref(false)
|
||
const selectedProduct = ref<ProductItem | null>(null)
|
||
|
||
function openDetail(product: ProductItem) {
|
||
selectedProduct.value = product
|
||
detailOpen.value = true
|
||
}
|
||
|
||
function goAdmin(product: ProductItem) {
|
||
if (product.adminUrl) {
|
||
window.open(product.adminUrl, '_blank')
|
||
}
|
||
}
|
||
|
||
async function ensureUser() {
|
||
if (currentUserId.value) return
|
||
const user = await getUserInfo()
|
||
currentUserId.value = user.userId ?? null
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
await ensureUser()
|
||
const userId = currentUserId.value
|
||
if (!userId) {
|
||
throw new Error('缺少用户信息')
|
||
}
|
||
|
||
const data = await pageShopOrder({
|
||
page: page.value,
|
||
limit: pageSize.value,
|
||
userId,
|
||
payStatus: 1, // 只查已支付
|
||
keywords: keywords.value?.trim() || undefined,
|
||
})
|
||
|
||
list.value = data?.list || []
|
||
total.value = data?.count || 0
|
||
} catch (e: unknown) {
|
||
console.error(e)
|
||
list.value = []
|
||
total.value = 0
|
||
error.value = e instanceof Error ? e.message : '加载失败'
|
||
message.error(error.value)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function refresh() { load() }
|
||
function doSearch() { page.value = 1; load() }
|
||
function onPageChange(p: number) { page.value = p; load() }
|
||
|
||
onMounted(() => { load() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card {
|
||
border-radius: 12px;
|
||
}
|
||
|
||
/* 迷你统计 */
|
||
.mini-stat {
|
||
padding: 14px;
|
||
border-radius: 10px;
|
||
border: 1px solid transparent;
|
||
text-align: center;
|
||
}
|
||
|
||
.mini-stat.blue { background: #eff6ff; border-color: #dbeafe; }
|
||
.mini-stat.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||
.mini-stat.orange { background: #fff7ed; border-color: #fed7aa; }
|
||
.mini-stat.purple { background: #f5f3ff; border-color: #e9d5ff; }
|
||
|
||
.mini-stat-value {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
.mini-stat-label {
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* 加载/空 */
|
||
.product-loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 60px 0;
|
||
}
|
||
|
||
.product-empty {
|
||
padding: 60px 0;
|
||
}
|
||
|
||
/* 产品网格 */
|
||
.product-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.product-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* 产品卡片 */
|
||
.product-card {
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.product-card:hover {
|
||
border-color: #d0d7ff;
|
||
box-shadow: 0 4px 16px rgba(79, 70, 229, 0.08);
|
||
}
|
||
|
||
.product-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 16px 0;
|
||
}
|
||
|
||
.product-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22px;
|
||
}
|
||
|
||
.product-icon.blue { background: #eff6ff; }
|
||
.product-icon.green { background: #f0fdf4; }
|
||
.product-icon.purple { background: #f5f3ff; }
|
||
.product-icon.gray { background: #f9fafb; }
|
||
|
||
.product-status {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.product-card-body {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.product-name {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.88);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.product-desc {
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.4);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.product-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.meta-dot {
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: #d1d5db;
|
||
}
|
||
|
||
.product-card-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.product-price {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 2px;
|
||
}
|
||
|
||
.price-amount {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
.price-period {
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.4);
|
||
}
|
||
</style>
|