feat: 完成 opsx-licence 许可证管理平台全栈实现

基于 opsx-web-lite 框架(Go+Gin+Vue3+Element Plus)构建许可证管理服务器,
业务域参考 Licence 服务器 Web 版。

【后端 - 4层架构】
- 认证:单 Admin 表 + JWT(HS256)
- 核心授权域:Product / Customer / License / Activation
  · License 生成/验证/激活/机器绑定/心跳/续期/吊销
  · 客户端契约接口:/api/v1/license/{verify,activate,deactivate,heartbeat}
- 定价订单:Plan / Order / 支付网关配置
- 防重复购买:CustomerEntitlement + 幂等支付 webhook + 手动发放
- 分析审计:Dashboard 统计 / Analytics / AuditLog / Setting 管理

【前端】
- Dashboard(ECharts 图表)、产品、客户、License、计划、订单、权益、日志、设置
- 9 个完整 CRUD 业务页面 + 登录/布局

【部署】
- SQLite + 单二进制(//go:embed 嵌入前端)
- 开发:./run.sh start(前端5181/后端9001)
- 生产:./run.sh build && ./run.sh binary start
- 默认账号:admin/admin

Author: Logan.Li
This commit is contained in:
Logan.Li committed 2026-08-07 15:02:00 +08:00
commit 4ae318335c
88 files changed
+10562

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
<template>
<div>
<el-card shadow="never">
<div class="toolbar">
<div class="filters">
<el-input v-model="keyword" placeholder="搜索 License Key" clearable style="width: 220px" @keyup.enter="loadData" @clear="loadData" />
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="loadData">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-select v-model="productFilter" placeholder="产品" clearable filterable style="width: 160px" @change="loadData">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</div>
<div>
<el-button @click="exportData">导出 CSV</el-button>
<el-button type="primary" @click="openCreate">生成 License</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="license_key" label="License Key" min-width="220" />
<el-table-column prop="customer_name" label="客户" min-width="100" />
<el-table-column prop="product_name" label="产品" min-width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="end_date" label="到期时间" width="110" />
<el-table-column label="剩余" width="70">
<template #default="{ row }">
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left }}天</span>
</template>
</el-table-column>
<el-table-column label="激活" width="80">
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">详情</el-button>
<el-button size="small" @click="renew(row)">续期</el-button>
<el-button size="small" type="warning" @click="toggle(row, row.status === 'suspended' ? 'active' : 'suspended')">
{{ row.status === 'suspended' ? '恢复' : '暂停' }}
</el-button>
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize"
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
@current-change="loadData"
/>
</el-card>
<!-- 生成 License -->
<el-dialog v-model="createVisible" title="生成 License" width="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable placeholder="选择客户" style="width: 100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="产品" prop="product_id">
<el-select v-model="form.product_id" filterable placeholder="选择产品" style="width: 100%">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="有效期" prop="duration">
<el-select v-model="form.duration" style="width: 100%">
<el-option v-for="d in durations" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<el-form-item label="最大激活数"><el-input-number v-model="form.max_activations" :min="1" /></el-form-item>
<el-form-item label="生成数量"><el-input-number v-model="form.count" :min="1" :max="100" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="submitCreate">生成</el-button>
</template>
</el-dialog>
<!-- 详情/激活记录 -->
<el-dialog v-model="detailVisible" title="License 详情" width="680px">
<el-descriptions :column="2" border size="small" v-if="detail.license">
<el-descriptions-item label="License Key">{{ detail.license.license_key }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.license.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.license.customer_name }}</el-descriptions-item>
<el-descriptions-item label="产品">{{ detail.license.product_name }}</el-descriptions-item>
<el-descriptions-item label="到期">{{ detail.license.end_date }}</el-descriptions-item>
<el-descriptions-item label="剩余天数">{{ detail.license.days_left }} 天</el-descriptions-item>
</el-descriptions>
<h4 style="margin: 16px 0 8px">激活设备</h4>
<el-table :data="detail.activations" size="small" stripe>
<el-table-column prop="device_id" label="设备 ID" min-width="120" />
<el-table-column prop="device_name" label="设备名" min-width="120" />
<el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="在线" width="70">
<template #default="{ row }">
<el-tag :type="row.is_online ? 'success' : 'info'" size="small">{{ row.is_online ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_seen_at" label="最后心跳" width="160" />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="handleForceOffline(row)">下线</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense,
activateLicense, getLicense, forceOffline, exportLicenses,
} from '@/api/license'
import { getAllProducts } from '@/api/product'
import { getAllCustomers } from '@/api/customer'
const list = ref([])
const products = ref([])
const customers = ref([])
const loading = ref(false)
const keyword = ref('')
const statusFilter = ref('')
const productFilter = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const statusOptions = [
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
{ label: '已过期', value: 'expired' }, { label: '已吊销', value: 'revoked' }, { label: '已暂停', value: 'suspended' },
]
const durations = [
{ label: '1 个月', value: 30 }, { label: '3 个月', value: 90 }, { label: '6 个月', value: 180 },
{ label: '1 年', value: 365 }, { label: '2 年', value: 730 }, { label: '3 年', value: 1095 },
{ label: '5 年', value: 1825 }, { label: '永久', value: 0 },
]
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
function statusTag(s) {
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || ''
}
const createVisible = ref(false)
const formRef = ref(null)
const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
const rules = {
customer_id: [{ required: true, message: '请选择客户' }],
product_id: [{ required: true, message: '请选择产品' }],
duration: [{ required: true, message: '请选择有效期' }],
}
const detailVisible = ref(false)
const detail = reactive({ license: null, activations: [] })
async function loadData() {
loading.value = true
try {
const res = await getLicenses({
page: page.value, page_size: pageSize.value,
keyword: keyword.value, status: statusFilter.value, product_id: productFilter.value,
})
list.value = res.data.list || []
total.value = res.data.total
} finally { loading.value = false }
}
function openCreate() {
Object.assign(form, { customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
createVisible.value = true
}
async function submitCreate() {
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
const res = await createLicense(form)
ElMessage.success(`成功生成 ${res.data.length} 个 License`)
createVisible.value = false
loadData()
} catch { /* */ }
})
}
async function viewDetail(row) {
const res = await getLicense(row.id)
detail.license = res.data.license
detail.activations = res.data.activations || []
detailVisible.value = true
}
async function renew(row) {
const { value } = await ElMessageBox.prompt('续期天数(0=永久)', '续期', {
inputType: 'number', inputValue: '365', inputValidator: (v) => v !== '' && v >= 0,
})
await renewLicense(row.id, parseInt(value))
loadData()
}
async function revoke(row) {
const { value } = await ElMessageBox.prompt('吊销原因', '吊销 License', { inputType: 'textarea' })
await revokeLicense(row.id, value || '')
loadData()
}
async function toggle(row, status) {
if (status === 'active') await activateLicense(row.id)
else await suspendLicense(row.id)
loadData()
}
async function handleForceOffline(act) {
await ElMessageBox.confirm(`确定强制下线设备「${act.device_name || act.device_id}」?`, '提示', { type: 'warning' })
await forceOffline(detail.license.id, act.id)
// 刷新详情 // Logan.Li
const res = await getLicense(detail.license.id)
detail.activations = res.data.activations || []
}
async function exportData() {
try {
const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value })
const url = window.URL.createObjectURL(new Blob([res]))
const a = document.createElement('a')
a.href = url
a.download = 'licenses.csv'
a.click()
window.URL.revokeObjectURL(url)
} catch { /* */ }
}
onMounted(async () => {
const [p, c] = await Promise.all([getAllProducts(), getAllCustomers()])
products.value = p.data
customers.value = c.data
loadData()
})
</script>
<style scoped>
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
.filters { display: flex; gap: 10px; }
</style>