mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat(web): add LicenseList page with el-tree panel (280px), stats, filter, table, issue dialog
This commit is contained in:
@@ -81,7 +81,7 @@ const menuItems = [
|
||||
{ path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","DEVELOPER"] },
|
||||
{ path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","DEVELOPER"] },
|
||||
{ path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","DEVELOPER"] },
|
||||
{ path: "/license-compare", icon: "🛡️", label: "许可证管理", badge: "NEW", roles: ["SYS_ADMIN","DEVELOPER"] },
|
||||
{ path: "/licenses", icon: "🛡️", label: "许可证管理", badge: "NEW", roles: ["SYS_ADMIN","DEVELOPER"] },
|
||||
{ path: "/callbacks", icon: "📨", label: "Callback 收件箱", roles: ["SYS_ADMIN","OPS"] },
|
||||
{ path: "/integration/environments", icon: "🌐", label: "集成环境", roles: ["SYS_ADMIN","DEVELOPER","OPS"] },
|
||||
{ path: "/integration/product-lines", icon: "📱", label: "产品线", roles: ["SYS_ADMIN","DEVELOPER","OPS"] },
|
||||
|
||||
@@ -110,6 +110,12 @@ const routes = [
|
||||
component: () => import("../views/LayoutCompareView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||
},
|
||||
{
|
||||
path: "licenses",
|
||||
name: "licenses",
|
||||
component: () => import("../views/LicenseList.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "许可证管理" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") },
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="license-page">
|
||||
<!-- ===== LEFT TREE PANEL 280px ===== -->
|
||||
<div class="tree-panel">
|
||||
<div class="tree-search">
|
||||
<span class="tree-search-icon">🔍</span>
|
||||
<input v-model="treeFilter" class="tree-search-input" placeholder="搜索客户/项目..." />
|
||||
</div>
|
||||
<div class="tree-body">
|
||||
<el-tree
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
:filter-node-method="filterNode"
|
||||
:expand-on-click-node="false"
|
||||
highlight-current
|
||||
@node-click="onTreeNodeClick"
|
||||
ref="treeRef"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="tree-node">
|
||||
<span class="tree-node-icon">{{ data.type === 'tenant' ? '🏢' : data.type === 'project' ? '📁' : '📄' }}</span>
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<span v-if="data.count" class="tree-node-count">{{ data.count }}</span>
|
||||
<el-tag v-if="data.type === 'contract'" size="small" :type="data.status==='active'?'success':'info'">{{ data.status==='active'?'活跃':'草稿' }}</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== RIGHT CONTENT ===== -->
|
||||
<div class="main-panel">
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row">
|
||||
<div v-for="s in stats" :key="s.label" class="stat-card">
|
||||
<div class="stat-value">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card filter-bar">
|
||||
<div class="filter-left">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input v-model="keyword" class="search-input" placeholder="搜索许可证 ID / 租户..." @keyup.enter="load" />
|
||||
</div>
|
||||
<select v-model="filterGrantType" class="form-select">
|
||||
<option value="">全部授权类型</option><option value="perpetual">永久</option><option value="subscription">订阅</option><option value="trial">试用</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="form-select">
|
||||
<option value="">全部状态</option><option value="active">活跃</option><option value="revoked">已吊销</option><option value="expired">已过期</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" @click="load">查询</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-create" @click="dialogVisible = true">+ 签发许可证</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card table-card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>许可证 ID</th><th>租户</th><th>产品</th><th>类型</th><th>终端</th><th>宽限</th><th>状态</th><th>签发时间</th><th class="col-action">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in filteredRows" :key="r.licenseId" @click="showDetail(r)" class="table-row">
|
||||
<td class="cell-id">{{ r.licenseId }}</td>
|
||||
<td>{{ r.tenantId }}</td>
|
||||
<td class="cell-secondary">{{ r.product }}</td>
|
||||
<td>{{ typeLabel(r.grantType) }}</td>
|
||||
<td class="cell-center">{{ r.maxDevices }}</td>
|
||||
<td class="cell-center">{{ r.offlineGraceDays }}天</td>
|
||||
<td><span :class="['tag', 'tag-'+r.status]">{{ statusLabel(r.status) }}</span></td>
|
||||
<td class="cell-time">{{ r.issuedAt?.replace('T',' ').slice(0,16) }}</td>
|
||||
<td class="cell-action" @click.stop>
|
||||
<button class="btn-link" @click="showDetail(r)">详情</button>
|
||||
<button v-if="r.status==='active'" class="btn-link btn-danger" @click="revoke(r)">吊销</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ filteredRows.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== ISSUE DIALOG ===== -->
|
||||
<el-dialog v-model="dialogVisible" title="签发许可证" width="560px" destroy-on-close>
|
||||
<el-form :model="form" label-width="110px" label-position="right">
|
||||
<el-form-item label="租户 ID">
|
||||
<el-input v-model="form.tenantId" placeholder="如 craftlabs-wharf-prod" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品名称">
|
||||
<el-input v-model="form.product" placeholder="如 wharf-inspection-v2" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="授权类型" label-width="80px">
|
||||
<el-select v-model="form.grantType" style="width:100%">
|
||||
<el-option label="订阅" value="subscription" /><el-option label="永久" value="perpetual" /><el-option label="试用" value="trial" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="有效期(天)" label-width="85px">
|
||||
<el-input-number v-model="form.validDays" :min="1" :max="3650" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大终端" label-width="80px">
|
||||
<el-input-number v-model="form.maxDevices" :min="1" :max="1000" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="离线宽限(天)" label-width="85px">
|
||||
<el-input-number v-model="form.offlineGraceDays" :min="0" :max="365" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="特性开关">
|
||||
<el-checkbox-group v-model="form.features">
|
||||
<el-checkbox label="advanced_analytics">高级分析</el-checkbox>
|
||||
<el-checkbox label="real_time_monitor">实时监控</el-checkbox>
|
||||
<el-checkbox label="api_export">API 导出</el-checkbox>
|
||||
<el-checkbox label="multi_tenant">多租户</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="issue">签发</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ===== DETAIL DIALOG ===== -->
|
||||
<el-dialog v-model="detailVisible" title="许可证详情" width="520px">
|
||||
<el-descriptions v-if="currentLicense" :column="1" border size="small">
|
||||
<el-descriptions-item label="许可证 ID" label-class-name="desc-label"><code style="font-size:12px">{{ currentLicense.licenseId }}</code></el-descriptions-item>
|
||||
<el-descriptions-item label="租户">{{ currentLicense.tenantId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品">{{ currentLicense.product }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授权类型">{{ typeLabel(currentLicense.grantType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大终端">{{ currentLicense.maxDevices }}</el-descriptions-item>
|
||||
<el-descriptions-item label="离线宽限期">{{ currentLicense.offlineGraceDays }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="状态"><el-tag :type="currentLicense.status==='active'?'success':'danger'">{{ statusLabel(currentLicense.status) }}</el-tag></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button v-if="currentLicense?.status==='active'" type="danger" @click="revoke(currentLicense); detailVisible = false">吊销</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ── Tree Data ──
|
||||
const treeRef = ref(null)
|
||||
const treeFilter = ref('')
|
||||
const treeProps = { children: 'children', label: 'label' }
|
||||
const treeData = ref([
|
||||
{ id:'t1', label:'创飞码头项目', type:'tenant', count:12, children:[
|
||||
{ id:'t1-p1', label:'码头南沙二期', type:'project', count:6, children:[
|
||||
{ id:'t1-p1-c1', label:'CT-2026-005', type:'contract', status:'active' },
|
||||
{ id:'t1-p1-c2', label:'CT-2026-003', type:'contract', status:'active' },
|
||||
]},
|
||||
{ id:'t1-p2', label:'码头检测一期', type:'project', count:4, children:[
|
||||
{ id:'t1-p2-c1', label:'CT-2026-001', type:'contract', status:'active' },
|
||||
]},
|
||||
]},
|
||||
{ id:'t2', label:'学校合作项目', type:'tenant', count:8, children:[
|
||||
{ id:'t2-p1', label:'深圳南山校区', type:'project', count:5, children:[
|
||||
{ id:'t2-p1-c1', label:'CT-2026-008', type:'contract', status:'active' },
|
||||
]},
|
||||
{ id:'t2-p2', label:'广州天河校区', type:'project', count:3, children:[] },
|
||||
]},
|
||||
{ id:'t3', label:'实验室项目', type:'tenant', count:5, children:[] },
|
||||
{ id:'t4', label:'内部测试', type:'tenant', count:3, children:[] },
|
||||
])
|
||||
|
||||
watch(treeFilter, (v) => { treeRef.value?.filter(v) })
|
||||
function filterNode(value, data) { if (!value) return true; return data.label.toLowerCase().includes(value.toLowerCase()) }
|
||||
function onTreeNodeClick(data) {
|
||||
if (data.type === 'tenant') activeFilter.value = data.label
|
||||
else if (data.type === 'contract') { keyword.value = data.label; load() }
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
const stats = reactive([
|
||||
{ label:'总许可证', value:47 },{ label:'活跃', value:38 },{ label:'已吊销', value:6 },{ label:'已过期', value:3 }
|
||||
])
|
||||
|
||||
// ── Filter & Table ──
|
||||
const keyword = ref(''), filterGrantType = ref(''), filterStatus = ref(''), activeFilter = ref('')
|
||||
const rows = ref([
|
||||
{ licenseId:'01JQNX...a1b2', tenantId:'craftlabs-wharf-prod', product:'wharf-inspection-v2', grantType:'perpetual', maxDevices:5, offlineGraceDays:7, status:'active', issuedAt:'2026-05-10T08:00:00Z' },
|
||||
{ licenseId:'01JQNY...c3d4', tenantId:'school-district-west', product:'school-edge-ai', grantType:'subscription', maxDevices:20, offlineGraceDays:3, status:'active', issuedAt:'2026-05-08T14:30:00Z' },
|
||||
{ licenseId:'01JQNZ...e5f6', tenantId:'floating-project-042', product:'floating-license', grantType:'trial', maxDevices:2, offlineGraceDays:1, status:'expired', issuedAt:'2026-01-15T10:00:00Z' },
|
||||
{ licenseId:'01JQPA...g7h8', tenantId:'craftlabs-demo', product:'demo-license', grantType:'subscription', maxDevices:10, offlineGraceDays:7, status:'revoked', issuedAt:'2026-03-20T09:15:00Z' },
|
||||
{ licenseId:'01JQPB...i9j0', tenantId:'internal-test', product:'test-license', grantType:'perpetual', maxDevices:1, offlineGraceDays:0, status:'active', issuedAt:'2026-05-15T16:45:00Z' },
|
||||
])
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
let r = rows.value
|
||||
if (keyword.value) { const kw = keyword.value.toLowerCase(); r = r.filter(x => x.licenseId.toLowerCase().includes(kw) || x.tenantId.toLowerCase().includes(kw) || x.product.toLowerCase().includes(kw)) }
|
||||
if (filterGrantType.value) r = r.filter(x => x.grantType === filterGrantType.value)
|
||||
if (filterStatus.value) r = r.filter(x => x.status === filterStatus.value)
|
||||
return r
|
||||
})
|
||||
|
||||
function load() {}
|
||||
|
||||
// ── Dialogs ──
|
||||
const dialogVisible = ref(false), detailVisible = ref(false), currentLicense = ref(null)
|
||||
const form = reactive({ tenantId:'', product:'default', grantType:'subscription', validDays:365, maxDevices:5, offlineGraceDays:7, features:['advanced_analytics','api_export'] })
|
||||
|
||||
function issue() {
|
||||
const id = '01JQ' + Math.random().toString(36).slice(2,10).toUpperCase()
|
||||
rows.value.unshift({ licenseId:id, tenantId:form.tenantId||'new-tenant', product:form.product||'default', grantType:form.grantType, maxDevices:form.maxDevices, offlineGraceDays:form.offlineGraceDays, status:'active', issuedAt:new Date().toISOString() })
|
||||
stats[0].value++; stats[1].value++
|
||||
ElMessage.success('签发成功: ' + id)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
function showDetail(r) { currentLicense.value = r; detailVisible.value = true }
|
||||
function revoke(r) {
|
||||
ElMessageBox.confirm(`确定吊销许可证「${r.licenseId}」?`, '危险操作', { type:'warning', confirmButtonText:'确认吊销', confirmButtonClass:'el-button--danger' })
|
||||
.then(() => { r.status = 'revoked'; stats[1].value--; stats[2].value++; ElMessage.success('已吊销') }).catch(() => {})
|
||||
}
|
||||
|
||||
function typeLabel(t) { return ({subscription:'订阅', perpetual:'永久', trial:'试用'})[t] || t }
|
||||
function statusLabel(s) { return ({active:'活跃', revoked:'已吊销', expired:'已过期'})[s] || s }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== PAGE LAYOUT ===== */
|
||||
.license-page { display: flex; height: 100%; gap: 0; }
|
||||
|
||||
/* ===== TREE PANEL 280px ===== */
|
||||
.tree-panel { width: 280px; flex-shrink: 0; background: #fff; border-right: 1px solid #E8ECF1; display: flex; flex-direction: column; }
|
||||
.tree-search { padding: 12px 14px; }
|
||||
.tree-search-icon { color: #C0C4CC; font-size: 12px; position: absolute; margin: 7px 0 0 8px; }
|
||||
.tree-search-input { width: 100%; border: 1px solid #E0E3E8; border-radius: 6px; padding: 6px 10px 6px 28px; font-size: 12px; outline: none; background: #F8F9FB; font-family: inherit; }
|
||||
.tree-search-input:focus { border-color: #2C3E6B; background: #fff; }
|
||||
.tree-body { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
|
||||
.tree-node { display: flex; align-items: center; gap: 6px; font-size: 13px; width: 100%; }
|
||||
.tree-node-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.tree-node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-node-count { font-size: 11px; color: #C0C4CC; flex-shrink: 0; }
|
||||
|
||||
/* ===== MAIN PANEL ===== */
|
||||
.main-panel { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.stat-card { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #E8ECF1; text-align: center; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #303133; }
|
||||
.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
|
||||
.card { background: #fff; border-radius: 6px; border: 1px solid #E8ECF1; padding: 14px 16px; }
|
||||
|
||||
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.filter-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.search-box { position: relative; }
|
||||
.search-icon { position: absolute; left: 10px; top: 7px; color: #C0C4CC; font-size: 13px; }
|
||||
.search-input { border: 1px solid #E0E3E8; border-radius: 4px; padding: 6px 10px 6px 30px; font-size: 13px; outline: none; width: 220px; font-family: inherit; background: #F8F9FB; }
|
||||
.search-input:focus { border-color: #2C3E6B; background: #fff; }
|
||||
.form-select { border: 1px solid #E0E3E8; border-radius: 4px; padding: 6px 10px; font-size: 13px; color: #606266; background: #fff; font-family: inherit; }
|
||||
|
||||
.btn { border: none; padding: 6px 14px; border-radius: 4px; font-size: 13px; cursor: pointer; font-weight: 500; font-family: inherit; }
|
||||
.btn-primary { background: #2C3E6B; color: #fff; }
|
||||
.btn-primary:hover { background: #3D5A99; }
|
||||
.btn-create { box-shadow: 0 2px 6px rgba(44,62,107,.2); }
|
||||
.btn-link { background: none; border: none; color: #2C3E6B; cursor: pointer; font-size: 13px; padding: 0; margin-right: 10px; font-family: inherit; }
|
||||
.btn-danger { color: #F56C6C; }
|
||||
|
||||
.table-card { padding: 0; overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th { padding: 10px 12px; text-align: left; font-weight: 600; font-size: 12px; color: #2C3E6B; background: #F2F5FC; border-bottom: 1px solid #E8ECF1; white-space: nowrap; }
|
||||
.data-table td { padding: 9px 12px; border-bottom: 1px solid #F2F5FC; }
|
||||
.table-row { cursor: pointer; transition: background 0.1s; }
|
||||
.table-row:hover { background: #F8F9FB; }
|
||||
.cell-id { font-family: monospace; font-size: 12px; color: #303133; }
|
||||
.cell-secondary { color: #606266; }
|
||||
.cell-center { text-align: center; }
|
||||
.cell-time { color: #909399; font-size: 12px; }
|
||||
.cell-action { white-space: nowrap; }
|
||||
.col-action { width: 110px; }
|
||||
.table-footer { padding: 10px 16px; display: flex; justify-content: flex-end; font-size: 12px; color: #909399; border-top: 1px solid #F2F5FC; }
|
||||
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: 500; }
|
||||
.tag-active { background: #E6F7EE; color: #1A7A3A; border: 1px solid #A8E6C1; }
|
||||
.tag-revoked { background: #FEF0F0; color: #F56C6C; border: 1px solid #FBC4C4; }
|
||||
.tag-expired { background: #f4f4f5; color: #909399; border: 1px solid #e9e9eb; }
|
||||
|
||||
:deep(.desc-label) { font-weight: 600; color: #606266; width: 100px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user