初始版本
This commit is contained in:
149
app/utils/appEntry.ts
Normal file
149
app/utils/appEntry.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE } from '@/api/app/appProduct/model'
|
||||
|
||||
/** 入口动作类型 */
|
||||
export type EntryType =
|
||||
| 'visit-site' // 访问网站
|
||||
| 'scan-qr' // 扫码体验(小程序)
|
||||
| 'download' // 下载安装
|
||||
| 'admin' // 管理后台
|
||||
|
||||
/** 单个入口配置 */
|
||||
export interface AppEntry {
|
||||
type: EntryType
|
||||
label: string
|
||||
/** Ant Design 图标组件名 */
|
||||
icon: string
|
||||
url?: string
|
||||
available: boolean
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
/** 扫码提示文案映射 */
|
||||
const SCAN_TIPS: Record<number, string> = {
|
||||
[APP_TYPE.WECHAT_MP]: '打开微信扫一扫体验',
|
||||
[APP_TYPE.DOUYIN_MP]: '打开抖音扫一扫体验',
|
||||
[APP_TYPE.BAIDU_MP]: '打开百度APP扫一扫体验',
|
||||
[APP_TYPE.ALIPAY_MP]: '打开支付宝扫一扫体验',
|
||||
}
|
||||
|
||||
/** 小程序 appType 集合 */
|
||||
const MINI_PROGRAM_TYPES = new Set([
|
||||
APP_TYPE.WECHAT_MP,
|
||||
APP_TYPE.DOUYIN_MP,
|
||||
APP_TYPE.BAIDU_MP,
|
||||
APP_TYPE.ALIPAY_MP,
|
||||
])
|
||||
|
||||
/** 移动端/桌面端 appType 集合 */
|
||||
const INSTALLABLE_TYPES = new Set([
|
||||
APP_TYPE.ANDROID,
|
||||
APP_TYPE.IOS,
|
||||
APP_TYPE.MACOS,
|
||||
APP_TYPE.WINDOWS,
|
||||
])
|
||||
|
||||
/**
|
||||
* 解析 Web 应用的前台 URL
|
||||
* 优先使用显式配置的 homeUrl,否则用 domain + prefix 拼接
|
||||
*/
|
||||
export function resolveWebHomeUrl(app: AppProduct): string | null {
|
||||
if (app.homeUrl) return app.homeUrl
|
||||
if (app.domain) {
|
||||
const prefix = app.prefix ? `/${app.prefix.replace(/^\//, '')}` : ''
|
||||
return `https://${app.domain}${prefix}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用的所有可用入口(按优先级排列)
|
||||
*/
|
||||
export function getAppEntries(app: AppProduct): AppEntry[] {
|
||||
const entries: AppEntry[] = []
|
||||
const type = app.appType
|
||||
|
||||
// 1. 访问网站(仅 Web 应用)
|
||||
if (type === APP_TYPE.WEBSITE) {
|
||||
const url = resolveWebHomeUrl(app)
|
||||
entries.push({
|
||||
type: 'visit-site',
|
||||
label: '访问网站',
|
||||
icon: 'GlobalOutlined',
|
||||
url: url || undefined,
|
||||
available: !!url,
|
||||
isPrimary: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 扫码体验(小程序类型)
|
||||
if (type && MINI_PROGRAM_TYPES.has(type)) {
|
||||
entries.push({
|
||||
type: 'scan-qr',
|
||||
label: '扫码体验',
|
||||
icon: 'QrcodeOutlined',
|
||||
url: app.qrcode,
|
||||
available: !!app.qrcode,
|
||||
isPrimary: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 下载安装(移动端 / 桌面端)
|
||||
if (type && INSTALLABLE_TYPES.has(type)) {
|
||||
entries.push({
|
||||
type: 'download',
|
||||
label: '下载安装',
|
||||
icon: 'DownloadOutlined',
|
||||
url: app.downloadUrl,
|
||||
available: !!app.downloadUrl,
|
||||
isPrimary: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 管理后台(所有类型通用)
|
||||
entries.push({
|
||||
type: 'admin',
|
||||
label: '管理后台',
|
||||
icon: 'SettingOutlined',
|
||||
url: app.adminUrl,
|
||||
available: !!app.adminUrl,
|
||||
// 插件的后台即主入口
|
||||
isPrimary: type === APP_TYPE.PLUGIN,
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主入口
|
||||
*/
|
||||
export function getPrimaryEntry(app: AppProduct): AppEntry | null {
|
||||
return getAppEntries(app).find(e => e.isPrimary && e.available) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扫码提示文案
|
||||
*/
|
||||
export function getScanTip(appType: number): string {
|
||||
return SCAN_TIPS[appType] || '扫一扫体验'
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行入口动作(返回是否由调用方自行处理,如扫码弹窗)
|
||||
*/
|
||||
export function executeEntry(entry: AppEntry): boolean {
|
||||
if (!entry.available) return false
|
||||
|
||||
switch (entry.type) {
|
||||
case 'visit-site':
|
||||
case 'download':
|
||||
case 'admin':
|
||||
window.open(entry.url, '_blank', 'noreferrer')
|
||||
return false
|
||||
case 'scan-qr':
|
||||
// 需要调用方弹出二维码弹窗
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
9
app/utils/domain.ts
Normal file
9
app/utils/domain.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function getTenantId(defaultTenantId = '5') {
|
||||
if (!import.meta.client) return defaultTenantId
|
||||
try {
|
||||
return localStorage.getItem('TenantId') || defaultTenantId
|
||||
} catch {
|
||||
return defaultTenantId
|
||||
}
|
||||
}
|
||||
|
||||
247
app/utils/permission.ts
Normal file
247
app/utils/permission.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 按钮级权限控制
|
||||
*
|
||||
* 参考 mp-vue/src/utils/permission.ts
|
||||
* 当前项目未引入 Pinia 的 user store,因此改为从 Nuxt state / localStorage 读取。
|
||||
*/
|
||||
import type { App } from 'vue'
|
||||
import { useState } from '#imports'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import type { Role } from '@/api/system/role/model'
|
||||
import type { Menu } from '@/api/system/menu/model'
|
||||
|
||||
type AuthzState = {
|
||||
roles: string[]
|
||||
authorities: string[]
|
||||
}
|
||||
|
||||
const AUTHZ_STORAGE_KEY = 'Authz'
|
||||
|
||||
function uniqNonEmpty(values: Array<string | undefined | null>) {
|
||||
const seen = new Set<string>()
|
||||
for (const v of values) {
|
||||
const s = typeof v === 'string' ? v.trim() : ''
|
||||
if (!s || s === 'null' || s === 'undefined') continue
|
||||
seen.add(s)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
function safeJsonParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return uniqNonEmpty(value.filter((v): v is string => typeof v === 'string'))
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return uniqNonEmpty(
|
||||
value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function readAuthzFromStorage(): AuthzState {
|
||||
if (!import.meta.client) return { roles: [], authorities: [] }
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTHZ_STORAGE_KEY)
|
||||
if (!raw) return { roles: [], authorities: [] }
|
||||
const parsed = safeJsonParse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return { roles: [], authorities: [] }
|
||||
const obj = parsed as Record<string, unknown>
|
||||
return {
|
||||
roles: normalizeStringArray(obj.roles),
|
||||
authorities: normalizeStringArray(obj.authorities)
|
||||
}
|
||||
} catch {
|
||||
return { roles: [], authorities: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuthzToStorage(next: AuthzState) {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(AUTHZ_STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthzStateRef() {
|
||||
try {
|
||||
return useState<AuthzState>('authz', () => readAuthzFromStorage())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthzSnapshot(): AuthzState {
|
||||
const state = getAuthzStateRef()
|
||||
return state?.value ?? readAuthzFromStorage()
|
||||
}
|
||||
|
||||
export function setAuthz(next: Partial<AuthzState>) {
|
||||
const current = getAuthzSnapshot()
|
||||
const merged: AuthzState = {
|
||||
roles: normalizeStringArray(next.roles ?? current.roles),
|
||||
authorities: normalizeStringArray(next.authorities ?? current.authorities)
|
||||
}
|
||||
|
||||
const state = getAuthzStateRef()
|
||||
if (state) state.value = merged
|
||||
writeAuthzToStorage(merged)
|
||||
}
|
||||
|
||||
export function clearAuthz() {
|
||||
const state = getAuthzStateRef()
|
||||
if (state) state.value = { roles: [], authorities: [] }
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.removeItem(AUTHZ_STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleCodesFromUser(user?: User | null) {
|
||||
const roleCodes: string[] = []
|
||||
|
||||
const fromUserRoleCode = normalizeStringArray(user?.roleCode)
|
||||
roleCodes.push(...fromUserRoleCode)
|
||||
|
||||
const roles: Role[] | undefined = user?.roles
|
||||
if (Array.isArray(roles)) {
|
||||
for (const role of roles) {
|
||||
if (!role) continue
|
||||
roleCodes.push(role.roleCode)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqNonEmpty(roleCodes)
|
||||
}
|
||||
|
||||
function getAuthoritiesFromUser(user?: User | null) {
|
||||
const authorities: string[] = []
|
||||
const list: Menu[] | undefined = user?.authorities
|
||||
if (Array.isArray(list)) {
|
||||
for (const item of list) {
|
||||
if (!item) continue
|
||||
authorities.push(item.authority)
|
||||
}
|
||||
}
|
||||
return uniqNonEmpty(authorities)
|
||||
}
|
||||
|
||||
export function setAuthzFromUser(user?: User | null) {
|
||||
setAuthz({
|
||||
roles: getRoleCodesFromUser(user),
|
||||
authorities: getAuthoritiesFromUser(user)
|
||||
})
|
||||
}
|
||||
|
||||
/* 判断数组是否有某些值(全包含) */
|
||||
function normalizeNeedles(value: string | string[]) {
|
||||
if (Array.isArray(value)) return uniqNonEmpty(value)
|
||||
const s = typeof value === 'string' ? value.trim() : ''
|
||||
return s ? [s] : []
|
||||
}
|
||||
|
||||
function normalizeHaystack(array: (string | undefined)[]) {
|
||||
return uniqNonEmpty(array)
|
||||
}
|
||||
|
||||
function arrayHas(array: (string | undefined)[], value: string | string[]): boolean {
|
||||
if (!value) return true
|
||||
if (!array) return false
|
||||
const needles = normalizeNeedles(value)
|
||||
if (needles.length === 0) return true
|
||||
const haystack = new Set(normalizeHaystack(array))
|
||||
for (let i = 0; i < needles.length; i++) {
|
||||
if (!haystack.has(needles[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/* 判断数组是否有任意值(任一包含) */
|
||||
function arrayHasAny(array: (string | undefined)[], value: string | string[]): boolean {
|
||||
if (!value) return true
|
||||
if (!array) return false
|
||||
const needles = normalizeNeedles(value)
|
||||
if (needles.length === 0) return true
|
||||
const haystack = new Set(normalizeHaystack(array))
|
||||
for (let i = 0; i < needles.length; i++) {
|
||||
if (haystack.has(needles[i])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有某些角色
|
||||
* @param value 角色字符或字符数组
|
||||
*/
|
||||
export function hasRole(value: string | string[]): boolean {
|
||||
const { roles } = getAuthzSnapshot()
|
||||
return arrayHas(roles, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有任意角色
|
||||
* @param value 角色字符或字符数组
|
||||
*/
|
||||
export function hasAnyRole(value: string | string[]): boolean {
|
||||
const { roles } = getAuthzSnapshot()
|
||||
return arrayHasAny(roles, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有某些权限
|
||||
* @param value 权限字符或字符数组
|
||||
*/
|
||||
export function hasPermission(value: string | string[]): boolean {
|
||||
const { authorities } = getAuthzSnapshot()
|
||||
return arrayHas(authorities, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有任意权限
|
||||
* @param value 权限字符或字符数组
|
||||
*/
|
||||
export function hasAnyPermission(value: string | string[]): boolean {
|
||||
const { authorities } = getAuthzSnapshot()
|
||||
return arrayHasAny(authorities, value)
|
||||
}
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
// 添加自定义指令
|
||||
app.directive('role', {
|
||||
mounted: (el, binding) => {
|
||||
if (!hasRole(binding.value)) el.parentNode?.removeChild(el)
|
||||
}
|
||||
})
|
||||
app.directive('any-role', {
|
||||
mounted: (el, binding) => {
|
||||
if (!hasAnyRole(binding.value)) el.parentNode?.removeChild(el)
|
||||
}
|
||||
})
|
||||
app.directive('permission', {
|
||||
mounted: (el, binding) => {
|
||||
if (!hasPermission(binding.value)) el.parentNode?.removeChild(el)
|
||||
}
|
||||
})
|
||||
app.directive('any-permission', {
|
||||
mounted: (el, binding) => {
|
||||
if (!hasAnyPermission(binding.value)) el.parentNode?.removeChild(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
app/utils/request.ts
Normal file
108
app/utils/request.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useRequestFetch, useRuntimeConfig } from '#imports'
|
||||
import { MODULES_API_URL, SERVER_API_URL, APP_API_URL } from '@/config/setting'
|
||||
import { getTenantId } from '@/utils/domain'
|
||||
import { getToken } from '@/utils/token-util'
|
||||
|
||||
type RequestConfig = {
|
||||
params?: Record<string, any>
|
||||
data?: any
|
||||
headers?: Record<string, any>
|
||||
responseType?: 'json' | 'blob' | 'text' | 'arrayBuffer'
|
||||
}
|
||||
|
||||
type AxiosLikeResponse<T> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
type NuxtFetch = ReturnType<typeof useRequestFetch>
|
||||
type NuxtFetchOptions = NonNullable<Parameters<NuxtFetch>[1]>
|
||||
type NuxtFetchMethod = NonNullable<NuxtFetchOptions['method']>
|
||||
|
||||
function getFetch(): NuxtFetch {
|
||||
try {
|
||||
return useRequestFetch()
|
||||
} catch {
|
||||
return globalThis.$fetch as unknown as NuxtFetch
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string) {
|
||||
const config = useRuntimeConfig()
|
||||
const serverBase = config.public.serverApiBase
|
||||
const modulesBase = config.public.modulesApiBase
|
||||
const appBase = config.public.appApiBase
|
||||
|
||||
if (url.startsWith(serverBase)) {
|
||||
return SERVER_API_URL + url.slice(serverBase.length)
|
||||
}
|
||||
if (url.startsWith(modulesBase)) {
|
||||
return MODULES_API_URL + url.slice(modulesBase.length)
|
||||
}
|
||||
if (url.startsWith(appBase)) {
|
||||
return APP_API_URL + url.slice(appBase.length)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function mergeHeaders(headers?: Record<string, any>) {
|
||||
const config = useRuntimeConfig()
|
||||
const tenantId = headers?.TenantId ?? headers?.tenantId ?? getTenantId(String(config.public.tenantId))
|
||||
const token = getToken()
|
||||
|
||||
const merged: Record<string, string> = {
|
||||
TenantId: String(tenantId)
|
||||
}
|
||||
|
||||
if (token) {
|
||||
merged.Authorization = token
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value === undefined || value === null) continue
|
||||
merged[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function withDefaultModuleBase(url: string) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
// 已带 /api 前缀的直接返回(避免重复拼接)
|
||||
if (url.startsWith('/api/_server') || url.startsWith('/api/_modules') || url.startsWith('/api/_file')) return url
|
||||
// App 模块走 /api/_app 前缀(proxy: server/api/_app/[...path].ts)
|
||||
if (url.startsWith('/api/app')) return '/api/_app' + url.slice('/api/app'.length)
|
||||
// 其余默认走 modules
|
||||
if (!url.startsWith('/')) return MODULES_API_URL + '/' + url
|
||||
return MODULES_API_URL + url
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: NuxtFetchMethod,
|
||||
url: string,
|
||||
body?: any,
|
||||
config: RequestConfig = {}
|
||||
): Promise<AxiosLikeResponse<T>> {
|
||||
const $fetch = getFetch()
|
||||
const normalized = withDefaultModuleBase(normalizeUrl(url))
|
||||
|
||||
const data = await $fetch<T>(normalized, {
|
||||
method,
|
||||
query: config.params,
|
||||
body: body ?? config.data,
|
||||
headers: mergeHeaders(config.headers),
|
||||
responseType: config.responseType as any
|
||||
})
|
||||
|
||||
return { data }
|
||||
}
|
||||
|
||||
const requestClient = {
|
||||
get: <T>(url: string, config?: RequestConfig) => request<T>('GET', url, undefined, config),
|
||||
delete: <T>(url: string, config?: RequestConfig) => request<T>('DELETE', url, undefined, config),
|
||||
post: <T>(url: string, data?: any, config?: RequestConfig) => request<T>('POST', url, data, config),
|
||||
put: <T>(url: string, data?: any, config?: RequestConfig) => request<T>('PUT', url, data, config)
|
||||
}
|
||||
|
||||
export default requestClient
|
||||
42
app/utils/token-util.ts
Normal file
42
app/utils/token-util.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const TOKEN_KEY = 'AccessToken'
|
||||
const TOKEN_EVENT = 'auth-token-changed'
|
||||
|
||||
function notifyTokenChange() {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
window.dispatchEvent(new Event(TOKEN_EVENT))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token?: string, remember?: boolean) {
|
||||
if (!token || !import.meta.client) return
|
||||
try {
|
||||
const storage = remember ? localStorage : sessionStorage
|
||||
storage.setItem(TOKEN_KEY, token)
|
||||
notifyTokenChange()
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
if (!import.meta.client) return ''
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
notifyTokenChange()
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user