Files
赵忠林 d8c559b5b1 feat(ui): 新增“天天系统”ERP管理平台主页布局与控制台页面优化
- 为控制台首页添加页面标题动态设置
- 为应用中心页面添加页面标题动态设置
- 修改控制台布局,实现动态浏览器标签页标题更新
- 新增“天天系统”ERP管理平台主页,包含侧边栏导航、顶部栏及数据概览模块
- 实现主页搜索框、通知、语言和用户信息区域交互
- 添加欢迎区、快捷入口、最近使用应用列表及应用详情抽屉功能
- 支持小程序扫码弹窗展示和应用类型图标及颜色区分
- 优化页面样式,支持响应式布局及交互效果
- 更新Nuxt国际化重定向路径片段标识符以兼容新版本
2026-04-09 00:58:15 +08:00

787 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">应用中心</h2>
<p class="page-desc">管理你订阅的所有应用快速进入后台</p>
</div>
<a-space>
<a-button @click="navigateTo('/market')">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</a-space>
</div>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" class="apps-tabs" @change="handleTabChange">
<a-tab-pane key="my-apps" tab="我的应用">
<AppsCenter :user-id="userId" />
</a-tab-pane>
<a-tab-pane key="purchased" tab="已购应用">
<div class="purchased-section">
<!-- 筛选栏 -->
<div class="filter-bar">
<a-input-search
v-model:value="purchasedSearch"
placeholder="搜索已购应用"
style="width: 300px"
@search="loadPurchasedApps"
/>
<a-select v-model:value="purchasedFilter" style="width: 140px" placeholder="全部状态">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="active">生效中</a-select-option>
<a-select-option value="expired">已过期</a-select-option>
<a-select-option value="cancelled">已取消</a-select-option>
</a-select>
</div>
<!-- 已购应用列表 -->
<a-table
:columns="purchasedColumns"
:data-source="filteredPurchasedApps"
:loading="purchasedLoading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'appInfo'">
<div class="app-info-cell">
<img v-if="record.appIcon" :src="record.appIcon" class="app-icon" />
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.appName) }">
{{ (record.appName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="app-info-text">
<div class="app-name">{{ record.appName }}</div>
<div class="app-developer">开发者{{ record.developerName }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'subscription'">
<div class="subscription-cell">
<div class="price-type">
<span v-if="record.priceType === 'free'" class="text-green-500">免费</span>
<span v-else class="text-orange-500">¥{{ (record.price || 0) / 100 }}</span>
<a-tag size="small" class="ml-2">{{ priceTypeText(record.priceType) }}</a-tag>
</div>
<div class="expire-time" v-if="record.endTime">
到期{{ record.endTime }}
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="subscriptionStatusColor(record.status)">
{{ subscriptionStatusText(record.status) }}
</a-tag>
<a-switch
v-if="record.status === 'active'"
v-model:checked="record.enabled"
size="small"
class="ml-2"
@change="(val) => handleToggleApp(record, val)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" size="small" @click="handleEnterApp(record)">
进入应用
</a-button>
<a-button size="small" @click="handleConfig(record)">
配置
</a-button>
<a-dropdown>
<a-button size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleRenew(record)">续费</a-menu-item>
<a-menu-item @click="handleViewDetail(record)">查看详情</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleUnsubscribe(record)">退订</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
<!-- 空状态 -->
<div v-if="filteredPurchasedApps.length === 0 && !purchasedLoading" 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>
</a-tab-pane>
<a-tab-pane key="store" tab="应用商店">
<div class="store-section">
<!-- 快捷入口卡片 -->
<div class="quick-cards">
<div class="quick-card" @click="navigateTo('/market')">
<div class="quick-card-icon blue">🛒</div>
<div class="quick-card-content">
<div class="quick-card-title">应用市场</div>
<div class="quick-card-desc">浏览和购买各类应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/market?type=plugin')">
<div class="quick-card-icon purple">🔌</div>
<div class="quick-card-content">
<div class="quick-card-title">插件中心</div>
<div class="quick-card-desc">扩展功能的插件</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/developer')">
<div class="quick-card-icon green">🛠</div>
<div class="quick-card-content">
<div class="quick-card-title">开发者中心</div>
<div class="quick-card-desc">开发自己的应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
</div>
<!-- 推荐应用 -->
<div class="recommend-section">
<div class="section-header">
<h3> 推荐应用</h3>
<a-button type="link" @click="navigateTo('/market')">查看更多</a-button>
</div>
<div class="recommend-apps">
<div
v-for="app in recommendApps"
:key="app.productId"
class="recommend-card"
@click="navigateTo(`/market?app=${app.productId}`)"
>
<img v-if="app.icon" :src="app.icon" class="recommend-icon" />
<div v-else class="recommend-icon-placeholder">{{ (app.productName || 'A').charAt(0) }}</div>
<div class="recommend-info">
<div class="recommend-name">{{ app.productName }}</div>
<div class="recommend-desc">{{ app.description || '暂无描述' }}</div>
</div>
<div class="recommend-price">
<span v-if="app.priceType === 'free'" class="price-free">免费</span>
<span v-else class="price-paid">¥{{ (app.price || 0) / 100 }}</span>
</div>
</div>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
<!-- 应用配置弹窗 -->
<a-modal
v-model:open="configModalVisible"
title="应用配置"
width="600px"
@ok="handleSaveConfig"
@cancel="configModalVisible = false"
>
<a-form :model="configForm" layout="vertical">
<a-form-item label="应用名称">
<a-input v-model:value="configForm.appName" disabled />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="configForm.enabled" />
</a-form-item>
<a-form-item label="自动续费">
<a-switch v-model:checked="configForm.autoRenew" />
</a-form-item>
<a-form-item label="配置参数">
<a-textarea v-model:value="configForm.config" :rows="5" placeholder="JSON 格式的配置参数" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ShopOutlined, RightOutlined, MoreOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import AppsCenter from '@/components/console/AppsCenter.vue'
import { pageAppProduct } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { getUserInfo } from '@/api/layout'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
definePageMeta({ layout: 'console' })
// 设置页面标题
useHead({
title: '应用中心'
})
// ─── 用户信息 ────────────────────────────────────────────────
const userId = ref<string | null>(import.meta.client ? localStorage.getItem('UserId') : null)
const userIdNum = computed(() => (userId.value ? Number(userId.value) : 0))
async function ensureUser() {
if (userIdNum.value) return
try {
const user = await getUserInfo()
const uid = user.userId
if (uid) {
userId.value = String(uid)
}
} catch {}
}
// Tab 状态
const activeTab = ref('my-apps')
// 已购应用状态
const purchasedLoading = ref(false)
const purchasedSearch = ref('')
const purchasedFilter = ref('')
// 推荐应用
const recommendApps = ref<AppProduct[]>([])
// ─── 已购应用数据从订单API加载 ──────────────────────────
interface PurchasedApp {
id: number
orderId: number
orderNo: string
appName: string
appIcon?: string
developerName: string
priceType: 'free' | 'one_time' | 'subscription'
price: number
status: 'active' | 'expired' | 'cancelled'
enabled: boolean
startTime: string
endTime?: string
autoRenew: boolean
config?: string
payPrice?: string
}
const purchasedApps = ref<PurchasedApp[]>([])
// 产品名称映射
const productCatalog: Record<string, string> = {
website: '企业官网',
shop: '电商系统',
mp: '小程序/公众号',
}
function resolveProductCode(order?: ShopOrder | null) {
if (!order?.description) return ''
try {
const extra = JSON.parse(order.description)
return typeof extra?.product === 'string' ? extra.product.trim() : ''
} catch {
return ''
}
}
function resolveProductName(order?: ShopOrder | null) {
const code = resolveProductCode(order)
if (code && productCatalog[code]) return productCatalog[code]
if (code) return code
return '应用服务'
}
function resolveMonths(order?: ShopOrder | null) {
if (!order?.description) return 0
try {
const extra = JSON.parse(order.description)
return typeof extra?.months === 'number' ? extra.months : 0
} catch {
return 0
}
}
function resolveTenantName(order?: ShopOrder | null) {
if (!order?.description) return ''
try {
const extra = JSON.parse(order.description)
return typeof extra?.tenantName === 'string' ? extra.tenantName.trim() : ''
} catch {
return ''
}
}
function isExpired(expirationTime?: string) {
if (!expirationTime) return false
return new Date(expirationTime).getTime() < Date.now()
}
function determinePriceType(order: ShopOrder): 'free' | 'one_time' | 'subscription' {
const months = resolveMonths(order)
if (Number(order.payType) === 12 || Number(order.payPrice) === 0) return 'free'
if (months > 0) return 'subscription'
return 'one_time'
}
function transformOrderToApp(order: ShopOrder): PurchasedApp {
const expired = isExpired(order.expirationTime)
const cancelled = Number(order.orderStatus) === 2 || Number(order.orderStatus) === 3
return {
id: order.orderId || 0,
orderId: order.orderId || 0,
orderNo: order.orderNo || '',
appName: resolveProductName(order),
developerName: resolveTenantName(order) || '官方',
priceType: determinePriceType(order),
price: Number(order.payPrice || order.totalPrice || 0),
status: cancelled ? 'cancelled' : expired ? 'expired' : 'active',
enabled: !expired && !cancelled && Number(order.orderStatus) === 1,
startTime: order.createTime ? String(order.createTime).replace(/T.*/, '') : '',
endTime: order.expirationTime ? String(order.expirationTime).replace(/T.*/, '') : undefined,
autoRenew: resolveMonths(order) > 1,
payPrice: order.payPrice || order.totalPrice,
}
}
// 表格列
const purchasedColumns = [
{ title: '应用信息', key: 'appInfo', width: 280 },
{ title: '订阅信息', key: 'subscription', width: 200 },
{ title: '状态', key: 'status', width: 120 },
{ title: '操作', key: 'action', width: 200 },
]
// 配置弹窗
const configModalVisible = ref(false)
const configForm = reactive({
appName: '',
enabled: true,
autoRenew: false,
config: '',
})
// 计算属性
const filteredPurchasedApps = computed(() => {
let result = [...purchasedApps.value]
if (purchasedFilter.value) {
result = result.filter(app => app.status === purchasedFilter.value)
}
if (purchasedSearch.value) {
const kw = purchasedSearch.value.toLowerCase()
result = result.filter(app =>
app.appName.toLowerCase().includes(kw) ||
app.developerName.toLowerCase().includes(kw)
)
}
return result
})
// 加载推荐应用
async function loadRecommendApps() {
try {
const res = await pageAppProduct({
page: 1,
limit: 4,
status: 1,
})
recommendApps.value = res.list || []
} catch (e) {
console.error('加载推荐应用失败', e)
}
}
// 加载已购应用从订单API
async function loadPurchasedApps() {
purchasedLoading.value = true
try {
await ensureUser()
const uid = userIdNum.value
if (!uid) {
purchasedApps.value = []
return
}
// 加载已支付且已完成的订单,作为已购应用列表
const data = await pageShopOrder({
page: 1,
limit: 100,
userId: uid,
payStatus: 1, // 已支付
})
const list = data?.list || []
purchasedApps.value = list.map(transformOrderToApp)
} catch (e) {
console.error('加载已购应用失败', e)
purchasedApps.value = []
} finally {
purchasedLoading.value = false
}
}
// 处理函数
function handleTabChange(key: string) {
if (key === 'store') {
loadRecommendApps()
} else if (key === 'purchased') {
loadPurchasedApps()
}
}
function priceTypeText(type: string) {
const map: Record<string, string> = {
free: '免费',
one_time: '一次性',
subscription: '订阅',
}
return map[type] || type
}
function subscriptionStatusColor(status: string) {
const map: Record<string, string> = {
active: 'success',
expired: 'error',
cancelled: 'default',
}
return map[status] || 'default'
}
function subscriptionStatusText(status: string) {
const map: Record<string, string> = {
active: '生效中',
expired: '已过期',
cancelled: '已取消',
}
return map[status] || status
}
function iconBgColor(name?: string) {
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a']
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]
}
function handleToggleApp(record: PurchasedApp, enabled: boolean) {
message.success(`${record.appName}${enabled ? '启用' : '禁用'}`)
}
function handleEnterApp(record: PurchasedApp) {
navigateTo('/market')
}
function handleConfig(record: PurchasedApp) {
configForm.appName = record.appName
configForm.enabled = record.enabled
configForm.autoRenew = record.autoRenew
configForm.config = record.config || '{}'
configModalVisible.value = true
}
function handleSaveConfig() {
message.success('配置保存成功')
configModalVisible.value = false
}
function handleRenew(record: PurchasedApp) {
navigateTo('/market')
}
function handleViewDetail(record: PurchasedApp) {
navigateTo('/console/orders')
}
function handleUnsubscribe(record: PurchasedApp) {
navigateTo('/tickets')
}
onMounted(() => {
ensureUser()
loadRecommendApps()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 2px 0 0;
}
/* Tab 样式 */
.apps-tabs :deep(.ant-tabs-nav) {
margin-bottom: 20px;
}
/* 已购应用 */
.purchased-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.app-info-cell {
display: flex;
align-items: center;
gap: 12px;
}
.app-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
.app-icon-placeholder {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.app-info-text {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.app-developer {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.subscription-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.expire-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
/* 应用商店 */
.store-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.quick-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.quick-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
}
.quick-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.quick-card-icon.blue { background: #eff6ff; }
.quick-card-icon.purple { background: #f5f3ff; }
.quick-card-icon.green { background: #f0fdf4; }
.quick-card-content {
flex: 1;
min-width: 0;
}
.quick-card-title {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.quick-card-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.quick-card-arrow {
color: rgba(0, 0, 0, 0.25);
transition: transform 0.2s;
}
.quick-card:hover .quick-card-arrow {
transform: translateX(4px);
color: rgba(0, 0, 0, 0.45);
}
/* 推荐应用 */
.recommend-section {
background: #fff;
border-radius: 12px;
padding: 20px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.recommend-apps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.recommend-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-icon {
width: 44px;
height: 44px;
border-radius: 10px;
object-fit: cover;
}
.recommend-icon-placeholder {
width: 44px;
height: 44px;
border-radius: 10px;
background: #4e6ef2;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-price {
flex-shrink: 0;
}
.price-free {
color: #22c55e;
font-weight: 500;
}
.price-paid {
color: #f59e0b;
font-weight: 600;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.ml-2 { margin-left: 8px; }
</style>