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

337 lines
8.6 KiB
Vue
Raw 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="产品开通使用与续费" :ghost="false" class="page-header">
<template #extra>
<a-segmented
:value="active"
:options="[
{ label: '已开通', value: 'index' },
{ label: '未开通', value: 'unopened' }
]"
@update:value="onSwitch"
/>
</template>
</a-page-header>
<!-- 产品分类 -->
<a-card :bordered="false" class="card">
<a-spin :spinning="loading">
<a-tabs v-model:activeKey="activeCategory" @change="loadProducts">
<a-tab-pane key="all" tab="全部产品" />
<a-tab-pane key="website" tab="企业官网" />
<a-tab-pane key="shop" tab="电商系统" />
<a-tab-pane key="mp" tab="小程序/公众号" />
<a-tab-pane key="plugin" tab="插件工具" />
</a-tabs>
<div v-if="!loading && filteredProducts.length === 0" class="empty-state">
<a-empty description="暂无可用产品">
<template #image>
<div class="empty-icon">🛒</div>
</template>
<a-button type="primary" @click="navigateTo('/market')">
前往应用商店
</a-button>
</a-empty>
</div>
<div v-else class="product-grid">
<div
v-for="product in filteredProducts"
:key="product.productId"
class="product-card"
>
<div class="product-icon-wrap">
<img v-if="product.icon" :src="product.icon" class="product-icon-img" />
<div v-else class="product-icon-placeholder" :style="{ background: iconBgColor(product.productName) }">
{{ (product.productName || 'P').charAt(0) }}
</div>
</div>
<div class="product-info">
<div class="product-name">{{ product.productName }}</div>
<div class="product-desc">{{ product.description || '暂无描述' }}</div>
<div class="product-tags">
<a-tag v-if="product.priceType === 'free'" color="green" size="small">免费</a-tag>
<a-tag v-else color="orange" size="small">
¥{{ (product.price || 0) / 100 }}
</a-tag>
<a-tag v-if="product.appType" size="small">
{{ typeName(product.appType) }}
</a-tag>
</div>
</div>
<div class="product-actions">
<a-button type="primary" size="small" @click="handleOpen(product)">
立即开通
</a-button>
<a-button size="small" @click="handlePreview(product)">
了解详情
</a-button>
</div>
</div>
</div>
</a-spin>
</a-card>
<!-- 开通引导 -->
<a-card :bordered="false" class="card guide-card">
<div class="guide-content">
<div class="guide-info">
<h3 class="guide-title">💡 需要定制化方案</h3>
<p class="guide-desc">如果以上产品无法满足您的需求可以联系我们的技术团队获取专属定制方案</p>
</div>
<div class="guide-actions">
<a-button type="primary" @click="navigateTo('/console/tickets')">
提交工单
</a-button>
<a-button @click="navigateTo('/market')">
浏览应用商店
</a-button>
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { pageAppProductAll } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'console' })
const route = useRoute()
const active = computed(() => (route.path.includes('/console/tenant/unopened') ? 'unopened' : ''))
function onSwitch(value: string | number) {
navigateTo(`/console/tenant/${String(value)}`)
}
// ─── 数据 ──────────────────────────────────────────────────────
const loading = ref(false)
const products = ref<AppProduct[]>([])
const activeCategory = ref('all')
const TYPE_NAME: Record<string, string> = {
web: 'Web 应用',
miniprogram: '小程序',
mobile: '移动 App',
api: 'API 服务',
plugin: '插件',
}
const TYPE_CATEGORY_MAP: Record<string, string> = {
web: 'website',
miniprogram: 'mp',
plugin: 'plugin',
}
function typeName(type?: string) {
return TYPE_NAME[type || ''] || type || '应用'
}
function getCategoryForProduct(product: AppProduct): string {
if (product.appType) {
return TYPE_CATEGORY_MAP[product.appType] || 'all'
}
// 根据 keywords 或 description 推断分类
const name = (product.productName || '').toLowerCase()
const desc = (product.description || '').toLowerCase()
if (name.includes('官网') || name.includes('企业') || desc.includes('官网') || desc.includes('企业站')) return 'website'
if (name.includes('电商') || name.includes('商城') || name.includes('shop') || desc.includes('电商')) return 'shop'
if (name.includes('小程序') || name.includes('公众号') || name.includes('mp') || desc.includes('小程序')) return 'mp'
if (name.includes('插件') || name.includes('plugin') || desc.includes('插件')) return 'plugin'
return 'all'
}
const filteredProducts = computed(() => {
if (activeCategory.value === 'all') return products.value
return products.value.filter(p => getCategoryForProduct(p) === activeCategory.value)
})
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#264653']
function iconBgColor(name?: string) {
if (!name) return PALETTE[0]
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
return PALETTE[Math.abs(h) % PALETTE.length]
}
async function loadProducts() {
loading.value = true
try {
const res = await pageAppProductAll({
current: 1,
size: 50,
status: 1, // 正常状态
})
products.value = res?.list || []
} catch (e) {
console.error('加载产品列表失败', e)
products.value = []
} finally {
loading.value = false
}
}
function handleOpen(product: AppProduct) {
navigateTo(`/market?app=${product.productId}`)
}
function handlePreview(product: AppProduct) {
navigateTo(`/market?app=${product.productId}`)
}
onMounted(() => {
loadProducts()
})
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
/* 产品网格 */
.product-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.product-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
transition: all 0.2s;
}
.product-card:hover {
border-color: #d6e4ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.product-icon-wrap {
flex-shrink: 0;
}
.product-icon-img {
width: 52px;
height: 52px;
border-radius: 12px;
object-fit: cover;
}
.product-icon-placeholder {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: #fff;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.product-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-tags {
display: flex;
gap: 6px;
}
.product-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* 引导卡片 */
.guide-card {
background: linear-gradient(135deg, #f5f3ff, #ede9fe);
border-color: #ede9fe;
}
.guide-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.guide-info { flex: 1; min-width: 200px; }
.guide-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin: 0 0 6px;
}
.guide-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.5);
margin: 0;
line-height: 1.6;
}
.guide-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.product-card {
flex-direction: column;
text-align: center;
}
.product-actions {
width: 100%;
justify-content: center;
}
}
</style>