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

598 lines
14 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="console-home">
<!-- 顶部欢迎区 -->
<div class="welcome-section">
<div class="welcome-content">
<h1 class="welcome-title">欢迎回来 👋</h1>
<p class="welcome-subtitle">管理您的应用订单与账号一站式控制台</p>
</div>
<div class="welcome-actions">
<a-button type="primary" size="large" @click="navigateTo('/console/apps')">
<template #icon><AppstoreOutlined /></template>
进入应用中心
</a-button>
<a-button size="large" @click="navigateTo('/market')">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</div>
</div>
<!-- 快捷入口 -->
<div class="section-block">
<div class="section-header">
<h3 class="section-title">快捷入口</h3>
</div>
<div class="quick-cards-grid">
<div
v-for="card in quickCards"
:key="card.to"
class="quick-card"
@click="navigateTo(card.to)"
>
<div class="quick-card-icon" :style="{ background: card.bg }">
<component :is="card.icon" :style="{ fontSize: '22px', color: card.color }" />
</div>
<div class="quick-card-info">
<div class="quick-card-label">{{ card.label }}</div>
<div class="quick-card-desc">{{ card.desc }}</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
</div>
</div>
<!-- 最近使用的应用 -->
<div class="section-block">
<div class="section-header">
<h3 class="section-title">最近使用</h3>
<NuxtLink to="/console/apps">
<a-button type="link">
查看全部 <RightOutlined />
</a-button>
</NuxtLink>
</div>
<!-- 加载中 -->
<div v-if="recentAppsLoading" class="recent-apps-loading">
<a-skeleton active :paragraph="{ rows: 1 }" />
</div>
<!-- 应用列表 -->
<div v-else-if="recentApps.length > 0" class="recent-apps-grid">
<div
v-for="app in recentApps.slice(0, 6)"
:key="app.productId"
class="recent-app-card"
@click="handleAppClick(app)"
>
<div class="recent-app-icon" :style="{ background: iconBgColor(app.productName) }">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.productName"
class="recent-app-icon-img"
/>
<span v-else class="recent-app-icon-text">{{ appTypeIcon(app.appType) }}</span>
</div>
<div class="recent-app-info">
<div class="recent-app-name">{{ app.productName }}</div>
<div class="recent-app-meta">
<span class="recent-app-type">{{ appTypeName(app.type, app.appType) }}</span>
<span class="recent-app-time">{{ formatTime(app.updateTime || app.createTime) }}</span>
</div>
</div>
<div class="recent-app-entries">
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a-button
v-if="entry.available"
:type="entry.isPrimary ? 'primary' : 'default'"
size="small"
class="recent-app-enter-btn"
@click.stop="handleEntryClick(entry, app)"
>
<component :is="entry.icon" />
{{ entry.label }}
</a-button>
</template>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="recent-apps-empty">
<a-empty description="暂无最近使用的应用">
<template #image>
<div class="empty-icon">📦</div>
</template>
<a-button type="primary" @click="navigateTo('/console/apps')">去创建应用</a-button>
</a-empty>
</div>
</div>
<!-- 应用详情抽屉 -->
<AppDetail v-model:open="detailOpen" :app="selectedApp" @deleted="handleDeletedFromDetail" @updated="handleUpdatedFromDetail" />
<!-- 小程序扫码弹窗 -->
<QrCodeModal
v-model:open="qrOpen"
:qrcode-url="qrApp?.qrcode"
:app-name="qrApp?.productName"
:title="qrApp ? (APP_TYPE_NAME[qrApp.appType ?? 10] || '小程序') + '二维码' : ''"
:tip="qrApp ? getScanTip(qrApp.appType ?? 20) : ''"
/>
</div>
</template>
<script setup lang="ts">
import {
AppstoreOutlined,
GiftOutlined,
SafetyCertificateOutlined,
ShoppingCartOutlined,
ShoppingOutlined,
UserOutlined,
ShopOutlined,
RightOutlined,
TeamOutlined,
CustomerServiceOutlined,
GlobalOutlined,
QrcodeOutlined,
DownloadOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getJoinedApps, recordVisit } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
import AppDetail from '@/components/developer/AppDetail.vue'
import QrCodeModal from '@/components/QrCodeModal.vue'
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
import type { AppEntry } from '@/utils/appEntry'
const router = useRouter()
definePageMeta({ layout: 'console' })
const userId = import.meta.client ? localStorage.getItem('UserId') : null
// 快捷入口配置
const quickCards = [
{
label: '应用中心',
desc: '管理我的应用',
to: '/console/apps',
icon: AppstoreOutlined,
bg: '#eff6ff',
color: '#3b82f6',
},
{
label: '已购产品',
desc: '查看授权与订阅',
to: '/console/products',
icon: ShoppingOutlined,
bg: '#f0fdf4',
color: '#22c55e',
},
{
label: '订单记录',
desc: '历史订单与账单',
to: '/console/orders',
icon: ShoppingCartOutlined,
bg: '#fff7ed',
color: '#f97316',
},
{
label: '优惠券',
desc: '查看可用优惠',
to: '/console/coupons',
icon: GiftOutlined,
bg: '#fdf4ff',
color: '#a855f7',
},
{
label: '成员管理',
desc: '团队与权限管理',
to: '/console/account/members',
icon: TeamOutlined,
bg: '#fefce8',
color: '#eab308',
},
{
label: '工单管理',
desc: '技术支持与反馈',
to: '/console/tickets',
icon: CustomerServiceOutlined,
bg: '#f0f9ff',
color: '#0ea5e9',
},
{
label: '账号安全',
desc: '密码与安全设置',
to: '/console/account/security',
icon: SafetyCertificateOutlined,
bg: '#fff1f2',
color: '#f43f5e',
},
]
// 最近使用的应用
const recentApps = ref<AppProduct[]>([])
const recentAppsLoading = ref(false)
const detailOpen = ref(false)
const selectedApp = ref<AppProduct | null>(null)
// 跳转方法(模板中不能直接调用 navigateTo
function navigateTo(path: string) {
router.push(path)
}
// 入口处理
function handleEntryClick(entry: AppEntry, app: AppProduct) {
if (entry.type === 'scan-qr') {
qrApp.value = app
qrOpen.value = true
return
}
executeEntry(entry)
}
// 扫码弹窗
const qrOpen = ref(false)
const qrApp = ref<AppProduct | null>(null)
// 应用类型名称(使用统一枚举)
function appTypeName(type?: number, appType?: number): string {
return APP_TYPE_NAME[type ?? 10] ?? 'Web 应用'
}
function appTypeIcon(appType?: number): string {
const iconMap: Record<number, string> = {
[APP_TYPE.WEBSITE]: '🌐',
[APP_TYPE.WECHAT_MP]: '📱',
[APP_TYPE.DOUYIN_MP]: '🎵',
[APP_TYPE.BAIDU_MP]: '🔍',
[APP_TYPE.ALIPAY_MP]: '💎',
[APP_TYPE.ANDROID]: '🤖',
[APP_TYPE.IOS]: '🍎',
[APP_TYPE.MACOS]: '💻',
[APP_TYPE.WINDOWS]: '🪟',
[APP_TYPE.PLUGIN]: '🔌',
}
return iconMap[appType ?? 10] ?? '🌐'
}
// 图标背景色
const PALETTE = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#87d068', '#108ee9']
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]
}
function formatTime(timestamp?: string | number | Date) {
if (!timestamp) return '-'
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60))
return minutes <= 1 ? '刚刚' : `${minutes}分钟前`
}
return `${hours}小时前`
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
}
// 加载最近使用的应用(包括我创建的和被邀请参与的应用)
async function loadRecentApps() {
if (!userId) return
recentAppsLoading.value = true
try {
const uid = Number(userId)
// 获取我参与的所有应用(后端按 app_user.update_time 排序,实现"最近使用"
const result = await getJoinedApps({ page: 1, limit: 10 })
recentApps.value = result?.list || []
} catch (e) {
console.error('加载最近应用失败', e)
} finally {
recentAppsLoading.value = false
}
}
function handleAppClick(app: AppProduct) {
// 记录访问(异步,不阻塞)
if (app.productId) {
recordVisit(app.productId)
}
selectedApp.value = app
detailOpen.value = true
}
function handleDeletedFromDetail() {
selectedApp.value = null
detailOpen.value = false
loadRecentApps()
}
function handleUpdatedFromDetail(updatedApp: AppProduct) {
const index = recentApps.value.findIndex((app) => app.productId === updatedApp.productId)
if (index !== -1) {
recentApps.value[index] = { ...recentApps.value[index], ...updatedApp }
}
if (selectedApp.value?.productId === updatedApp.productId) {
selectedApp.value = { ...selectedApp.value, ...updatedApp }
}
}
onMounted(() => {
loadRecentApps()
})
</script>
<style scoped>
/* ===== 页面整体 ===== */
.console-home {
padding-bottom: 24px;
}
/* ===== 欢迎区 ===== */
.welcome-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 32px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0 0 8px;
}
.welcome-subtitle {
font-size: 15px;
color: rgba(255, 255, 255, 0.85);
margin: 0;
}
.welcome-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
text-align: center;
padding: 24px;
}
.welcome-actions {
width: 100%;
justify-content: center;
}
}
/* ===== 区块样式 ===== */
.section-block {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin: 0;
}
/* ===== 快捷入口 ===== */
.quick-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.quick-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.quick-card:hover {
background: #fff;
border-color: #d6e4ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.quick-card-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-card-info {
flex: 1;
min-width: 0;
}
.quick-card-label {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 2px;
}
.quick-card-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.4;
}
.quick-card-arrow {
color: rgba(0, 0, 0, 0.25);
font-size: 12px;
transition: all 0.2s;
}
.quick-card:hover .quick-card-arrow {
color: rgba(0, 0, 0, 0.45);
transform: translateX(4px);
}
/* ===== 最近使用 ===== */
.recent-apps-loading {
padding: 20px 0;
}
.recent-apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.recent-app-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.recent-app-card:hover {
background: #fff;
border-color: #d6e4ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.recent-app-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.recent-app-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.recent-app-icon-text {
font-size: 20px;
}
.recent-app-info {
flex: 1;
min-width: 0;
}
.recent-app-name {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-app-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.recent-app-type {
color: rgba(0, 0, 0, 0.45);
background: #f0f0f0;
padding: 1px 6px;
border-radius: 4px;
}
.recent-app-time {
color: rgba(0, 0, 0, 0.35);
}
.recent-app-entries {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s;
}
.recent-app-card:hover .recent-app-entries {
opacity: 1;
}
.recent-app-enter-btn {
font-size: 12px;
}
/* 移动端始终显示进入按钮 */
@media (max-width: 768px) {
.recent-app-entries {
opacity: 1;
}
}
.recent-apps-empty {
padding: 40px 0;
}
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
</style>