Files
craftlabs-authorization-sdk/web/delivery-platform-ui/src/views/LicenseList.vue
T

301 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>