初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

View File

@@ -0,0 +1,550 @@
<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>