Show app license device list directly

这个提交包含在:
XuqmGroup 2026-05-15 21:42:17 +08:00
父节点 690d930a17
当前提交 c1c80e1a7c
共有 2 个文件被更改,包括 104 次插入264 次删除

查看文件

@ -66,7 +66,7 @@ licenseClient.interceptors.response.use(
},
)
export interface LicenseCompany {
export interface AppLicense {
id: string
name: string
maxDevices: number
@ -80,7 +80,7 @@ export interface LicenseCompany {
export interface LicenseDevice {
id: string
companyId: string
appKey: string
deviceId: string
deviceName?: string | null
deviceModel?: string | null
@ -98,34 +98,12 @@ export interface LicenseDevice {
}
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)}`,
getAppLicense(appKey: string) {
return licenseClient.get<{ data: { license: AppLicense; devices: LicenseDevice[] } }>(
`/api/license/admin/apps/${encodeURIComponent(appKey)}`,
)
},
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)}`)
},

查看文件

@ -6,7 +6,7 @@
<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-page-header v-else @back="$router.back()" :content="`授权管理 - ${appName}`" style="margin-bottom:20px" />
<el-empty v-if="isServicesPortal && !appKey" description="请选择一个应用" style="margin-top:80px" />
<template v-if="!isServicesPortal || appKey">
@ -22,285 +22,137 @@
</el-row>
<el-card shadow="never" style="margin-top:16px">
<div class="license-summary">
<el-descriptions :column="isMobile ? 1 : 3" border>
<el-descriptions-item label="应用">{{ appName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">{{ appKey || '-' }}</el-descriptions-item>
<el-descriptions-item label="授权状态">
<el-tag :type="license?.isActive ? 'success' : 'danger'" size="small">
{{ license?.isActive ? '正常' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最大设备数">{{ license?.maxDevices ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="已注册设备">{{ license?.registeredDevices ?? devices.length }}</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ license?.expiresAt ? formatDate(license.expiresAt) : '永久' }}
</el-descriptions-item>
</el-descriptions>
</div>
<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">
<el-table :data="devices" v-loading="loading" border stripe>
<el-table-column prop="deviceId" label="设备ID" min-width="220" />
<el-table-column prop="deviceName" label="设备名称" min-width="130">
<template #default="{ row }">{{ row.deviceName || '-' }}</template>
</el-table-column>
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
{{ row.expiresAt ? formatDate(row.expiresAt) : '永久' }}
<div class="stack-cell">
<span>{{ row.userName || row.userId || '-' }}</span>
<small v-if="row.userName && row.userId">{{ row.userId }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="联系方式" min-width="190">
<template #default="{ row }">
<div class="stack-cell">
<span>{{ row.userPhone || '-' }}</span>
<small v-if="row.userEmail">{{ row.userEmail }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="设备信息" min-width="190">
<template #default="{ row }">
<div class="stack-cell">
<span>{{ [row.deviceVendor, row.deviceModel].filter(Boolean).join(' ') || '-' }}</span>
<small v-if="row.osVersion">{{ row.osVersion }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="注册时间" width="170">
<template #default="{ row }">{{ formatDate(row.registeredAt) }}</template>
</el-table-column>
<el-table-column label="最后验证" width="170">
<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 ? '正常' : '停用' }}
{{ row.isActive ? '正常' : '吊销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<el-table-column label="操作" width="120" 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>
<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>
</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="用户" min-width="180">
<template #default="{ row }">
<div class="user-cell">
<span>{{ row.userName || row.userId || '-' }}</span>
<small v-if="row.userName && row.userId">{{ row.userId }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="联系方式" min-width="190">
<template #default="{ row }">
<div class="user-cell">
<span>{{ row.userPhone || '-' }}</span>
<small v-if="row.userEmail">{{ row.userEmail }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="设备信息" min-width="180">
<template #default="{ row }">
<div class="user-cell">
<span>{{ [row.deviceVendor, row.deviceModel].filter(Boolean).join(' ') || '-' }}</span>
<small v-if="row.osVersion">{{ row.osVersion }}</small>
</div>
</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 { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { licenseApi, type LicenseCompany, type LicenseDevice } from '@/api/license'
import { licenseApi, type AppLicense, 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 currentApp = ref<App | null>(null)
const license = ref<AppLicense | null>(null)
const devices = 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 appName = computed(() => currentApp.value?.name || license.value?.name || appKey.value || '-')
const activeDevices = computed(() => devices.value.filter(d => d.isActive).length)
const revokedDevices = computed(() => devices.value.filter(d => !d.isActive).length)
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 },
{ label: '最大设备数', value: license.value?.maxDevices ?? '-' },
{ label: '已注册设备', value: license.value?.registeredDevices ?? devices.value.length },
{ label: '正常设备', value: activeDevices.value },
{ label: '吊销设备', value: revokedDevices.value },
])
const detailTitle = computed(() => selectedCompany.value ? `${selectedCompany.value.name} — 详情` : '公司详情')
const formTitle = computed(() => isEditing.value ? '编辑公司' : '新增公司')
async function loadData() {
const key = appKey.value
if (!key) return
loading.value = true
try {
const res = await licenseApi.listCompanies()
companies.value = res.data.data || []
} catch (e) {
// error handled by interceptor
const [licenseRes, appRes] = await Promise.all([
licenseApi.getAppLicense(key),
appApi.get(key).catch(() => null),
])
license.value = licenseRes.data.data.license
devices.value = licenseRes.data.data.devices || []
currentApp.value = appRes?.data.data ?? portalApps.value.find(item => item.appKey === key) ?? null
} 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
@ -310,9 +162,6 @@ async function revokeDevice(row: LicenseDevice) {
async function reactivateDevice(row: LicenseDevice) {
await licenseApi.reactivateDevice(row.id)
ElMessage.success('已激活')
if (selectedCompany.value) {
viewCompany(selectedCompany.value)
}
loadData()
}
@ -320,9 +169,8 @@ 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 filename = parseFilename(disposition) ?? `${appName.value || key}.xuqmlicense`
const url = URL.createObjectURL(res.data)
const link = document.createElement('a')
link.href = url
@ -347,19 +195,32 @@ function switchApp(key: string) {
function formatDate(d: string | number) {
const date = new Date(d)
return date.toLocaleString('zh-CN')
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN')
}
onMounted(() => {
loadData()
function updateViewport() {
isMobile.value = window.innerWidth < 768
window.addEventListener('resize', () => { isMobile.value = window.innerWidth < 768 })
}
watch(appKey, () => {
loadData()
})
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
loadData()
if (isServicesPortal.value) {
appApi.list().then(res => {
portalApps.value = res.data.data || []
currentApp.value = portalApps.value.find(item => item.appKey === appKey.value) ?? currentApp.value
})
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script>
<style scoped>
@ -367,10 +228,11 @@ onMounted(() => {
.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; }
.license-summary { margin-bottom: 16px; }
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.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; }
.user-cell { display: flex; flex-direction: column; gap: 2px; line-height: 1.35; }
.user-cell small { color: #909399; font-size: 12px; }
.stack-cell { display: flex; flex-direction: column; gap: 2px; line-height: 1.35; }
.stack-cell small { color: #909399; font-size: 12px; }
</style>