diff --git a/tenant-platform/src/api/app.ts b/tenant-platform/src/api/app.ts index 29450c5..cae28df 100644 --- a/tenant-platform/src/api/app.ts +++ b/tenant-platform/src/api/app.ts @@ -27,7 +27,7 @@ export interface FeatureService { id: string appKey: string platform: 'ANDROID' | 'IOS' | 'HARMONY' - serviceType: 'IM' | 'PUSH' | 'UPDATE' + serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'LICENSE' enabled: boolean config?: string | null createdAt: string @@ -195,4 +195,7 @@ export const appApi = { resetSecret: (appKey: string, code: string) => client.post<{ data: { appSecret: string } }>(`/apps/${appKey}/reset-secret`, { code }), + + downloadLicenseFile: (appKey: string) => + client.get(`/apps/${appKey}/license-file`, { responseType: 'blob' }), } diff --git a/tenant-platform/src/api/license.ts b/tenant-platform/src/api/license.ts new file mode 100644 index 0000000..2dca8a7 --- /dev/null +++ b/tenant-platform/src/api/license.ts @@ -0,0 +1,128 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' +import { isJwtExpired } from '@/utils/jwt' + +const licenseClient = axios.create({ + baseURL: import.meta.env.VITE_LICENSE_API_BASE_URL ?? '', + timeout: 15000, +}) + +if (import.meta.env.DEV) { + licenseClient.interceptors.request.use((config) => { + console.debug('[tenant-platform][License] request', { + method: config.method?.toUpperCase(), + url: config.baseURL ? `${config.baseURL}${config.url ?? ''}` : config.url, + params: config.params, + }) + return config + }) + licenseClient.interceptors.response.use((res) => { + console.debug('[tenant-platform][License] response', { + status: res.status, + url: res.config.url, + }) + return res + }) +} + +licenseClient.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token && !isJwtExpired(token)) { + config.headers.Authorization = `Bearer ${token}` + } else if (token && isJwtExpired(token)) { + localStorage.removeItem('token') + if (router.currentRoute.value.path !== '/login') { + router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录')) + } + return Promise.reject(new Error('登录已失效,请重新登录')) + } + return config +}) + +licenseClient.interceptors.response.use( + (res) => res, + (error) => { + const status = error.response?.status + if (status === 401) { + localStorage.removeItem('token') + if (router.currentRoute.value.path !== '/login') { + router.push('/login') + } + ElMessage.error('登录已失效,请重新登录') + return Promise.reject(error) + } + if (status === 403) { + localStorage.removeItem('token') + if (router.currentRoute.value.path !== '/login') { + router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录')) + } + ElMessage.error(error.response?.data?.message ?? '登录已失效,请重新登录') + return Promise.reject(error) + } + const msg = error.response?.data?.message ?? '授权请求失败' + ElMessage.error(msg) + return Promise.reject(error) + }, +) + +export interface LicenseCompany { + id: string + name: string + maxDevices: number + registeredDevices: number + expiresAt?: string | null + isActive: boolean + remark?: string | null + createdAt: string + updatedAt: string +} + +export interface LicenseDevice { + id: string + companyId: string + deviceId: string + deviceName?: string | null + registeredAt: string + lastVerifiedAt?: string | null + isActive: boolean + createdAt: string +} + +export const licenseApi = { + listCompanies() { + return licenseClient.get<{ data: LicenseCompany[] }>('/api/license/admin/companies') + }, + + createCompany(data: { name: string; maxDevices: number; expiresAt?: string; remark?: string }) { + return licenseClient.post<{ data: LicenseCompany }>('/api/license/admin/companies', data) + }, + + getCompany(id: string) { + return licenseClient.get<{ data: { company: LicenseCompany; devices: LicenseDevice[] } }>( + `/api/license/admin/companies/${encodeURIComponent(id)}`, + ) + }, + + updateCompany( + id: string, + data: { name?: string; maxDevices?: number; expiresAt?: string; isActive?: boolean; remark?: string }, + ) { + return licenseClient.put<{ data: LicenseCompany }>( + `/api/license/admin/companies/${encodeURIComponent(id)}`, + data, + ) + }, + + deleteCompany(id: string) { + return licenseClient.delete<{ data: null }>(`/api/license/admin/companies/${encodeURIComponent(id)}`) + }, + + revokeDevice(id: string) { + return licenseClient.delete<{ data: null }>(`/api/license/admin/devices/${encodeURIComponent(id)}`) + }, + + reactivateDevice(id: string) { + return licenseClient.put<{ data: null }>(`/api/license/admin/devices/${encodeURIComponent(id)}/reactivate`) + }, +} diff --git a/tenant-platform/src/router/index.ts b/tenant-platform/src/router/index.ts index 39cd9a3..7ceb810 100644 --- a/tenant-platform/src/router/index.ts +++ b/tenant-platform/src/router/index.ts @@ -81,6 +81,10 @@ const router = createRouter({ path: 'apps/:appKey/update', component: () => import('@/views/update/VersionManagementView.vue'), }, + { + path: 'apps/:appKey/license', + component: () => import('@/views/license/LicenseManagementView.vue'), + }, { path: 'services/im/:appKey?', component: () => import('@/views/im/ImManagementView.vue'), diff --git a/tenant-platform/src/views/apps/AppDetailView.vue b/tenant-platform/src/views/apps/AppDetailView.vue index 2fe7a94..94736f7 100644 --- a/tenant-platform/src/views/apps/AppDetailView.vue +++ b/tenant-platform/src/views/apps/AppDetailView.vue @@ -128,6 +128,43 @@ + + +
+ +
+
+ {{ serviceLabel('LICENSE') }} + {{ serviceHelp('LICENSE') }} +
+ +
+
+ + {{ isServiceEnabled('LICENSE') ? '已开通' : '未开通' }} + + + 管理 PAD 设备的授权注册与验证。 + +
+
+ + 授权管理 → + + + 下载 License 文件 + + + 申请开通 + +
+
+
+
+
@@ -205,7 +242,7 @@ function isServiceEnabled(svcType: string) { } function serviceLabel(type: string) { - return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type + return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理', LICENSE: '授权管理' }[type] ?? type } function serviceHelp(type: string) { @@ -213,6 +250,7 @@ function serviceHelp(type: string) { IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。', PUSH: '一次开通后,可在推送配置页按厂商维护配置。', UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。', + LICENSE: '管理 PAD 设备的授权注册与验证。', }[type] ?? '' } @@ -311,6 +349,30 @@ async function submitVerify() { } } +async function downloadLicenseFile() { + const current = app.value + if (!current) return + const res = await appApi.downloadLicenseFile(current.appKey) + const disposition = res.headers['content-disposition'] as string | undefined + const filename = parseFilename(disposition) ?? `${current.name || current.appKey}.xuqmlicense` + const url = URL.createObjectURL(res.data) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +function parseFilename(disposition?: string) { + if (!disposition) return null + const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) return decodeURIComponent(encoded) + const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1] + return plain ? decodeURIComponent(plain) : null +} + function copy(text: string) { navigator.clipboard.writeText(text) ElMessage.success('已复制') diff --git a/tenant-platform/src/views/license/LicenseManagementView.vue b/tenant-platform/src/views/license/LicenseManagementView.vue new file mode 100644 index 0000000..f75a470 --- /dev/null +++ b/tenant-platform/src/views/license/LicenseManagementView.vue @@ -0,0 +1,350 @@ + + + + +