feat: License 支持筛选多选批量删除
仅已吊销或已过期可删,有效授权自动跳过;单条删除同步限制。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f26e787cfc
commit
d0e1ca3729
6 files changed
+152
-9
No files matched your search
@@ -135,7 +135,7 @@ func (ctrl *LicenseController) Update(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": lic})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": lic})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除 License // Logan.Li
|
// Delete 删除 License(仅已吊销/已过期) // Logan.Li
|
||||||
func (ctrl *LicenseController) Delete(c *gin.Context) {
|
func (ctrl *LicenseController) Delete(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
lic, err := ctrl.svc.Delete(uint(id))
|
lic, err := ctrl.svc.Delete(uint(id))
|
||||||
@@ -147,6 +147,32 @@ func (ctrl *LicenseController) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDelete 批量删除:非吊销/未到期自动跳过 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) BatchDelete(c *gin.Context) {
|
||||||
|
var req dto.LicenseBatchDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: 请提供 ids"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := ctrl.svc.BatchDelete(req.IDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "batch_delete", "license", 0, "", "", "",
|
||||||
|
"批量删除 License deleted="+strconv.Itoa(result.Deleted)+" skipped="+strconv.Itoa(result.Skipped))
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"message": "批量删除完成",
|
||||||
|
"data": dto.LicenseBatchDeleteResponse{
|
||||||
|
Deleted: result.Deleted,
|
||||||
|
Skipped: result.Skipped,
|
||||||
|
Failed: result.Failed,
|
||||||
|
SkippedIDs: result.SkippedIDs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Renew 续期 // Logan.Li
|
// Renew 续期 // Logan.Li
|
||||||
func (ctrl *LicenseController) Renew(c *gin.Context) {
|
func (ctrl *LicenseController) Renew(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ type LicenseRenew struct {
|
|||||||
Duration int `json:"duration" binding:"required"`
|
Duration int `json:"duration" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseRevoke 吊销请求 // Logan.Li
|
// LicenseBatchDeleteRequest 批量删除请求 // Logan.Li
|
||||||
type LicenseRevoke struct {
|
type LicenseBatchDeleteRequest struct {
|
||||||
Reason string `json:"reason"`
|
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseBatchDeleteResponse 批量删除响应 // Logan.Li
|
||||||
|
type LicenseBatchDeleteResponse struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
SkippedIDs []uint `json:"skipped_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseResponse License 响应 // Logan.Li
|
// LicenseResponse License 响应 // Logan.Li
|
||||||
|
|||||||
@@ -161,18 +161,72 @@ func (s *LicenseService) Update(id uint, updates map[string]interface{}) (*dto.L
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除 License(软删除) // Logan.Li
|
// Delete 删除 License(仅允许已吊销或已过期;软删除) // Logan.Li
|
||||||
func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.First(&lic, id).Error; err != nil {
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
return nil, errors.New("License 不存在")
|
return nil, errors.New("License 不存在")
|
||||||
}
|
}
|
||||||
|
if !isDeletableLicense(lic) {
|
||||||
|
return nil, errors.New("仅已吊销或已过期的 License 可删除")
|
||||||
|
}
|
||||||
if err := s.db.Delete(&model.License{}, id).Error; err != nil {
|
if err := s.db.Delete(&model.License{}, id).Error; err != nil {
|
||||||
return nil, errors.New("删除 License 失败")
|
return nil, errors.New("删除 License 失败")
|
||||||
}
|
}
|
||||||
return &lic, nil
|
return &lic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDeleteResult 批量删除结果 // Logan.Li
|
||||||
|
type BatchDeleteResult struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
SkippedIDs []uint `json:"skipped_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete 批量删除:仅删已吊销/已过期,其余跳过 // Logan.Li
|
||||||
|
func (s *LicenseService) BatchDelete(ids []uint) (*BatchDeleteResult, error) {
|
||||||
|
result := &BatchDeleteResult{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
seen := map[uint]struct{}{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
result.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isDeletableLicense(lic) {
|
||||||
|
result.Skipped++
|
||||||
|
result.SkippedIDs = append(result.SkippedIDs, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.db.Delete(&model.License{}, id).Error; err != nil {
|
||||||
|
result.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Deleted++
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeletableLicense 仅吊销或到期可物理清理(软删) // Logan.Li
|
||||||
|
func isDeletableLicense(lic model.License) bool {
|
||||||
|
if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusExpired {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return utils.IsLicenseExpired(lic.EndDate)
|
||||||
|
}
|
||||||
|
|
||||||
// Renew 续期(仅在线证;离线证签名自包含,到期后应重新签发) // Logan.Li
|
// Renew 续期(仅在线证;离线证签名自包含,到期后应重新签发) // Logan.Li
|
||||||
func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) {
|
func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
register(licenses, "POST", "/offline", licenseController.IssueOffline)
|
register(licenses, "POST", "/offline", licenseController.IssueOffline)
|
||||||
register(licenses, "GET", "/offline/public-key", licenseController.OfflinePublicKey)
|
register(licenses, "GET", "/offline/public-key", licenseController.OfflinePublicKey)
|
||||||
register(licenses, "GET", "/export", licenseController.Export)
|
register(licenses, "GET", "/export", licenseController.Export)
|
||||||
|
register(licenses, "POST", "/batch-delete", licenseController.BatchDelete)
|
||||||
register(licenses, "GET", "/:id", licenseController.Get)
|
register(licenses, "GET", "/:id", licenseController.Get)
|
||||||
register(licenses, "PUT", "/:id", licenseController.Update)
|
register(licenses, "PUT", "/:id", licenseController.Update)
|
||||||
register(licenses, "DELETE", "/:id", licenseController.Delete)
|
register(licenses, "DELETE", "/:id", licenseController.Delete)
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function deleteLicense(id) {
|
|||||||
return request({ url: `/licenses/${id}`, method: 'delete' })
|
return request({ url: `/licenses/${id}`, method: 'delete' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function batchDeleteLicenses(ids) {
|
||||||
|
return request({ url: '/licenses/batch-delete', method: 'post', data: { ids } })
|
||||||
|
}
|
||||||
|
|
||||||
export function renewLicense(id, duration) {
|
export function renewLicense(id, duration) {
|
||||||
return request({ url: `/licenses/${id}/renew`, method: 'post', data: { duration } })
|
return request({ url: `/licenses/${id}/renew`, method: 'post', data: { duration } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="selectedRows.length === 0"
|
||||||
|
@click="batchDelete"
|
||||||
|
>批量删除</el-button>
|
||||||
<el-button @click="exportData">导出 CSV</el-button>
|
<el-button @click="exportData">导出 CSV</el-button>
|
||||||
<el-button type="primary" @click="openCreate">生成 License</el-button>
|
<el-button type="primary" @click="openCreate">生成 License</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,11 +27,12 @@
|
|||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。"
|
title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。仅「已吊销 / 已过期」可删除;有效授权会自动跳过。"
|
||||||
style="margin-bottom: 12px"
|
style="margin-bottom: 12px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-table :data="list" v-loading="loading" stripe>
|
<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="license_key" label="License Key" min-width="220" />
|
||||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||||
<el-table-column prop="product_name" label="产品" min-width="100" />
|
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||||
@@ -43,7 +50,7 @@
|
|||||||
<el-table-column label="激活" width="80">
|
<el-table-column label="激活" width="80">
|
||||||
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||||
<el-button size="small" @click="renew(row)">续期</el-button>
|
<el-button size="small" @click="renew(row)">续期</el-button>
|
||||||
@@ -51,6 +58,13 @@
|
|||||||
{{ row.status === 'suspended' ? '恢复' : '暂停' }}
|
{{ row.status === 'suspended' ? '恢复' : '暂停' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" @click="revoke(row)">吊销</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -126,7 +140,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense,
|
getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense,
|
||||||
activateLicense, getLicense, forceOffline, exportLicenses,
|
activateLicense, getLicense, forceOffline, exportLicenses, deleteLicense, batchDeleteLicenses,
|
||||||
} from '@/api/license'
|
} from '@/api/license'
|
||||||
import { getAllProducts } from '@/api/product'
|
import { getAllProducts } from '@/api/product'
|
||||||
import { getAllCustomers } from '@/api/customer'
|
import { getAllCustomers } from '@/api/customer'
|
||||||
@@ -141,6 +155,7 @@ const productFilter = ref('')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const selectedRows = ref([])
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
|
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
|
||||||
@@ -157,6 +172,15 @@ function statusTag(s) {
|
|||||||
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[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 createVisible = ref(false)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
||||||
@@ -220,6 +244,32 @@ async function revoke(row) {
|
|||||||
loadData()
|
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) {
|
async function toggle(row, status) {
|
||||||
if (status === 'active') await activateLicense(row.id)
|
if (status === 'active') await activateLicense(row.id)
|
||||||
else await suspendLicense(row.id)
|
else await suspendLicense(row.id)
|
||||||
|
|||||||
Reference in new issue
Block a user