Add tenant license management
这个提交包含在:
父节点
a917932a2d
当前提交
02d64f1cb9
@ -27,7 +27,7 @@ export interface FeatureService {
|
|||||||
id: string
|
id: string
|
||||||
appKey: string
|
appKey: string
|
||||||
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
||||||
serviceType: 'IM' | 'PUSH' | 'UPDATE'
|
serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'LICENSE'
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
config?: string | null
|
config?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@ -195,4 +195,7 @@ export const appApi = {
|
|||||||
|
|
||||||
resetSecret: (appKey: string, code: string) =>
|
resetSecret: (appKey: string, code: string) =>
|
||||||
client.post<{ data: { appSecret: string } }>(`/apps/${appKey}/reset-secret`, { code }),
|
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',
|
path: 'apps/:appKey/update',
|
||||||
component: () => import('@/views/update/VersionManagementView.vue'),
|
component: () => import('@/views/update/VersionManagementView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'apps/:appKey/license',
|
||||||
|
component: () => import('@/views/license/LicenseManagementView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'services/im/:appKey?',
|
path: 'services/im/:appKey?',
|
||||||
component: () => import('@/views/im/ImManagementView.vue'),
|
component: () => import('@/views/im/ImManagementView.vue'),
|
||||||
|
|||||||
@ -128,6 +128,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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) -->
|
<!-- Email Verify Dialog (reveal or reset) -->
|
||||||
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
|
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
|
||||||
<div v-if="!codeSent">
|
<div v-if="!codeSent">
|
||||||
@ -205,7 +242,7 @@ function isServiceEnabled(svcType: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serviceLabel(type: string) {
|
function serviceLabel(type: string) {
|
||||||
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
|
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理', LICENSE: '授权管理' }[type] ?? type
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceHelp(type: string) {
|
function serviceHelp(type: string) {
|
||||||
@ -213,6 +250,7 @@ function serviceHelp(type: string) {
|
|||||||
IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。',
|
IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。',
|
||||||
PUSH: '一次开通后,可在推送配置页按厂商维护配置。',
|
PUSH: '一次开通后,可在推送配置页按厂商维护配置。',
|
||||||
UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。',
|
UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。',
|
||||||
|
LICENSE: '管理 PAD 设备的授权注册与验证。',
|
||||||
}[type] ?? ''
|
}[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) {
|
function copy(text: string) {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
ElMessage.success('已复制')
|
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>
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户