初始化2

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

View File

@@ -0,0 +1,283 @@
/**
* 应用级权限管理 composable
*
* 管理当前用户在各应用中的角色权限,提供:
* 1. 开发者中心访问检查
* 2. 可访问应用列表 + 角色映射
* 3. 按角色判断某应用的具体权限
*/
import type { AppRole, AppPermissionInfo } from '@/api/app/appUser/model'
// ============ 角色层级 ============
/** 角色层级:数字越大权限越高 */
export const ROLE_HIERARCHY: Record<AppRole, number> = {
viewer: 1,
developer: 2,
admin: 3,
owner: 4,
}
/** 角色中文名 */
export const ROLE_LABEL: Record<AppRole, string> = {
owner: '所有者',
admin: '管理员',
developer: '开发者',
viewer: '只读',
}
/** 角色颜色(用于 RoleTag */
export const ROLE_COLOR: Record<AppRole, string> = {
owner: 'gold',
admin: 'blue',
developer: 'green',
viewer: 'default',
}
// ============ 权限定义 ============
export interface AppPermission {
appId: number
productName: string
productCode?: string
icon?: string
role: AppRole
isOwner: boolean
canManageMembers: boolean // owner | admin
canEditApp: boolean // owner | admin
canDeleteApp: boolean // owner only
canSubmitReview: boolean // owner | admin
canEditResource: boolean // owner | admin | developer
canViewSensitive: boolean // owner | admin | developer
canCreateApiKey: boolean // owner | admin | developer
canEditConfig: boolean // owner | admin
canTriggerBuild: boolean // owner | admin | developer
}
/** 根据 role 计算权限字段 */
function buildPermission(info: AppPermissionInfo): AppPermission {
const { role } = info
const isOwner = role === 'owner'
const isAtLeastAdmin = isOwner || role === 'admin'
const isAtLeastDeveloper = isAtLeastAdmin || role === 'developer'
return {
appId: info.appId,
productName: info.productName,
productCode: info.productCode,
icon: info.icon,
role,
isOwner,
canManageMembers: isAtLeastAdmin,
canEditApp: isAtLeastAdmin,
canDeleteApp: isOwner,
canSubmitReview: isAtLeastAdmin,
canEditResource: isAtLeastDeveloper,
canViewSensitive: isAtLeastDeveloper,
canCreateApiKey: isAtLeastDeveloper,
canEditConfig: isAtLeastAdmin,
canTriggerBuild: isAtLeastDeveloper,
}
}
// ============ 全局状态(模块级单例) ============
const appPermissionsMap = ref<Map<number, AppPermission>>(new Map())
const isPlatformDeveloper = ref(false)
const hasCheckedAccess = ref(false)
const loading = ref(false)
// ============ 核心方法 ============
/**
* 检查用户是否有开发者中心访问权限
* 并自动加载可访问的应用列表
*/
async function checkDeveloperAccess(): Promise<{
accessible: boolean
isPlatformDeveloper: boolean
hasJoinedApps: boolean
}> {
if (loading.value) return { accessible: false, isPlatformDeveloper: false, hasJoinedApps: false }
loading.value = true
try {
// 尝试获取可访问应用列表后端接口GET /api/app/app-user/check-access
const { checkAppAccess } = await import('@/api/app/appUser')
const res = await checkAppAccess()
if (res?.accessible) {
isPlatformDeveloper.value = res.isPlatformDeveloper ?? false
hasCheckedAccess.value = true
// 如果返回了可访问应用列表,直接缓存
if (res.apps?.length) {
const newMap = new Map<number, AppPermission>()
for (const info of res.apps) {
newMap.set(info.appId, buildPermission(info))
}
appPermissionsMap.value = newMap
}
return {
accessible: true,
isPlatformDeveloper: res.isPlatformDeveloper ?? false,
hasJoinedApps: (res.apps?.length ?? 0) > 0,
}
}
return { accessible: false, isPlatformDeveloper: false, hasJoinedApps: false }
}
catch {
// 接口失败时降级:如果 localStorage 有 UserId 且是 type=2仍允许访问
if (import.meta.client) {
const userType = localStorage.getItem('UserType')
if (userType === '2') {
isPlatformDeveloper.value = true
hasCheckedAccess.value = true
return { accessible: true, isPlatformDeveloper: true, hasJoinedApps: false }
}
}
return { accessible: false, isPlatformDeveloper: false, hasJoinedApps: false }
}
finally {
loading.value = false
}
}
/**
* 加载用户所有可访问应用的权限
* 后端接口GET /api/app/product/accessible
*/
async function loadAppPermissions(): Promise<Map<number, AppPermission>> {
try {
const { getMyAccessibleApps } = await import('@/api/app/appProduct')
const apps = await getMyAccessibleApps()
const newMap = new Map<number, AppPermission>()
for (const app of apps) {
const role = (app.myRole as AppRole) || 'viewer'
newMap.set(app.productId!, {
appId: app.productId!,
productName: app.productName || '',
productCode: app.productCode,
icon: app.icon,
role,
...(buildPermission({ appId: app.productId!, productName: app.productName || '', role } as AppPermissionInfo)),
})
}
appPermissionsMap.value = newMap
return newMap
}
catch {
console.warn('[useAppPermission] 加载应用权限失败')
return appPermissionsMap.value
}
}
/**
* 设置单个应用的权限缓存
*/
function setAppPermission(info: AppPermissionInfo) {
const permission = buildPermission(info)
appPermissionsMap.value.set(info.appId, permission)
}
/**
* 获取指定应用的权限
*/
function getAppPermission(appId: number | undefined | null): AppPermission | null {
if (!appId) return null
return appPermissionsMap.value.get(appId) ?? null
}
/**
* 检查指定应用是否拥有某项权限
*/
function hasPermission(
appId: number | undefined | null,
permission: keyof AppPermission,
): boolean {
const perm = getAppPermission(appId)
if (!perm) return false
return !!perm[permission]
}
/**
* 检查当前用户在指定应用中的角色是否 >= 某个级别
*/
function hasRole(appId: number | undefined | null, minRole: AppRole): boolean {
const perm = getAppPermission(appId)
if (!perm) return false
return ROLE_HIERARCHY[perm.role] >= ROLE_HIERARCHY[minRole]
}
/**
* 清空权限缓存(登出时调用)
*/
function clearPermissions() {
appPermissionsMap.value.clear()
isPlatformDeveloper.value = false
hasCheckedAccess.value = false
}
// ============ Computed ============
/** 可访问的应用 ID 列表 */
const accessibleAppIds = computed(() => [...appPermissionsMap.value.keys()])
/** 是否已检查过访问权限 */
const isChecked = computed(() => hasCheckedAccess.value)
/** 是否正在加载 */
const isLoading = computed(() => loading.value)
/** 权限不足时的提示文字 */
function getNoPermissionTip(role?: AppRole): string {
const roleName = role ? ROLE_LABEL[role] : '当前角色'
return `当前身份为「${roleName}」,无法执行此操作。如需更多权限,请联系应用管理员。`
}
// ============ 敏感信息遮罩 ============
/**
* 遮罩敏感字段值
* @param value 原始值
* @param canView 是否有查看权限
* @param maskLength 遮罩长度,默认 6 个 *
*/
function maskSensitiveValue(value: string | null | undefined, canView: boolean, maskLength = 6): string {
if (canView) return value || ''
if (!value) return ''
const mask = '*'.repeat(maskLength)
// 对于较短的值直接全部遮罩,较长的值显示前几个字符
if (value.length <= maskLength) return mask
return value.slice(0, 4) + mask
}
// ============ 组合式函数导出 ============
export function useAppPermission() {
return {
// 状态
appPermissions: appPermissionsMap,
isPlatformDeveloper: readonly(isPlatformDeveloper),
isChecked: readonly(isChecked),
isLoading: readonly(isLoading),
// 方法
checkDeveloperAccess,
loadAppPermissions,
setAppPermission,
getAppPermission,
hasPermission,
hasRole,
clearPermissions,
getNoPermissionTip,
// 工具
accessibleAppIds,
maskSensitiveValue,
}
}

