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:
commit
4ae318335c
88 files changed
+10562
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user