Files
opsx-licence/frontend/src/views/Licenses.vue
T

322 lines
13 KiB
Vue

<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
type="danger"
plain
:disabled="selectedRows.length === 0"
@click="batchDelete"
>批量删除</el-button>
<el-button @click="exportData">导出 CSV</el-button>
<el-button type="primary" @click="openCreate">生成 License</el-button>
</div>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。仅「已吊销 / 已过期」可删除;有效授权会自动跳过。"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<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="90">
<template #default="{ row }">
<el-tooltip
v-if="row.abuse_suspected"
:content="`不重复 IP ${row.distinct_ips} 个,近 7 天新增设备 ${row.new_devices_7d} 台`"
placement="top"
>
<el-tag type="danger" size="small">疑似共享</el-tag>
</el-tooltip>
<span v-else style="color: var(--el-text-color-placeholder)">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" 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>
<el-button
v-if="canDelete(row)"
size="small"
type="danger"
plain
@click="removeOne(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, deleteLicense, batchDeleteLicenses,
} 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 selectedRows = ref([])
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] || ''
}
// 仅已吊销 / 已过期(含剩余天数<=0)可删除 // Logan.Li
function canDelete(row) {
return row.status === 'revoked' || row.status === 'expired' || row.days_left <= 0
}
function onSelectionChange(rows) {
selectedRows.value = rows || []
}
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,
channel: 'online',
})
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 removeOne(row) {
if (!canDelete(row)) {
ElMessage.warning('仅已吊销或已过期的 License 可删除')
return
}
await ElMessageBox.confirm(`确认删除 License「${row.license_key}」?此操作不可恢复。`, '删除', { type: 'warning' })
await deleteLicense(row.id)
loadData()
}
async function batchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
const deletable = selectedRows.value.filter(canDelete).length
await ElMessageBox.confirm(
`已选 ${ids.length} 条,其中约 ${deletable} 条可删(仅已吊销/已过期);其余将跳过。确认继续?`,
'批量删除',
{ type: 'warning' },
)
const res = await batchDeleteLicenses(ids)
const data = res.data || {}
ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`)
selectedRows.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)
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, channel: 'online' })
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>