Files
tiantian-system/app/components/developer/AppsCenter.vue
2026-04-08 17:10:58 +08:00

871 lines
22 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="apps-center">
<!-- 统计条 -->
<div class="app-stats-bar">
<span class="stats-total">
<b>{{ total }}</b> 个应用
</span>
<span v-if="ownerCount > 0" class="stats-item owner">
<span class="stats-dot owner-dot" />我创建的 {{ ownerCount }}
</span>
<span v-if="memberCount > 0" class="stats-item member">
<span class="stats-dot member-dot" />我参与的 {{ memberCount }}
</span>
</div>
<!-- 工具栏搜索 + 视图切换 -->
<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="filteredApps.length === 0" class="state-wrap">
<a-empty :description="keywords ? '没有匹配的应用' : '还没有应用,点击上方按钮创建第一个吧'">
<template #image>
<div class="empty-icon">📦</div>
</template>
<a-button v-if="!keywords" type="primary" @click="$emit('create')">
<template #icon><PlusOutlined /></template>
创建企业自建应用
</a-button>
</a-empty>
</div>
<!-- 网格视图 -->
<div v-else-if="viewMode === 'grid'" class="app-grid">
<div
v-for="app in filteredApps"
:key="app.productId"
class="app-card"
@click="handleCardClick(app)"
>
<!-- 右上角状态标签 + 角色标签 -->
<div class="card-top-actions">
<div class="card-role-badge">
<RoleTag :role="app.myRole || 'owner'" size="small" />
</div>
<div class="card-status-badge" :class="`status-${app.status}`">
{{ statusText(app.status, app.statusText) }}
</div>
</div>
<!-- 图标 + 基本信息 -->
<div class="card-main">
<div class="app-icon-wrap">
<img
v-if="app.icon || app.logo"
:src="app.icon || app.logo"
:alt="app.productName"
class="app-icon-img"
/>
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
</div>
</div>
<div class="card-info">
<div class="app-name">{{ app.productName || '-' }}</div>
<div class="app-meta">
<!-- 应用类型标签 -->
<span class="app-type-tag" :class="appTypeClass(app.appType)">
{{ appTypeIcon(app.appType) }} {{ appTypeName(app.appType) }}
</span>
</div>
</div>
</div>
<!-- 分割线 -->
<div class="card-divider" />
<!-- 最新动态 -->
<div class="card-activity">
<span class="activity-label">最新动态</span>
<span class="activity-time">{{ formatDateTime(app.updateTime || app.createTime) }}</span>
<span class="activity-dot">·</span>
<span class="activity-action">{{ app.domain ? '已绑域名' : '已创建' }}</span>
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a
v-if="entry.available"
class="card-enter-btn"
:class="{ 'primary-entry': entry.isPrimary }"
@click.stop="handleEntryClick(entry, app)"
>{{ entry.label }} </a>
</template>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="app-list">
<div
v-for="app in filteredApps"
:key="app.productId"
class="app-list-item"
@click="openDetail(app)"
>
<div class="list-icon-wrap">
<img
v-if="app.icon || app.logo"
:src="app.icon || app.logo"
:alt="app.productName"
class="list-icon-img"
/>
<div v-else class="list-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
</div>
</div>
<div class="list-info">
<div class="list-name">{{ app.productName || '-' }}</div>
<div class="list-meta">
<span class="app-type-tag" :class="appTypeClass(app.appType)">
{{ appTypeIcon(app.appType) }} {{ appTypeName(app.type, app.appType) }}
</span>
<span class="meta-dot">·</span>
{{ app.domain || app.productCode || '-' }}
<span class="meta-dot">·</span>
{{ app.username || app.companyName || '管理员' }}
</div>
</div>
<div class="list-right">
<div class="list-time">{{ formatDateTime(app.updateTime || app.createTime) }}</div>
<div class="list-actions">
<RoleTag :role="app.myRole || 'owner'" size="small" />
<span class="list-status-badge" :class="`status-${app.status}`">
{{ statusText(app.status, app.statusText) }}
</span>
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a
v-if="entry.available"
class="list-enter-link"
:class="{ 'primary-entry': entry.isPrimary }"
@click.stop="handleEntryClick(entry, app)"
>{{ entry.label }}</a>
</template>
<a-popconfirm
v-if="app.myRole === 'owner'"
:title="`确认删除应用「${app.productName}」?`"
ok-text="确认删除"
cancel-text="取消"
ok-type="danger"
@confirm="handleDeleteApp(app)"
>
<a-button type="text" danger size="small" @click.stop>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
</div>
</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 v-if="total > pageSize" class="pagination-wrap">
<a-pagination
:current="page"
:page-size="pageSize"
: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,
PlusOutlined,
GlobalOutlined,
QrcodeOutlined,
DownloadOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { removeAppProduct, getMyAccessibleApps } 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 RoleTag from '@/components/developer/RoleTag.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 emit = defineEmits<{
create: []
}>()
const viewMode = ref<'grid' | 'list'>('grid')
const page = ref(1)
const pageSize = ref(20)
const keywords = ref('')
// 所有应用合并列表
const allApps = ref<AppProduct[]>([])
const pending = ref(false)
const error = ref<string | null>(null)
// 统计
const total = computed(() => allApps.value.length)
const ownerCount = computed(() => allApps.value.filter(a => a.myRole === 'owner' || !a.myRole).length)
const memberCount = computed(() => allApps.value.filter(a => a.myRole && a.myRole !== 'owner').length)
// 关键词搜索过滤
const filteredApps = computed(() => {
let result = allApps.value
if (keywords.value.trim()) {
const kw = keywords.value.trim().toLowerCase()
result = result.filter(app =>
(app.productName || '').toLowerCase().includes(kw) ||
(app.productCode || '').toLowerCase().includes(kw)
)
}
// 前端分页
const start = (page.value - 1) * pageSize.value
return result.slice(start, start + pageSize.value)
})
// 详情抽屉
const detailOpen = ref(false)
const selectedApp = ref<AppProduct | null>(null)
function openDetail(app: AppProduct) {
selectedApp.value = app
detailOpen.value = true
}
// 加载所有可访问的应用(带角色信息)
async function fetchApps() {
pending.value = true
error.value = null
try {
const apps = await getMyAccessibleApps()
// 排序owner 在前,然后按更新时间倒序
apps.sort((a, b) => {
const aIsOwner = a.myRole === 'owner' ? 0 : 1
const bIsOwner = b.myRole === 'owner' ? 0 : 1
if (aIsOwner !== bIsOwner) return aIsOwner - bIsOwner
return new Date(b.updateTime || b.createTime || 0).getTime() - new Date(a.updateTime || a.createTime || 0).getTime()
})
allApps.value = apps
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
pending.value = false
}
}
// 初始化时加载
fetchApps()
function doSearch() {
page.value = 1
}
function onPageChange(nextPage: number) {
page.value = nextPage
}
function onPageSizeChange(_current: number, nextSize: number) {
pageSize.value = nextSize
page.value = 1
}
function handleCardClick(app: AppProduct) {
openDetail(app)
}
// 删除应用
const deletingAppId = ref<number | null>(null)
function handleDeletedFromDetail() {
selectedApp.value = null
detailOpen.value = false
refresh()
}
// 更新应用后同步本地数据
function handleUpdatedFromDetail(updatedApp: AppProduct) {
const index = allApps.value.findIndex((app) => app.productId === updatedApp.productId)
if (index !== -1) {
allApps.value[index] = { ...allApps.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?: 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] ?? '🌐'
}
function appTypeClass(appType?: number | string): 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 statusText(status?: number, fallback?: string) {
if (fallback) return fallback
const map: Record<number, string> = {
0: '未开通',
1: '已启用',
2: '维护中',
3: '已关闭',
4: '欠费停机',
5: '违规关停',
}
return typeof status === 'number' && status in map ? map[status] : '未知'
}
function formatDateTime(dateStr?: string) {
if (!dateStr) return '-'
// 格式化为 "2026-03-28 10:46"
return dateStr.slice(0, 16).replace('T', ' ')
}
// 根据名称生成一致的背景色
const PALETTE = [
'#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f',
'#e9c46a', '#457b9d', '#a8dadc', '#f77f00',
'#6d6875', '#b5838d',
]
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]
}
</script>
<style scoped>
.apps-center {
padding: 0;
}
/* ===== 统计条 ===== */
.app-stats-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.stats-total b {
color: rgba(0, 0, 0, 0.85);
font-size: 16px;
margin: 0 2px;
}
.stats-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.stats-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.owner-dot { background: #4f46e5; }
.member-dot { background: #16a34a; }
/* ===== 工具栏 ===== */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.search-input {
width: 300px;
}
.toolbar-right {
display: flex;
gap: 4px;
}
.view-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 状态/空状态 ===== */
.state-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 4px;
}
/* ===== 网格布局 ===== */
.app-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
@media (max-width: 900px) {
.app-grid { grid-template-columns: 1fr; }
}
/* ===== 单张卡片 ===== */
.app-card {
position: relative;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 18px 20px 14px;
cursor: pointer;
transition: box-shadow 0.18s, border-color 0.18s;
display: flex;
flex-direction: column;
gap: 0;
}
.app-card:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08);
border-color: #c5d8ff;
}
/* 右上角操作区 */
.card-top-actions {
position: absolute;
top: 14px;
right: 14px;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
}
/* 状态角标 */
.card-status-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 20px;
font-weight: 500;
line-height: 1.8;
}
/* 角色角标 */
.card-role-badge {
margin-right: 4px;
}
/* 卡片更多操作按钮 */
.card-more-btn {
color: #999;
}
.card-more-btn:hover { color: #333; background: #f5f5f5; }
.status-1 { background: #e6f7ee; color: #389e0d; }
.status-0 { background: #f5f5f5; color: #999; }
.status-2 { background: #fff7e6; color: #d46b08; }
.status-3 { background: #fff1f0; color: #cf1322; }
.status-4 { background: #fff2e8; color: #d4380d; }
.status-5 { background: #fff1f0; color: #cf1322; }
/* 卡片主体:图标 + 信息 */
.card-main {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
padding-right: 100px; /* 给右上角状态标签 + 更多按钮留空间 */
}
.app-icon-wrap {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 14px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.app-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-icon-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
color: #fff;
letter-spacing: -1px;
}
.card-info {
flex: 1;
min-width: 0;
padding-top: 4px;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.app-meta {
font-size: 13px;
color: #666;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
}
.meta-label { color: #999; }
.meta-dot { color: #ccc; margin: 0 4px; }
.meta-link {
color: #1677ff;
cursor: pointer;
text-decoration: none;
}
.meta-link:hover { text-decoration: underline; }
.meta-value { color: #555; }
/* 分割线 */
.card-divider {
height: 1px;
background: #f0f0f0;
margin: 0 0 10px;
}
/* 最新动态 */
.card-activity {
display: flex;
align-items: center;
font-size: 13px;
color: #999;
gap: 2px;
flex-wrap: wrap;
}
.activity-label { color: #bbb; }
.activity-time { color: #666; }
.activity-dot { color: #ddd; margin: 0 4px; }
.activity-action { color: #666; }
.card-enter-btn {
margin-left: auto;
color: #1677ff;
font-size: 12px;
text-decoration: none;
white-space: nowrap;
padding: 2px 0;
}
.card-enter-btn:hover { text-decoration: underline; }
.card-enter-btn.primary-entry {
color: #1677ff;
font-weight: 500;
}
/* ===== 列表视图 ===== */
.app-list {
border: 1px solid #e8e8e8;
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
background: #fff;
}
.app-list-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
cursor: pointer;
}
.app-list-item:last-child { border-bottom: none; }
.app-list-item:hover { background: #fafafa; }
.list-icon-wrap {
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 10px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.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;
}
.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;
}
.list-status-badge {
font-size: 11px;
padding: 1px 7px;
border-radius: 20px;
}
.list-enter-link {
font-size: 12px;
color: #1677ff;
text-decoration: none;
}
.list-enter-link:hover { text-decoration: underline; }
.list-enter-link.primary-entry {
font-weight: 500;
}
/* ===== 分页 ===== */
.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; }
</style>