Add tenant license management

这个提交包含在:
XuqmGroup 2026-05-15 21:00:24 +08:00
父节点 a917932a2d
当前提交 02d64f1cb9
共有 5 个文件被更改,包括 549 次插入2 次删除

查看文件

@ -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<Blob>(`/apps/${appKey}/license-file`, { responseType: 'blob' }),
}

查看文件

@ -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`)
},
}

查看文件

@ -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'),

查看文件

@ -128,6 +128,43 @@
</div>
</el-card>
<el-card class="info-card" style="margin-top:16px">
<template #header>授权管理</template>
<div class="service-grid">
<el-card class="service-card">
<div class="service-header">
<div class="service-title-block">
<span class="service-name">{{ serviceLabel('LICENSE') }}</span>
<span class="service-help">{{ serviceHelp('LICENSE') }}</span>
</div>
<el-switch
:model-value="isServiceEnabled('LICENSE')"
@change="(val: boolean) => onToggleService('LICENSE', val)"
/>
</div>
<div class="service-status-row">
<el-tag :type="isServiceEnabled('LICENSE') ? 'success' : 'info'" size="small">
{{ isServiceEnabled('LICENSE') ? '已开通' : '未开通' }}
</el-tag>
<span class="service-status-text">
管理 PAD 设备的授权注册与验证
</span>
</div>
<div class="service-actions">
<el-button v-if="isServiceEnabled('LICENSE')" size="small" type="primary" plain @click="$router.push(`/apps/${app.appKey}/license`)">
授权管理
</el-button>
<el-button v-if="isServiceEnabled('LICENSE')" size="small" @click="downloadLicenseFile">
下载 License 文件
</el-button>
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('LICENSE')">
申请开通
</el-button>
</div>
</el-card>
</div>
</el-card>
<!-- Email Verify Dialog (reveal or reset) -->
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
<div v-if="!codeSent">
@ -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('已复制')

查看文件

@ -0,0 +1,350 @@
<template>
<div>
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">授权管理</span>
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.appKey" :label="a.name" :value="a.appKey" />
</el-select>
</div>
<el-page-header v-else @back="$router.back()" :content="`授权管理 — ${appKey}`" style="margin-bottom:20px" />
<el-empty v-if="isServicesPortal && !appKey" description="请选择一个应用" style="margin-top:80px" />
<template v-if="!isServicesPortal || appKey">
<el-row :gutter="16" class="stat-grid">
<el-col :xs="24" :sm="12" :md="6" v-for="item in statCards" :key="item.label">
<el-card shadow="never">
<div class="stat-card">
<span class="stat-value">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:16px">
<div class="toolbar responsive-toolbar">
<el-button type="primary" @click="openCreateDialog">新增公司</el-button>
<el-button v-if="appKey" @click="downloadLicenseFile">下载 License 文件</el-button>
<el-button @click="loadData" :loading="loading">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="companies" v-loading="loading" border stripe>
<el-table-column prop="name" label="公司名称" min-width="160" />
<el-table-column prop="maxDevices" label="最大设备数" width="120" />
<el-table-column prop="registeredDevices" label="已注册设备" width="120" />
<el-table-column label="过期时间" width="160">
<template #default="{ row }">
{{ row.expiresAt ? formatDate(row.expiresAt) : '永久' }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'" size="small">
{{ row.isActive ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewCompany(row)">详情</el-button>
<el-button size="small" type="primary" plain @click="editCompany(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="deleteCompany(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- Company Detail Dialog -->
<el-dialog v-model="showDetailDialog" :title="detailTitle" :width="dialogWidth" destroy-on-close>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="ID">{{ selectedCompany?.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ selectedCompany?.name }}</el-descriptions-item>
<el-descriptions-item label="最大设备">{{ selectedCompany?.maxDevices }}</el-descriptions-item>
<el-descriptions-item label="已注册">{{ selectedCompany?.registeredDevices }}</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ selectedCompany?.expiresAt ? formatDate(selectedCompany.expiresAt) : '永久' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedCompany?.isActive ? 'success' : 'danger'" size="small">
{{ selectedCompany?.isActive ? '正常' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedCompany?.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px">设备列表</h4>
<el-table :data="companyDevices" border stripe size="small">
<el-table-column prop="deviceId" label="设备ID" min-width="200" />
<el-table-column prop="deviceName" label="设备名称" min-width="120">
<template #default="{ row }">{{ row.deviceName || '-' }}</template>
</el-table-column>
<el-table-column label="注册时间" width="160">
<template #default="{ row }">{{ formatDate(row.registeredAt) }}</template>
</el-table-column>
<el-table-column label="最后验证" width="160">
<template #default="{ row }">{{ row.lastVerifiedAt ? formatDate(row.lastVerifiedAt) : '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'" size="small">
{{ row.isActive ? '正常' : '吊销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isActive" size="small" type="danger" plain @click="revokeDevice(row)">吊销</el-button>
<el-button v-else size="small" type="success" plain @click="reactivateDevice(row)">激活</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- Create/Edit Dialog -->
<el-dialog v-model="showFormDialog" :title="formTitle" :width="dialogWidth">
<el-form label-width="100px">
<el-form-item label="公司名称" required>
<el-input v-model="form.name" placeholder="请输入公司名称" />
</el-form-item>
<el-form-item label="最大设备数" required>
<el-input-number v-model="form.maxDevices" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker v-model="form.expiresAt" type="datetime" placeholder="选择过期时间(留空为永久)" style="width:100%" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.isActive" active-text="正常" inactive-text="停用" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showFormDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm">确认</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { licenseApi, type LicenseCompany, type LicenseDevice } from '@/api/license'
import { appApi, type App } from '@/api/app'
const route = useRoute()
const router = useRouter()
const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '720px'))
const appKey = computed(() => route.query.appKey as string || route.params.appKey as string)
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
const portalApps = ref<App[]>([])
const companies = ref<LicenseCompany[]>([])
const companyDevices = ref<LicenseDevice[]>([])
const loading = ref(false)
const showDetailDialog = ref(false)
const showFormDialog = ref(false)
const selectedCompany = ref<LicenseCompany | null>(null)
const submitting = ref(false)
const isEditing = ref(false)
const form = ref({
id: '',
name: '',
maxDevices: 1,
expiresAt: null as Date | null,
isActive: true,
remark: '',
})
const statCards = computed(() => [
{ label: '公司总数', value: companies.value.length },
{ label: '设备总数', value: companies.value.reduce((sum, c) => sum + (c.registeredDevices || 0), 0) },
{ label: '活跃公司', value: companies.value.filter(c => c.isActive).length },
{ label: '过期公司', value: companies.value.filter(c => c.expiresAt && new Date(c.expiresAt) < new Date()).length },
])
const detailTitle = computed(() => selectedCompany.value ? `${selectedCompany.value.name} — 详情` : '公司详情')
const formTitle = computed(() => isEditing.value ? '编辑公司' : '新增公司')
async function loadData() {
loading.value = true
try {
const res = await licenseApi.listCompanies()
companies.value = res.data.data || []
} catch (e) {
// error handled by interceptor
} finally {
loading.value = false
}
}
async function viewCompany(row: LicenseCompany) {
selectedCompany.value = row
loading.value = true
try {
const res = await licenseApi.getCompany(row.id)
companyDevices.value = res.data.data.devices || []
showDetailDialog.value = true
} finally {
loading.value = false
}
}
function editCompany(row: LicenseCompany) {
isEditing.value = true
form.value = {
id: row.id,
name: row.name,
maxDevices: row.maxDevices,
expiresAt: row.expiresAt ? new Date(row.expiresAt) : null,
isActive: row.isActive,
remark: row.remark || '',
}
showFormDialog.value = true
}
function openCreateDialog() {
isEditing.value = false
form.value = {
id: '',
name: '',
maxDevices: 1,
expiresAt: null,
isActive: true,
remark: '',
}
showFormDialog.value = true
}
async function submitForm() {
if (!form.value.name.trim()) {
return ElMessage.warning('请输入公司名称')
}
submitting.value = true
try {
const data = {
name: form.value.name,
maxDevices: form.value.maxDevices,
expiresAt: form.value.expiresAt ? form.value.expiresAt.toISOString().slice(0, 19).replace('T', ' ') : undefined,
isActive: form.value.isActive,
remark: form.value.remark || undefined,
}
if (isEditing.value) {
await licenseApi.updateCompany(form.value.id, data)
ElMessage.success('更新成功')
} else {
await licenseApi.createCompany({
name: data.name,
maxDevices: data.maxDevices,
expiresAt: data.expiresAt,
remark: data.remark,
})
ElMessage.success('创建成功')
}
showFormDialog.value = false
loadData()
} finally {
submitting.value = false
}
}
async function deleteCompany(row: LicenseCompany) {
try {
await ElMessageBox.confirm(`确认删除公司 "${row.name}"?关联的设备也将被删除。`, '删除确认', {
type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消',
})
await licenseApi.deleteCompany(row.id)
ElMessage.success('删除成功')
loadData()
} catch (e) {
// cancelled
}
}
async function revokeDevice(row: LicenseDevice) {
try {
await ElMessageBox.confirm('确认吊销该设备?', '吊销确认', { type: 'warning' })
await licenseApi.revokeDevice(row.id)
ElMessage.success('已吊销')
if (selectedCompany.value) {
viewCompany(selectedCompany.value)
}
loadData()
} catch (e) {
// cancelled
}
}
async function reactivateDevice(row: LicenseDevice) {
await licenseApi.reactivateDevice(row.id)
ElMessage.success('已激活')
if (selectedCompany.value) {
viewCompany(selectedCompany.value)
}
loadData()
}
async function downloadLicenseFile() {
const key = appKey.value
if (!key) return
const res = await appApi.downloadLicenseFile(key)
const app = portalApps.value.find(item => item.appKey === key)
const disposition = res.headers['content-disposition'] as string | undefined
const filename = parseFilename(disposition) ?? `${app?.name || key}.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 switchApp(key: string) {
router.push({ path: route.path, query: { ...route.query, appKey: key } })
}
function formatDate(d: string | number) {
const date = new Date(d)
return date.toLocaleString('zh-CN')
}
onMounted(() => {
loadData()
isMobile.value = window.innerWidth < 768
window.addEventListener('resize', () => { isMobile.value = window.innerWidth < 768 })
if (isServicesPortal.value) {
appApi.list().then(res => {
portalApps.value = res.data.data || []
})
}
})
</script>
<style scoped>
.stat-grid { margin-bottom: 16px; }
.stat-card { display: flex; flex-direction: column; align-items: center; padding: 12px; }
.stat-value { font-size: 28px; font-weight: 700; color: #0E7BF2; }
.stat-label { font-size: 13px; color: #64748b; margin-top: 4px; }
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; }
.table-wrap { overflow-x: auto; }
.portal-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.portal-bar-title { font-size: 18px; font-weight: 600; }
</style>