322 lines
13 KiB
Vue
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>
|