Files
tiantian-system/app/pages/console/products.vue
2026-04-08 17:10:58 +08:00

551 lines
15 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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