初始版本

This commit is contained in:
2026-04-23 17:14:29 +08:00
parent 0d0683a6e6
commit 6dca87b988
204 changed files with 3894 additions and 52759 deletions

View File

@@ -6,8 +6,7 @@
<div class="flex items-center gap-8 nav-left">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center logo-link cursor-pointer flex-shrink-0">
<img src="/logo.png" alt="websopy" class="logo-img" />
<div class="site-name mx-2">{{ 'websopy' }}</div>
<div class="logo-text">决策咨询网</div>
</NuxtLink>
<!-- PC 导航菜单 -->
@@ -63,26 +62,10 @@
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="profile"><ProfileOutlined style="margin-right: 8px" />个人信息</a-menu-item>
<a-menu-item key="orders"><ShoppingCartOutlined style="margin-right: 8px" />我的订单</a-menu-item>
<a-menu-item key="account-kyc">
<IdcardOutlined style="margin-right: 8px" />
实名认证
</a-menu-item>
<template v-if="isDeveloper">
<a-menu-divider />
<a-menu-item key="developer">🛠 {{ $t('nav.developer') || '开发者中心' }}</a-menu-item>
<a-menu-item key="env-dev" @click.stop="switchEnv('dev')">
<span :class="{ 'font-bold': currentEnv === 'dev' }">🔧 {{ $t('common.devEnv') || '开发环境' }}</span>
<span v-if="currentEnv === 'dev'" class="ml-2 text-green-500"></span>
</a-menu-item>
<a-menu-item key="env-prod" @click.stop="switchEnv('prod')">
<span :class="{ 'font-bold': currentEnv === 'prod' }">🚀 {{ $t('common.prodEnv') || '生产环境' }}</span>
<span v-if="currentEnv === 'prod'" class="ml-2 text-green-500"></span>
</a-menu-item>
</template>
<a-menu-item key="my-suggestions"><MessageOutlined style="margin-right: 8px" />我的建言</a-menu-item>
<template v-if="isSuperAdmin">
<a-menu-divider />
<a-menu-item key="admin"> 台管理</a-menu-item>
<a-menu-item key="admin"> 台管理</a-menu-item>
</template>
<a-menu-divider />
<a-menu-item key="logout">{{ $t('nav.logout') }}</a-menu-item>
@@ -141,37 +124,26 @@ import { getUserInfo } from '@/api/layout'
import type { User } from '@/api/system/user/model'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import { UserOutlined, ProfileOutlined, IdcardOutlined, ShoppingCartOutlined } from '@ant-design/icons-vue'
import { UserOutlined, ProfileOutlined, MessageOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { ENV_CONFIG, getCurrentEnv, setCurrentEnv, type EnvKey } from '@/config/setting'
import InviteBell from './invite/InviteBell.vue'
const nav = computed(() => mainNav)
const route = useRoute()
const open = ref(false)
// 环境切换
const currentEnv = ref<EnvKey>(getCurrentEnv())
function switchEnv(env: EnvKey) {
setCurrentEnv(env)
currentEnv.value = env
// 同时设置 Cookie让服务器端代理也能识别环境
const cookieValue = env === 'dev' ? 'dev' : 'prod'
document.cookie = `websopy_api_env=${cookieValue}; path=/; max-age=31536000`
message.success(`已切换到 ${ENV_CONFIG[env].name},正在刷新...`)
setTimeout(() => {
window.location.reload()
}, 500)
}
const selectedKeys = computed(() => {
const hit = nav.value.find((n) => n.to === route.path)
if (hit) return [hit.to]
if (route.path.startsWith('/products')) return ['/products']
if (route.path.startsWith('/ai-agent')) return ['/ai-agent']
if (route.path.startsWith('/news')) return ['/news']
if (route.path.startsWith('/consultation')) return ['/consultation']
if (route.path.startsWith('/reference')) return ['/reference']
if (route.path.startsWith('/expert')) return ['/expert']
if (route.path.startsWith('/think-tank')) return ['/think-tank']
if (route.path.startsWith('/suggestions')) return ['/suggestions']
if (route.path.startsWith('/membership')) return ['/membership']
if (route.path.startsWith('/hanmo')) return ['/hanmo']
if (route.path.startsWith('/about')) return ['/about']
return ['/']
})
@@ -194,7 +166,6 @@ const user = ref<User | null>(null)
const isAuthed = computed(() => !!token.value)
const userName = computed(() => String(user.value?.nickname || user.value?.username || '已登录'))
const isSuperAdmin = computed(() => !!(user.value as any)?.isAdmin)
const isDeveloper = computed(() => (user.value as any)?.type === 2)
const userAvatar = computed(() => {
const candidate =
user.value?.avatarUrl ||
@@ -232,13 +203,7 @@ async function refreshAuth() {
function goConsoleCenter() {
if (!isAuthed.value) return navigateTo('/login')
open.value = false
navigateTo('/console')
}
function goDeveloperCenter() {
if (!isAuthed.value) return navigateTo('/login')
open.value = false
navigateTo('/developer')
navigateTo('/profile')
}
function logout() {
@@ -257,10 +222,8 @@ function logout() {
}
function onUserMenuClick(info: { key: string }) {
if (info.key === 'profile') return navigateTo('/console/account')
if (info.key === 'orders') return navigateTo('/console/orders')
if (info.key === 'account-kyc') return navigateTo('/console/account/kyc')
if (info.key === 'developer') return navigateTo('/developer')
if (info.key === 'profile') return navigateTo('/profile')
if (info.key === 'my-suggestions') return navigateTo('/my/suggestions')
if (info.key === 'admin') return navigateTo('/admin')
if (info.key === 'logout') return logout()
}
@@ -312,16 +275,10 @@ onUnmounted(() => {
display: flex;
align-items: center;
}
.logo-img {
height: 22px;
width: auto;
display: block;
transition: opacity 0.2s;
}
.site-name {
.logo-text {
color: #fff;
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
font-size: 18px;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
@@ -332,10 +289,7 @@ onUnmounted(() => {
background-clip: text;
transition: opacity 0.2s;
}
.logo-link:hover .site-name {
opacity: 0.85;
}
.logo-link:hover .logo-img {
.logo-link:hover .logo-text {
opacity: 0.85;
}
.nav-item-wrapper {

View File

@@ -1,932 +0,0 @@
<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>

View File

@@ -1,176 +0,0 @@
<template>
<a-layout-header class="top-header !p-0">
<div class="h-full px-4 flex items-center justify-between">
<div class="logo">
<a-space size="large">
<div class="logo-brand" @click="navigateTo('/console')">
<img src="/logo.png" alt="logo" class="logo-img" />
<span class="logo-name">控制台</span>
</div>
</a-space>
</div>
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-space>
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<AppstoreOutlined />
</template>
</a-avatar>
<span class="user-name">
{{ userDisplayName }}
</span>
</a-space>
</div>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item v-for="item in mergedUserMenuItems" :key="item.key">
{{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
</template>
<script setup lang="ts">
import { AppstoreOutlined } from '@ant-design/icons-vue'
import type { MenuProps } from 'ant-design-vue'
import type { User } from '@/api/system/user/model'
type ConsoleHeaderMenuItem = {
key: string
label: string
}
const props = withDefaults(
defineProps<{
productLabel?: string
defaultJumpKey?: 'oa' | 'developer'
user: User | null
userDisplayName: string
userMenuItems?: ConsoleHeaderMenuItem[]
}>(),
{
productLabel: '云·企业官网',
defaultJumpKey: 'developer',
userMenuItems: () => [{ key: 'logout', label: '退出登录' }],
}
)
const emit = defineEmits<{
(e: 'logout'): void
(e: 'userMenuClick', key: string): void
}>()
const mergedUserMenuItems = computed<ConsoleHeaderMenuItem[]>(() => {
const items = Array.isArray(props.userMenuItems) ? props.userMenuItems.slice() : []
if (!items.some((i) => i.key === 'account')) {
const accountItem: ConsoleHeaderMenuItem = { key: 'account', label: '账号管理' }
const logoutIndex = items.findIndex((i) => i.key === 'logout')
if (logoutIndex >= 0) items.splice(logoutIndex, 0, accountItem)
else items.push(accountItem)
}
return items
})
const consoleJumpTargets = {
oa: '/oa',
developer: '/developer',
} as const
function openExternal(url: string) {
if (!import.meta.client) return
if (url.startsWith('http://') || url.startsWith('https://')) {
window.location.href = url
return
}
void navigateTo(url)
}
const handleButtonClick = () => {
openExternal(consoleJumpTargets[props.defaultJumpKey])
}
const handleProductMenuClick: MenuProps['onClick'] = (e) => {
const key = String(e.key) as keyof typeof consoleJumpTargets
const url = consoleJumpTargets[key]
if (!url) return
openExternal(url)
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
emit('userMenuClick', key)
if (key === 'account') {
void navigateTo('/console/account')
return
}
if (key === 'logout') emit('logout')
}
</script>
<style scoped>
.top-header {
height: 56px;
line-height: 56px;
border-radius: 12px;
background: #111827 !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
color: rgba(255, 255, 255, 0.85);
}
.user-trigger:hover {
background: rgba(255, 255, 255, 0.08);
}
.user-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.85);
}
.logo-brand {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
text-decoration: none;
}
.logo-brand:hover .logo-img,
.logo-brand:hover .logo-name {
opacity: 0.8;
}
.logo-img {
height: 22px;
width: auto;
display: block;
}
.logo-name {
font-family: 'Alimama FangYuanTi VF, sans-serif', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>

View File

@@ -1,605 +0,0 @@
<template>
<div class="contract-management">
<!-- 顶部操作栏 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<a-input-search
v-model:value="queryParam.keywords"
placeholder="搜索合同名称/编号"
allow-clear
style="width: 220px"
@search="handleSearch"
@change="onKeywordChange"
/>
<a-select
v-model:value="queryParam.contractType"
placeholder="合同类型"
allow-clear
style="width: 140px"
@change="handleSearch"
>
<a-select-option v-for="opt in contractTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-select
v-model:value="queryParam.status"
placeholder="合同状态"
allow-clear
style="width: 140px"
@change="handleSearch"
>
<a-select-option v-for="opt in contractStatusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</div>
<a-button type="primary" @click="openAddModal">
<template #icon><PlusOutlined /></template>
新增合同
</a-button>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[12, 12]" class="mb-4">
<a-col :xs="12" :sm="8" :md="4" v-for="stat in statsCards" :key="stat.label">
<div class="stat-card" :class="stat.cls">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<!-- 表格 -->
<a-card :bordered="false">
<a-table
:data-source="contracts"
:loading="loading"
:pagination="pagination"
size="middle"
:row-key="(r: Contract) => r.contractId!"
@change="handleTableChange"
>
<a-table-column title="合同编号" data-index="contractNo" width="185">
<template #default="{ record }">
<span class="font-mono text-sm text-gray-600">{{ record.contractNo || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="合同名称" data-index="title" ellipsis>
<template #default="{ record }">
<span class="font-medium cursor-pointer text-blue-600 hover:text-blue-400" @click="openDetail(record)">
{{ record.title }}
</span>
</template>
</a-table-column>
<a-table-column title="合同类型" data-index="contractType" width="110">
<template #default="{ record }">
{{ contractTypeText(record.contractType) }}
</template>
</a-table-column>
<a-table-column title="签约方" width="230">
<template #default="{ record }">
<div class="text-xs leading-5">
<div>甲方:{{ record.partyA || '-' }}</div>
<div>乙方:{{ record.partyB || '-' }}</div>
</div>
</template>
</a-table-column>
<a-table-column title="合同金额" data-index="amount" width="120">
<template #default="{ record }">
<span class="text-orange-600 font-semibold">¥{{ formatAmount(record.amount) }}</span>
</template>
</a-table-column>
<a-table-column title="有效期" width="210">
<template #default="{ record }">
<span v-if="record.startDate && record.endDate" class="text-sm">
{{ record.startDate }} ~ {{ record.endDate }}
</span>
<span v-else class="text-gray-400 text-sm">-</span>
</template>
</a-table-column>
<a-table-column title="状态" data-index="status" width="100">
<template #default="{ record }">
<a-tag :color="contractStatusInfo(record.status).color">
{{ contractStatusInfo(record.status).text }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" width="180" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openDetail(record)">查看</a-button>
<a-button size="small" @click="openEdit(record)">编辑</a-button>
<a-button danger size="small" @click="confirmRemove(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="editingId ? '编辑合同' : '新增合同'"
:width="660"
:confirm-loading="formSubmitting"
@ok="handleFormSubmit"
@cancel="closeForm"
>
<a-form ref="formRef" layout="vertical" :model="formData" :rules="formRules" class="mt-2">
<a-form-item label="合同名称" name="title">
<a-input v-model:value="formData.title" placeholder="请输入合同名称" />
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="合同类型" name="contractType">
<a-select v-model:value="formData.contractType" placeholder="请选择合同类型" style="width:100%">
<a-select-option v-for="opt in contractTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="合同状态" name="status">
<a-select v-model:value="formData.status" placeholder="请选择状态" style="width:100%">
<a-select-option v-for="opt in contractStatusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="甲方" name="partyA">
<a-input v-model:value="formData.partyA" placeholder="请输入甲方名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="乙方" name="partyB">
<a-input v-model:value="formData.partyB" placeholder="请输入乙方名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item label="合同金额" name="amount">
<a-input-number
v-model:value="formData.amount"
:min="0"
:precision="2"
placeholder="金额"
style="width: 100%"
>
<template #prefix>¥</template>
</a-input-number>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="开始日期" name="startDate">
<a-date-picker
v-model:value="formData.startDate"
value-format="YYYY-MM-DD"
placeholder="开始日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="结束日期" name="endDate">
<a-date-picker
v-model:value="formData.endDate"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 合同附件上传 -->
<a-form-item label="合同附件">
<a-upload
:file-list="fileList"
:max-count="1"
:before-upload="() => false"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
@change="handleFileChange"
>
<a-button>
<UploadOutlined /> 上传合同文件
</a-button>
<span class="ml-2 text-gray-400 text-xs">支持 PDF、Word、图片最大 20MB</span>
</a-upload>
<div v-if="formData.fileUrl && !fileList.length" class="mt-1">
<a :href="formData.fileUrl" target="_blank" class="text-blue-500 text-sm">
<PaperClipOutlined /> {{ formData.fileName || '查看已上传文件' }}
</a>
<a-button type="link" danger size="small" class="ml-2" @click="clearFile">移除</a-button>
</div>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="3" placeholder="备注选填" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="合同详情"
:width="700"
:footer="null"
>
<a-descriptions v-if="detail" bordered size="small" :column="2" class="mt-2">
<a-descriptions-item label="合同编号" :span="2">
<span class="font-mono">{{ detail.contractNo || '-' }}</span>
</a-descriptions-item>
<a-descriptions-item label="合同名称" :span="2">{{ detail.title }}</a-descriptions-item>
<a-descriptions-item label="合同类型">{{ contractTypeText(detail.contractType) }}</a-descriptions-item>
<a-descriptions-item label="合同状态">
<a-tag :color="contractStatusInfo(detail.status).color">{{ contractStatusInfo(detail.status).text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="甲方">{{ detail.partyA || '-' }}</a-descriptions-item>
<a-descriptions-item label="乙方">{{ detail.partyB || '-' }}</a-descriptions-item>
<a-descriptions-item label="合同金额">
<span class="text-orange-600 font-semibold">¥{{ formatAmount(detail.amount) }}</span>
</a-descriptions-item>
<a-descriptions-item label="有效期">
<span v-if="detail.startDate && detail.endDate">{{ detail.startDate }} ~ {{ detail.endDate }}</span>
<span v-else class="text-gray-400">-</span>
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detail.userName || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="detail.fileUrl" label="合同附件" :span="2">
<a :href="detail.fileUrl" target="_blank" class="text-blue-500">
<PaperClipOutlined /> {{ detail.fileName || '查看附件' }}
</a>
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, UploadOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
import { uploadFile } from '@/api/system/file'
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import {
pageContract,
addContract,
updateContract,
removeContract,
statsContract,
contractTypeOptions,
contractStatusOptions,
contractTypeText,
contractStatusInfo,
type Contract,
type ContractType,
type ContractStatus,
} from '@/api/app/contract'
defineOptions({ name: 'ContractManagement' })
// ─── 列表 & 分页 ──────────────────────────────────────────────
const contracts = ref<Contract[]>([])
const loading = ref(false)
const total = ref(0)
const queryParam = reactive<{
keywords?: string
contractType?: ContractType
status?: ContractStatus
page: number
limit: number
}>({
page: 1,
limit: 15,
})
const pagination = computed(() => ({
total: total.value,
current: queryParam.page,
pageSize: queryParam.limit,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
pageSizeOptions: ['10', '15', '20', '50'],
}))
async function loadData() {
loading.value = true
try {
const res = await pageContract({
page: queryParam.page,
limit: queryParam.limit,
keywords: queryParam.keywords,
contractType: queryParam.contractType,
status: queryParam.status,
})
contracts.value = res?.list ?? []
total.value = res?.total ?? 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
queryParam.page = 1
loadData()
}
let keywordTimer: ReturnType<typeof setTimeout> | null = null
function onKeywordChange() {
if (keywordTimer) clearTimeout(keywordTimer)
keywordTimer = setTimeout(handleSearch, 400)
}
function handleTableChange(pag: { current?: number; pageSize?: number }) {
queryParam.page = pag.current ?? 1
queryParam.limit = pag.pageSize ?? 15
loadData()
}
// ─── 统计 ─────────────────────────────────────────────────────
const statsData = ref<Record<string, number>>({})
const statsCards = computed(() => [
{ label: '全部', value: statsData.value.total ?? 0, cls: 'blue' },
{ label: '生效中', value: statsData.value.active ?? 0, cls: 'green' },
{ label: '待签署', value: statsData.value.pending ?? 0, cls: 'orange' },
{ label: '已过期', value: statsData.value.expired ?? 0, cls: 'red' },
{ label: '草稿', value: statsData.value.draft ?? 0, cls: 'gray' },
{ label: '已终止', value: statsData.value.terminated ?? 0, cls: 'purple' },
])
async function loadStats() {
try {
const res = await statsContract()
statsData.value = res ?? {}
} catch {}
}
// ─── 表单 ─────────────────────────────────────────────────────
const formVisible = ref(false)
const formSubmitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref()
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
interface FormData {
title: string
contractType?: ContractType
partyA?: string
partyB?: string
amount?: number
startDate?: string
endDate?: string
status?: ContractStatus
remark?: string
fileUrl?: string
fileName?: string
}
const formData = reactive<FormData>({
title: '',
contractType: undefined,
partyA: '',
partyB: '',
amount: undefined,
startDate: undefined,
endDate: undefined,
status: 'draft',
remark: '',
fileUrl: '',
fileName: '',
})
const formRules = {
title: [{ required: true, message: '请输入合同名称' }],
contractType: [{ required: true, message: '请选择合同类型' }],
status: [{ required: true, message: '请选择合同状态' }],
}
function resetFormData() {
formData.title = ''
formData.contractType = undefined
formData.partyA = ''
formData.partyB = ''
formData.amount = undefined
formData.startDate = undefined
formData.endDate = undefined
formData.status = 'draft'
formData.remark = ''
formData.fileUrl = ''
formData.fileName = ''
fileList.value = []
editingId.value = null
}
function openAddModal() {
resetFormData()
formVisible.value = true
}
function openEdit(record: Contract) {
resetFormData()
editingId.value = record.contractId ?? null
Object.assign(formData, {
title: record.title,
contractType: record.contractType,
partyA: record.partyA ?? '',
partyB: record.partyB ?? '',
amount: record.amount,
startDate: record.startDate,
endDate: record.endDate,
status: record.status,
remark: record.remark ?? '',
fileUrl: record.fileUrl ?? '',
fileName: record.fileName ?? '',
})
formVisible.value = true
}
function closeForm() {
formVisible.value = false
formRef.value?.resetFields()
resetFormData()
}
// 文件上传
function handleFileChange({ fileList: list }: UploadChangeParam) {
fileList.value = list.slice(-1)
}
function clearFile() {
formData.fileUrl = ''
formData.fileName = ''
}
async function uploadPendingFile(): Promise<{ url: string; name: string } | null> {
const raw = fileList.value[0]?.originFileObj
if (!raw) return null
uploading.value = true
try {
const result = await uploadFile(raw as File)
return { url: (result as any).url ?? (result as any).fileUrl, name: raw.name }
} finally {
uploading.value = false
}
}
async function handleFormSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
formSubmitting.value = true
try {
// 如有待上传文件先上传
if (fileList.value.length > 0) {
const uploaded = await uploadPendingFile()
if (uploaded) {
formData.fileUrl = uploaded.url
formData.fileName = uploaded.name
}
}
const payload: Partial<Contract> = { ...formData }
if (editingId.value) {
await updateContract(editingId.value, payload)
message.success('合同已更新')
} else {
await addContract(payload)
message.success('合同已创建')
}
closeForm()
await loadData()
await loadStats()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
formSubmitting.value = false
}
}
// ─── 详情 ─────────────────────────────────────────────────────
const detailVisible = ref(false)
const detail = ref<Contract | null>(null)
function openDetail(record: Contract) {
detail.value = record
detailVisible.value = true
}
// ─── 删除 ─────────────────────────────────────────────────────
function confirmRemove(record: Contract) {
Modal.confirm({
title: '确认删除该合同?',
content: '删除后不可恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await removeContract(record.contractId!)
message.success('已删除')
if (detail.value?.contractId === record.contractId) {
detailVisible.value = false
detail.value = null
}
await loadData()
await loadStats()
},
})
}
// ─── 工具函数 ─────────────────────────────────────────────────
function formatAmount(value?: number | string | null) {
if (value === null || value === undefined) return '0.00'
const n = typeof value === 'string' ? parseFloat(value) : value
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
// ─── 初始化 ───────────────────────────────────────────────────
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.contract-management {
width: 100%;
}
.stat-card {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange{ background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fef2f2; border-color: #fecaca; }
.stat-card.gray { background: #f9fafb; border-color: #e5e7eb; }
.stat-card.purple{ background: #faf5ff; border-color: #e9d5ff; }
.stat-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1;
}
.stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,870 +0,0 @@
<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>

View File

@@ -1,53 +0,0 @@
<script setup lang="ts">
/**
* 权限守卫组件
* 根据应用角色控制子内容的显示/隐藏
*
* 用法:
* <PermissionGuard :app-id="123" permission="canManageMembers">
* <a-button>邀请成员</a-button>
* </PermissionGuard>
*
* <PermissionGuard :app-id="123" :min-role="'admin'">
* 仅管理员和 Owner 可见
* </PermissionGuard>
*/
import type { AppRole } from '@/api/app/appUser/model'
import { useAppPermission } from '@/composables/useAppPermission'
const props = defineProps<{
/** 应用 ID */
appId?: number | null
/** 要检查的权限字段(与 canManageMembers 等对应) */
permission?: string
/** 最低角色要求(优先级高于 permission */
minRole?: AppRole
/** 权限不足时是否显示提示(而非隐藏) */
showTip?: boolean
}>()
const { hasPermission, hasRole, getNoPermissionTip, getAppPermission } = useAppPermission()
const hasAccess = computed(() => {
if (!props.appId) return false
if (props.minRole) return hasRole(props.appId, props.minRole)
if (props.permission) return hasPermission(props.appId, props.permission as 'canManageMembers')
return true
})
const tipText = computed(() => {
const perm = getAppPermission(props.appId)
return perm ? getNoPermissionTip(perm.role) : ''
})
</script>
<template>
<slot v-if="hasAccess" />
<slot v-else-if="showTip" name="fallback">
<a-tooltip :title="tipText">
<span class="text-gray-400 cursor-not-allowed">
<slot name="disabled-content" />
</span>
</a-tooltip>
</slot>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
/**
* 角色标签组件
* 在应用卡片、成员列表等位置显示当前用户的角色
*/
import type { AppRole } from '@/api/app/appUser/model'
import { ROLE_LABEL, ROLE_COLOR } from '@/composables/useAppPermission'
const props = defineProps<{
role: AppRole
size?: 'small' | 'default'
}>()
const colorMap: Record<AppRole, string> = {
owner: '#faad14',
admin: '#1677ff',
developer: '#52c41a',
viewer: '#8c8c8c',
}
</script>
<template>
<a-tag :color="colorMap[props.role]" :size="props.size || 'default'">
{{ ROLE_LABEL[props.role] }}
</a-tag>
</template>

View File

@@ -1,276 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
listPendingInvites,
acceptInvite,
rejectInvite,
type AppUser
} from '@/api/app/appUser'
import { useRouter } from 'vue-router'
import { TeamOutlined, PlusOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const invites = ref<AppUser[]>([])
const loading = ref(false)
// 待确认邀请数量
const pendingCount = computed(() => invites.value.length)
// 是否有待确认邀请
const hasPending = computed(() => pendingCount.value > 0)
// 加载邀请列表
async function loadInvites() {
try {
loading.value = true
invites.value = await listPendingInvites()
} catch (error) {
console.error('加载邀请列表失败:', error)
} finally {
loading.value = false
}
}
// 接受邀请
async function handleAccept(invite: AppUser) {
if (!invite.id) return
try {
await acceptInvite(invite.id)
message.success('已接受邀请,加入应用成功')
// 移除已处理的邀请
invites.value = invites.value.filter(i => i.id !== invite.id)
// 刷新页面或跳转到应用
setTimeout(() => {
router.push('/developer/apps')
}, 500)
} catch (error: any) {
message.error(error.message || '接受邀请失败')
}
}
// 拒绝邀请
async function handleReject(invite: AppUser) {
if (!invite.id) return
Modal.confirm({
title: '确认拒绝邀请?',
content: `拒绝后将无法加入应用「${invite.productName || '未知应用'}`,
okText: '确认拒绝',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await rejectInvite(invite.id!)
message.success('已拒绝邀请')
invites.value = invites.value.filter(i => i.id !== invite.id)
} catch (error: any) {
message.error(error.message || '拒绝邀请失败')
}
}
})
}
// 查看全部邀请
function viewAllInvites() {
router.push('/console/invites')
}
onMounted(() => {
loadInvites()
})
</script>
<template>
<ClientOnly>
<a-dropdown v-if="hasPending" :trigger="['hover']" placement="bottomRight">
<a-badge :count="pendingCount" :offset="[-2, 2]">
<a-button type="text" class="invite-bell-btn">
<template #icon>
<TeamOutlined style="font-size: 18px; color: #fff" />
</template>
</a-button>
</a-badge>
<template #overlay>
<a-menu class="invite-dropdown-menu">
<a-menu-item-group title="应用邀请">
<a-menu-item v-for="invite in invites.slice(0, 3)" :key="invite.id" class="invite-menu-item">
<div class="invite-item-content">
<a-avatar
:src="invite.icon || '/logo.png'"
:size="32"
class="app-icon"
/>
<div class="invite-info">
<div class="app-name">{{ invite.productName || '未知应用' }}</div>
<div class="invite-meta">
<span class="role-tag" :class="invite.role">
{{ invite.role === 'owner' ? '所有者' :
invite.role === 'admin' ? '管理员' :
invite.role === 'developer' ? '开发者' : '访客' }}
</span>
</div>
</div>
<div class="invite-actions">
<a-button
type="primary"
size="small"
shape="circle"
class="action-btn accept"
@click.stop="handleAccept(invite)"
>
<CheckOutlined />
</a-button>
<a-button
size="small"
shape="circle"
class="action-btn reject"
@click.stop="handleReject(invite)"
>
<CloseOutlined />
</a-button>
</div>
</div>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider v-if="invites.length > 0" />
<a-menu-item key="view-all" @click="viewAllInvites">
<span style="text-align: center; display: block;">
查看全部 {{ pendingCount }} 个邀请
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</ClientOnly>
</template>
<style scoped>
.invite-bell-btn {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.invite-bell-btn:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
</style>
<style>
/* 下拉菜单样式 */
.invite-dropdown-menu {
min-width: 280px !important;
max-width: 320px;
}
.invite-dropdown-menu .ant-dropdown-menu-item-group-title {
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
}
.invite-menu-item {
padding: 12px 16px !important;
height: auto !important;
line-height: normal !important;
}
.invite-menu-item:hover {
background-color: #f5f5f5 !important;
}
.invite-item-content {
display: flex;
align-items: center;
gap: 12px;
}
.invite-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 500;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invite-meta {
margin-top: 2px;
}
.role-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.role-tag.owner {
background: #fff2e8;
color: #fa541c;
}
.role-tag.admin {
background: #e6f7ff;
color: #1890ff;
}
.role-tag.developer {
background: #f6ffed;
color: #52c41a;
}
.role-tag.viewer {
background: #f9f0ff;
color: #722ed1;
}
.invite-actions {
display: flex;
gap: 6px;
}
.action-btn {
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.accept {
background: #52c41a;
border-color: #52c41a;
}
.action-btn.accept:hover {
background: #73d13d;
border-color: #73d13d;
}
.action-btn.reject {
background: #ff4d4f;
border-color: #ff4d4f;
color: #fff;
}
.action-btn.reject:hover {
background: #ff7875;
border-color: #ff7875;
}
.app-icon {
flex-shrink: 0;
}
</style>

View File

@@ -1,335 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
listPendingInvites,
acceptInvite,
rejectInvite,
type AppUser
} from '@/api/app/appUser'
import { useRouter } from 'vue-router'
import { TeamOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const invites = ref<AppUser[]>([])
const loading = ref(false)
// 待确认邀请数量
const pendingCount = computed(() => invites.value.length)
// 是否有待确认邀请
const hasPending = computed(() => pendingCount.value > 0)
// 加载邀请列表
async function loadInvites() {
try {
loading.value = true
const data = await listPendingInvites()
console.log('邀请列表数据:', data)
invites.value = data
} catch (error) {
console.error('加载邀请列表失败:', error)
} finally {
loading.value = false
}
}
// 接受邀请
async function handleAccept(invite: AppUser) {
if (!invite.id) return
try {
await acceptInvite(invite.id)
message.success('已接受邀请,加入应用成功')
// 移除已处理的邀请
invites.value = invites.value.filter(i => i.id !== invite.id)
// 刷新页面或跳转到应用
setTimeout(() => {
router.push('/developer/apps')
}, 500)
} catch (error: any) {
message.error(error.message || '接受邀请失败')
}
}
// 拒绝邀请
async function handleReject(invite: AppUser) {
if (!invite.id) return
Modal.confirm({
title: '确认拒绝邀请?',
content: `拒绝后将无法加入应用「${invite.productName || '未知应用'}`,
okText: '确认拒绝',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await rejectInvite(invite.id!)
message.success('已拒绝邀请')
invites.value = invites.value.filter(i => i.id !== invite.id)
} catch (error: any) {
message.error(error.message || '拒绝邀请失败')
}
}
})
}
// 查看全部邀请
function viewAllInvites() {
router.push('/console/invites')
}
onMounted(() => {
loadInvites()
})
defineExpose({
loadInvites,
pendingCount,
hasPending
})
</script>
<template>
<div class="invite-notification">
<!-- 悬浮卡片 - 直接展示邀请列表 -->
<div v-if="hasPending" class="invite-float-card">
<div class="invite-card-header">
<div class="invite-title">
<TeamOutlined class="invite-icon" />
应用邀请
</div>
<div class="invite-count">{{ pendingCount }}</div>
</div>
<div class="invite-card-body">
<a-spin :spinning="loading">
<div
v-for="invite in invites.slice(0, 3)"
:key="invite.id"
class="invite-item"
>
<a-avatar
:src="invite.icon || '/logo.png'"
:size="40"
class="app-icon"
/>
<div class="invite-info">
<div class="app-name">{{ invite.productName || '未知应用' }}</div>
<div class="invite-meta">
<span class="role-tag" :class="invite.role">
{{ invite.role === 'owner' ? '所有者' :
invite.role === 'admin' ? '管理员' :
invite.role === 'developer' ? '开发者' : '访客' }}
</span>
<span class="inviter"> {{ invite.username || '未知用户' }} 邀请</span>
</div>
<div v-if="invite.inviteExpireTime" class="expire-time">
<ClockCircleOutlined />
有效期至{{ invite.inviteExpireTime }}
</div>
</div>
<div class="invite-actions">
<a-button
size="small"
@click="handleReject(invite)"
>
拒绝
</a-button>
<a-button
type="primary"
size="small"
@click="handleAccept(invite)"
>
接受
</a-button>
</div>
</div>
<div v-if="invites.length > 3" class="view-more" @click="viewAllInvites">
查看全部 {{ pendingCount }} 个邀请
</div>
</a-spin>
</div>
</div>
</div>
</template>
<style scoped>
.invite-notification {
position: relative;
}
/* 悬浮卡片 */
.invite-float-card {
position: fixed;
right: 24px;
bottom: 100px;
z-index: 100;
width: 420px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.06);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 卡片头部 */
.invite-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px 12px 0 0;
}
.invite-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.invite-icon {
font-size: 20px;
}
.invite-count {
min-width: 24px;
height: 24px;
padding: 0 8px;
background: #ff4d4f;
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(255, 77, 79, 0.4);
}
/* 卡片内容 */
.invite-card-body {
padding: 8px 0;
max-height: 400px;
overflow-y: auto;
}
.invite-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f5f5f5;
transition: background-color 0.2s;
}
.invite-item:hover {
background-color: #fafafa;
}
.invite-item:last-child {
border-bottom: none;
}
.app-icon {
flex-shrink: 0;
}
.invite-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 15px;
font-weight: 600;
color: #262626;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invite-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.role-tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.role-tag.owner {
background: #fff2e8;
color: #fa541c;
}
.role-tag.admin {
background: #e6f7ff;
color: #1890ff;
}
.role-tag.developer {
background: #f6ffed;
color: #52c41a;
}
.role-tag.viewer {
background: #f9f0ff;
color: #722ed1;
}
.inviter {
font-size: 12px;
color: #8c8c8c;
}
.expire-time {
font-size: 12px;
color: #faad14;
display: flex;
align-items: center;
gap: 4px;
}
.invite-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-more {
text-align: center;
padding: 12px 20px;
color: #667eea;
font-size: 14px;
cursor: pointer;
border-top: 1px solid #f5f5f5;
transition: background-color 0.2s;
}
.view-more:hover {
background-color: #f5f5f5;
color: #764ba2;
}
</style>