Files
jczxw-pc/app/components/console/AppsCenter.vue
2026-04-23 16:30:57 +08:00

933 lines
22 KiB
Vue
Raw 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 class="apps-center">
<!-- 工具栏搜索 + 视图切换 -->
<div class="toolbar">
<a-input
v-model:value="keywords"
placeholder="搜索应用名称或标识"
class="search-input"
allow-clear
@press-enter="doSearch"
>
<template #prefix>
<SearchOutlined style="color: #bbb" />
</template>
</a-input>
<div class="toolbar-right">
<a-tooltip title="网格视图">
<a-button
:type="viewMode === 'grid' ? 'primary' : 'default'"
shape="default"
size="small"
class="view-btn"
@click="viewMode = 'grid'"
>
<template #icon><AppstoreOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="列表视图">
<a-button
:type="viewMode === 'list' ? 'primary' : 'default'"
shape="default"
size="small"
class="view-btn"
@click="viewMode = 'list'"
>
<template #icon><UnorderedListOutlined /></template>
</a-button>
</a-tooltip>
</div>
</div>
<!-- 错误提示 -->
<a-alert v-if="error" show-icon type="error" :message="String(error)" class="mb-4" />
<!-- 加载中 -->
<div v-if="pending" class="state-wrap">
<a-spin size="large" tip="加载中..." />
</div>
<!-- 应用列表 -->
<div v-else-if="list.length > 0" class="app-list">
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'" class="grid-view">
<div v-for="app in list" :key="app.productId" class="app-card" @click="handleCardClick(app)">
<div class="card-body">
<div class="card-header">
<div class="app-icon" :style="{ background: iconBgColor(app.productName) }">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.productName"
class="icon-img"
loading="lazy"
/>
<div v-else class="icon-placeholder">
{{ appTypeIcon(app.appType) }}
</div>
</div>
<div class="app-info">
<div class="app-name">{{ app.productName }}</div>
<div class="app-code">{{ app.productCode }}</div>
<div class="app-type">
<span v-if="(app as any)._isOwner" class="owner-badge">创建者</span>
<span v-else class="member-badge">成员</span>
<span class="app-type-tag" :class="appTypeClass(app.appType)">
{{ appTypeName(app.type, app.appType) }}
</span>
</div>
</div>
</div>
<div class="card-content">
<div class="app-desc">{{ app.description || '暂无描述' }}</div>
</div>
<div class="card-footer">
<div class="app-meta">
<span class="meta-item">
<UserOutlined style="color: #999; margin-right: 4px" />
{{ app.developer }}
</span>
<span class="meta-item">
<ClockCircleOutlined style="color: #999; margin-right: 4px" />
{{ formatDateTime(app.updateTime || app.createTime) }}
</span>
</div>
<div class="app-actions">
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a
v-if="entry.available"
class="enter-link"
:class="{ 'primary-entry': entry.isPrimary }"
@click.stop="handleEntryClick(entry, app)"
>
<component :is="entry.icon" style="margin-right: 3px" />
{{ entry.label }}
</a>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="list-view">
<div v-for="app in list" :key="app.productId" class="app-row">
<div class="list-left">
<div class="list-icon" :style="{ background: iconBgColor(app.productName) }">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.productName"
class="list-icon-img"
loading="lazy"
/>
<div v-else class="list-icon-placeholder">
{{ appTypeIcon(app.appType) }}
</div>
</div>
<div class="list-info">
<div class="list-name">{{ app.productName }}</div>
<div class="list-meta">
<span>{{ app.productCode }}</span>
<span v-if="(app as any)._isOwner" class="owner-badge-sm">创建者</span>
<span v-else class="member-badge-sm">成员</span>
<span class="list-type-tag" :class="appTypeClass(app.appType)">
{{ appTypeName(app.type, app.appType) }}
</span>
<span>{{ app.description || '暂无描述' }}</span>
</div>
</div>
</div>
<div class="list-right">
<div class="list-time">{{ formatTime(app.createTime) }}</div>
<div class="list-actions">
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a
v-if="entry.available"
class="enter-link-small"
:class="{ 'primary-entry': entry.isPrimary }"
@click.stop="handleEntryClick(entry, app)"
>
<component :is="entry.icon" style="margin-right: 3px" />
{{ entry.label }}
</a>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="list.length === 0" class="state-wrap">
<a-empty description="暂无应用,快来创建你的第一个应用吧">
<template #image>
<div class="empty-icon">📦</div>
</template>
<div class="empty-guide">
<div class="empty-actions">
<a-button type="primary" @click="goToDeveloperCenter">
<template #icon><PlusOutlined /></template>
创建企业自建应用
</a-button>
<a-button @click="goToMarket">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</div>
<div class="empty-tips">
<span class="empty-tip">🛠 前往开发者中心创建专属应用</span>
<span class="empty-tip">🛒 从应用商店购买现成应用快速使用</span>
</div>
</div>
</a-empty>
</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 v-if="total > limit" class="pagination-wrap">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
AppstoreOutlined,
SearchOutlined,
UnorderedListOutlined,
DeleteOutlined,
EllipsisOutlined,
EyeOutlined,
UserOutlined,
ClockCircleOutlined,
PlusOutlined,
ShopOutlined,
GlobalOutlined,
QrcodeOutlined,
DownloadOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { removeAppProduct, getMyApps, getJoinedApps } 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 props = defineProps<{
userId?: number | string | null
ownerName?: string
}>()
const viewMode = ref<'grid' | 'list'>('grid')
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const total = ref(0)
// 详情抽屉
const detailOpen = ref(false)
const selectedApp = ref<AppProduct | null>(null)
function openDetail(app: AppProduct) {
selectedApp.value = app
detailOpen.value = true
}
// 我的应用(创建的应用)+ 参与的应用(被邀请的)
const myApps = ref<AppProduct[]>([])
const joinedApps = ref<AppProduct[]>([])
const pending = ref(false)
const error = ref<string | null>(null)
// 计算合并后的应用列表(去重)
const list = computed(() => {
const map = new Map<number, AppProduct>()
// 先加我创建的
myApps.value.forEach(app => {
if (app.productId) map.set(app.productId, { ...app, _isOwner: true })
})
// 再加我参与的(避免重复)
joinedApps.value.forEach(app => {
if (app.productId && !map.has(app.productId)) {
map.set(app.productId, { ...app, _isOwner: false })
}
})
return Array.from(map.values())
})
async function fetchApps() {
pending.value = true
error.value = null
try {
// 并行加载:我创建的应用 + 我参与的应用
const [myResult, joinedResult] = await Promise.all([
getMyApps({ page: 1, limit: 100 }),
getJoinedApps({ page: 1, limit: 100 }),
])
myApps.value = myResult?.list ?? []
joinedApps.value = joinedResult?.list ?? []
total.value = list.value.length
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
pending.value = false
}
}
function doSearch() {
page.value = 1
fetchApps()
}
function onPageChange(nextPage: number) {
page.value = nextPage
fetchApps()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
fetchApps()
}
function handleCardClick(app: AppProduct) {
openDetail(app)
}
// 菜单操作
function handleMenuAction(key: string, app: AppProduct) {
if (key === 'detail') {
openDetail(app)
} else if (key === 'delete') {
handleDeleteApp(app)
}
}
// 删除应用
const deletingAppId = ref<number | null>(null)
function handleDeletedFromDetail() {
selectedApp.value = null
detailOpen.value = false
refresh()
}
// 更新应用后同步本地数据
function handleUpdatedFromDetail(updatedApp: AppProduct) {
// 更新列表中的应用数据
const index = list.value.findIndex((app) => app.productId === updatedApp.productId)
if (index !== -1) {
list.value[index] = { ...list.value[index], ...updatedApp }
}
// 更新当前选中的应用
if (selectedApp.value?.productId === updatedApp.productId) {
selectedApp.value = { ...selectedApp.value, ...updatedApp }
}
}
function handleDeleteApp(app: AppProduct) {
const name = app.productName || app.productCode || '该应用'
Modal.confirm({
title: '确认删除应用',
content: `确定要删除应用「${name}」吗?删除后所有配置、成员和版本记录将被永久清除,且无法恢复。`,
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
async onOk() {
deletingAppId.value = app.productId ?? null
try {
await removeAppProduct(app.productId)
message.success(`应用「${name}」已删除`)
// 如果详情抽屉打开的是当前应用,关闭它
if (selectedApp.value?.productId === app.productId) {
detailOpen.value = false
selectedApp.value = null
}
// 刷新列表
await refresh()
} catch (e: any) {
message.error(e.message || '删除失败')
} finally {
deletingAppId.value = null
}
},
})
}
// 外部刷新:重新查询应用列表
async function refresh() {
await fetchApps()
}
// 暴露 refresh 方法供父组件调用
defineExpose({ refresh })
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?: string): string {
return APP_TYPE_NAME[type ?? 0] ?? 'Web 应用'
}
function appTypeIcon(appType?: string | number): string {
const numType = typeof appType === 'string' ? Number(appType) : (appType ?? 10)
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[numType] ?? '🌐'
}
function appTypeClass(appType?: string | number): string {
const numType = typeof appType === 'string' ? Number(appType) : (appType ?? 10)
const classMap: Record<number, string> = {
[APP_TYPE.WEBSITE]: 'type-10',
[APP_TYPE.WECHAT_MP]: 'type-20',
[APP_TYPE.DOUYIN_MP]: 'type-30',
[APP_TYPE.BAIDU_MP]: 'type-40',
[APP_TYPE.ALIPAY_MP]: 'type-50',
[APP_TYPE.ANDROID]: 'type-60',
[APP_TYPE.IOS]: 'type-70',
[APP_TYPE.MACOS]: 'type-80',
[APP_TYPE.WINDOWS]: 'type-90',
[APP_TYPE.PLUGIN]: 'type-100',
}
return classMap[numType] ?? 'type-10'
}
function formatDateTime(dateStr?: string) {
if (!dateStr) return '-'
// 格式化为 "2026-03-28 10:46"
return dateStr.slice(0, 16).replace('T', ' ')
}
// 图标背景色
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)
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
// 导航函数
function goToDeveloperCenter() {
navigateTo('/developer/apps')
}
function goToMarket() {
navigateTo('/market')
}
// 组件挂载时加载数据
onMounted(() => {
fetchApps()
})
</script>
<style scoped>
.apps-center {
min-height: 400px;
}
/* ===== 工具栏 ===== */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.search-input {
flex: 1;
max-width: 300px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.view-btn {
width: 32px;
height: 32px;
}
/* ===== 状态容器 ===== */
.state-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
background: #fff;
border-radius: 12px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 4px;
}
/* 空状态引导 */
.empty-guide {
margin-top: 16px;
text-align: center;
}
.empty-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 16px;
}
.empty-tips {
display: flex;
flex-direction: column;
gap: 6px;
}
.empty-tip {
font-size: 13px;
color: #999;
}
/* ===== 网格视图 ===== */
.grid-view {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.app-card {
background: #fff;
border-radius: 12px;
border: 1px solid #f0f0f0;
transition: all 0.2s;
cursor: pointer;
overflow: hidden;
}
.app-card:hover {
border-color: #d6e4ff;
box-shadow: 0 4px 12px rgba(102, 102, 102, 0.08);
transform: translateY(-2px);
}
.card-body {
padding: 20px;
}
.card-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.app-icon {
width: 70px;
height: 70px;
border-radius: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
overflow: hidden;
}
.icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: #fff;
}
.app-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.app-code {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.app-type {
margin-top: 2px;
}
.card-content {
margin-bottom: 12px;
}
.app-desc {
font-size: 13px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.app-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #999;
}
.meta-item {
display: flex;
align-items: center;
}
.app-actions {
display: flex;
align-items: center;
gap: 8px;
}
.enter-link {
font-size: 12px;
color: #1890ff;
text-decoration: none;
padding: 2px 8px;
border-radius: 4px;
background: #e6f4ff;
transition: all 0.2s;
}
.enter-link:hover {
background: #bae0ff;
color: #0958d9;
}
.enter-link.primary-entry {
color: #fff;
background: #1890ff;
}
.enter-link.primary-entry:hover {
background: #0958d9;
color: #fff;
}
.enter-link-disabled {
color: #999;
background: #f5f5f5;
cursor: not-allowed;
}
.enter-link-disabled:hover {
color: #999;
background: #f5f5f5;
}
/* ===== 列表视图 ===== */
.list-view {
background: #fff;
border-radius: 12px;
border: 1px solid #f0f0f0;
overflow: hidden;
}
.app-row {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
cursor: pointer;
}
.app-row:last-child { border-bottom: none; }
.app-row:hover { background: #fafafa; }
.list-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.list-icon {
width: 40px;
height: 40px;
border-radius: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
overflow: hidden;
}
.list-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.list-icon-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.list-info {
flex: 1;
min-width: 0;
}
.list-name {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-meta {
font-size: 12px;
color: #999;
margin-top: 2px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.list-type-tag {
font-size: 11px;
padding: 1px 7px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #f0f0f0;
color: #666;
}
.list-type-tag.type-10 { background: #e6f4ff; color: #0958d9; }
.list-type-tag.type-20 { background: #f6ffed; color: #389e0d; }
.list-type-tag.type-60 { background: #fff7e6; color: #d46b08; }
.list-type-tag.type-100 { background: #f9f0ff; color: #722ed1; }
.list-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.list-time {
font-size: 12px;
color: #bbb;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.enter-link-small {
font-size: 12px;
color: #1890ff;
text-decoration: none;
padding: 2px 8px;
border-radius: 4px;
background: #e6f4ff;
transition: all 0.2s;
}
.enter-link-small:hover {
background: #bae0ff;
color: #0958d9;
}
.enter-link-small.primary-entry {
color: #fff;
background: #1890ff;
}
.enter-link-small.primary-entry:hover {
background: #0958d9;
color: #fff;
}
.enter-link-small.enter-link-disabled {
color: #999;
background: #f5f5f5;
cursor: not-allowed;
}
.enter-link-small.enter-link-disabled:hover {
color: #999;
background: #f5f5f5;
}
/* ===== 分页 ===== */
.pagination-wrap {
display: flex;
justify-content: center;
padding: 4px 0 0;
}
/* ===== 应用类型标签 ===== */
.app-type-tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
padding: 1px 7px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #f0f0f0;
color: #666;
}
.app-type-tag.type-10 { background: #e6f4ff; color: #0958d9; }
.app-type-tag.type-20 { background: #f6ffed; color: #389e0d; }
.app-type-tag.type-60 { background: #fff7e6; color: #d46b08; }
.app-type-tag.type-100 { background: #f9f0ff; color: #722ed1; }
/* ===== 创建者/成员标签 ===== */
.owner-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
padding: 1px 7px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #fff1f0;
color: #cf1322;
margin-right: 6px;
}
.member-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
padding: 1px 7px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #f6ffed;
color: #389e0d;
margin-right: 6px;
}
.owner-badge-sm {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 6px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #fff1f0;
color: #cf1322;
margin-right: 4px;
}
.member-badge-sm {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 6px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
background: #f6ffed;
color: #389e0d;
margin-right: 4px;
}
</style>