初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

781
app/pages/console/apps.vue Normal file
View File

@@ -0,0 +1,781 @@
<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' })
// ─── 用户信息 ────────────────────────────────────────────────
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>