View File

@@ -0,0 +1,165 @@
import { ref, computed } from 'vue'
import {
getUnreadCount,
listRecentNotification,
markRead,
markAllRead,
} from '@/api/app/notification'
import type { Notification, UnreadCountResult, NotificationType } from '@/api/app/notification/model'
import { message } from 'ant-design-vue'
/** 轮询间隔30 秒 */
const POLL_INTERVAL = 30_000
/** 全局未读数(跨组件共享) */
const unreadTotal = ref(0)
const unreadByType = ref<Partial<Record<NotificationType, number>>>({})
const recentList = ref<Notification[]>([])
const loading = ref(false)
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 防止重复轮询的计数器 */
let pollRefCount = 0
/** 通知类型 → 显示名称 & 图标 */
export const notificationTypeMap: Record<NotificationType, { label: string; icon: string; color: string }> = {
ticket: { label: '工单通知', icon: '🎫', color: '#1890ff' },
review: { label: '审核通知', icon: '✅', color: '#52c41a' },
system: { label: '系统通知', icon: '📢', color: '#faad14' },
resource: { label: '资源通知', icon: '🖥️', color: '#722ed1' },
permission: { label: '权限通知', icon: '🔐', color: '#eb2f96' },
member: { label: '成员通知', icon: '👥', color: '#13c2c2' },
payment: { label: '账单通知', icon: '💳', color: '#fa8c16' },
}
/**
* 通知中心 composable全局共享状态
*/
export function useNotificationCenter() {
// ---------- 加载未读数 ----------
async function fetchUnreadCount() {
try {
const data = await getUnreadCount()
unreadTotal.value = data.total ?? 0
unreadByType.value = { ...data } as Partial<Record<NotificationType, number>>
;(unreadByType.value as Record<string, unknown>).total = undefined // 去掉 total
} catch {
// 静默失败,不打扰用户
}
}
// ---------- 加载最近通知 ----------
async function fetchRecentNotifications(limit = 10) {
loading.value = true
try {
recentList.value = await listRecentNotification({ limit })
} catch {
// 静默失败
} finally {
loading.value = false
}
}
// ---------- 标记单条已读 ----------
async function markNotificationRead(id: number) {
try {
await markRead(id)
// 乐观更新
const item = recentList.value.find((n) => n.id === id)
if (item) item.isRead = 1
if (unreadTotal.value > 0) unreadTotal.value--
} catch {
message.error('标记失败,请重试')
}
}
// ---------- 全部已读 ----------
async function markAllAsRead(type?: string) {
try {
await markAllRead(type ? { type } : undefined)
recentList.value.forEach((n) => {
if (!type || n.type === type) n.isRead = 1
})
if (type) {
unreadTotal.value -= (unreadByType.value[type as NotificationType] ?? 0)
unreadByType.value[type as NotificationType] = 0
} else {
unreadTotal.value = 0
Object.keys(unreadByType.value).forEach((k) => {
unreadByType.value[k as NotificationType] = 0
})
}
if (unreadTotal.value < 0) unreadTotal.value = 0
message.success('已全部标记为已读')
} catch {
message.error('操作失败,请重试')
}
}
// ---------- 启动 / 停止轮询 ----------
function startPolling() {
pollRefCount++
if (pollTimer) return
// 立即拉取一次
fetchUnreadCount()
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
}
function stopPolling() {
pollRefCount = Math.max(0, pollRefCount - 1)
if (pollRefCount === 0 && pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// ---------- 时间格式化 ----------
function formatTime(time?: string): string {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return '刚刚'
if (diffMin < 60) return `${diffMin} 分钟前`
if (diffHour < 24) return `${diffHour} 小时前`
if (diffDay < 7) return `${diffDay} 天前`
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
// ---------- 获取通知跳转链接 ----------
function getNotificationLink(n: Notification): string {
if (n.linkUrl) return n.linkUrl
switch (n.refType) {
case 'ticket':
return `/console/tickets`
case 'permission_request':
return `/developer/requests`
default:
return '/console/notifications'
}
}
return {
// 状态
unreadTotal,
unreadByType,
recentList,
loading,
// 方法
fetchUnreadCount,
fetchRecentNotifications,
markNotificationRead,
markAllAsRead,
startPolling,
stopPolling,
// 工具
formatTime,
getNotificationLink,
}
}

View File

@@ -0,0 +1,37 @@
import { useHead, useRequestURL, useSeoMeta } from '#app'
type SeoInput = {
title: string
description: string
path?: string
}
function getSiteOrigin() {
if (import.meta.client) return window.location.origin
try {
return useRequestURL().origin
} catch {
return ''
}
}
export function usePageSeo(input: SeoInput) {
const origin = getSiteOrigin()
const url = input.path && origin ? new URL(input.path, origin).toString() : undefined
useSeoMeta({
title: input.title,
description: input.description,
ogTitle: input.title,
ogDescription: input.description,
ogType: 'website',
...(url ? { ogUrl: url } : {}),
twitterCard: 'summary_large_image'
})
if (url) {
useHead({
link: [{ rel: 'canonical', href: url }]
})
}
}

View File

@@ -0,0 +1,33 @@
import QRCode from 'qrcode'
/**
* 将 URL 转换为二维码图片的 data URL
*/
export async function generateQrCodeDataUrl(url: string, options?: QRCode.QRCodeToDataURLOptions): Promise<string> {
const defaultOptions: QRCode.QRCodeToDataURLOptions = {
width: 280,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
}
return QRCode.toDataURL(url, { ...defaultOptions, ...options })
}
/**
* 将 URL 转换为二维码图片的 Canvas 元素
*/
export async function generateQrCodeCanvas(url: string, canvas: HTMLCanvasElement, options?: QRCode.QRCodeToCanvasOptions): Promise<void> {
const defaultOptions: QRCode.QRCodeToCanvasOptions = {
width: 280,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
}
return QRCode.toCanvas(canvas, url, { ...defaultOptions, ...options })
}

View File

@@ -0,0 +1,134 @@
/**
* 资源中心协作权限 composable
*
* 权限级别:
* 0 - 无权限
* 1 - 基础查看:名称/IP/端口/状态(所有团队成员)
* 2 - 连接查看用户名、Host、连接方式技术负责人及以上
* 3 - 完全权限:密码、私钥、编辑/删除(仅资源 Owner
*/
import type { AppResource } from '@/api/app/appResource/model'
export type ResourceAccessLevel = 0 | 1 | 2 | 3
/**
* 判断当前用户是否是资源所有者
* 后端在返回时会带 ownerUserId 字段,前端基于 localStorage UserId 判断
*/
export function isResourceOwner(resource: AppResource): boolean {
if (!import.meta.client) return false
const currentUserId = localStorage.getItem('UserId')
if (!currentUserId) return false
return Number(currentUserId) === Number(resource.ownerUserId)
|| Number(currentUserId) === Number(resource.userId)
}
/**
* 获取当前用户对某资源的访问级别
* - 后端若已计算 accessLevel直接使用
* - 否则前端保底逻辑Owner=3其余=1
*/
export function getResourceAccessLevel(resource: AppResource): ResourceAccessLevel {
// 后端已计算的权限级别,优先使用
if (resource.accessLevel !== undefined) {
return resource.accessLevel
}
// 降级:判断是否是 owner
return isResourceOwner(resource) ? 3 : 1
}
/** 是否有基础查看权限(所有人) */
export function canViewBasic(resource: AppResource): boolean {
return getResourceAccessLevel(resource) >= 1
}
/** 是否有连接信息查看权限用户名、Host 等) */
export function canViewConnection(resource: AppResource): boolean {
return getResourceAccessLevel(resource) >= 2
}
/** 是否有完整权限(编辑、删除、查看密码/私钥) */
export function canViewSensitive(resource: AppResource): boolean {
return getResourceAccessLevel(resource) >= 3
}
/** 是否能编辑/删除该资源 */
export function canManageResource(resource: AppResource): boolean {
return getResourceAccessLevel(resource) >= 3
}
/** 脱敏占位符:判断某字段是否已被后端脱敏 */
export function isMaskedValue(value: string | null | undefined): boolean {
if (!value) return false
return value === '******' || value === '***' || /^\*{3,}$/.test(value)
}
/**
* 资源权限 composablereactive接受 computed/ref 的资源对象)
*/
export function useResourceAccess(resource: AppResource | (() => AppResource | null | undefined)) {
const getResource = (): AppResource | null | undefined =>
typeof resource === 'function' ? resource() : resource
const accessLevel = computed<ResourceAccessLevel>(() => {
const r = getResource()
if (!r) return 0
return getResourceAccessLevel(r)
})
const isOwner = computed(() => {
const r = getResource()
if (!r) return false
return isResourceOwner(r)
})
const canBasic = computed(() => accessLevel.value >= 1)
const canConnection = computed(() => accessLevel.value >= 2)
const canSensitive = computed(() => accessLevel.value >= 3)
const canManage = computed(() => accessLevel.value >= 3)
/** 权限级别对应的文字说明 */
const accessLevelText = computed(() => {
switch (accessLevel.value) {
case 3: return '完全权限'
case 2: return '连接权限'
case 1: return '查看权限'
default: return '无权限'
}
})
/** 权限不足时的提示文字 */
const noPermissionTip = computed(() =>
isOwner.value ? '' : '如需查看完整信息,请联系资源创建者',
)
return {
accessLevel,
isOwner,
canBasic,
canConnection,
canSensitive,
canManage,
accessLevelText,
noPermissionTip,
}
}
/**
* 批量处理资源列表,为每个资源附加 isOwner 字段
* 调用时机:后端未返回 accessLevel 时的前端降级处理
*/
export function enrichResourcesWithPermission<T extends AppResource>(resources: T[]): T[] {
if (!import.meta.client) return resources
const currentUserId = localStorage.getItem('UserId')
if (!currentUserId) return resources
return resources.map(r => ({
...r,
isOwner: Number(currentUserId) === Number(r.ownerUserId) || Number(currentUserId) === Number(r.userId),
accessLevel: r.accessLevel ?? (
(Number(currentUserId) === Number(r.ownerUserId) || Number(currentUserId) === Number(r.userId))
? 3
: 1
),
}))
}