Add tenant license management
这个提交包含在:
父节点
a917932a2d
当前提交
02d64f1cb9
@ -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' }),
|
||||
}
|
||||
|
||||
128
tenant-platform/src/api/license.ts
普通文件
128
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`)
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
正在加载...
在新工单中引用
屏蔽一个用户