初始化2
This commit is contained in:
550
app/pages/console/products.vue
Normal file
550
app/pages/console/products.vue
Normal 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>
|
||||
Reference in New Issue
Block a user