16 Commits

Author SHA1 Message Date
shiran cae5551107 feat: 镜像管理新增mode模式字段支持(local/remote)
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 17:08:01 +08:00
shiran 4bf7c4857b feat: 用户详情代金券管理与优惠模块修复
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:46:57 +08:00
shiran 6f82e5e79d feat: 用户商品状态筛选与统计对接
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 39s
- 新增 getUserGoodsCount 接口对接,列表页/虚拟机列表页增加状态筛选与统计卡片

- 已删除/已到期商品适配及相关页面更新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:12:50 +08:00
shiran a8954bd85d fix: 修复用户详情页订单列表字段映射错误
Build and Deploy Vue3 / build (push) Successful in 4m30s
Build and Deploy Vue3 / deploy (push) Successful in 50s
- state替代status、CreatedAt替代created_at

- 新增订单类型列(create/renew/upgrade)与到期时间列

- 金额列显示续费价格、详情跳转改用row.id

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 21:37:49 +08:00
shiran bdf6dd9382 feat: 优惠管理合并重构与商品续费价格参数
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 39s
- 合并优惠码/代金券为商品管理下优惠管理页面,卡片化展示与过期遮罩

- 用户组新增优惠绑定,商品关联改用懒加载树选择器

- 商品/套餐表单新增 renew_price、renew_recommend_rebate、renew_fixed_price

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:06:23 +08:00
shiran 5e81d33285 refactor: 商品管理参数对齐与表单优化 - 移除冗余ProductList.vue - 表单新增table/soldOut/maxPerUser/sendNotice - 表单改Tab栏布局 - 修复attrs.phase改为range
Build and Deploy Vue3 / build (push) Successful in 1m35s
Build and Deploy Vue3 / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:12:06 +08:00
shiran 38c63cc451 feat: 邮箱平台管理与商品购买限制 - 新增邮箱平台主控服务管理(页面/API/路由/菜单) - 商品与套餐表单新增max_per_user单用户购买限制 - 邮件主控控制台跳转改为/ui/index.html?token=
Build and Deploy Vue3 / build (push) Successful in 1m47s
Build and Deploy Vue3 / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 15:18:29 +08:00
shiran 4180f73c53 feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s
- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:27:23 +08:00
shiran 3227a50f9a feat(vm): 虚拟机详情页新增 SSH 连接按钮
Build and Deploy Vue3 / build (push) Successful in 1m41s
Build and Deploy Vue3 / deploy (push) Failing after 32s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:33:32 +08:00
shiran 86794145f1 feat(admin): 新增短信平台管理功能
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 35s
- 新增短信主控服务和额度商品的API接口
- 添加短信平台管理菜单项,包含主控服务管理和额度商品管理子菜单
- 实现短信平台管理相关路由配置
- 创建短信额度商品管理页面,支持额度类型配置、商品管理等功
2026-06-07 18:25:13 +08:00
shiran 84769954c4 feat(system): 管理员权限页重构与用户选择器升级
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 36s
- 重构 PermissionAdmin.vue:卡片式权限类型选择、拥有者名称解析、过期标识

- getUserList API 改用 params 对象,支持 is_admin 筛选

- UserList 新增管理员/普通用户身份筛选

- UserListSelector 重构为卡片网格布局,选中角标、动画提示条

- UserSelector 搜索栏加入身份筛选

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:59:24 +08:00
shiran a827fc5c41 feat(system): 通知管理与文件选择器来源筛选
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 34s
- 新增通知管理(渠道卡片化、模板 CRUD、参数按钮插入)

- ImageSelector/AvatarSelector 增加上传来源 is_admin 筛选

- 宿主机详情页实时指标与硬件/网卡 IPv6 展示优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:38:47 +08:00
shiran 0829dc9ce4 feat(monitor): 监控时间选择器统一为相对时间+自定义范围双模式
Build and Deploy Vue3 / build (push) Successful in 2m32s
Build and Deploy Vue3 / deploy (push) Successful in 32s
- VmMonitor/VmDetail/UserVmDetail/HostDetail 四个页面统一改造
- 支持「最近」模式(动态计算时间范围)和「自定义」模式(固定日期范围)
- 每小时流量图表同步应用双模式选择器
- 移除旧的 monitorShortcuts 快捷方式

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:28:12 +08:00
shiran d01c4e2e34 fix(monitor): 虚拟机指标单位矫正与监控体验优化
Build and Deploy Vue3 / build (push) Successful in 1m28s
Build and Deploy Vue3 / deploy (push) Successful in 33s
VmMonitor: 同指标多VM合并为一张多折线图;net_rx/net_tx按Bytes/s直接使用不再差分;时间选择器改为相对时间动态计算;新增自动刷新。VmDetail/UserVmDetail: 磁盘IOPS改为磁盘IO速率;磁盘I/O改为磁盘读写量;网络流量改为网络速率。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:13:49 +08:00
shiran f667fe420a feat(host-detail): 新增虚拟机监控 tab
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 32s
支持多选虚拟机和监控指标(CPU/内存/磁盘IO/网络),基于 metrics_history 接口渲染 ECharts 图表;时间范围可选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:59:53 +08:00
shiran 09fb74cd0d feat(kvm): 宿主机管理树表点击体验优化
Build and Deploy Vue3 / build (push) Successful in 1m25s
Build and Deploy Vue3 / deploy (push) Successful in 33s
宿主机组行整行可点击触发展开/折叠;宿主机行点击名称直接跳转详情页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:52:30 +08:00
55 changed files with 12286 additions and 6738 deletions
+33
View File
@@ -816,3 +816,36 @@ export const removeUserNetworkingNetwork = (data) => {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}) })
} }
/**
* ================================
* 回收站管理 API
* ================================
*/
/** 获取回收站列表 */
export const getRecycleBinList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/list', { params })
}
/** 获取回收站记录详情 */
export const getRecycleBinDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/detail', { params })
}
/** 从回收站恢复虚拟机 */
export const restoreRecycleBin = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/recycle_bin/restore', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 永久删除回收站记录 */
export const deleteRecycleBin = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/delete', { params })
}
/** 清空回收站(all=true 全部清空,all=false 仅清理到期) */
export const cleanRecycleBin = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/clean', { params })
}
+43
View File
@@ -0,0 +1,43 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
// ========== 邮件主控服务 ==========
export const getMailServiceList = (params) => {
return http2.get('/api/v1/admin/server/mail_service/list', { params })
}
export const getMailServiceDetail = (params) => {
return http2.get('/api/v1/admin/server/mail_service/detail', { params })
}
export const createMailService = (data) => {
return http2.post('/api/v1/admin/server/mail_service/create', data, formHeaders)
}
export const updateMailService = (data) => {
return http2.post('/api/v1/admin/server/mail_service/update', data, formHeaders)
}
export const deleteMailService = (data) => {
return http2.delete('/api/v1/admin/server/mail_service/delete', { data, ...formHeaders })
}
// ========== 邮件额度商品 ==========
export const getMailGoodsList = (params) => {
return http2.get('/api/v1/admin/server/mail_service/goods/list', { params })
}
export const createMailGoods = (data) => {
return http2.post('/api/v1/admin/server/mail_service/goods/create', data, formHeaders)
}
export const updateMailGoods = (data) => {
return http2.post('/api/v1/admin/server/mail_service/goods/update', data, formHeaders)
}
export const deleteMailGoods = (data) => {
return http2.delete('/api/v1/admin/server/mail_service/goods/delete', { data, ...formHeaders })
}
+48
View File
@@ -0,0 +1,48 @@
import { http2 } from '@/utils/request.js'
// ========== 通知渠道配置 ==========
/** 获取全部通知渠道配置列表(无分页) */
export const getNoticeChannelList = () => {
return http2.get('/api/v1/admin/notice_message/channel/list')
}
/** 修改通知渠道配置 */
export const updateNoticeChannel = (data) => {
return http2.post('/api/v1/admin/notice_message/channel/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 通知模板管理 ==========
/** 获取全部通知模板列表(无分页) */
export const getNoticeTemplateList = () => {
return http2.get('/api/v1/admin/notice_message/template/list')
}
/** 添加通知模板 */
export const addNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改通知模板 */
export const updateNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除通知模板 */
export const deleteNoticeTemplate = (params) => {
return http2.delete('/api/v1/admin/notice_message/template/delete', { params })
}
/** 使用默认参数预览渲染模板 */
export const previewNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/default_msg', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+4
View File
@@ -57,6 +57,10 @@ export const deleteProductGroup = (data) => {
export const getProductList = (params) => { export const getProductList = (params) => {
return http2.get('/api/v1/admin/good/goods/list', {params: params}) return http2.get('/api/v1/admin/good/goods/list', {params: params})
} }
/**获取商品详情(含商品参数列表) */
export const getProductDetail = (params) => {
return http2.get('/api/v1/admin/good/goods/detail', {params: params})
}
/**获取商品标签列表 */ /**获取商品标签列表 */
export const getProductTagList = () => { export const getProductTagList = () => {
return http2.get('/api/v1/admin/good/goods/tag_list') return http2.get('/api/v1/admin/good/goods/tag_list')
+145
View File
@@ -0,0 +1,145 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'multipart/form-data' } }
// ========== 短信主控服务 ==========
export const getSmsServiceList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/list', { params })
}
export const createSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/create', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const updateSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/update', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const deleteSmsService = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/delete', {
data,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const setDefaultSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/set_default', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
// ========== 短信额度商品 ==========
export const getSmsGoodsList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/goods/list', { params })
}
export const createSmsGoods = (data) => {
return http2.post('/api/v1/admin/server/sms_service/goods/create', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const updateSmsGoods = (data) => {
return http2.post('/api/v1/admin/server/sms_service/goods/update', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const deleteSmsGoods = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/goods/delete', {
data,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
// ========== 短信签名管理 ==========
export const getSmsSignatureList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/signature/list', { params })
}
export const getSmsSignatureDetail = (params) => {
return http2.get('/api/v1/admin/server/sms_service/signature/detail', { params })
}
export const createSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/create', data, formHeaders)
}
export const updateSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/update', data, formHeaders)
}
export const deleteSmsSignature = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/signature/delete', { data, ...formHeaders })
}
export const submitSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/submit', data, formHeaders)
}
export const approveSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/approve', data, formHeaders)
}
export const rejectSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/reject', data, formHeaders)
}
// ========== 短信模板管理 ==========
export const getSmsTemplateList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/list', { params })
}
export const getSmsTemplateDetail = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/detail', { params })
}
export const createSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/create', data, formHeaders)
}
export const updateSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/update', data, formHeaders)
}
export const deleteSmsTemplate = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/template/delete', { data, ...formHeaders })
}
export const submitSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/submit', data, formHeaders)
}
export const approveSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/approve', data, formHeaders)
}
export const rejectSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/reject', data, formHeaders)
}
// ========== 推荐模板管理 ==========
export const getSmsRecommendedTemplateList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/recommended/list', { params })
}
export const createSmsRecommendedTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/recommended/create', data, formHeaders)
}
export const updateSmsRecommendedTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/recommended/update', data, formHeaders)
}
export const deleteSmsRecommendedTemplate = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/template/recommended/delete', { data, ...formHeaders })
}
+2 -2
View File
@@ -34,8 +34,8 @@ export const getUserInfo = (data) => {
} }
/**获取用户列表 */ /**获取用户列表 */
export const getUserList = (data) => { export const getUserList = (params) => {
return http2.get('/api/v1/admin/user/user/list?page=' + data.page + '&count=' + data.count + '&key=' + data.key) return http2.get('/api/v1/admin/user/user/list', { params })
} }
/**更新用户信息 */ /**更新用户信息 */
+30
View File
@@ -0,0 +1,30 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
// ========== 用户组优惠(用户组 与 商品/商品组 绑定优惠) ==========
/** 获取用户组优惠列表(可按 user_group_id / good_id / good_group_id 过滤) */
export const getUserGroupDiscountList = (params) => {
return http2.get('/api/v1/admin/user_group/discount/list', { params })
}
/** 获取用户组优惠详情 */
export const getUserGroupDiscountDetail = (params) => {
return http2.get('/api/v1/admin/user_group/discount/detail', { params })
}
/** 添加用户组优惠(将用户组与商品/商品组绑定优惠) */
export const addUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/add', data, formHeaders)
}
/** 修改用户组优惠(可改绑商品/商品组) */
export const updateUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/update', data, formHeaders)
}
/** 删除用户组优惠(解绑用户组与商品/商品组的优惠) */
export const deleteUserGroupDiscount = (params) => {
return http2.delete('/api/v1/admin/user_group/discount/delete', { params })
}
+2
View File
@@ -103,6 +103,8 @@ export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networki
// ========== 用户商品 ========== // ========== 用户商品 ==========
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params }) export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
// 用户商品数量统计(正常 / 已删除 / 已到期),参数与列表接口一致但不分页
export const getUserGoodsCount = (params) => http2.get(`${GOODS_BASE}/count`, { params })
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params }) export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } }) export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } }) export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
+28 -11
View File
@@ -26,6 +26,10 @@
</el-button> </el-button>
</template> </template>
</el-input> </el-input>
<el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
<el-option label="管理员" :value="true" />
<el-option label="普通用户" :value="false" />
</el-select>
<el-button @click="handleReset" class="reset-btn"> <el-button @click="handleReset" class="reset-btn">
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
重置 重置
@@ -118,6 +122,7 @@ const selectedUser = ref(null)
const searchParams = reactive({ const searchParams = reactive({
key: '', key: '',
is_admin: undefined,
page: 1, page: 1,
count: 10 count: 10
}) })
@@ -161,6 +166,7 @@ const handleSearch = () => {
const handleReset = () => { const handleReset = () => {
searchParams.key = '' searchParams.key = ''
searchParams.is_admin = undefined
searchParams.page = 1 searchParams.page = 1
fetchUserList() fetchUserList()
} }
@@ -191,22 +197,25 @@ const confirmSelection = () => {
<style scoped> <style scoped>
.user-selector-content { .user-selector-content {
max-height: 500px; max-height: 520px;
overflow: hidden; overflow: hidden;
padding: 4px 0;
} }
.selector-search { .selector-search {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding-bottom: 16px; padding: 14px 16px;
border-bottom: 1px solid #ebeef5; background: linear-gradient(135deg, #f7f8fa 0%, #f0f2f5 100%);
border-radius: 10px;
border: 1px solid #ebeef5;
margin-bottom: 16px; margin-bottom: 16px;
} }
.search-input { .search-input {
flex: 1; flex: 1;
max-width: 350px; min-width: 0;
} }
.reset-btn { .reset-btn {
@@ -231,7 +240,7 @@ const confirmSelection = () => {
} }
.selector-pagination { .selector-pagination {
margin-top: 16px; margin-top: 14px;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -244,19 +253,26 @@ const confirmSelection = () => {
.selected-info { .selected-info {
margin-right: auto; margin-right: auto;
color: #606266; color: #409eff;
font-size: 13px; font-size: 13px;
font-weight: 500;
}
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
} }
:deep(.el-table__row) { :deep(.el-table__row) {
cursor: pointer; cursor: pointer;
transition: background-color .15s;
} }
:deep(.el-table__row):hover { :deep(.el-table__row:hover > td) {
background-color: #f5f7fa; background-color: #f0f7ff !important;
} }
:deep(.current-row) { :deep(.current-row > td) {
background-color: var(--el-color-primary-light-9) !important; background-color: var(--el-color-primary-light-9) !important;
} }
@@ -265,8 +281,9 @@ const confirmSelection = () => {
} }
:deep(.el-avatar) { :deep(.el-avatar) {
background-color: var(--el-color-primary-light-5); background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary));
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 600;
} }
</style> </style>
+29 -7
View File
@@ -13,9 +13,15 @@
<div class="file-list-container"> <div class="file-list-container">
<div class="file-list-header"> <div class="file-list-header">
<h4>文件列表</h4> <h4>文件列表</h4>
<el-button type="primary" @click="switchToUpload" :icon="Upload"> <div class="header-actions">
上传新文件 <el-select v-model="sourceFilter" placeholder="上传来源" clearable size="default" style="width: 120px" @change="handleSourceChange">
</el-button> <el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
<el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新文件
</el-button>
</div>
</div> </div>
<div class="file-grid" v-loading="loading"> <div class="file-grid" v-loading="loading">
<div <div
@@ -137,6 +143,7 @@ import { closeAllMessage } from '../../utils/message'
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const sourceFilter = ref(undefined)
// 监听 modelValue 变化 // 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => { watch(() => props.modelValue, (newVal) => {
@@ -144,6 +151,7 @@ import { closeAllMessage } from '../../utils/message'
if (newVal) { if (newVal) {
selectedId.value = props.currentCoverId selectedId.value = props.currentCoverId
currentPage.value = 1 currentPage.value = 1
sourceFilter.value = undefined
fetchFileList() fetchFileList()
} }
}) })
@@ -161,10 +169,11 @@ import { closeAllMessage } from '../../utils/message'
fileList.value = [] // 清空列表 fileList.value = [] // 清空列表
try { try {
const res = await getFileList({ const params = { page: currentPage.value, count: pageSize.value }
page: currentPage.value, if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
count: pageSize.value params.is_admin = sourceFilter.value
}) }
const res = await getFileList(params)
console.log("获取文件列表:", res) console.log("获取文件列表:", res)
@@ -219,6 +228,12 @@ import { closeAllMessage } from '../../utils/message'
fetchFileList() fetchFileList()
} }
// 来源筛选变化
const handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 切换到上传标签页 // 切换到上传标签页
const switchToUpload = () => { const switchToUpload = () => {
activeTab.value = 'upload' activeTab.value = 'upload'
@@ -312,6 +327,7 @@ import { closeAllMessage } from '../../utils/message'
fileList.value = [] fileList.value = []
currentPage.value = 1 currentPage.value = 1
total.value = 0 total.value = 0
sourceFilter.value = undefined
} }
// 确认选择 // 确认选择
@@ -347,6 +363,12 @@ import { closeAllMessage } from '../../utils/message'
margin: 0; margin: 0;
color: #303133; color: #303133;
} }
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.file-grid { .file-grid {
display: grid; display: grid;
+20 -5
View File
@@ -33,6 +33,10 @@
@input="handleSearch" @input="handleSearch"
style="width: 300px;" style="width: 300px;"
/> />
<el-select v-model="sourceFilter" placeholder="上传来源" clearable style="width: 130px; margin-left: 12px;" @change="handleSourceChange">
<el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
</div> </div>
<div class="file-grid" v-loading="loading"> <div class="file-grid" v-loading="loading">
@@ -178,6 +182,7 @@ const currentPage = ref(1)
const pageSize = ref(12) const pageSize = ref(12)
const total = ref(0) const total = ref(0)
const searchKeyword = ref('') const searchKeyword = ref('')
const sourceFilter = ref(undefined)
const pendingFiles = ref([]) // 待上传文件列表 const pendingFiles = ref([]) // 待上传文件列表
const uploading = ref(false) // 批量上传中 const uploading = ref(false) // 批量上传中
let fetchVersion = 0 // 防止 fetchFileList 竞态条件 let fetchVersion = 0 // 防止 fetchFileList 竞态条件
@@ -190,6 +195,7 @@ watch(() => props.modelValue, (newVal) => {
selectedIds.value = new Set() selectedIds.value = new Set()
currentPage.value = 1 currentPage.value = 1
searchKeyword.value = '' searchKeyword.value = ''
sourceFilter.value = undefined
fetchFileList() fetchFileList()
} }
}) })
@@ -224,10 +230,11 @@ const fetchFileList = async () => {
loading.value = true loading.value = true
try { try {
const res = await getFileList({ const params = { page: currentPage.value, count: pageSize.value }
page: currentPage.value, if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
count: pageSize.value params.is_admin = sourceFilter.value
}) }
const res = await getFileList(params)
// 如果有更新的请求发起,丢弃当前结果 // 如果有更新的请求发起,丢弃当前结果
if (currentFetchVersion !== fetchVersion) return if (currentFetchVersion !== fetchVersion) return
@@ -285,10 +292,15 @@ const handleTabClick = (tab) => {
// 处理搜索 // 处理搜索
const handleSearch = () => { const handleSearch = () => {
// 搜索时重置到第一页
currentPage.value = 1 currentPage.value = 1
} }
// 来源筛选变化
const handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 分页处理 // 分页处理
const handleSizeChange = (size) => { const handleSizeChange = (size) => {
pageSize.value = size pageSize.value = size
@@ -436,6 +448,7 @@ const handleClose = () => {
currentPage.value = 1 currentPage.value = 1
total.value = 0 total.value = 0
searchKeyword.value = '' searchKeyword.value = ''
sourceFilter.value = undefined
// 清理待上传文件的预览URL // 清理待上传文件的预览URL
pendingFiles.value.forEach(f => { pendingFiles.value.forEach(f => {
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
@@ -495,6 +508,8 @@ const handleConfirm = () => {
.filter-section { .filter-section {
margin-bottom: 20px; margin-bottom: 20px;
display: flex;
align-items: center;
} }
.file-grid { .file-grid {
@@ -36,13 +36,18 @@
<el-table-column prop="gateway" label="网关" width="130" /> <el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip /> <el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" /> <el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center"> <el-table-column label="占用" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag> <el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag> <el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
<el-tag v-else type="info" size="small">-</el-tag> <el-tag v-else type="info" size="small">-</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="禁用" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
</template>
</el-table-column>
</el-table> </el-table>
<div class="pagination-wrapper" v-if="total > 0"> <div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" <el-pagination v-model:current-page="page" v-model:page-size="pageSize"
@@ -118,6 +123,7 @@ const loadList = async () => {
const effectiveUsed = props.filterUsed || usedFilter.value const effectiveUsed = props.filterUsed || usedFilter.value
if (effectiveUsed) params.used = effectiveUsed if (effectiveUsed) params.used = effectiveUsed
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
params.disable = false
const res = await getNetworkList(params) const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
+173 -467
View File
@@ -1,166 +1,111 @@
<template> <template>
<el-dialog <el-dialog v-model="visible" title="选择用户" width="860px" append-to-body @close="handleClose" class="user-selector-dialog">
v-model="visible" <div class="uls-root">
title="选择用户" <el-tabs v-model="activeTab" @tab-click="handleTabClick" class="uls-tabs">
width="900px" <!-- ====== 选择用户 ====== -->
append-to-body <el-tab-pane name="selectUser">
@close="handleClose" <template #label>
> <span class="tab-lbl">
<div class="user-selector"> <svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
<el-tabs v-model="activeTab" @tab-click="handleTabClick"> 选择用户
<!-- 选择用户 --> </span>
<el-tab-pane label="选择用户" name="selectUser"> </template>
<div class="user-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名或ID"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户状态">
<el-select
v-model="searchParams.status"
placeholder="全部状态"
clearable
style="width: 120px"
>
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
<el-button type="success" @click="switchToAdd" :icon="Plus">
添加新用户
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 用户列表表格 --> <!-- 搜索栏 -->
<el-table <div class="uls-search">
v-loading="loading" <el-input v-model="searchParams.key" placeholder="搜索用户名 / 邮箱 / ID" clearable @keyup.enter="handleSearch" class="uls-search-input" :prefix-icon="Search" />
:data="userList" <el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
highlight-current-row <el-option label="管理员" :value="true" />
@current-change="handleCurrentChange" <el-option label="普通用户" :value="false" />
style="width: 100%" </el-select>
:height="350" <el-button type="primary" @click="handleSearch" :icon="Search" circle />
:row-class-name="tableRowClassName" <el-button @click="handleReset" :icon="Refresh" circle />
</div>
<!-- 已选提示 -->
<transition name="fade">
<div class="uls-selected-bar" v-if="selectedUser">
<div class="uls-sel-left">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#67c23a"/></svg>
<span>已选择</span>
<el-avatar :size="24" :src="selectedUser.cover" class="sel-avatar">{{ selectedUser.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
<b>{{ selectedUser.user_name }}</b>
<span class="sel-id">#{{ selectedUser.user_id }}</span>
</div>
<el-button size="small" link type="danger" @click="selectedUser = null">取消选择</el-button>
</div>
</transition>
<!-- 用户卡片列表 -->
<div class="uls-grid" v-loading="loading">
<div
v-for="user in userList" :key="user.user_id"
class="uls-card" :class="{ active: selectedUser?.user_id === user.user_id }"
@click="handleCurrentChange(user)"
> >
<el-table-column type="index" label="序号" width="60" align="center" /> <div class="uls-card-check" v-if="selectedUser?.user_id === user.user_id">
<el-table-column prop="user_id" label="用户ID" width="100" align="center" /> <svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#fff"/></svg>
<el-table-column prop="user_name" label="用户名" min-width="120"> </div>
<template #default="{ row }"> <el-avatar :size="42" :src="user.cover" class="uls-avatar">{{ user.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
<div class="user-info-cell"> <div class="uls-card-body">
<el-avatar :size="32" :src="row.cover"> <div class="uls-card-name">
<el-icon><User /></el-icon> {{ user.user_name }}
</el-avatar> <el-tag v-if="user.is_admin" type="warning" size="small" effect="dark" round class="admin-tag">管理员</el-tag>
<span class="user-name">{{ row.user_name }}</span> </div>
</div> <div class="uls-card-meta">
</template> <span class="meta-id">#{{ user.user_id }}</span>
</el-table-column> <span v-if="user.email" class="meta-email">{{ user.email }}</span>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip /> <span v-if="user.phone" class="meta-phone">{{ user.phone }}</span>
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip /> </div>
<!-- <el-table-column prop="status" label="状态" width="80" align="center"> </div>
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="注册时间" width="160" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div> </div>
</div>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" :image-size="80" />
<!-- 分页 -->
<div class="uls-pagination" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[12, 24, 48]"
:total="total"
layout="total, sizes, prev, pager, next"
background small
@size-change="s => { searchParams.count = s; searchParams.page = 1; fetchUserList() }"
@current-change="p => { searchParams.page = p; fetchUserList() }"
/>
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 添加用户 --> <!-- ====== 添加用户 ====== -->
<el-tab-pane label="添加用户" name="addUser"> <el-tab-pane name="addUser">
<div class="add-user-section"> <template #label>
<el-form <span class="tab-lbl">
ref="addFormRef" <svg viewBox="0 0 24 24" width="16" height="16"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
:model="addForm" 添加用户
:rules="addFormRules" </span>
label-width="100px" </template>
class="add-user-form" <div class="uls-add-section">
> <el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="90px" class="uls-add-form">
<el-form-item label="用户名" prop="user_name"> <el-form-item label="用户名" prop="user_name">
<el-input <el-input v-model="addForm.user_name" placeholder="请输入用户名" maxlength="50" show-word-limit />
v-model="addForm.user_name"
placeholder="请输入用户名"
maxlength="50"
show-word-limit
/>
</el-form-item> </el-form-item>
<el-form-item label="邮箱" prop="email"> <el-form-item label="邮箱" prop="email">
<el-input <el-input v-model="addForm.email" placeholder="请输入邮箱地址" type="email" />
v-model="addForm.email"
placeholder="请输入邮箱地址"
type="email"
/>
</el-form-item> </el-form-item>
<el-form-item label="手机号" prop="phone"> <el-form-item label="手机号" prop="phone">
<el-input <el-input v-model="addForm.phone" placeholder="请输入手机号选填" maxlength="11" />
v-model="addForm.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
<el-input <el-input v-model="addForm.password" placeholder="请输入密码" type="password" show-password />
v-model="addForm.password"
placeholder="请输入密码"
type="password"
show-password
/>
</el-form-item> </el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"> <el-form-item label="确认密码" prop="confirmPassword">
<el-input <el-input v-model="addForm.confirmPassword" placeholder="请再次输入密码" type="password" show-password />
v-model="addForm.confirmPassword"
placeholder="请再次输入密码"
type="password"
show-password
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleAddUser" :loading="addLoading"> <el-button type="primary" @click="handleAddUser" :loading="addLoading" :icon="Plus">立即创建</el-button>
<el-icon><Plus /></el-icon> <el-button @click="resetAddForm" :icon="Refresh">重置</el-button>
立即创建
</el-button>
<el-button @click="resetAddForm">
<el-icon><Refresh /></el-icon>
重置表单
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
@@ -169,16 +114,9 @@
</div> </div>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="uls-footer">
<el-button @click="handleClose">取消</el-button> <el-button @click="handleClose">取消</el-button>
<el-button <el-button type="primary" @click="handleConfirm" :disabled="!selectedUser" v-if="activeTab === 'selectUser'">确定选择</el-button>
type="primary"
@click="handleConfirm"
:disabled="!selectedUser"
v-if="activeTab === 'selectUser'"
>
确定选择
</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -190,23 +128,12 @@ import { ElMessage } from 'element-plus'
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue' import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
import { getUserList, createTask } from '@/api/admin/user' import { getUserList, createTask } from '@/api/admin/user'
// Props
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: Boolean, default: false },
type: Boolean, currentUserId: { type: [String, Number], default: '' }
default: false
},
// 当前已选中的用户ID(用于回显)
currentUserId: {
type: [String, Number],
default: ''
}
}) })
// Emits
const emit = defineEmits(['update:modelValue', 'confirm']) const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false) const visible = ref(false)
const activeTab = ref('selectUser') const activeTab = ref('selectUser')
const loading = ref(false) const loading = ref(false)
@@ -216,352 +143,131 @@ const total = ref(0)
const selectedUser = ref(null) const selectedUser = ref(null)
const addFormRef = ref(null) const addFormRef = ref(null)
// 搜索参数 const searchParams = reactive({ key: '', is_admin: undefined, page: 1, count: 12 })
const searchParams = reactive({
key: '',
status: '',
page: 1,
count: 10
})
// 添加用户表单 const addForm = reactive({ user_name: '', email: '', phone: '', password: '', confirmPassword: '' })
const addForm = reactive({
user_name: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
})
// 密码确认验证
const validateConfirmPassword = (rule, value, callback) => { const validateConfirmPassword = (rule, value, callback) => {
if (value === '') { if (!value) callback(new Error('请再次输入密码'))
callback(new Error('请再次输入密码')) else if (value !== addForm.password) callback(new Error('次输入密码不一致'))
} else if (value !== addForm.password) { else callback()
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
} }
// 添加用户表单验证规则
const addFormRules = { const addFormRules = {
user_name: [ user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 50, message: '2-50 个字符', trigger: 'blur' }],
{ required: true, message: '请输入用户名', trigger: 'blur' }, email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
{ min: 2, max: 50, message: '用户名长度在 2 到 50 个字符', trigger: 'blur' } phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
], password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '6-20 个字符', trigger: 'blur' }],
email: [ confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }]
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
} }
// 监听 modelValue 变化 watch(() => props.modelValue, (v) => {
watch(() => props.modelValue, (newVal) => { visible.value = v
visible.value = newVal if (v) { activeTab.value = 'selectUser'; selectedUser.value = null; searchParams.page = 1; fetchUserList() }
if (newVal) {
// 重置状态
activeTab.value = 'selectUser'
selectedUser.value = null
searchParams.page = 1
fetchUserList()
}
}) })
watch(visible, (v) => emit('update:modelValue', v))
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取用户列表
const fetchUserList = async () => { const fetchUserList = async () => {
loading.value = true loading.value = true; userList.value = []
userList.value = []
try { try {
const params = { const params = { page: searchParams.page, count: searchParams.count, key: searchParams.key || '' }
page: searchParams.page, if (searchParams.is_admin !== undefined && searchParams.is_admin !== null && searchParams.is_admin !== '') params.is_admin = searchParams.is_admin
count: searchParams.count,
key: searchParams.key || ''
}
const res = await getUserList(params) const res = await getUserList(params)
if (res.data.code === 200) { if (res.data.code === 200) {
userList.value = res.data.data?.data || [] userList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0 total.value = res.data.data?.all_count || 0
if (props.currentUserId) { const u = userList.value.find(u => u.user_id === props.currentUserId); if (u) selectedUser.value = u }
// 如果有当前选中的用户ID,自动选中
if (props.currentUserId) {
const currentUser = userList.value.find(
user => user.user_id === props.currentUserId
)
if (currentUser) {
selectedUser.value = currentUser
}
}
} else {
ElMessage.error(res.data.msg || '获取用户列表失败')
} }
} catch (error) { } catch { ElMessage.error('获取用户列表失败') }
console.error('获取用户列表失败:', error) finally { loading.value = false }
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
} }
// 处理标签页切换 const handleTabClick = (tab) => { if (tab.paneName === 'selectUser') fetchUserList() }
const handleTabClick = (tab) => { const handleSearch = () => { searchParams.page = 1; fetchUserList() }
if (tab.paneName === 'selectUser') { const handleReset = () => { searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; fetchUserList() }
fetchUserList() const handleCurrentChange = (row) => { selectedUser.value = (selectedUser.value?.user_id === row.user_id) ? null : row }
} const switchToAdd = () => { activeTab.value = 'addUser' }
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchUserList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
fetchUserList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchUserList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchUserList()
}
// 切换到添加用户标签页
const switchToAdd = () => {
activeTab.value = 'addUser'
}
// 选择用户
const handleCurrentChange = (row) => {
selectedUser.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedUser.value && row.user_id === selectedUser.value.user_id) {
return 'selected-row'
}
return ''
}
// 添加用户
const handleAddUser = async () => { const handleAddUser = async () => {
if (!addFormRef.value) return if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => { await addFormRef.value.validate(async (valid) => {
if (!valid) return if (!valid) return
addLoading.value = true addLoading.value = true
try { try {
const formData = new FormData() const fd = new FormData()
formData.append('user_name', addForm.user_name) fd.append('user_name', addForm.user_name); fd.append('email', addForm.email)
formData.append('email', addForm.email) if (addForm.phone) fd.append('phone', addForm.phone)
if (addForm.phone) { fd.append('password', addForm.password)
formData.append('phone', addForm.phone) const res = await createTask(fd)
}
formData.append('password', addForm.password)
const res = await createTask(formData)
if (res.data.code === 200) { if (res.data.code === 200) {
ElMessage.success('用户创建成功') ElMessage.success('用户创建成功')
// 获取新创建的用户信息
const newUser = res.data.data const newUser = res.data.data
if (newUser) { selectedUser.value = { user_id: newUser.user_id || newUser.id, user_name: newUser.user_name || addForm.user_name, email: newUser.email || addForm.email, phone: newUser.phone || addForm.phone, ...newUser }; emit('confirm', selectedUser.value); handleClose() }
// 自动选择新创建的用户 else { activeTab.value = 'selectUser'; searchParams.page = 1; await fetchUserList() }
if (newUser) {
selectedUser.value = {
user_id: newUser.user_id || newUser.id,
user_name: newUser.user_name || addForm.user_name,
email: newUser.email || addForm.email,
phone: newUser.phone || addForm.phone,
...newUser
}
// 触发确认事件并关闭弹窗
emit('confirm', selectedUser.value)
handleClose()
} else {
// 如果没有返回用户信息,切换到选择标签页并刷新列表
activeTab.value = 'selectUser'
searchParams.page = 1
await fetchUserList()
}
// 重置表单
resetAddForm() resetAddForm()
} else { } else { ElMessage.error(res.data.msg || '创建失败') }
ElMessage.error(res.data.msg || '用户创建失败') } catch { ElMessage.error('创建失败') }
} finally { addLoading.value = false }
} catch (error) {
console.error('用户创建失败:', error)
ElMessage.error('用户创建失败')
} finally {
addLoading.value = false
}
}) })
} }
// 重置添加表单 const resetAddForm = () => { Object.assign(addForm, { user_name: '', email: '', phone: '', password: '', confirmPassword: '' }); addFormRef.value?.resetFields() }
const resetAddForm = () => { const handleClose = () => { visible.value = false; selectedUser.value = null; userList.value = []; searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; total.value = 0; resetAddForm() }
addForm.user_name = '' const handleConfirm = () => { if (selectedUser.value) { emit('confirm', selectedUser.value); handleClose() } else ElMessage.warning('请选择一个用户') }
addForm.email = ''
addForm.phone = ''
addForm.password = ''
addForm.confirmPassword = ''
addFormRef.value?.resetFields()
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedUser.value = null
userList.value = []
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
total.value = 0
resetAddForm()
}
// 确认选择
const handleConfirm = () => {
if (selectedUser.value) {
emit('confirm', selectedUser.value)
handleClose()
} else {
ElMessage.warning('请选择一个用户')
}
}
</script> </script>
<style scoped> <style scoped>
.user-selector { .uls-root { min-height: 460px; }
min-height: 450px;
}
.user-list-container { /* 标签页 */
padding: 10px 0; .tab-lbl { display: inline-flex; align-items: center; gap: 5px; }
} :deep(.el-tabs__item) { font-size: 15px; padding: 0 20px; }
:deep(.el-tabs__item.is-active) { font-weight: 600; }
.filter-section { /* 搜索栏 */
margin-bottom: 16px; .uls-search { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
padding: 16px; .uls-search-input { flex: 1; max-width: 320px; }
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form { /* 已选提示条 */
display: flex; .uls-selected-bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; margin-bottom: 12px; background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); border: 1px solid #c2e7b0; border-radius: 8px; }
flex-wrap: wrap; .uls-sel-left { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #1d2129; }
align-items: center; .uls-sel-left b { font-weight: 600; }
gap: 8px; .sel-id { color: #86909c; font-size: 12px; }
} .sel-avatar { flex-shrink: 0; font-size: 11px; background: linear-gradient(135deg, #67c23a, #85ce61); color: #fff; }
.search-form :deep(.el-form-item) { .fade-enter-active, .fade-leave-active { transition: all .25s ease; }
margin-bottom: 0; .fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-6px); }
margin-right: 12px;
}
.user-info-cell { /* 卡片网格 */
display: flex; .uls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; max-height: 340px; overflow-y: auto; padding: 2px; }
align-items: center;
gap: 10px;
}
.user-name { .uls-card { position: relative; display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 2px solid #ebeef5; border-radius: 10px; cursor: pointer; transition: all .2s ease; background: #fff; }
font-weight: 500; .uls-card:hover { border-color: #b3d8ff; background: #f5faff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(64,158,255,.1); }
color: #303133; .uls-card.active { border-color: #409eff; background: #ecf5ff; }
}
.pagination-container { .uls-card-check { position: absolute; top: -1px; right: -1px; width: 24px; height: 24px; background: #409eff; border-radius: 0 8px 0 8px; display: flex; align-items: center; justify-content: center; }
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.add-user-section { .uls-avatar { flex-shrink: 0; font-size: 16px; font-weight: 700; background: linear-gradient(135deg, #c6e2ff, #409eff); color: #fff; }
padding: 30px 60px; .uls-card.active .uls-avatar { background: linear-gradient(135deg, #409eff, #337ecc); }
}
.add-user-form { .uls-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
max-width: 500px; .uls-card-name { font-size: 14px; font-weight: 600; color: #1d2129; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
margin: 0 auto; .admin-tag { transform: scale(.85); transform-origin: left center; }
}
.add-user-form :deep(.el-input) { .uls-card-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 11px; color: #a8abb2; line-height: 1.3; }
width: 100%; .meta-id { color: #86909c; font-weight: 500; }
} .meta-email, .meta-phone { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
.dialog-footer { /* 分页 */
display: flex; .uls-pagination { margin-top: 14px; display: flex; justify-content: flex-end; }
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */ /* 添加用户 */
:deep(.el-table__row) { .uls-add-section { padding: 24px 40px; }
cursor: pointer; .uls-add-form { max-width: 460px; margin: 0 auto; }
}
:deep(.el-table__row:hover) { /* 底部 */
background-color: #f5f7fa; .uls-footer { display: flex; justify-content: flex-end; gap: 10px; }
}
:deep(.selected-row) { /* 滚动条美化 */
background-color: var(--el-color-primary-light-9) !important; .uls-grid::-webkit-scrollbar { width: 5px; }
} .uls-grid::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 4px; }
.uls-grid::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style> </style>
+15 -9
View File
@@ -130,7 +130,10 @@
<el-icon :size="18"><Box /></el-icon> <el-icon :size="18"><Box /></el-icon>
</div> </div>
<div class="result-info"> <div class="result-info">
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span> <span class="result-title">
<span v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
<el-tag v-if="isGoodsDeleted(item)" size="small" type="danger" class="deleted-flag">已删除</el-tag>
</span>
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span> <span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
</div> </div>
<el-icon class="result-arrow"><ArrowRight /></el-icon> <el-icon class="result-arrow"><ArrowRight /></el-icon>
@@ -225,7 +228,7 @@ const searchOrders = async (key) => {
results.order.loading = true results.order.loading = true
results.order.list = [] results.order.list = []
try { try {
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key }) const res = await getOrderList({ page: results.order.page, count: pageSize, key })
if (res.data?.code === 200) { if (res.data?.code === 200) {
results.order.list = res.data.data?.list || [] results.order.list = res.data.data?.list || []
results.order.total = res.data.data?.all_count || results.order.list.length results.order.total = res.data.data?.all_count || results.order.list.length
@@ -251,7 +254,7 @@ const searchGoods = async (key) => {
results.goods.loading = true results.goods.loading = true
results.goods.list = [] results.goods.list = []
try { try {
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key }) const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, key })
if (res.data?.code === 200) { if (res.data?.code === 200) {
results.goods.list = res.data.data?.data || [] results.goods.list = res.data.data?.data || []
results.goods.total = res.data.data?.all_count || results.goods.list.length results.goods.total = res.data.data?.all_count || results.goods.list.length
@@ -260,6 +263,9 @@ const searchGoods = async (key) => {
results.goods.loading = false results.goods.loading = false
} }
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
const isGoodsDeleted = (item) => !!(item?.deleteAt || item?.DeleteAt || item?.deleted_at)
const highlight = (text) => { const highlight = (text) => {
if (!text || !keyword.value) return text if (!text || !keyword.value) return text
const key = keyword.value.trim() const key = keyword.value.trim()
@@ -292,12 +298,7 @@ const goToTicket = (item) => {
const goToGoods = (item) => { const goToGoods = (item) => {
visible.value = false visible.value = false
const tag = (item.tag || item.good?.tag || '').toLowerCase() router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
}
} }
const orderStatusText = (status) => { const orderStatusText = (status) => {
@@ -528,6 +529,11 @@ onUnmounted(() => {
border-radius: 2px; border-radius: 2px;
} }
.result-title .deleted-flag {
margin-left: 6px;
vertical-align: middle;
}
.result-desc { .result-desc {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
+40 -17
View File
@@ -47,7 +47,8 @@ export const menus = [
title: '商品管理', title: '商品管理',
icon: 'Goods', icon: 'Goods',
children: [ children: [
{ path: '/product/manage', title: '商品管理' } { path: '/product/manage', title: '商品管理' },
{ path: '/product/discount', title: '优惠管理' }
] ]
}, },
{ {
@@ -70,22 +71,6 @@ export const menus = [
} }
] ]
}, },
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{ {
path: '/activity', path: '/activity',
title: '活动管理', title: '活动管理',
@@ -149,6 +134,40 @@ export const menus = [
} }
] ]
}, },
{
path: '/sms',
title: '短信平台管理',
icon: 'ChatDotRound',
children: [
{
path: '/sms/service',
title: '主控服务管理'
},
{
path: '/sms/goods',
title: '额度商品管理'
},
{
path: '/sms/signature',
title: '签名管理'
},
{
path: '/sms/template',
title: '模板管理'
}
]
},
{
path: '/mail',
title: '邮箱平台管理',
icon: 'Message',
children: [
{
path: '/mail/service',
title: '主控服务管理'
}
]
},
{ {
title: '虚拟化平台管理', title: '虚拟化平台管理',
icon: 'Platform', icon: 'Platform',
@@ -194,6 +213,10 @@ export const menus = [
path: '/system/setting-manage', path: '/system/setting-manage',
title: '配置管理' title: '配置管理'
}, },
{
path: '/system/notice-channel',
title: '通知管理'
},
{ {
path: '/system/menu', path: '/system/menu',
title: '菜单管理', title: '菜单管理',
+79 -36
View File
@@ -253,6 +253,18 @@ const routes = [
component: () => import('../views/product/ProductGroup.vue'), component: () => import('../views/product/ProductGroup.vue'),
meta: { title: '商品管理' } meta: { title: '商品管理' }
}, },
{
path: 'discount',
name: 'DiscountManage',
component: () => import('../views/product/DiscountManage.vue'),
meta: { title: '优惠管理' }
},
{
path: 'discount/voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: { title: '代金券分发管理', hidden: true, activeMenu: '/product/discount' }
},
{ path: 'list', redirect: '/product/manage' }, { path: 'list', redirect: '/product/manage' },
{ path: 'group', redirect: '/product/manage' } { path: 'group', redirect: '/product/manage' }
] ]
@@ -282,12 +294,6 @@ const routes = [
component: () => import('../views/user-vm/UserVmList.vue'), component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云服务器' } meta: { title: '云服务器' }
}, },
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
] ]
}, },
// 订单管理路由 // 订单管理路由
@@ -310,43 +316,20 @@ const routes = [
} }
] ]
}, },
// 优惠营销路由 // 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接)
{ {
path: 'marketing', path: 'marketing',
name: 'Marketing', name: 'Marketing',
meta: { meta: {
title: '优惠营销', title: '优惠营销',
icon: 'Present' icon: 'Present',
hidden: true
}, },
redirect: '/marketing/discount', redirect: '/product/discount',
children: [ children: [
{ { path: 'discount', redirect: '/product/discount' },
path: 'discount', { path: 'voucher', redirect: '/product/discount' },
name: 'DiscountCode', { path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` }
component: () => import('../views/marketing/DiscountCode.vue'),
meta: {
title: '优惠码管理'
}
},
{
path: 'voucher',
name: 'Voucher',
component: () => import('../views/marketing/Voucher.vue'),
meta: {
title: '代金券管理'
}
},
{
path: 'voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: {
title: '代金券详情管理',
hidden: true,
activeMenu: '/marketing/voucher'
}
},
] ]
}, },
// 活动管理路由 // 活动管理路由
@@ -424,6 +407,12 @@ const routes = [
component: () => import('../views/system/SettingManage.vue'), component: () => import('../views/system/SettingManage.vue'),
meta: { title: '配置管理' } meta: { title: '配置管理' }
}, },
{
path: 'notice-channel',
name: 'NoticeChannel',
component: () => import('../views/system/NoticeChannel.vue'),
meta: { title: '通知管理' }
},
{ {
path: 'menu-manage', path: 'menu-manage',
name: 'MenuManage', name: 'MenuManage',
@@ -603,6 +592,60 @@ const routes = [
} }
] ]
}, },
// 短信平台管理路由
{
path: 'sms',
name: 'Sms',
meta: {
title: '短信平台管理',
icon: 'ChatDotRound'
},
redirect: '/sms/service',
children: [
{
path: 'service',
name: 'SmsService',
component: () => import('../views/sms/SmsService.vue'),
meta: { title: '主控服务管理' }
},
{
path: 'goods',
name: 'SmsGoods',
component: () => import('../views/sms/SmsGoods.vue'),
meta: { title: '额度商品管理' }
},
{
path: 'signature',
name: 'SmsSignature',
component: () => import('../views/sms/SmsSignature.vue'),
meta: { title: '签名管理' }
},
{
path: 'template',
name: 'SmsTemplateMgr',
component: () => import('../views/sms/SmsTemplate.vue'),
meta: { title: '模板管理' }
}
]
},
// 邮箱平台管理路由
{
path: 'mail',
name: 'Mail',
meta: {
title: '邮箱平台管理',
icon: 'Message'
},
redirect: '/mail/service',
children: [
{
path: 'service',
name: 'MailService',
component: () => import('../views/mail/MailService.vue'),
meta: { title: '主控服务管理' }
}
]
},
// 站点审计路由 // 站点审计路由
{ {
path: 'audit', path: 'audit',
+420
View File
@@ -0,0 +1,420 @@
<template>
<div class="mail-service-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<h2 class="header-title">邮件主控服务管理</h2>
<p class="header-desc">管理邮件平台的主控服务实例每个服务对应一个 mail-server 节点</p>
</div>
</div>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增服务
</el-button>
</div>
<!-- 搜索栏 -->
<div class="filter-bar">
<el-input
v-model="queryParams.key"
placeholder="搜索名称 / 说明 / 地址"
clearable
style="width: 300px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="服务名称" min-width="150">
<template #default="{ row }">
<div class="service-name-cell">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6"/>
</svg>
<span class="name-text">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="host" label="服务地址" min-width="220">
<template #default="{ row }">
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
</template>
</el-table-column>
<el-table-column label="SMTP 配置" min-width="200">
<template #default="{ row }">
<div v-if="row.smtpHost" class="smtp-info">
<span class="smtp-host">{{ row.smtpHost }}:{{ row.smtpPort || '-' }}</span>
<el-tag v-if="row.smtpTlsEnable" type="success" size="small" effect="plain">TLS</el-tag>
<el-tag v-else type="info" size="small" effect="plain">无TLS</el-tag>
</div>
<span v-else class="note-text">-</span>
</template>
</el-table-column>
<el-table-column prop="serviceToken" label="Service Token" min-width="180">
<template #default="{ row }">
<div class="token-cell">
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
<span v-else class="token-full">{{ row.serviceToken }}</span>
<el-button link size="small" @click="row._showToken = !row._showToken">
{{ row._showToken ? '隐藏' : '显示' }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.createdAt || row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确认删除该邮件服务?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑邮件主控服务' : '新增邮件主控服务'"
width="600px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="服务名称" prop="name">
<el-input v-model="form.name" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
</el-form-item>
<el-form-item label="服务地址" prop="host">
<el-input v-model="form.host" placeholder="https://mail.example.com" />
</el-form-item>
<el-form-item label="Service Token" prop="service_token">
<el-input v-model="form.service_token" placeholder="mail-server 的 SERVICE_TOKEN" show-password />
</el-form-item>
<el-divider content-position="left">SMTP 配置可选</el-divider>
<el-form-item label="SMTP 地址" prop="smtp_host">
<el-input v-model="form.smtp_host" placeholder="smtp.example.com" />
</el-form-item>
<el-form-item label="SMTP 端口" prop="smtp_port">
<el-input-number v-model="form.smtp_port" :min="1" :max="65535" placeholder="465" style="width: 100%" />
</el-form-item>
<el-form-item label="启用 TLS" prop="smtp_tls_enable">
<el-switch v-model="form.smtp_tls_enable" active-text="启用" inactive-text="关闭" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
getMailServiceList,
createMailService,
updateMailService,
deleteMailService
} from '@/api/admin/mailService.js'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const queryParams = reactive({
page: 1,
count: 10,
key: ''
})
const form = ref({
id: null,
name: '',
note: '',
host: '',
service_token: '',
smtp_host: '',
smtp_port: null,
smtp_tls_enable: false
})
const rules = {
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const maskToken = (token) => {
if (!token) return '-'
if (token.length <= 8) return '****'
return token.slice(0, 4) + '****' + token.slice(-4)
}
const fetchList = async () => {
loading.value = true
try {
const res = await getMailServiceList(queryParams)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
total.value = body.data?.all_count || body.total || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', host: '', service_token: '', smtp_host: '', smtp_port: null, smtp_tls_enable: false }
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
host: row.host,
service_token: row.serviceToken || row.service_token || '',
smtp_host: row.smtpHost || row.smtp_host || '',
smtp_port: row.smtpPort || row.smtp_port || null,
smtp_tls_enable: !!(row.smtpTlsEnable ?? row.smtp_tls_enable)
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
if (isEdit.value) params.append('id', form.value.id)
params.append('name', form.value.name)
params.append('note', form.value.note)
params.append('host', form.value.host)
params.append('service_token', form.value.service_token)
if (form.value.smtp_host) params.append('smtp_host', form.value.smtp_host)
if (form.value.smtp_port) params.append('smtp_port', form.value.smtp_port)
params.append('smtp_tls_enable', form.value.smtp_tls_enable ? 'true' : 'false')
const fn = isEdit.value ? updateMailService : createMailService
const res = await fn(params)
if (res.data.code === 200) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteMailService(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
const openConsole = (row) => {
const base = (row.host || '').replace(/\/+$/, '')
if (!base) return ElMessage.warning('该服务未配置地址')
window.open(`${base}/ui/index.html?token=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.mail-service-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
border-radius: 12px;
border: 1px solid #e0ecf5;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.header-desc {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.service-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
color: #303133;
}
.note-text {
color: #909399;
font-size: 13px;
}
.host-tag {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
}
.smtp-info {
display: flex;
align-items: center;
gap: 8px;
}
.smtp-host {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #606266;
}
.token-cell {
display: flex;
align-items: center;
gap: 8px;
}
.token-mask {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #909399;
}
.token-full {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #303133;
word-break: break-all;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
</style>
-677
View File
@@ -1,677 +0,0 @@
<template>
<div class="discount-code-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增优惠码
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 优惠码列表 -->
<div class="table-section">
<!-- 骨架屏 -->
<div v-if="loading" class="skeleton-container">
<div v-for="i in 5" :key="i" class="skeleton-row">
<div class="skeleton-cell skeleton-checkbox"></div>
<div class="skeleton-cell skeleton-id"></div>
<div class="skeleton-cell skeleton-code"></div>
<div class="skeleton-cell skeleton-name"></div>
<div class="skeleton-cell skeleton-type"></div>
<div class="skeleton-cell skeleton-value"></div>
<div class="skeleton-cell skeleton-min"></div>
<div class="skeleton-cell skeleton-max"></div>
<div class="skeleton-cell skeleton-times"></div>
<div class="skeleton-cell skeleton-action"></div>
</div>
</div>
<el-table
v-else
v-loading="loading"
:data="discountList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="code" label="优惠码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="优惠类型" width="120">
<template #default="{ row }">
<el-tag :type="row.percentage ? 'success' : 'primary'">
{{ row.percentage ? '百分比折扣' : '固定金额' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠值" width="120">
<template #default="{ row }">
<span v-if="row.percentage" class="discount-value">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="120" />
<el-table-column prop="userTimes" label="单用户次数" width="120" />
<el-table-column label="可叠加" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.canStacking" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 优惠码表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增优惠码' : '编辑优惠码'"
width="700px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountRules"
label-width="140px"
>
<el-form-item label="优惠码" prop="code">
<el-input v-model="discountForm.code" placeholder="请输入优惠码" />
</el-form-item>
<el-form-item label="优惠码名称" prop="name">
<el-input v-model="discountForm.name" placeholder="请输入优惠码名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="discountForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="优惠类型" prop="discount_mode">
<el-radio-group v-model="discountForm.discount_mode">
<el-radio label="amount">固定金额</el-radio>
<el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="discountForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期" prop="timeRange">
<el-date-picker
v-model="discountForm.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="discount-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="discountForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="discountForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="discountForm.can_combine" active-text="是" inactive-text="否" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="code"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
// 查询参数
const queryParams = reactive({
discount_type: 'code', // 固定为code表示优惠码
page: 1,
count: 10
})
// 优惠码表单
const discountForm = reactive({
code_id: undefined,
discount_type: 'code', // 固定为code
code: '',
name: '',
note: '',
discount_mode: 'amount', // amount 或 percentage
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const discountRules = {
code: [
{ required: true, message: '请输入优惠码', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入优惠码名称', trigger: 'blur' }
],
discount_mode: [
{ required: true, message: '请选择优惠类型', trigger: 'change' }
]
}
// 状态数据
const loading = ref(false)
const discountList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const discountFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
// 获取优惠码列表
const fetchDiscountList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('优惠码列表数据:', res.data)
if (res.data.code === 200) {
discountList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
}
} catch (error) {
console.error('获取优惠码列表失败:', error)
ElMessage.error('获取优惠码列表失败')
} finally {
loading.value = false
}
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchDiscountList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchDiscountList()
}
// 新增优惠码
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(discountForm, {
code_id: undefined,
discount_type: 'code',
code: '',
name: '',
note: '',
discount_mode: 'amount',
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
discountFormRef.value?.resetFields()
}
// 编辑优惠码
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
// 转换日期字符串为日期选择器格式
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
Object.assign(discountForm, {
code_id: row.id,
discount_type: 'code',
code: row.code,
name: row.name,
note: row.note || '',
discount_mode: row.percentage ? 'percentage' : 'amount',
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
}
// 查看优惠码详情
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('优惠码详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取优惠码详情失败:', error)
ElMessage.error('获取优惠码详情失败')
}
}
// 删除优惠码
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除优惠码 ${row.code} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchDiscountList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
// 处理日期选择器回车事件
const handleDatePickerEnter = (event) => {
// 回车键确认日期选择
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
// 触发失焦事件,确认日期选择
event.target.blur()
}
}
// 提交表单
const submitForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'code',
code: discountForm.code,
name: discountForm.name,
note: discountForm.note,
min_amount: Math.round(discountForm.min_amount * 100),
max_amount: Math.round(discountForm.max_amount * 100),
max_times: discountForm.max_times || 0,
user_times: discountForm.user_times || 0,
renew: discountForm.renew,
can_stacking: discountForm.can_stacking,
can_combine: discountForm.can_combine
}
// 根据优惠类型设置amount或percentage
if (discountForm.discount_mode === 'percentage') {
submitData.percentage = Math.round(discountForm.percentage * 100)
submitData.amount = 0
} else {
submitData.amount = Math.round(discountForm.amount * 100)
submitData.percentage = 0
}
// 处理时间(转换为秒级时间戳)
if (discountForm.timeRange && discountForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(discountForm.timeRange[0])
submitData.end_time = timeToTimestamp(discountForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// 如果是编辑,添加code_id
if (dialogType.value === 'edit') {
submitData.code_id = discountForm.code_id
}
console.log('提交优惠码数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
// 初始化
onMounted(() => {
fetchDiscountList()
})
</script>
<style scoped>
.discount-code-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.discount-value {
color: #67c23a;
font-weight: bold;
font-size: 14px;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
/* 骨架屏样式 */
.skeleton-container {
padding: 20px;
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-checkbox { width: 55px; }
.skeleton-id { width: 80px; }
.skeleton-code { width: 150px; }
.skeleton-name { width: 180px; }
.skeleton-type { width: 120px; }
.skeleton-value { width: 120px; }
.skeleton-min { width: 120px; }
.skeleton-max { width: 120px; }
.skeleton-times { width: 120px; }
.skeleton-action { width: 200px; height: 32px; }
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.discount-date-picker {
z-index: 9999 !important;
}
.discount-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+235 -159
View File
@@ -161,47 +161,39 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择类型" prop="select_type" v-if="dialogType === 'add'"> <el-form-item label="选择关联对象" v-if="dialogType === 'add'">
<el-radio-group v-model="form.select_type" @change="handleSelectTypeChange"> <div class="goods-tree-wrapper">
<el-radio value="product">商品</el-radio> <div class="goods-tree-toolbar">
<el-radio value="product_group">商品组</el-radio> <span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span>
</el-radio-group> <div class="tree-summary">
</el-form-item> 已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
</div>
<el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'"> </div>
<el-select <el-tree
v-model="form.selected_product" ref="goodsTreeRef"
placeholder="请选择商品" :props="treeProps"
filterable :load="loadTreeNode"
clearable lazy
style="width: 100%" show-checkbox
@change="handleProductChange" check-strictly
> node-key="key"
<el-option class="goods-tree"
v-for="item in productOptions" @check="handleTreeCheck"
:key="item.id" >
:label="`${item.name} (ID: ${item.id})`" <template #default="{ data }">
:value="item.id" <span class="tree-node">
/> <el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
</el-select> {{ data.nodeType === 'group' ? '组' : '品' }}
</el-form-item> </el-tag>
<span class="tree-node-label">{{ data.label }}</span>
<el-form-item label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'"> <span class="tree-node-id">ID: {{ data.rawId }}</span>
<el-select <span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
v-model="form.selected_group" ¥{{ (data.price / 100).toFixed(2) }}
placeholder="请选择商品组" </span>
filterable </span>
clearable </template>
style="width: 100%" </el-tree>
@change="handleProductGroupChange" </div>
>
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID: ${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item> </el-form-item>
<!-- 编辑模式显示字段 --> <!-- 编辑模式显示字段 -->
@@ -234,7 +226,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue' import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
import { import {
@@ -276,36 +268,27 @@ const form = reactive({
code_id: undefined, code_id: undefined,
goods_id: undefined, goods_id: undefined,
goods_name: '', goods_name: '',
goods_type: '', goods_type: ''
select_type: 'product', // 选择类型:product 或 product_group
selected_product: undefined, // 选中的商品ID
selected_group: undefined // 选中的商品组ID
}) })
const formRules = { const formRules = {
code_id: [ code_id: [
{ required: true, message: '请选择代金券', trigger: 'change' } { required: true, message: '请选择代金券', trigger: 'change' }
], ],
select_type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
selected_product: [
{ required: true, message: '请选择商品', trigger: 'change' }
],
selected_group: [
{ required: true, message: '请选择商品组', trigger: 'change' }
],
goods_id: [ goods_id: [
{ required: true, message: '请输入商品ID', trigger: 'blur' } { required: true, message: '请输入商品ID', trigger: 'blur' }
],
goods_name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' }
],
goods_type: [
{ required: true, message: '请选择商品类型', trigger: 'change' }
] ]
} }
// 折叠层级选择器相关
const goodsTreeRef = ref(null)
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const checkedSummary = reactive({ groupCount: 0, productCount: 0 })
// 状态数据 // 状态数据
const loading = ref(false) const loading = ref(false)
const goodsList = ref([]) const goodsList = ref([])
@@ -436,33 +419,79 @@ const fetchProductGroupList = async () => {
} }
} }
// 选择类型变化 // 折叠层级选择器:懒加载节点
const handleSelectTypeChange = (type) => { // node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品
form.selected_product = undefined const loadTreeNode = async (node, resolve) => {
form.selected_group = undefined try {
form.goods_id = undefined // 根节点:仅加载 level=1 的顶级商品组
form.goods_name = '' if (node.level === 0) {
form.goods_type = '' const res = await getProductGroupList({ level: 1 })
} if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildGroupNode))
}
return resolve([])
}
// 选择商品变化 // 商品组节点:逐级加载子分组 + 下属商品
const handleProductChange = (productId) => { if (node.data?.nodeType === 'group') {
const product = productOptions.value.find(item => item.id === productId) const groupId = node.data.rawId
if (product) { const childLevel = (node.data.level || 1) + 1
form.goods_id = product.id const tasks = [
form.goods_name = product.goodsName || product.name || '' getProductList({ good_group_id: groupId, delete: false })
form.goods_type = 'product' ]
// 仅当存在子分组时才请求下一级分组
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productRes = results[0]
const productNodes = (productRes.data.code === 200 ? (productRes.data.data?.data || []) : [])
.map(buildProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
ElMessage.error('加载层级数据失败')
return resolve([])
} }
} }
// 选择商品组变化 // 构建商品组节点
const handleProductGroupChange = (groupId) => { const buildGroupNode = (group) => ({
const group = productGroupOptions.value.find(item => item.id === groupId) key: `group_${group.id}`,
if (group) { rawId: group.id,
form.goods_id = group.id nodeType: 'group',
form.goods_name = group.name || '' label: group.name,
form.goods_type = 'product_group' level: group.level || 1,
} existSub: group.existSub || false,
isLeaf: false
})
// 构建商品节点
const buildProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
// 勾选变化时更新汇总
const handleTreeCheck = () => {
const nodes = goodsTreeRef.value?.getCheckedNodes() || []
checkedSummary.groupCount = nodes.filter(n => n.nodeType === 'group').length
checkedSummary.productCount = nodes.filter(n => n.nodeType === 'product').length
} }
// 获取商品关联列表 // 获取商品关联列表
@@ -527,16 +556,15 @@ const handleAdd = () => {
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined, code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
goods_id: undefined, goods_id: undefined,
goods_name: '', goods_name: '',
goods_type: '', goods_type: ''
select_type: 'product',
selected_product: undefined,
selected_group: undefined
}) })
checkedSummary.groupCount = 0
checkedSummary.productCount = 0
formRef.value?.resetFields() formRef.value?.resetFields()
// 等待对话框渲染后清空树的勾选状态
// 加载商品和商品组列表 nextTick(() => {
fetchProductList() goodsTreeRef.value?.setCheckedKeys([])
fetchProductGroupList() })
} }
// 编辑商品关联 // 编辑商品关联
@@ -641,80 +669,77 @@ const handleBatchDelete = () => {
// 提交表单 // 提交表单
const submitForm = () => { const submitForm = () => {
// 新增模式下的额外验证 if (!form.code_id) {
if (dialogType.value === 'add') { ElMessage.warning('请选择代金券')
if (!form.code_id) { return
ElMessage.warning('请选择代金券')
return
}
if (form.select_type === 'product' && !form.selected_product) {
ElMessage.warning('请选择商品')
return
}
if (form.select_type === 'product_group' && !form.selected_group) {
ElMessage.warning('请选择商品组')
return
}
if (!form.goods_id || !form.goods_name || !form.goods_type) {
ElMessage.warning('请先选择商品或商品组')
return
}
} }
if (dialogType.value === 'add') {
// 收集树中勾选的商品组与商品
const checkedNodes = goodsTreeRef.value?.getCheckedNodes() || []
const goodIds = checkedNodes.filter(n => n.nodeType === 'product').map(n => n.rawId)
const goodGroupIds = checkedNodes.filter(n => n.nodeType === 'group').map(n => n.rawId)
if (goodIds.length === 0 && goodGroupIds.length === 0) {
ElMessage.warning('请至少勾选一个商品或商品组')
return
}
submitAdd(goodIds, goodGroupIds)
return
}
// 编辑模式
formRef.value?.validate(async (valid) => { formRef.value?.validate(async (valid) => {
if (valid) { if (!valid) return
try { try {
const submitData = { const submitData = {
code_id: String(form.code_id) code_id: String(form.code_id),
} discount_good_id: String(form.id)
// 根据选择类型决定传 good_id 还是 good_group_id
if (dialogType.value === 'add') {
if (form.select_type === 'product') {
// 选择的是商品,传 good_id
submitData.good_ids = String(form.goods_id)
} else if (form.select_type === 'product_group') {
// 选择的是商品组,传 good_group_id
submitData.good_group_ids = String(form.goods_id)
}
} else {
// 编辑模式:传 discount_good_id
submitData.discount_good_id = String(form.id)
// 根据 goods_type 判断传 good_id 还是 good_group_id
if (form.goods_type === 'product') {
submitData.good_id = String(form.goods_id)
} else if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
// 其他类型默认使用 good_id
submitData.good_id = String(form.goods_id)
}
}
console.log('提交商品关联数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await addDiscountGoods(submitData)
} else {
res = await updateDiscountGoods(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
} }
if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
submitData.good_id = String(form.goods_id)
}
const res = await updateDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
} }
}) })
} }
// 新增提交:根据勾选构建 good_ids / good_group_ids(逗号分隔)
const submitAdd = async (goodIds, goodGroupIds) => {
try {
const submitData = { code_id: String(form.code_id) }
if (goodIds.length > 0) {
submitData.good_ids = goodIds.join(',')
}
if (goodGroupIds.length > 0) {
submitData.good_group_ids = goodGroupIds.join(',')
}
console.log('提交商品关联数据:', submitData)
const res = await addDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('新增成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
fetchVoucherListOptions() fetchVoucherListOptions()
@@ -798,6 +823,57 @@ onMounted(() => {
padding: 0; padding: 0;
} }
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+1 -1
View File
@@ -359,7 +359,7 @@ const editForm = reactive({
const editRules = { const editRules = {
discount_id: [ discount_id: [
{ required: true, message: '请输入代金券ID', trigger: 'blur' } { required: false, message: '请输入代金券ID', trigger: 'blur' }
], ],
use_times: [ use_times: [
{ required: true, message: '请输入已使用次数', trigger: 'blur' } { required: true, message: '请输入已使用次数', trigger: 'blur' }
-569
View File
@@ -1,569 +0,0 @@
<template>
<div class="voucher-container">
<!-- 搜索和操作栏 -->
<el-card class="filter-container" shadow="never">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增代金券
</el-button>
<el-button type="success" @click="fetchVoucherList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</el-card>
<!-- 代金券列表 -->
<el-card class="table-container" shadow="never">
<el-table
v-loading="loading"
:data="voucherList"
@selection-change="handleSelectionChange"
style="width: 100%"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="代金券名称" min-width="200" />
<el-table-column label="面额" width="120">
<template #default="{ row }">
<span class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>无限制</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="130">
<template #default="{ row }">
{{ row.maxTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column prop="userTimes" label="单用户次数" width="120">
<template #default="{ row }">
{{ row.userTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column label="有效期(天)" width="100">
<template #default="{ row }">
{{ row.duration ? (row.duration / 86400).toFixed(0) : '-' }}
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleManage(row)">管理</el-button>
<el-button type="success" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</el-card>
<!-- 代金券表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增代金券' : '编辑代金券'"
width="700px"
>
<el-form
ref="voucherFormRef"
:model="voucherForm"
:rules="voucherRules"
label-width="140px"
>
<el-form-item label="代金券名称" prop="name">
<el-input v-model="voucherForm.name" placeholder="请输入代金券名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="面额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期" prop="duration_days">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
<span class="unit-text"></span>
</div>
<div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item>
<el-form-item label="发放时间范围" prop="timeRange">
<el-date-picker
v-model="voucherForm.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="voucher-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
<div class="form-tip">代金券可以发放给用户的时间范围</div>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="voucherForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="voucherForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="voucherForm.can_combine" active-text="是" inactive-text="否" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="coupon"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
const router = useRouter()
// 查询参数
const queryParams = reactive({
discount_type: 'coupon', // 固定为coupon表示代金券
page: 1,
count: 10
})
// 代金券表单
const voucherForm = reactive({
code_id: undefined,
discount_type: 'coupon', // 固定为coupon
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30, // 默认30天
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const voucherRules = {
name: [
{ required: true, message: '请输入代金券名称', trigger: 'blur' }
],
amount: [
{ required: true, message: '请输入面额', trigger: 'blur' }
],
duration_days: [
{ required: true, message: '请输入有效期天数', trigger: 'blur' }
]
}
// 状态数据
const loading = ref(false)
const voucherList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const voucherFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
// 获取代金券列表
const fetchVoucherList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('代金券列表数据:', res.data)
if (res.data.code === 200) {
voucherList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
}
} catch (error) {
console.error('获取代金券列表失败:', error)
ElMessage.error('获取代金券列表失败')
} finally {
loading.value = false
}
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchVoucherList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchVoucherList()
}
// 新增代金券
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(voucherForm, {
code_id: undefined,
discount_type: 'coupon',
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
voucherFormRef.value?.resetFields()
}
// 编辑代金券
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
console.log('编辑代金券原始数据:', row)
// 转换时间为日期字符串(YYYY-MM-DD HH:mm:ss 格式)
let startTime = ''
let endTime = ''
if (row.startTime) {
// 处理字符串格式的时间(如 "2026-01-08T00:00:00+08:00"
const start = new Date(row.startTime)
if (!isNaN(start.getTime())) {
startTime = start.getFullYear() + '-' +
String(start.getMonth() + 1).padStart(2, '0') + '-' +
String(start.getDate()).padStart(2, '0') + ' ' +
String(start.getHours()).padStart(2, '0') + ':' +
String(start.getMinutes()).padStart(2, '0') + ':' +
String(start.getSeconds()).padStart(2, '0')
}
}
if (row.endTime) {
// 处理字符串格式的时间(如 "2026-02-25T00:00:00+08:00"
const end = new Date(row.endTime)
if (!isNaN(end.getTime())) {
endTime = end.getFullYear() + '-' +
String(end.getMonth() + 1).padStart(2, '0') + '-' +
String(end.getDate()).padStart(2, '0') + ' ' +
String(end.getHours()).padStart(2, '0') + ':' +
String(end.getMinutes()).padStart(2, '0') + ':' +
String(end.getSeconds()).padStart(2, '0')
}
}
console.log('转换后的时间:', { startTime, endTime })
Object.assign(voucherForm, {
code_id: row.id,
discount_type: 'coupon',
name: row.name,
note: row.note || '',
amount: row.amount ? row.amount / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
duration_days: row.duration ? Math.round(row.duration / 86400) : 30, // 秒转天
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
console.log('表单数据:', voucherForm)
}
// 管理代金券
const handleManage = (row) => {
router.push(`/marketing/voucher/${row.id}/manage`)
}
// 查看代金券详情
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('代金券详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取代金券详情失败:', error)
ElMessage.error('获取代金券详情失败')
}
}
// 删除代金券
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除代金券 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchVoucherList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchVoucherList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
// 处理日期选择器回车事件
const handleDatePickerEnter = (event) => {
// 回车键确认日期选择
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
// 触发失焦事件,确认日期选择
event.target.blur()
}
}
// 提交代金券表单
const submitForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'coupon',
name: voucherForm.name,
note: voucherForm.note,
amount: Math.round(voucherForm.amount * 100),
percentage: 0, // 代金券固定为0
min_amount: Math.round(voucherForm.min_amount * 100),
max_amount: Math.round(voucherForm.max_amount * 100),
max_times: voucherForm.max_times || 0,
user_times: voucherForm.user_times || 0,
duration: voucherForm.duration_days * 86400, // 天转秒
renew: voucherForm.renew,
can_stacking: voucherForm.can_stacking,
can_combine: voucherForm.can_combine
}
// 处理时间(转换为秒级时间戳)
if (voucherForm.timeRange && voucherForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(voucherForm.timeRange[0])
submitData.end_time = timeToTimestamp(voucherForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// 如果是编辑,添加code_id
if (dialogType.value === 'edit') {
submitData.code_id = voucherForm.code_id
}
console.log('提交代金券数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchVoucherList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
// 初始化
onMounted(() => {
fetchVoucherList()
})
</script>
<style scoped>
.voucher-container {
padding: 0;
}
.filter-container {
margin-bottom: 20px;
border-radius: 8px;
}
.action-bar {
display: flex;
gap: 12px;
}
.table-container {
border-radius: 8px;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination {
margin-top: 24px;
justify-content: flex-end;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.voucher-date-picker {
z-index: 9999 !important;
}
.voucher-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+8 -15
View File
@@ -15,9 +15,6 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="关联信息ID">
<el-input v-model="queryParams.id" placeholder="请输入关联信息ID" clearable style="width: 180px" />
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleQuery"> <el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
@@ -203,15 +200,14 @@ const router = useRouter()
// 查询参数 // 查询参数
const queryParams = reactive({ const queryParams = reactive({
user_id: undefined, user_id: undefined,
code_id: props.codeId || undefined, id: props.codeId || undefined,
id: '',
page: 1, page: 1,
count: 10 count: 10
}) })
watch(() => props.codeId, (newVal) => { watch(() => props.codeId, (newVal) => {
if (newVal) { if (newVal) {
queryParams.code_id = newVal queryParams.id = newVal
fetchHistoryList() fetchHistoryList()
} }
}) })
@@ -285,8 +281,8 @@ const statistics = computed(() => {
}) })
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取使用记录列表 // 获取使用记录列表
@@ -347,15 +343,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -372,8 +366,7 @@ const handleQuery = () => {
// 重置查询 // 重置查询
const resetQuery = () => { const resetQuery = () => {
queryParams.user_id = undefined queryParams.user_id = undefined
queryParams.code_id = undefined queryParams.id = undefined
queryParams.id = ''
queryParams.page = 1 queryParams.page = 1
fetchHistoryList() fetchHistoryList()
} }
+7 -9
View File
@@ -423,15 +423,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -451,14 +449,14 @@ const clearEditUser = () => {
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取编辑用户名称 // 获取编辑用户名称
const getEditUserName = () => { const getEditUserName = () => {
const user = UserOptions.value.find(u => u.UserId === editForm.user_id) const user = UserOptions.value.find(u => u.user_id === editForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${editForm.user_id}`
} }
// 获取代金券列表 // 获取代金券列表
+1 -1
View File
@@ -50,7 +50,7 @@ const activeTab = ref('user-distribution')
const voucherId = computed(() => route.params.id) const voucherId = computed(() => route.params.id)
const goBack = () => { const goBack = () => {
router.push('/marketing/voucher') router.push('/product/discount')
} }
</script> </script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292 -129
View File
@@ -122,6 +122,12 @@
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag> <el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag
v-if="(row.isGroup && row.recommendWords) || (row.isProduct && row.data?.recommendWords)"
type="danger"
size="small"
style="margin-left: 6px;"
>{{ row.isGroup ? row.recommendWords : row.data?.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -207,6 +213,7 @@
<div class="group-name-cell"> <div class="group-name-cell">
<el-avatar v-if="row.cover" :size="32" :src="row.cover" /> <el-avatar v-if="row.cover" :size="32" :src="row.cover" />
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag v-if="row.recommendWords" type="danger" size="small" style="margin-left: 6px;">{{ row.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -393,6 +400,11 @@
</div> </div>
</div> </div>
<!-- 推荐词 -->
<el-form-item prop="recommend_words" label="推荐词">
<el-input v-model="groupForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable />
</el-form-item>
<!-- 备注 --> <!-- 备注 -->
<el-form-item prop="note" label="备注" class="note-form-item"> <el-form-item prop="note" label="备注" class="note-form-item">
<el-input <el-input
@@ -567,6 +579,9 @@
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon> <el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="所属表" prop="table">
<el-input v-model="productForm.table" placeholder="请输入商品所属表(如 kvm_service" />
</el-form-item>
<el-form-item label="商品介绍" prop="content"> <el-form-item label="商品介绍" prop="content">
<el-input <el-input
v-model="productForm.content" v-model="productForm.content"
@@ -588,125 +603,190 @@
@confirm="handleProductCoverSelect" @confirm="handleProductCoverSelect"
/> />
<!-- 价格与销售 --> <!-- Tab 栏管理 -->
<div class="form-section"> <el-tabs v-model="productFormTab" class="product-form-tabs">
<div class="form-section-title">价格与销售</div> <el-tab-pane label="价格与销售" name="sales">
<div class="form-grid"> <div class="form-grid">
<el-form-item prop="price"> <el-form-item prop="price">
<template #label> <template #label>
<span>商品价格<span class="unit-suffix"></span></span> <span>商品价格<span class="unit-suffix"></span></span>
</template> </template>
<el-input-number <el-input-number
v-model="productForm.price" v-model="productForm.price"
:min="0" :min="0"
:precision="2" :precision="2"
:step="0.01" :step="0.01"
placeholder="请输入价格" placeholder="请输入价格"
controls-position="right" controls-position="right"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="单个商品数量" prop="pay_num"> <el-form-item label="单个商品数量" prop="pay_num">
<el-input-number <el-input-number
v-model="productForm.pay_num" v-model="productForm.pay_num"
:min="1" :min="1"
placeholder="请输入数量" placeholder="请输入数量"
controls-position="right" controls-position="right"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="expire_time"> <el-form-item prop="expire_time">
<template #label> <template #label>
<span>有效期<span class="unit-suffix">0 为永久</span></span> <span>有效期<span class="unit-suffix">0 为永久</span></span>
</template> </template>
<el-input-number <el-input-number
v-model="productForm.expire_time" v-model="productForm.expire_time"
:min="0" :min="0"
placeholder="请输入有效期" placeholder="请输入有效期"
controls-position="right" controls-position="right"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="允许续费" prop="can_renew"> <el-form-item label="允许续费" prop="can_renew">
<el-switch <el-switch
v-model="productForm.can_renew" v-model="productForm.can_renew"
active-text="允许" active-text="允许"
inactive-text="禁止" inactive-text="禁止"
/> />
</el-form-item> </el-form-item>
<el-form-item label="购买类型" prop="arg_type"> <el-form-item prop="renew_price" v-if="productForm.can_renew">
<el-select <template #label>
v-model="productForm.arg_type" <span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
placeholder="请选择购买类型" </template>
style="width: 100%" <el-input-number
> v-model="productForm.renew_price"
<el-option label="所有类型" value="all" /> :min="0"
<el-option label="仅套餐" value="plan" /> :precision="2"
<el-option label="仅自定义参数" value="customize" /> :step="0.01"
</el-select> placeholder="续费基础价格"
</el-form-item> controls-position="right"
<el-form-item label="需要实名" prop="require_real_name"> style="width: 100%"
<el-switch />
v-model="productForm.require_real_name" </el-form-item>
active-text="需要" <el-form-item label="购买类型" prop="arg_type">
inactive-text="不需要" <el-select
/> v-model="productForm.arg_type"
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div> placeholder="请选择购买类型"
</el-form-item> style="width: 100%"
</div> >
</div> <el-option label="所有类型" value="all" />
<el-option label="仅套餐" value="plan" />
<el-option label="仅自定义参数" value="customize" />
</el-select>
</el-form-item>
<el-form-item label="需要实名" prop="require_real_name">
<el-switch
v-model="productForm.require_real_name"
active-text="需要"
inactive-text="不需要"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
</div>
</el-tab-pane>
<!-- 库存管理 --> <el-tab-pane label="库存管理" name="inventory">
<div class="form-section"> <div class="form-grid">
<div class="form-section-title">库存管理</div> <el-form-item label="库存控制" prop="inventory_control">
<div class="form-grid"> <el-switch
<el-form-item label="库存控制" prop="inventory_control"> v-model="productForm.inventory_control"
<el-switch active-text="启用"
v-model="productForm.inventory_control" inactive-text="禁用"
active-text="启用" />
inactive-text="禁用" </el-form-item>
/> <el-form-item label="库存数量" prop="inventory">
</el-form-item> <el-input-number
<el-form-item label="库存数量" prop="inventory"> v-model="productForm.inventory"
<el-input-number :min="0"
v-model="productForm.inventory" :disabled="!productForm.inventory_control"
:min="0" placeholder="请输入库存"
:disabled="!productForm.inventory_control" controls-position="right"
placeholder="请输入库存" style="width: 100%"
controls-position="right" />
style="width: 100%" </el-form-item>
/> <el-form-item label="售罄状态" prop="sold_out">
</el-form-item> <el-switch
</div> v-model="productForm.sold_out"
</div> active-text="售罄"
inactive-text="正常"
active-color="#f56c6c"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">开启后用户端将无法选购该商品</div>
</el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number
v-model="productForm.max_per_user"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量0 表示不限制</div>
</el-form-item>
<el-form-item label="首购最大时长" prop="max_first_purchase_duration">
<el-input-number
v-model="productForm.max_first_purchase_duration"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制用户首次购买的最大时长单位0 表示不限制</div>
</el-form-item>
</div>
</el-tab-pane>
<!-- 推荐与参数 --> <el-tab-pane label="推荐与通知" name="promotion">
<div class="form-section"> <div class="form-grid">
<div class="form-section-title">推荐与参数</div> <el-form-item label="推荐" prop="recommend">
<div class="form-grid"> <el-switch
<el-form-item label="推荐" prop="recommend"> v-model="productForm.recommend"
<el-switch active-text="启用"
v-model="productForm.recommend" inactive-text="禁用"
active-text="启用" />
inactive-text="禁用" </el-form-item>
/> <el-form-item prop="recommend_rebate">
</el-form-item> <template #label>
<el-form-item prop="recommend_rebate"> <span>推荐返还<span class="unit-suffix">%</span></span>
<template #label> </template>
<span>推荐返还<span class="unit-suffix">%</span></span> <el-input-number
</template> v-model="productForm.recommend_rebate"
<el-input-number :min="0"
v-model="productForm.recommend_rebate" :max="100"
:min="0" :disabled="!productForm.recommend"
:max="100" placeholder="返还百分比"
:disabled="!productForm.recommend" controls-position="right"
placeholder="返还百分比" style="width: 100%"
controls-position="right" />
style="width: 100%" </el-form-item>
/> <el-form-item prop="renew_recommend_rebate">
</el-form-item> <template #label>
</div> <span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</div> </template>
<el-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="推荐词" prop="recommend_words">
<el-input v-model="productForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="购买通知" prop="send_notice">
<el-switch
v-model="productForm.send_notice"
active-text="发送"
inactive-text="不发送"
/>
<div style="font-size:12px;color:#909399;margin:4px">用户购买后是否发送通知给管理员</div>
</el-form-item>
</div>
</el-tab-pane>
</el-tabs>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@@ -764,7 +844,8 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue' import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
import { import {
@@ -777,10 +858,14 @@ import {
getProductGroupTagList, getProductGroupTagList,
deleteProductGroupTag, deleteProductGroupTag,
getProductList, getProductList,
getProductDetail,
createProduct, createProduct,
updateProduct, updateProduct,
deleteProduct, deleteProduct,
} from '@/api/admin/product' } from '@/api/admin/product'
const route = useRoute()
const router = useRouter()
import AvatarSelector from '@/components/admin/AvatarSelector.vue' import AvatarSelector from '@/components/admin/AvatarSelector.vue'
import GroupTagManager from './components/GroupTagManager.vue' import GroupTagManager from './components/GroupTagManager.vue'
import ProductParameterManager from './components/ProductParameterManager.vue' import ProductParameterManager from './components/ProductParameterManager.vue'
@@ -823,7 +908,8 @@ const groupForm = reactive({
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
const groupRules = { const groupRules = {
@@ -873,7 +959,14 @@ const productForm = reactive({
recommend_rebate: 0, recommend_rebate: 0,
arg_type: 'all', arg_type: 'all',
can_renew: true, can_renew: true,
require_real_name: false require_real_name: false,
sold_out: false,
max_per_user: 0,
max_first_purchase_duration: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0,
recommend_words: ''
}) })
const productRules = { const productRules = {
@@ -895,6 +988,7 @@ const productRules = {
// 商品对话框状态 // 商品对话框状态
const productDialogVisible = ref(false) const productDialogVisible = ref(false)
const productDialogType = ref('add') const productDialogType = ref('add')
const productFormTab = ref('sales')
const productFormRef = ref(null) const productFormRef = ref(null)
const coverSelectorVisible = ref(false) const coverSelectorVisible = ref(false)
@@ -1419,7 +1513,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: parentTagId || undefined, tag_id: parentTagId || undefined,
index: 0 index: 0,
recommend_words: ''
}) })
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag) console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
} else { } else {
@@ -1434,7 +1529,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
} }
@@ -1455,7 +1551,8 @@ const handleEdit = (row) => {
cover_id: row.coverId || undefined, cover_id: row.coverId || undefined,
cover_url: row.cover || '', cover_url: row.cover || '',
tag_id: row.tag?.id || row.tagId || undefined, tag_id: row.tag?.id || row.tagId || undefined,
index: row.index || 0 index: row.index || 0,
recommend_words: row.recommendWords || ''
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -1523,7 +1620,10 @@ const handleAddProduct = () => {
recommend: false, recommend: false,
recommend_rebate: 0, recommend_rebate: 0,
arg_type: 'all', arg_type: 'all',
require_real_name: false require_real_name: false,
max_per_user: 0,
max_first_purchase_duration: 0,
recommend_words: ''
}) })
selectedProductGroup.value = null selectedProductGroup.value = null
@@ -1557,7 +1657,14 @@ const handleEditProduct = (product, parentGroupId) => {
recommend_rebate: product.recommendRebate, recommend_rebate: product.recommendRebate,
arg_type: product.argType || 'all', arg_type: product.argType || 'all',
can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true), can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true),
require_real_name: product.requireRealName ?? product.require_real_name ?? false require_real_name: product.requireRealName ?? product.require_real_name ?? false,
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
}) })
productDialogVisible.value = true productDialogVisible.value = true
@@ -1583,7 +1690,14 @@ const submitProductForm = () => {
recommend_rebate: Number(productForm.recommend_rebate) || 0, recommend_rebate: Number(productForm.recommend_rebate) || 0,
arg_type: productForm.arg_type, arg_type: productForm.arg_type,
can_renew: productForm.can_renew, can_renew: productForm.can_renew,
require_real_name: productForm.require_real_name require_real_name: productForm.require_real_name,
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
recommend_words: productForm.recommend_words || ''
} }
let res let res
@@ -1722,7 +1836,8 @@ const submitForm = () => {
name: groupForm.name.trim(), name: groupForm.name.trim(),
note: groupForm.note || '', note: groupForm.note || '',
disable: groupForm.disable, disable: groupForm.disable,
index: Number(groupForm.index) || 0 index: Number(groupForm.index) || 0,
recommend_words: groupForm.recommend_words || ''
} }
if (groupForm.parent_id) { if (groupForm.parent_id) {
@@ -1795,9 +1910,45 @@ watch(activeTab, (newVal) => {
const groupTagManagerRef = ref(null) const groupTagManagerRef = ref(null)
// 初始化 // 初始化
onMounted(() => { // 根据 good_id 请求商品详情并弹出对应商品弹窗(用于从订单详情等页面跳转)
fetchGroupList() const openProductByGoodId = async (goodId) => {
const id = Number(goodId)
if (!id) return
try {
const res = await getProductDetail({ good_id: id })
if (res.data.code === 200 && res.data.data) {
handleEditProduct(res.data.data)
} else {
ElMessage.error(res.data.message || '获取商品详情失败')
}
} catch (error) {
console.error('获取商品详情失败:', error)
ElMessage.error('获取商品详情失败')
}
}
// 检查并处理 good_id 参数
const checkAndOpenGoodId = async () => {
if (route.query.good_id) {
await openProductByGoodId(route.query.good_id)
router.replace({ query: {} })
}
}
onMounted(async () => {
await fetchGroupList()
fetchAllTagOptions() fetchAllTagOptions()
await checkAndOpenGoodId()
})
// 组件被 keep-alive 缓存后重新激活时,再次检查 good_id
onActivated(() => {
checkAndOpenGoodId()
})
// 同一页面内 query 变化时也触发(如从其他标签页点击跳转)
watch(() => route.query.good_id, (newId) => {
if (newId) checkAndOpenGoodId()
}) })
</script> </script>
@@ -2185,6 +2336,18 @@ onMounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.product-form-tabs {
margin-top: 16px;
}
.product-form-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.product-form-tabs :deep(.el-tab-pane) {
padding: 4px 0;
}
.form-section { .form-section {
margin-bottom: var(--section-gap); margin-bottom: var(--section-gap);
padding: 16px; padding: 16px;
File diff suppressed because it is too large Load Diff
+398 -224
View File
@@ -3,94 +3,237 @@
<div class="page-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
<el-button @click="goBack" link class="back-btn"> <el-button @click="goBack" link class="back-btn">
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表 <el-icon><ArrowLeft /></el-icon> 返回列表
</el-button> </el-button>
<el-divider direction="vertical" /> <el-divider direction="vertical" />
<span class="page-title">所有商品详情</span> <span class="page-title">用户商品详情</span>
<el-tag v-if="detail" :type="currentTag === '云服务器' ? 'primary' : 'info'" size="small" style="margin-left:8px">{{ detail.tag || detail.good?.tag || '通用' }}</el-tag>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button type="primary" plain @click="loadDetail" :loading="loading"> <el-button plain @click="loadDetail" :loading="loading">
<el-icon><Refresh /></el-icon> 刷新 <el-icon><Refresh /></el-icon> 刷新
</el-button> </el-button>
<el-button type="primary" plain @click="openEdit" :disabled="!detail">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-button type="danger" plain @click="handleDelete" :disabled="!detail">
<el-icon><Delete /></el-icon> 删除
</el-button>
</div> </div>
</div> </div>
<div class="main-content" v-loading="loading"> <div class="main-content" v-loading="loading">
<!-- 空状态 -->
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160"> <el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
<el-button type="primary" @click="loadDetail">重新加载</el-button> <el-button type="primary" @click="loadDetail">重新加载</el-button>
</el-empty> </el-empty>
<el-card class="profile-card" shadow="hover" v-if="detail"> <template v-if="detail">
<div class="profile-header"> <!-- 已删除提示 -->
<div class="profile-basic"> <el-alert
<div class="icon-wrapper"> v-if="isDeleted"
<el-icon :size="48" color="#409eff"><Monitor /></el-icon> type="error"
:closable="false"
show-icon
class="deleted-alert"
title="该用户商品已删除"
:description="`删除时间:${deletedTime}。已删除商品仅展示上游快照信息(itemArg),不再请求虚拟机等实时详情。`"
/>
<!-- Hero 概览区 -->
<div class="hero-section">
<div class="hero-left">
<div class="hero-icon">
<svg viewBox="0 0 48 48" width="44" height="44" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="8" width="40" height="28" rx="4" stroke="currentColor" stroke-width="2.5" />
<path d="M16 40h16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
<path d="M24 36v4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
<circle cx="24" cy="22" r="6" stroke="currentColor" stroke-width="2" />
<path d="M24 19v3l2 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div> </div>
<div class="identity"> <div class="hero-info">
<div class="name-row"> <h1 class="hero-name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1> <div class="hero-meta">
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button> <span class="meta-chip">
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button> <svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM1 8a7 7 0 1114 0A7 7 0 011 8z" fill="currentColor"/><path d="M8 4v4.5l3 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
</div> {{ expireLabel }}
<div class="id-row"> </span>
<span class="label">ID:</span> <span class="meta-chip" :class="expireStatusClass">
<span class="value">{{ detail.id || goodsId }}</span> <svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
<el-divider direction="vertical" /> {{ expireStatusText }}
<span class="label">用户ID:</span> </span>
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span> <span class="meta-chip id-chip">ID: {{ detail.id || goodsId }}</span>
<el-divider direction="vertical" /> <span v-if="isDeleted" class="meta-chip status-deleted">
<span class="label">到期:</span> <svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span> 已删除
<el-divider direction="vertical" /> </span>
<span class="label">续费价:</span>
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="profile-stats"> <div class="hero-right">
<div class="stat-item"> <div class="price-block">
<div class="stat-label">套餐ID</div> <div class="price-label">续费价格</div>
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div> <div class="price-value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</div>
</div> </div>
<div class="stat-item"> <div class="price-divider"></div>
<div class="stat-label">备注</div> <div class="price-block">
<div class="stat-value note-value">{{ detail.note || '-' }}</div> <div class="price-label">基础价格</div>
<div class="price-value secondary">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</div>
</div> </div>
</div> </div>
</div> </div>
<el-divider style="margin: 16px 0 12px" />
<el-descriptions :column="3" border size="small" style="width:100%">
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="hover" v-if="detail" class="related-card"> <!-- 数据指标卡片行 -->
<template #header> <div class="metrics-row">
<span class="card-title">关联信息</span> <div class="metric-card is-link" @click="goToUser">
</template> <div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
<el-descriptions :column="2" border size="small"> <svg viewBox="0 0 20 20" width="20" height="20" fill="none"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-2.76 3.13-5 7-5s7 2.24 7 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item> </div>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item> <div class="metric-body">
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item> <div class="metric-label">所属用户</div>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item> <div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
<el-descriptions-item label="订单状态"> <div class="metric-name">{{ detail.user?.userName || '-' }}</div>
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small"> </div>
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }} <div class="metric-arrow">
</el-tag> <svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span v-else>-</span> </div>
</el-descriptions-item> </div>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item> <div class="metric-card is-link" @click="goToProduct">
</el-descriptions> <div class="metric-icon" style="background:#f0faf0;color:#52c41a">
</el-card> <svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M17 7l-7-4-7 4v6l7 4 7-4V7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 11v7M3 7l7 4 7-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联商品</div>
<div class="metric-value">#{{ detail.goodId || '-' }}</div>
<div class="metric-name">{{ detail.good?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card is-link" @click="goToOrder">
<div class="metric-icon" style="background:#fff7ed;color:#f5a623">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5.5M6 13h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联订单</div>
<div class="metric-value">#{{ detail.orderId || '-' }}</div>
<div class="metric-name">{{ detail.order?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#fef0f0;color:#f56c6c">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">套餐ID</div>
<div class="metric-value">#{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#f5f0ff;color:#722ed1">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M7 3h6l4 4v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7l4-4z" stroke="currentColor" stroke-width="1.5"/><path d="M7 3v4H3M10 9v5M8 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">归属项ID</div>
<div class="metric-value">#{{ detail.itemId || '-' }}</div>
</div>
</div>
</div>
<!-- 详细信息折叠区 -->
<div class="detail-section">
<div class="section-header" @click="detailExpanded = !detailExpanded">
<div class="section-header-left">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="section-title">详细信息</span>
</div>
<el-icon :class="{ 'is-expanded': detailExpanded }"><ArrowDown /></el-icon>
</div>
<transition name="slide">
<div v-show="detailExpanded" class="section-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">商品标签</span>
<span class="info-value"><el-tag size="small" type="info">{{ detail.good?.tag || detail.tag || '-' }}</el-tag></span>
</div>
<div class="info-item">
<span class="info-label">商品Table</span>
<span class="info-value">{{ detail.good?.table || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ detail.note || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">订单状态</span>
<span class="info-value">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatTime(detail.CreatedAt) }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatTime(detail.UpdatedAt) }}</span>
</div>
</div>
</div>
</transition>
</div>
<!-- 已删除展示 itemArg 上游快照信息不再请求虚拟机详情 -->
<el-card v-if="isDeleted" shadow="hover" class="related-card">
<template #header>
<div class="card-header-row">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="card-title">上游快照信息 (itemArg)</span>
</div>
</template>
<el-descriptions v-if="itemArgEntries.length" :column="2" border size="small">
<el-descriptions-item v-for="item in itemArgEntries" :key="item.key" :label="item.label">
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="该商品无 itemArg 快照信息" :image-size="100" />
</el-card>
<!-- 类型适配栏目区域 -->
<UserVmDetail v-else-if="currentTag === '云服务器'" embedded :goods-id="goodsId" />
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
<template #header>
<div class="card-header-row">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
<span class="card-title">关联信息</span>
</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
</div> </div>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close> <el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
<el-form :model="editForm" label-width="110px"> <el-form :model="editForm" label-width="110px">
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item> <el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
@@ -142,14 +285,15 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue' import { ArrowLeft, ArrowDown, Refresh, Edit, Delete } from '@element-plus/icons-vue'
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm' import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue' import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const route = useRoute() const route = useRoute()
@@ -159,6 +303,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const detail = ref(null) const detail = ref(null)
const detailExpanded = ref(false)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-' const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => { const formatExpireTime = (t) => {
@@ -168,8 +313,82 @@ const formatExpireTime = (t) => {
return d.format('YYYY-MM-DD HH:mm:ss') return d.format('YYYY-MM-DD HH:mm:ss')
} }
const expireLabel = computed(() => formatExpireTime(detail.value?.expireTime || detail.value?.expire_time))
const expireStatusText = computed(() => {
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return '未知'
const d = dayjs(t)
if (d.year() < 2000) return '永久有效'
const diff = d.diff(dayjs(), 'day')
if (diff < 0) return '已到期'
if (diff <= 7) return `即将到期 (${diff}天)`
return '正常'
})
const expireStatusClass = computed(() => {
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return ''
const d = dayjs(t)
if (d.year() < 2000) return 'status-forever'
const diff = d.diff(dayjs(), 'day')
if (diff < 0) return 'status-expired'
if (diff <= 7) return 'status-warning'
return 'status-ok'
})
const goBack = () => router.push('/user-goods/list') const goBack = () => router.push('/user-goods/list')
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
// deleteAt
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
const deletedTime = computed(() => {
const t = detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at
return t ? formatTime(t) : '-'
})
// itemArg JSON
const parsedItemArg = computed(() => {
const raw = detail.value?.itemArg ?? detail.value?.ItemArg ?? detail.value?.item_arg
if (!raw) return null
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch { return null }
}
if (typeof raw === 'object') return raw
return null
})
// itemArg
const itemArgLabelMap = {
id: '实例ID',
name: '实例名称',
vcpu: 'CPU核数',
memory: '内存',
status: '运行状态',
state: '运行状态',
ips: 'IP地址',
ip: 'IP地址',
disk: '磁盘',
bandwidth: '带宽',
image: '镜像',
image_name: '镜像名称',
os_type: '系统类型'
}
// itemArg
const itemArgEntries = computed(() => {
const obj = parsedItemArg.value
if (!obj || typeof obj !== 'object') return []
return Object.entries(obj)
.filter(([, v]) => v !== null && v !== undefined && v !== '' && typeof v !== 'object')
.map(([k, v]) => ({
key: k,
label: itemArgLabelMap[k] || k,
value: String(v)
}))
})
const loadDetail = async () => { const loadDetail = async () => {
if (!goodsId.value) return if (!goodsId.value) return
loading.value = true loading.value = true
@@ -182,6 +401,22 @@ const loadDetail = async () => {
finally { loading.value = false } finally { loading.value = false }
} }
const goToUser = () => {
const uid = detail.value?.userId || detail.value?.user_id
if (!uid) return
router.push({ path: '/user/detail', query: { user_id: uid } })
}
const goToProduct = () => {
const gid = detail.value?.goodId
if (!gid) return
router.push({ path: '/product/manage', query: { good_id: gid } })
}
const goToOrder = () => {
const oid = detail.value?.orderId
if (!oid) return
router.push({ path: '/order/list', query: { order_id: oid } })
}
const editVisible = ref(false) const editVisible = ref(false)
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' }) const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
const showVmSelector = ref(false) const showVmSelector = ref(false)
@@ -202,7 +437,6 @@ const openEdit = () => {
_serviceName: '', _serviceName: '',
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : '' _itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
}) })
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
editVisible.value = true editVisible.value = true
} }
@@ -233,186 +467,126 @@ const handleDelete = () => {
}).catch(() => {}) }).catch(() => {})
} }
let isInitialMount = true
onMounted(loadDetail) onMounted(loadDetail)
onActivated(() => {
if (isInitialMount) {
isInitialMount = false
return
}
detail.value = null
loadDetail()
})
watch(goodsId, (newId, oldId) => { watch(goodsId, (newId, oldId) => {
if (newId && newId !== oldId) { detail.value = null; loadDetail() } if (newId && newId !== oldId) { detail.value = null; loadDetail() }
}) })
</script> </script>
<style scoped> <style scoped>
.goods-detail-page { .goods-detail-page { padding: 0; }
padding: 0;
}
.page-header { /* ---- Page Header ---- */
display: flex; .page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
justify-content: space-between; .header-left { display: flex; align-items: center; gap: 0; }
align-items: center; .back-btn { font-size: 14px; color: #606266; }
padding: 16px 20px; .back-btn:hover { color: #409eff; }
background: #fff; .page-title { font-size: 16px; font-weight: 600; color: #303133; }
border-bottom: 1px solid #e1e8ed; .header-right { display: flex; gap: 8px; }
}
.header-left { /* ---- Main Content ---- */
display: flex; .main-content { padding: 20px; min-height: 300px; }
align-items: center;
gap: 0;
}
.back-btn { /* ---- Hero Section ---- */
font-size: 14px; .hero-section {
color: #606266; display: flex; justify-content: space-between; align-items: center;
background: linear-gradient(135deg, #f0f5ff 0%, #f8faff 60%, #fff 100%);
border: 1px solid #e1e8f0; border-radius: 10px;
padding: 24px 28px; gap: 24px;
} }
.hero-left { display: flex; align-items: center; gap: 20px; flex: 1; min-width: 0; }
.back-btn:hover { .hero-icon {
color: #409eff; width: 72px; height: 72px; border-radius: 16px;
background: linear-gradient(135deg, #409eff 0%, #6366f1 100%);
color: #fff; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; box-shadow: 0 6px 16px rgba(64, 158, 255, 0.25);
} }
.hero-info { min-width: 0; flex: 1; }
.page-title { .hero-name { font-size: 20px; font-weight: 700; color: #1a1a2e; margin: 0 0 10px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
font-size: 16px; .hero-meta { display: flex; gap: 10px; flex-wrap: wrap; }
font-weight: 600; .meta-chip {
color: #303133; display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: #606266; background: #fff; border: 1px solid #e8e8e8;
padding: 3px 10px; border-radius: 20px; white-space: nowrap;
} }
.meta-chip.id-chip { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.3px; }
.meta-chip.status-ok { color: #52c41a; border-color: #b7eb8f; background: #f6ffed; }
.meta-chip.status-warning { color: #fa8c16; border-color: #ffd591; background: #fff7e6; }
.meta-chip.status-expired { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
.meta-chip.status-forever { color: #722ed1; border-color: #d3adf7; background: #f9f0ff; }
.meta-chip.status-deleted { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
.header-right { .deleted-alert { margin-bottom: 16px; }
display: flex;
gap: 8px; .hero-right { display: flex; align-items: center; gap: 0; flex-shrink: 0; }
.price-block { text-align: center; padding: 0 20px; }
.price-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
.price-value { font-size: 22px; font-weight: 700; color: #1a1a2e; }
.price-value.secondary { color: #606266; font-weight: 600; }
.price-divider { width: 1px; height: 36px; background: #e0e0e0; }
/* ---- Metrics Row ---- */
.metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 16px; }
.metric-card {
display: flex; align-items: center; gap: 12px;
background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
padding: 14px 16px; transition: all 0.2s; position: relative;
} }
.metric-card:hover { border-color: #d0d5dd; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.metric-card.is-link { cursor: pointer; }
.metric-card.is-link:hover { border-color: #409eff; background: #f8fbff; box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); }
.metric-card.is-link:hover .metric-arrow { color: #409eff; transform: translateX(2px); }
.metric-card.is-link:hover .metric-value { color: #409eff; }
.metric-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.metric-body { min-width: 0; flex: 1; }
.metric-label { font-size: 12px; color: #909399; margin-bottom: 2px; }
.metric-value { font-size: 15px; font-weight: 600; color: #303133; font-family: 'SF Mono', 'Consolas', monospace; transition: color 0.2s; }
.metric-name { font-size: 12px; color: #909399; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.metric-arrow { color: #c0c4cc; transition: all 0.2s; flex-shrink: 0; }
.main-content { /* ---- Detail Section (collapsible) ---- */
padding: 20px; .detail-section {
min-height: 300px; margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
}
.profile-card {
margin-bottom: 0;
border: 1px solid #e1e8ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.profile-basic {
display: flex;
align-items: center;
gap: 20px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.identity {
display: flex;
flex-direction: column;
gap: 8px;
}
.name-row {
display: flex;
align-items: center;
gap: 10px;
}
.name {
font-size: 22px;
font-weight: 600;
color: #303133;
margin: 0;
}
.id-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
flex-wrap: wrap;
}
.id-row .label {
color: #909399;
}
.id-row .value {
color: #606266;
font-weight: 500;
}
.profile-stats {
display: flex;
gap: 24px;
flex-shrink: 0;
}
.stat-item {
text-align: center;
min-width: 80px;
padding: 10px 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.note-value {
font-weight: 400;
font-size: 13px;
max-width: 200px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.section-header {
.related-card { display: flex; justify-content: space-between; align-items: center;
margin-top: 20px; padding: 14px 18px; cursor: pointer; user-select: none;
border: 1px solid #e1e8ed; transition: background 0.15s;
border-radius: 8px;
} }
.section-header:hover { background: #fafbfc; }
.section-header-left { display: flex; align-items: center; gap: 8px; color: #303133; }
.section-title { font-size: 14px; font-weight: 600; }
.section-header .el-icon { transition: transform 0.25s; color: #909399; font-size: 14px; }
.section-header .el-icon.is-expanded { transform: rotate(180deg); }
.section-body { padding: 0 18px 18px; }
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 24px; }
.info-item { display: flex; flex-direction: column; gap: 4px; }
.info-label { font-size: 12px; color: #909399; }
.info-value { font-size: 14px; color: #303133; font-weight: 500; }
.card-title { .slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
font-size: 15px; .slide-enter-from, .slide-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
font-weight: 600; .slide-enter-to, .slide-leave-from { opacity: 1; max-height: 300px; }
color: #303133;
}
.selector-row { /* ---- Related Card (fallback for non-VM) ---- */
display: flex; .related-card { margin-top: 16px; border: 1px solid #ebeef5; border-radius: 8px; }
align-items: center; .card-header-row { display: flex; align-items: center; gap: 8px; color: #303133; }
width: 100%; .card-title { font-size: 15px; font-weight: 600; color: #303133; }
}
:deep(.el-descriptions__label) {
font-weight: 500;
color: #606266;
}
/* ---- Shared ---- */
.selector-row { display: flex; align-items: center; width: 100%; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; } .unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; } .unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
</style> </style>
+167 -17
View File
@@ -1,6 +1,45 @@
<template> <template>
<div class="user-goods-list"> <div class="user-goods-list">
<el-card class="main-container" shadow="never"> <el-card class="main-container" shadow="never">
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card" :class="{ active: query.status === 'all' }" @click="handleStatusCard('all')">
<div class="stat-icon stat-icon-total"><el-icon><Box /></el-icon></div>
<div class="stat-body">
<div class="stat-label">商品总数</div>
<div class="stat-value">{{ countTotal }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'normal' }" @click="handleStatusCard('normal')">
<div class="stat-icon stat-icon-normal"><el-icon><CircleCheck /></el-icon></div>
<div class="stat-body">
<div class="stat-label">正常</div>
<div class="stat-value">{{ counts.normal }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'expired' }" @click="handleStatusCard('expired')">
<div class="stat-icon stat-icon-expired"><el-icon><Clock /></el-icon></div>
<div class="stat-body">
<div class="stat-label">已到期</div>
<div class="stat-value">{{ counts.expired }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'pending' }" @click="handleStatusCard('pending')">
<div class="stat-icon stat-icon-pending"><el-icon><Timer /></el-icon></div>
<div class="stat-body">
<div class="stat-label">待开通</div>
<div class="stat-value">{{ counts.pending }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'deleted' }" @click="handleStatusCard('deleted')">
<div class="stat-icon stat-icon-deleted"><el-icon><Delete /></el-icon></div>
<div class="stat-body">
<div class="stat-label">已删除</div>
<div class="stat-value">{{ counts.deleted }}</div>
</div>
</div>
</div>
<!-- 筛选与操作栏 --> <!-- 筛选与操作栏 -->
<div class="filter-section"> <div class="filter-section">
<div class="filter-content"> <div class="filter-content">
@@ -23,11 +62,20 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
<el-option label="全部" value="all" />
<el-option label="正常" value="normal" />
<el-option label="已到期" value="expired" />
<el-option label="待开通" value="pending" />
<el-option label="已删除" value="deleted" />
</el-select>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
</el-button> </el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button> <el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="action-bar"> <div class="action-bar">
@@ -87,12 +135,22 @@
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${formatTime(row.deleteAt || row.DeleteAt || row.deleted_at)}`" placement="top">
<el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip>
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag>
<el-tag v-else-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="套餐ID" width="90"> <el-table-column label="套餐ID" width="90">
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template> <template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="订单" min-width="180" show-overflow-tooltip> <el-table-column label="订单" min-width="180" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { key: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link> <el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { order_id: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -464,8 +522,8 @@
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue' import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete, Timer } from '@element-plus/icons-vue'
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm' import { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool' import { formatToApiTime } from '@/utils/tool'
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product' import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
@@ -480,12 +538,16 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const list = ref([]) const list = ref([])
const total = ref(0) const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' }) const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '', status: 'all' })
const filterUserName = ref('') const filterUserName = ref('')
const filterGoodName = ref('') const filterGoodName = ref('')
const showFilterUserSelector = ref(false) const showFilterUserSelector = ref(false)
const showFilterProductSelector = ref(false) const showFilterProductSelector = ref(false)
//
const counts = reactive({ normal: 0, deleted: 0, expired: 0, pending: 0 })
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0) + (counts.pending || 0))
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-' const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => { const formatExpireTime = (t) => {
@@ -495,6 +557,17 @@ const formatExpireTime = (t) => {
return d.format('YYYY-MM-DD HH:mm:ss') return d.format('YYYY-MM-DD HH:mm:ss')
} }
// deleteAt
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
//
const isExpired = (row) => {
const t = row?.expireTime || row?.expire_time
if (!t) return false
const d = dayjs(t)
return d.year() >= 2000 && d.isBefore(dayjs())
}
const formatMemory = (kb) => { const formatMemory = (kb) => {
if (!kb) return '-' if (!kb) return '-'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB' if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
@@ -520,13 +593,20 @@ const getStatusText = (status) => {
} }
} }
// count list
const buildFilterParams = () => {
const params = {}
if (query.key) params.key = query.key
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
return params
}
const loadList = async () => { const loadList = async () => {
loading.value = true loading.value = true
try { try {
const params = { page: query.page, count: query.count } const params = { page: query.page, count: query.count, ...buildFilterParams() }
if (query.key) params.key = query.key if (query.status && query.status !== 'all') params.status = query.status
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
const res = await getUserGoodsList(params) const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data const d = res.data.data
@@ -536,7 +616,27 @@ const loadList = async () => {
} catch { list.value = []; total.value = 0 } finally { loading.value = false } } catch { list.value = []; total.value = 0 } finally { loading.value = false }
} }
const handleSearch = () => { query.page = 1; loadList() } //
const loadCount = async () => {
try {
const res = await getUserGoodsCount(buildFilterParams())
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
counts.normal = d.normal ?? 0
counts.deleted = d.deleted ?? 0
counts.expired = d.expired ?? 0
counts.pending = d.pending ?? 0
}
} catch { /* 统计失败不阻断列表 */ }
}
const handleSearch = () => { query.page = 1; loadList(); loadCount() }
//
const handleStatusCard = (status) => {
query.status = status
handleSearch()
}
// ---- ---- // ---- ----
const argsSpecList = ref([]) const argsSpecList = ref([])
@@ -841,12 +941,7 @@ const submitEdit = async () => {
// ---- / ---- // ---- / ----
const handleDetail = (row) => { const handleDetail = (row) => {
const tag = (row.tag || row.good?.tag || '').toLowerCase() router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
}
} }
const handleMoreCmd = (cmd, row) => { const handleMoreCmd = (cmd, row) => {
@@ -911,7 +1006,7 @@ const handleSendRemind = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
onMounted(loadList) onMounted(() => { loadList(); loadCount() })
</script> </script>
<style scoped> <style scoped>
@@ -924,6 +1019,61 @@ onMounted(loadList)
background: #ffffff; background: #ffffff;
} }
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #ffffff;
border: 1px solid #e1e8ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card.active {
border-color: #409eff;
background: #ecf5ff;
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
font-size: 20px;
flex-shrink: 0;
}
.stat-icon-total { background: #eef3ff; color: #4f6ef7; }
.stat-icon-normal { background: #f0faf0; color: #52c41a; }
.stat-icon-expired { background: #fff7e6; color: #fa8c16; }
.stat-icon-deleted { background: #fff1f0; color: #f5222d; }
.stat-icon-pending { background: #e6f7ff; color: #1890ff; }
.stat-body { display: flex; flex-direction: column; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 2px; }
.stat-value { font-size: 22px; font-weight: 600; color: #2c3e50; line-height: 1.1; }
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
@@ -275,13 +275,13 @@
<el-form-item v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range"> <el-form-item v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range">
<div class="range-config-row"> <div class="range-config-row">
<el-select v-model="paramValueForm.range_type" class="range-type-select"> <el-select v-model="paramValueForm.range_type" class="range-type-select">
<el-option label="" value="before"> <el-option label="" value="before">
<span class="range-opt-symbol"></span> <span class="range-opt-symbol"></span>
<span class="range-opt-desc">小于</span> <span class="range-opt-desc">小于等于</span>
</el-option> </el-option>
<el-option label="" value="after"> <el-option label="" value="after">
<span class="range-opt-symbol"></span> <span class="range-opt-symbol"></span>
<span class="range-opt-desc">大于</span> <span class="range-opt-desc">大于等于</span>
</el-option> </el-option>
<el-option label="" value="equal"> <el-option label="" value="equal">
<span class="range-opt-symbol"></span> <span class="range-opt-symbol"></span>
@@ -635,7 +635,7 @@ const getArgTypeTag = (type) => {
return tagMap[type] || 'info' return tagMap[type] || 'info'
} }
const getRangeTypeText = (type) => { const getRangeTypeText = (type) => {
const typeMap = { 'after': '', 'before': '', 'equal': '' } const typeMap = { 'after': '', 'before': '', 'equal': '' }
return typeMap[type] || type || '-' return typeMap[type] || type || '-'
} }
@@ -47,6 +47,9 @@
<span class="price-symbol">¥</span> <span class="price-symbol">¥</span>
<span class="price-amount">{{ formatPlanPrice(row) }}</span> <span class="price-amount">{{ formatPlanPrice(row) }}</span>
</div> </div>
<div v-if="isFixedPrice(row) && getRenewPrice(row) > 0" class="plan-renew-price">
续费 ¥{{ formatRenewPrice(row) }}
</div>
</div> </div>
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)"> <div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
<div class="plan-stat-label"> <div class="plan-stat-label">
@@ -113,6 +116,9 @@
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small" effect="plain"> <el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small" effect="plain">
{{ row.canUpdate || row.can_update ? '允许升级' : '禁止升级' }} {{ row.canUpdate || row.can_update ? '允许升级' : '禁止升级' }}
</el-tag> </el-tag>
<el-tag v-if="(row.maxPerUser || row.max_per_user) > 0" type="warning" size="small" effect="plain">
限购{{ row.maxPerUser || row.max_per_user }}
</el-tag>
</div> </div>
<!-- 说明 --> <!-- 说明 -->
@@ -271,6 +277,13 @@
<span class="unit-text"></span> <span class="unit-text"></span>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="续费固定价格" prop="renew_fixed_price" v-if="planForm.enable_fixed_price === true">
<div class="unit-input-row">
<el-input-number v-model="planForm.renew_fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="0 表示沿用固定价格" />
<span class="unit-text"></span>
</div>
<div class="form-tip">续费时使用的固定价格 0 时沿用上方固定价格</div>
</el-form-item>
<el-form-item label="排序索引" prop="index"> <el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" /> <el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
</el-form-item> </el-form-item>
@@ -288,6 +301,10 @@
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" /> <el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
<div class="form-tip">控制用户是否可以升级到此套餐</div> <div class="form-tip">控制用户是否可以升级到此套餐</div>
</el-form-item> </el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number v-model="planForm.max_per_user" :min="0" placeholder="单用户最大购买数量" style="width: 100%" />
<div class="form-tip">限制单个用户对该套餐的最大购买数量0 表示不限制</div>
</el-form-item>
</el-form> </el-form>
</div> </div>
<template #footer> <template #footer>
@@ -378,7 +395,9 @@ const planForm = reactive({
index: 0, index: 0,
disable: false, disable: false,
show_home: false, show_home: false,
can_update: false can_update: false,
max_per_user: 0,
renew_fixed_price: 0
}) })
const planFormRules = { const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }] name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
@@ -455,6 +474,14 @@ const formatPlanPrice = (row) => {
return (Number(raw) / 100).toFixed(2) return (Number(raw) / 100).toFixed(2)
} }
const getRenewPrice = (row) => {
return Number(row.renewFixedPrice ?? row.renew_fixed_price ?? 0)
}
const formatRenewPrice = (row) => {
return (getRenewPrice(row) / 100).toFixed(2)
}
const getInventoryNum = (row) => { const getInventoryNum = (row) => {
return Number(row.inventory ?? 0) || 0 return Number(row.inventory ?? 0) || 0
} }
@@ -546,11 +573,11 @@ const findMatchingNumberAttr = (spec, numValue) => {
if (!spec.attrs || spec.attrs.length === 0) return null if (!spec.attrs || spec.attrs.length === 0) return null
const sortedAttrs = [...spec.attrs].sort((a, b) => (a.index || 0) - (b.index || 0)) const sortedAttrs = [...spec.attrs].sort((a, b) => (a.index || 0) - (b.index || 0))
for (const attr of sortedAttrs) { for (const attr of sortedAttrs) {
const phase = attr.phase || 0 const rangeVal = attr.range ?? attr.phase ?? 0
const rangeType = attr.rangeType || 'before' const rangeType = attr.rangeType || 'before'
if (rangeType === 'before' && numValue < phase) return attr if (rangeType === 'before' && numValue <= rangeVal) return attr
else if (rangeType === 'after' && numValue > phase) return attr else if (rangeType === 'after' && numValue >= rangeVal) return attr
else if (rangeType === 'equal' && numValue === phase) return attr else if (rangeType === 'equal' && numValue === rangeVal) return attr
} }
return sortedAttrs[sortedAttrs.length - 1] return sortedAttrs[sortedAttrs.length - 1]
} }
@@ -758,7 +785,7 @@ const handleAddPlan = async () => {
displayUnits[spec.id] = getParamDefaultUnit(spec) displayUnits[spec.id] = getParamDefaultUnit(spec)
} }
} }
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false }) Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false, max_per_user: 0 })
planFormDialogVisible.value = true planFormDialogVisible.value = true
nextTick(() => { planFormRef.value?.resetFields() }) nextTick(() => { planFormRef.value?.resetFields() })
} }
@@ -785,7 +812,9 @@ const handleEditPlan = async (row) => {
inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1, inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1,
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home), index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
can_update: !!(data.canUpdate || data.can_update) can_update: !!(data.canUpdate || data.can_update),
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0,
renew_fixed_price: ((data.renewFixedPrice ?? data.renew_fixed_price ?? 0) / 100).toFixed(2) * 1
}) })
initSelectedArgsFromJson(data.args, extraArgIdsArray) initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true planFormDialogVisible.value = true
@@ -842,7 +871,9 @@ const submitPlanForm = () => {
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '', args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '',
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
index: Number(planForm.index) || 0, show_home: planForm.show_home === true, index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
can_update: planForm.can_update === true can_update: planForm.can_update === true,
max_per_user: Number(planForm.max_per_user) || 0,
renew_fixed_price: Math.round(Number(planForm.renew_fixed_price) * 100) || 0
} }
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res let res
@@ -998,6 +1029,14 @@ watch(() => props.visible, (val) => {
color: #f56c6c; color: #f56c6c;
font-weight: 700; font-weight: 700;
} }
.plan-renew-price {
font-size: 11px;
color: #e6a23c;
font-weight: 500;
margin-top: 2px;
line-height: 1;
}
.plan-stat-price.is-dynamic { .plan-stat-price.is-dynamic {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%); background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
border-color: #c6e2ff; border-color: #c6e2ff;
+892
View File
@@ -0,0 +1,892 @@
<template>
<div class="sms-goods-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#e6a23c" stroke-width="1.8">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div>
<h2 class="header-title">短信额度商品管理</h2>
<p class="header-desc">
管理短信平台的额度商品配置
<template v-if="filterServiceName">
当前筛选<el-tag size="small" type="warning">{{ filterServiceName }}</el-tag>
</template>
</p>
</div>
</div>
<div class="header-actions">
<el-button @click="router.push('/sms/service')">
<el-icon><Back /></el-icon> 返回服务列表
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增商品
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select
v-model="queryParams.service_id"
placeholder="筛选主控服务"
clearable
style="width: 220px"
@change="handleSearch"
>
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-input
v-model="queryParams.key"
placeholder="搜索商品名称"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="关联服务" min-width="140">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goToService(row.serviceId)">
{{ resolveServiceName(row.serviceId) }}
</el-button>
</template>
</el-table-column>
<el-table-column label="关联商品" min-width="150">
<template #default="{ row }">
<span class="goods-name">{{ row.good?.name || `商品#${row.goodId}` }}</span>
</template>
</el-table-column>
<el-table-column label="额度类型" width="120" align="center">
<template #default="{ row }">
<el-tag :type="quotaTypeTag(row.quotaType).type" effect="dark" size="small">
{{ quotaTypeTag(row.quotaType).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="有效期/周期配置" min-width="200">
<template #default="{ row }">
<template v-if="row.quotaType === 2">
<span class="config-label">有效期模式</span>
<el-tag size="small" type="info">{{ row.expireMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
</template>
<template v-else-if="row.quotaType === 3">
<span class="config-label">周期模式</span>
<el-tag size="small" type="info">{{ row.cycleMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
<template v-if="row.cycleMode === 'fixed'">
<span class="config-detail">{{ row.cycleValue || 1 }}{{ cycleUnitLabel(row.cycleUnit) }}</span>
</template>
</template>
<span v-else class="config-detail">永久有效</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确认删除该商品绑定?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增弹窗 -->
<el-dialog
v-model="addDialogVisible"
title="新增短信额度商品"
width="680px"
destroy-on-close
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="130px">
<el-divider content-position="left">基本信息</el-divider>
<el-form-item label="关联主控服务" prop="service_id">
<el-select v-model="addForm.service_id" placeholder="选择短信主控服务" style="width: 100%">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="商品分组" prop="good_group_id">
<el-tree-select
v-model="addForm.good_group_id"
:data="groupTreeData"
:props="groupTreeProps"
lazy
:load="loadGroupChildren"
node-key="id"
placeholder="选择商品分组"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="商品名称" prop="name">
<el-input v-model="addForm.name" placeholder="如:短信1000条包" />
</el-form-item>
<el-form-item label="商品介绍">
<el-input v-model="addForm.content" type="textarea" :rows="2" placeholder="商品介绍(可选,系统自动追加配额说明)" />
</el-form-item>
<el-form-item label="管理备注">
<el-input v-model="addForm.note" placeholder="管理备注(可选)" />
</el-form-item>
<el-divider content-position="left">额度配置</el-divider>
<el-form-item label="额度类型" prop="quota_type">
<div class="quota-type-cards">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: addForm.quota_type === qt.value }"
@click="addForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
<div class="qt-desc">{{ qt.desc }}</div>
</div>
</div>
</el-form-item>
<el-form-item label="额度数量模式" prop="quota_value_type">
<el-radio-group v-model="addForm.quota_value_type">
<el-radio value="number">数值范围用户输入数值</el-radio>
<el-radio value="select">固定选项用户选择档位</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="addForm.quota_value_type === 'number'">
<el-form-item label="最小值">
<el-input-number v-model="addForm.quota_min" :min="1" :step="100" />
</el-form-item>
<el-form-item label="最大值">
<el-input-number v-model="addForm.quota_max" :min="1" :step="100" />
</el-form-item>
<el-form-item label="步长">
<el-input-number v-model="addForm.quota_step" :min="1" :step="10" />
</el-form-item>
<el-form-item label="单条价格" prop="quota_unit_price">
<el-input-number v-model="addForm.quota_unit_price" :min="0" :step="0.01" :precision="2" controls-position="right" />
<span class="opt-unit">/</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="额度选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.quota_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="数量" controls-position="right" class="opt-value" />
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-input-number v-model="item.price" :min="0" :step="0.01" :precision="2" placeholder="价格" controls-position="right" class="opt-price" />
<span class="opt-unit"></span>
<el-button link type="danger" @click="addForm.quota_options_list.splice(idx, 1)" :disabled="addForm.quota_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.quota_options_list.push({ value: null, label: '', price: null })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 短期额度配置 -->
<template v-if="addForm.quota_type === 2">
<el-divider content-position="left">有效期配置</el-divider>
<el-form-item label="有效期模式" prop="expire_mode">
<el-radio-group v-model="addForm.expire_mode">
<el-radio value="fixed">固定天数管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'fixed'" label="有效天数">
<el-input-number v-model="addForm.expire_fixed" :min="1" />
<span style="margin-left: 8px; color: #909399"></span>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'select'" label="有效期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.expire_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="天数" controls-position="right" class="opt-value" />
<span class="opt-unit"></span>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.expire_options_list.splice(idx, 1)" :disabled="addForm.expire_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.expire_options_list.push({ value: null, label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 周期额度配置 -->
<template v-if="addForm.quota_type === 3">
<el-divider content-position="left">周期配置</el-divider>
<el-form-item label="周期单位模式" prop="cycle_mode">
<el-radio-group v-model="addForm.cycle_mode">
<el-radio value="fixed">固定单位管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="addForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'select'" label="周期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.cycle_options_list" :key="idx" class="option-row">
<el-select v-model="item.value" placeholder="周期单位" class="opt-cycle-unit">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.cycle_options_list.splice(idx, 1)" :disabled="addForm.cycle_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.cycle_options_list.push({ value: '', label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
<el-form-item label="周期数值" prop="cycle_value">
<el-input-number v-model="addForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitAdd">确定</el-button>
</template>
</el-dialog>
<!-- 编辑弹窗仅可修改 SmsGoods 自身字段 -->
<el-dialog
v-model="editDialogVisible"
title="编辑短信额度商品"
width="580px"
destroy-on-close
>
<el-form ref="editFormRef" :model="editForm" label-width="130px">
<el-form-item label="管理备注">
<el-input v-model="editForm.note" placeholder="管理备注" />
</el-form-item>
<el-form-item label="额度类型">
<div class="quota-type-cards small">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: editForm.quota_type === qt.value }"
@click="editForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
</div>
</div>
</el-form-item>
<template v-if="editForm.quota_type === 2">
<el-form-item label="有效期模式">
<el-radio-group v-model="editForm.expire_mode">
<el-radio value="fixed">固定天数</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-if="editForm.quota_type === 3">
<el-form-item label="周期单位模式">
<el-radio-group v-model="editForm.cycle_mode">
<el-radio value="fixed">固定单位</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="editForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="editForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item label="周期数值">
<el-input-number v-model="editForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitEdit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Search, Back, Delete } from '@element-plus/icons-vue'
import {
getSmsServiceList,
getSmsGoodsList,
createSmsGoods,
updateSmsGoods,
deleteSmsGoods
} from '@/api/admin/smsService.js'
import { getProductGroupList } from '@/api/admin/product'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const serviceOptions = ref([])
const serviceMap = ref({})
const addDialogVisible = ref(false)
const editDialogVisible = ref(false)
const addFormRef = ref(null)
const editFormRef = ref(null)
const groupTreeData = ref([])
const groupTreeProps = { label: 'name', children: 'children', isLeaf: (data) => !data.existSub }
const filterServiceName = computed(() => route.query.service_name || '')
const queryParams = reactive({
page: 1,
count: 10,
service_id: route.query.service_id ? parseInt(route.query.service_id) : undefined,
key: ''
})
const quotaTypes = [
{
value: 1, label: '长期',
desc: '永久有效,一次购买持续可用',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
},
{
value: 2, label: '短期',
desc: '限时有效,到期后额度失效',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'
},
{
value: 3, label: '周期',
desc: '按周期自动重置额度',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
}
]
const defaultAddForm = () => ({
service_id: queryParams.service_id || null,
good_group_id: null,
name: '',
content: '',
note: '',
quota_type: 1,
quota_value_type: 'number',
quota_options_list: [{ value: null, label: '', price: null }],
quota_min: 100,
quota_max: 100000,
quota_step: 100,
quota_unit_price: 0.05,
expire_mode: 'fixed',
expire_fixed: 30,
expire_options_list: [{ value: null, label: '' }],
cycle_mode: 'fixed',
cycle_unit: 'month',
cycle_value: 1,
cycle_options_list: [{ value: '', label: '' }]
})
const serializeOptions = (list, withPrice = false) => {
return list
.filter(item => item.value !== null && item.value !== '' && item.label)
.map(item => withPrice ? `${item.value}:${item.label}:${item.price ?? 0}` : `${item.value}:${item.label}`)
.join(',')
}
const addForm = ref(defaultAddForm())
const editForm = ref({
id: null,
note: '',
quota_type: 1,
expire_mode: '',
cycle_mode: '',
cycle_unit: '',
cycle_value: 1
})
const addRules = {
service_id: [{ required: true, message: '请选择主控服务', trigger: 'change' }],
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const quotaTypeTag = (type) => {
const map = { 1: { label: '长期', type: 'success' }, 2: { label: '短期', type: 'warning' }, 3: { label: '周期', type: '' } }
return map[type] || { label: '未知', type: 'info' }
}
const cycleUnitLabel = (unit) => {
const map = { day: '天', week: '周', month: '月', year: '年' }
return map[unit] || unit
}
const resolveServiceName = (id) => {
return serviceMap.value[id] || `服务#${id}`
}
const goToService = (serviceId) => {
router.push({ path: '/sms/service' })
}
const loadGroupOptions = async () => {
try {
const res = await getProductGroupList({ level: 1, count: 199 })
if (res.data.code === 200) {
groupTreeData.value = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
}
} catch (e) {
console.error(e)
}
}
const loadGroupChildren = async (node, resolve) => {
if (node.level === 0) return resolve(groupTreeData.value)
try {
const res = await getProductGroupList({ parent_id: node.data.id, level: node.data.level + 1, count: 199 })
if (res.data.code === 200) {
const children = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
resolve(children)
} else {
resolve([])
}
} catch (e) {
resolve([])
}
}
const loadServiceOptions = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
const body = res.data
if (body.code === 200) {
const list = body.data?.data || body.data || []
serviceOptions.value = Array.isArray(list) ? list : []
const map = {}
serviceOptions.value.forEach(s => { map[s.id] = s.name })
serviceMap.value = map
}
} catch (e) {
console.error(e)
}
}
const fetchList = async () => {
loading.value = true
try {
const params = { ...queryParams }
if (!params.service_id) delete params.service_id
const res = await getSmsGoodsList(params)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = Array.isArray(d) ? d : []
total.value = body.data?.all_count || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.service_id = undefined
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
addForm.value = defaultAddForm()
addDialogVisible.value = true
}
const handleSubmitAdd = async () => {
await addFormRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
params.append('service_id', addForm.value.service_id)
if (addForm.value.good_group_id) params.append('good_group_id', addForm.value.good_group_id)
params.append('name', addForm.value.name)
if (addForm.value.content) params.append('content', addForm.value.content)
if (addForm.value.note) params.append('note', addForm.value.note)
params.append('quota_type', addForm.value.quota_type)
params.append('quota_value_type', addForm.value.quota_value_type)
if (addForm.value.quota_value_type === 'number') {
params.append('quota_min', addForm.value.quota_min)
params.append('quota_max', addForm.value.quota_max)
params.append('quota_step', addForm.value.quota_step)
params.append('quota_unit_price', addForm.value.quota_unit_price)
} else {
params.append('quota_options', serializeOptions(addForm.value.quota_options_list, true))
}
if (addForm.value.quota_type === 2) {
params.append('expire_mode', addForm.value.expire_mode)
if (addForm.value.expire_mode === 'fixed') {
params.append('expire_fixed', addForm.value.expire_fixed)
} else {
params.append('expire_options', serializeOptions(addForm.value.expire_options_list))
}
}
if (addForm.value.quota_type === 3) {
params.append('cycle_mode', addForm.value.cycle_mode)
params.append('cycle_value', addForm.value.cycle_value)
if (addForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', addForm.value.cycle_unit)
} else {
params.append('cycle_options', serializeOptions(addForm.value.cycle_options_list))
}
}
const res = await createSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('创建成功')
addDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '创建失败')
}
} catch (e) {
ElMessage.error('创建失败')
} finally {
submitting.value = false
}
}
const handleEdit = (row) => {
editForm.value = {
id: row.id,
note: row.note || '',
quota_type: row.quotaType,
expire_mode: row.expireMode || 'fixed',
cycle_mode: row.cycleMode || 'fixed',
cycle_unit: row.cycleUnit || 'month',
cycle_value: row.cycleValue || 1
}
editDialogVisible.value = true
}
const handleSubmitEdit = async () => {
submitting.value = true
try {
const params = new URLSearchParams()
params.append('id', editForm.value.id)
if (editForm.value.note) params.append('note', editForm.value.note)
params.append('quota_type', editForm.value.quota_type)
if (editForm.value.quota_type === 2) {
params.append('expire_mode', editForm.value.expire_mode)
}
if (editForm.value.quota_type === 3) {
params.append('cycle_mode', editForm.value.cycle_mode)
params.append('cycle_value', editForm.value.cycle_value)
if (editForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', editForm.value.cycle_unit)
}
}
const res = await updateSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('更新成功')
editDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '更新失败')
}
} catch (e) {
ElMessage.error('更新失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(async () => {
await Promise.all([loadServiceOptions(), loadGroupOptions()])
fetchList()
})
</script>
<style scoped>
.sms-goods-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #fef9f0 0%, #fdf2e4 100%);
border-radius: 12px;
border: 1px solid #f0dfc8;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.15);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
}
.header-desc {
font-size: 13px;
color: #909399;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.goods-name {
font-weight: 500;
color: #303133;
}
.config-label {
color: #909399;
font-size: 12px;
margin-right: 4px;
}
.config-detail {
color: #606266;
font-size: 13px;
margin-left: 6px;
}
.note-text {
color: #909399;
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* 额度类型卡片 */
.quota-type-cards {
display: flex;
gap: 12px;
}
.quota-type-cards.small .qt-card {
padding: 10px 16px;
}
.qt-card {
flex: 1;
padding: 16px;
border: 2px solid #ebeef5;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
background: #fafbfc;
}
.qt-card:hover {
border-color: #c0c4cc;
background: #fff;
}
.qt-card.active {
border-color: #409eff;
background: #f0f7ff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.qt-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
color: #909399;
}
.qt-card.active .qt-icon {
color: #409eff;
}
.qt-label {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.qt-desc {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.form-hint {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
/* 动态选项列表 */
.dynamic-options {
width: 100%;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.option-row:last-of-type {
margin-bottom: 10px;
}
.opt-value {
width: 140px;
flex-shrink: 0;
}
.opt-unit {
font-size: 13px;
color: #909399;
flex-shrink: 0;
}
.opt-label {
flex: 1;
min-width: 0;
}
.opt-price {
width: 120px;
flex-shrink: 0;
}
.opt-cycle-unit {
width: 120px;
flex-shrink: 0;
}
</style>
+410
View File
@@ -0,0 +1,410 @@
<template>
<div class="sms-service-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M8 9h8M8 13h4" stroke-linecap="round"/>
</svg>
</div>
<div>
<h2 class="header-title">短信主控服务管理</h2>
<p class="header-desc">管理短信平台的主控服务实例每个服务对应一个 sms-server 节点</p>
</div>
</div>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增服务
</el-button>
</div>
<!-- 搜索栏 -->
<div class="filter-bar">
<el-input
v-model="queryParams.key"
placeholder="搜索名称 / 说明 / 地址"
clearable
style="width: 300px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="服务名称" min-width="150">
<template #default="{ row }">
<div class="service-name-cell">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8M12 17v4"/>
</svg>
<span class="name-text">{{ row.name }}</span>
<el-tag v-if="row.default" type="success" size="small" effect="dark" style="margin-left: 6px">默认</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="host" label="服务地址" min-width="250">
<template #default="{ row }">
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="serviceToken" label="Service Token" min-width="200">
<template #default="{ row }">
<div class="token-cell">
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
<span v-else class="token-full">{{ row.serviceToken }}</span>
<el-button link size="small" @click="row._showToken = !row._showToken">
{{ row._showToken ? '隐藏' : '显示' }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
<el-button v-if="!row.default" link type="warning" size="small" @click="handleSetDefault(row)">设为默认</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="goToGoods(row)">额度商品</el-button>
<el-popconfirm :title="row.default ? '该服务为默认服务,删除前请先设置其他服务为默认' : '确认删除该服务?'" :disabled="row.default" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small" :disabled="row.default">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑短信主控服务' : '新增短信主控服务'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="服务名称" prop="name">
<el-input v-model="form.name" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
</el-form-item>
<el-form-item label="服务地址" prop="host">
<el-input v-model="form.host" placeholder="https://sms.example.com" />
</el-form-item>
<el-form-item label="Service Token" prop="service_token">
<el-input v-model="form.service_token" placeholder="sms-server 的 SERVICE_TOKEN" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
getSmsServiceList,
createSmsService,
updateSmsService,
deleteSmsService,
setDefaultSmsService
} from '@/api/admin/smsService.js'
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const queryParams = reactive({
page: 1,
count: 10,
key: ''
})
const form = ref({
id: null,
name: '',
note: '',
host: '',
service_token: ''
})
const rules = {
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const maskToken = (token) => {
if (!token) return '-'
if (token.length <= 8) return '****'
return token.slice(0, 4) + '****' + token.slice(-4)
}
const fetchList = async () => {
loading.value = true
try {
const res = await getSmsServiceList(queryParams)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
total.value = body.data?.all_count || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', host: '', service_token: '' }
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
host: row.host,
service_token: row.serviceToken || ''
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
if (isEdit.value) params.append('id', form.value.id)
params.append('name', form.value.name)
params.append('note', form.value.note)
params.append('host', form.value.host)
params.append('service_token', form.value.service_token)
const fn = isEdit.value ? updateSmsService : createSmsService
const res = await fn(params)
if (res.data.code === 200) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteSmsService(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
const handleSetDefault = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await setDefaultSmsService(params)
if (res.data.code === 200) {
ElMessage.success(`已将「${row.name}」设为默认短信服务`)
fetchList()
} else {
ElMessage.error(res.data.message || '设置失败')
}
} catch (e) {
ElMessage.error('设置默认服务失败')
}
}
const openConsole = (row) => {
const base = (row.host || '').replace(/\/+$/, '')
if (!base) return ElMessage.warning('该服务未配置地址')
window.open(`${base}/login?serverToken=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
}
const goToGoods = (row) => {
router.push({ path: '/sms/goods', query: { service_id: row.id, service_name: row.name } })
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.sms-service-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
border-radius: 12px;
border: 1px solid #e0ecf5;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
}
.header-desc {
font-size: 13px;
color: #909399;
margin: 0;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.service-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
color: #303133;
}
.note-text {
color: #909399;
font-size: 13px;
}
.host-tag {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.token-cell {
display: flex;
align-items: center;
gap: 8px;
}
.token-mask {
font-family: 'Consolas', 'Monaco', monospace;
color: #c0c4cc;
letter-spacing: 1px;
}
.token-full {
font-family: 'Consolas', 'Monaco', monospace;
color: #606266;
font-size: 12px;
word-break: break-all;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<template>
<div class="sms-signature-page">
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#67c23a" stroke-width="1.8">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15l2 2 4-4"/>
</svg>
</div>
<div>
<h2 class="header-title">短信签名管理</h2>
<p class="header-desc">管理用户提交的短信签名审核通过后方可使用</p>
</div>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增签名
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="queryParams.service_id" placeholder="选择主控服务" style="width: 200px" @change="handleSearch">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width: 140px" @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="审核中" :value="1" />
<el-option label="已通过" :value="2" />
<el-option label="已驳回" :value="3" />
</el-select>
<el-input
v-if="filterUserInfo"
:model-value="`${filterUserInfo.user_name} (ID: ${queryParams.user_id})`"
readonly
style="width: 200px"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearFilterUser"><Close /></el-icon>
</template>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="filterUserSelectorVisible = true">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 卡片列表 -->
<div v-loading="loading" class="signature-cards">
<div v-if="!tableData.length && !loading" class="empty-state">
<el-empty description="暂无签名数据" :image-size="80" />
</div>
<div v-for="item in tableData" :key="item.ID" class="sig-card" :class="`status-${item.status}`">
<div class="sig-card-header">
<div class="sig-card-title">
<span class="sig-title-text">{{ item.title }}</span>
<el-tag :type="statusTagType(item.status)" size="small" effect="dark">{{ statusText(item.status) }}</el-tag>
</div>
<span class="sig-card-id">#{{ item.ID }}</span>
</div>
<div class="sig-card-body">
<div class="sig-card-info">
<div class="sig-info-row">
<span class="sig-label">申请人</span>
<span class="sig-value">{{ item.applicant_name || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">用户</span>
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: item.user_id } })">ID: {{ item.user_id }}</el-link>
</div>
<div class="sig-info-row">
<span class="sig-label">公司</span>
<span class="sig-value">{{ item.applicant_company || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">身份证</span>
<span class="sig-value sig-id-card">{{ item.applicant_id_card || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">创建时间</span>
<span class="sig-value">{{ formatDate(item.CreatedAt) }}</span>
</div>
<div v-if="item.reject_reason" class="sig-info-row reject-row">
<span class="sig-label">驳回原因</span>
<span class="sig-value reject-text">{{ item.reject_reason }}</span>
</div>
</div>
<div class="sig-card-license">
<el-image
v-if="item.license_url"
:src="item.license_url"
:preview-src-list="[item.license_url]"
fit="cover"
class="license-img"
preview-teleported
/>
<div v-else class="license-placeholder">
<el-icon :size="24"><Picture /></el-icon>
<span>无资质证明</span>
</div>
</div>
</div>
<div class="sig-card-footer">
<el-button type="primary" size="small" @click="handleEdit(item)">编辑</el-button>
<el-button v-if="item.status === 0" type="warning" size="small" @click="handleSubmit(item)">提交审核</el-button>
<el-button v-if="item.status === 1" type="success" size="small" @click="handleApprove(item)">通过</el-button>
<el-button v-if="item.status === 1" type="danger" size="small" plain @click="handleReject(item)">驳回</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(item)">删除</el-button>
</div>
</div>
</div>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
@size-change="fetchList"
@current-change="fetchList"
background
class="pagination"
/>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增签名' : '编辑签名'" width="560px" append-to-body destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
<el-form-item label="主控服务" prop="service_id">
<el-select v-model="form.service_id" placeholder="选择服务" style="width: 100%" :disabled="dialogType === 'edit'">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<el-input
v-if="selectedUserInfo"
:model-value="`${selectedUserInfo.user_name} (ID: ${form.user_id})`"
readonly
>
<template #suffix>
<el-icon v-if="dialogType === 'add'" class="clear-icon" @click="clearUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="userSelectorVisible = true" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
<el-input v-else placeholder="请选择用户" readonly @click="openUserSelector">
<template #append>
<el-button @click="openUserSelector" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="签名标题" prop="title">
<el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit />
</el-form-item>
<el-form-item label="申请人姓名">
<el-input v-model="form.applicant_name" placeholder="申请人真实姓名" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号" />
</el-form-item>
<el-form-item label="公司名称">
<el-input v-model="form.applicant_company" placeholder="申请人公司(可选)" />
</el-form-item>
<el-form-item label="营业执照URL">
<el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleFormSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 驳回原因对话框 -->
<el-dialog v-model="rejectDialogVisible" title="驳回签名" width="420px" append-to-body>
<el-form label-width="80px">
<el-form-item label="驳回原因">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 -->
<UserListSelector
v-model="userSelectorVisible"
:current-user-id="form.user_id"
@confirm="handleUserSelect"
/>
<!-- 筛选用户选择器 -->
<UserListSelector
v-model="filterUserSelectorVisible"
:current-user-id="queryParams.user_id ? Number(queryParams.user_id) : undefined"
@confirm="handleFilterUserSelect"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, User, Close, Picture } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/tool'
import {
getSmsServiceList,
getSmsSignatureList, createSmsSignature, updateSmsSignature, deleteSmsSignature,
submitSmsSignature, approveSmsSignature, rejectSmsSignature
} from '@/api/admin/smsService'
import UserListSelector from '@/components/admin/UserListSelector.vue'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const serviceOptions = ref([])
const queryParams = reactive({ service_id: null, page: 1, count: 10, user_id: '', status: null })
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const form = reactive({ service_id: null, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
const formRules = {
service_id: [{ required: true, message: '请选择服务', trigger: 'change' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
title: [{ required: true, message: '请输入签名标题', trigger: 'blur' }]
}
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const currentRow = ref(null)
const userSelectorVisible = ref(false)
const selectedUserInfo = ref(null)
const filterUserSelectorVisible = ref(false)
const filterUserInfo = ref(null)
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
const loadServices = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
const body = res.data
if (body.code === 200) {
const list = body.data?.data || body.data || []
serviceOptions.value = Array.isArray(list) ? list : []
if (serviceOptions.value.length && !queryParams.service_id) {
queryParams.service_id = serviceOptions.value[0].id
}
}
} catch (e) { console.error(e) }
}
const fetchList = async () => {
if (!queryParams.service_id) return
loading.value = true
try {
const params = { service_id: queryParams.service_id, page: queryParams.page, count: queryParams.count }
if (queryParams.user_id) params.user_id = Number(queryParams.user_id)
if (queryParams.status !== null && queryParams.status !== '') params.status = queryParams.status
const res = await getSmsSignatureList(params)
if (res.data.code === 200) {
const d = res.data.data
tableData.value = d?.data || d || []
total.value = d?.all_count || 0
}
} catch (e) { ElMessage.error('获取签名列表失败') }
finally { loading.value = false }
}
const handleFilterUserSelect = (user) => {
queryParams.user_id = String(user.user_id)
filterUserInfo.value = user
handleSearch()
}
const clearFilterUser = () => {
queryParams.user_id = ''
filterUserInfo.value = null
handleSearch()
}
const handleSearch = () => { queryParams.page = 1; fetchList() }
const handleReset = () => { queryParams.user_id = ''; queryParams.status = null; filterUserInfo.value = null; queryParams.page = 1; fetchList() }
const openUserSelector = () => {
if (dialogType.value === 'edit') return
userSelectorVisible.value = true
}
const handleUserSelect = (user) => {
form.user_id = user.user_id
selectedUserInfo.value = user
}
const clearUser = () => {
form.user_id = null
selectedUserInfo.value = null
}
const handleAdd = () => {
dialogType.value = 'add'
selectedUserInfo.value = null
Object.assign(form, { service_id: queryParams.service_id, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
dialogVisible.value = true
}
const handleEdit = (row) => {
dialogType.value = 'edit'
selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
Object.assign(form, {
service_id: queryParams.service_id,
signature_id: row.ID,
user_id: row.user_id,
title: row.title,
applicant_name: row.applicant_name || '',
applicant_id_card: row.applicant_id_card || '',
applicant_company: row.applicant_company || '',
license_url: row.license_url || ''
})
dialogVisible.value = true
}
const handleFormSubmit = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const data = { ...form }
let res
if (dialogType.value === 'add') {
res = await createSmsSignature(data)
} else {
res = await updateSmsSignature(data)
}
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
dialogVisible.value = false
fetchList()
} else { ElMessage.error(res.data.message || '操作失败') }
} catch (e) { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleSubmit = (row) => {
ElMessageBox.confirm(`确认提交签名「${row.title}」进行审核?`, '提交审核', { type: 'info' }).then(async () => {
try {
const res = await submitSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已提交审核'); fetchList() }
else { ElMessage.error(res.data.message || '提交失败') }
} catch { ElMessage.error('提交失败') }
}).catch(() => {})
}
const handleApprove = (row) => {
ElMessageBox.confirm(`确认通过签名「${row.title}」?`, '审核通过', { type: 'success' }).then(async () => {
try {
const res = await approveSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已通过'); fetchList() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleReject = (row) => {
currentRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
submitting.value = true
try {
const res = await rejectSmsSignature({ service_id: queryParams.service_id, signature_id: currentRow.value.ID, reject_reason: rejectReason.value })
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchList() }
else { ElMessage.error(res.data.message || '驳回失败') }
} catch { ElMessage.error('驳回失败') }
finally { submitting.value = false }
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除签名「${row.title}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchList() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
onMounted(async () => {
await loadServices()
fetchList()
})
</script>
<style scoped>
.sms-signature-page { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-info { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #f0f9eb; display: flex; align-items: center; justify-content: center; }
.header-title { margin: 0; font-size: 20px; color: #303133; }
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
.header-actions { display: flex; gap: 10px; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
.clear-icon:hover { color: #f56c6c; }
/* 卡片列表 */
.signature-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
min-height: 200px;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: 40px 0;
}
.sig-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 10px;
padding: 18px;
transition: all 0.25s;
display: flex;
flex-direction: column;
}
.sig-card:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
transform: translateY(-2px);
}
.sig-card.status-1 { border-left: 3px solid #e6a23c; }
.sig-card.status-2 { border-left: 3px solid #67c23a; }
.sig-card.status-3 { border-left: 3px solid #f56c6c; }
.sig-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.sig-card-title {
display: flex;
align-items: center;
gap: 10px;
}
.sig-title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.sig-card-id {
font-size: 12px;
color: #c0c4cc;
}
.sig-card-body {
display: flex;
gap: 16px;
flex: 1;
margin-bottom: 14px;
}
.sig-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.sig-info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.sig-label {
color: #909399;
min-width: 56px;
flex-shrink: 0;
}
.sig-value {
color: #303133;
word-break: break-all;
}
.sig-id-card {
font-family: monospace;
font-size: 12px;
color: #606266;
}
.reject-row .sig-value {
color: #f56c6c;
}
.reject-text { color: #f56c6c; }
.sig-card-license {
flex-shrink: 0;
width: 90px;
display: flex;
align-items: center;
justify-content: center;
}
.license-img {
width: 80px;
height: 80px;
border-radius: 6px;
border: 1px solid #ebeef5;
cursor: pointer;
}
.license-placeholder {
width: 80px;
height: 80px;
border-radius: 6px;
background: #f5f7fa;
border: 1px dashed #dcdfe6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #c0c4cc;
font-size: 11px;
}
.sig-card-footer {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
flex-wrap: wrap;
}
</style>
+632
View File
@@ -0,0 +1,632 @@
<template>
<div class="sms-template-page">
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<path d="M4 4h16v16H4z" rx="2"/>
<path d="M8 8h8M8 12h6M8 16h4" stroke-linecap="round"/>
</svg>
</div>
<div>
<h2 class="header-title">短信模板管理</h2>
<p class="header-desc">管理用户短信模板的审核推荐模板的维护</p>
</div>
</div>
</div>
<!-- 服务选择 -->
<div class="filter-bar">
<el-select v-model="serviceId" placeholder="选择主控服务" style="width: 200px" @change="onServiceChange">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</div>
<!-- 主内容 Tabs -->
<el-tabs v-model="activeTab" type="border-card">
<!-- 用户模板 -->
<el-tab-pane label="用户模板" name="user">
<div class="tab-toolbar">
<div class="tab-filters">
<el-select v-model="tplQuery.status" placeholder="全部状态" clearable style="width: 130px" @change="fetchTemplates">
<el-option label="草稿" :value="0" />
<el-option label="审核中" :value="1" />
<el-option label="已通过" :value="2" />
<el-option label="已驳回" :value="3" />
</el-select>
<el-input
v-if="tplFilterUserInfo"
:model-value="`${tplFilterUserInfo.user_name} (ID: ${tplQuery.user_id})`"
readonly
style="width: 200px"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearTplFilterUser"><Close /></el-icon>
</template>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="tplFilterUserSelectorVisible = true">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-button type="primary" @click="fetchTemplates">搜索</el-button>
</div>
<el-button type="primary" @click="handleAddTemplate">
<el-icon><Plus /></el-icon> 新增模板
</el-button>
</div>
<el-table :data="tplList" v-loading="tplLoading" stripe border style="width: 100%">
<el-table-column prop="ID" label="ID" width="70" align="center" />
<el-table-column label="用户ID" width="90">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
</template>
</el-table-column>
<el-table-column prop="name" label="模板名称" min-width="140" />
<el-table-column label="内容" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="驳回原因" min-width="130">
<template #default="{ row }">
<span v-if="row.reject_reason" class="reject-text">{{ row.reject_reason }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditTemplate(row)">编辑</el-button>
<el-button v-if="row.status === 0" type="warning" link @click="handleSubmitTpl(row)">提交审核</el-button>
<el-button v-if="row.status === 1" type="success" link @click="handleApproveTpl(row)">通过</el-button>
<el-button v-if="row.status === 1" type="danger" link @click="handleRejectTpl(row)">驳回</el-button>
<el-button type="danger" link @click="handleDeleteTpl(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="tplQuery.page"
v-model:page-size="tplQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="tplTotal"
@size-change="fetchTemplates"
@current-change="fetchTemplates"
background
class="pagination"
/>
</el-tab-pane>
<!-- 推荐模板 -->
<el-tab-pane label="推荐模板" name="recommended">
<div class="tab-toolbar">
<div></div>
<el-button type="primary" @click="handleAddRecommended">
<el-icon><Plus /></el-icon> 新增推荐模板
</el-button>
</div>
<el-table :data="recList" v-loading="recLoading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag v-if="row.category" size="small">{{ row.category }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditRecommended(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteRecommended(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="recQuery.page"
v-model:page-size="recQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="recTotal"
@size-change="fetchRecommended"
@current-change="fetchRecommended"
background
class="pagination"
/>
</el-tab-pane>
</el-tabs>
<!-- 用户模板表单对话框 -->
<el-dialog v-model="tplDialogVisible" :title="tplDialogType === 'add' ? '新增模板' : '编辑模板'" width="640px" append-to-body destroy-on-close>
<el-form ref="tplFormRef" :model="tplForm" :rules="tplFormRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<el-input
v-if="tplSelectedUser"
:model-value="`${tplSelectedUser.user_name} (ID: ${tplForm.user_id})`"
readonly
>
<template #suffix>
<el-icon v-if="tplDialogType === 'add'" class="clear-icon" @click="clearTplUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="tplUserSelectorVisible = true" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
<el-input v-else placeholder="请选择用户" readonly @click="openTplUserSelector">
<template #append>
<el-button @click="openTplUserSelector" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="推荐模板">
<el-select v-model="tplForm.recommended_id" placeholder="选择推荐模板(可选)" clearable style="width: 100%" @change="onRecommendedSelect">
<el-option v-for="r in recOptionsCache" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in tplForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="tplForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="tplForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="tplForm.content" type="textarea" :rows="4" placeholder="如:您的验证码为{code},有效期{time}分钟。" />
<div v-if="tplForm.paramsList.length" class="insert-btns">
<span class="insert-label">插入参数</span>
<el-button v-for="p in tplForm.paramsList.filter(x => x.key)" :key="p.key" size="small" @click="insertParam(p.key)">{{ '{' + p.key + '}' }}</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTplForm">确定</el-button>
</template>
</el-dialog>
<!-- 推荐模板表单对话框 -->
<el-dialog v-model="recDialogVisible" :title="recDialogType === 'add' ? '新增推荐模板' : '编辑推荐模板'" width="600px" append-to-body destroy-on-close>
<el-form ref="recFormRef" :model="recForm" :rules="recFormRules" label-width="100px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="recForm.name" placeholder="请输入名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="recForm.category" placeholder="如:验证码、通知、营销" />
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in recForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="recForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="recForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="recForm.content" type="textarea" :rows="4" placeholder="模板内容,参数使用 {key} 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="recDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitRecForm">确定</el-button>
</template>
</el-dialog>
<!-- 驳回对话框 -->
<el-dialog v-model="rejectDialogVisible" title="驳回模板" width="420px" append-to-body>
<el-form label-width="80px">
<el-form-item label="驳回原因">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="confirmRejectTpl">确认驳回</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 -->
<UserListSelector
v-model="tplUserSelectorVisible"
:current-user-id="tplForm.user_id"
@confirm="handleTplUserSelect"
/>
<!-- 筛选用户选择器 -->
<UserListSelector
v-model="tplFilterUserSelectorVisible"
:current-user-id="tplQuery.user_id ? Number(tplQuery.user_id) : undefined"
@confirm="handleTplFilterUserSelect"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, User, Close } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/tool'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import {
getSmsServiceList,
getSmsTemplateList, createSmsTemplate, updateSmsTemplate, deleteSmsTemplate,
submitSmsTemplate, approveSmsTemplate, rejectSmsTemplate,
getSmsRecommendedTemplateList, createSmsRecommendedTemplate, updateSmsRecommendedTemplate, deleteSmsRecommendedTemplate
} from '@/api/admin/smsService'
const serviceOptions = ref([])
const serviceId = ref(null)
const activeTab = ref('user')
const submitting = ref(false)
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
// ========== ==========
const loadServices = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
if (res.data.code === 200) {
const list = res.data.data?.data || res.data.data || []
serviceOptions.value = Array.isArray(list) ? list : []
if (serviceOptions.value.length && !serviceId.value) {
serviceId.value = serviceOptions.value[0].id
}
}
} catch (e) { console.error(e) }
}
const onServiceChange = () => {
fetchTemplates()
fetchRecommended()
}
// ========== ==========
const tplLoading = ref(false)
const tplList = ref([])
const tplTotal = ref(0)
const tplQuery = reactive({ page: 1, count: 10, user_id: '', status: null })
const tplFilterUserSelectorVisible = ref(false)
const tplFilterUserInfo = ref(null)
const handleTplFilterUserSelect = (user) => {
tplQuery.user_id = String(user.user_id)
tplFilterUserInfo.value = user
fetchTemplates()
}
const clearTplFilterUser = () => {
tplQuery.user_id = ''
tplFilterUserInfo.value = null
fetchTemplates()
}
const fetchTemplates = async () => {
if (!serviceId.value) return
tplLoading.value = true
try {
const params = { service_id: serviceId.value, page: tplQuery.page, count: tplQuery.count }
if (tplQuery.user_id) params.user_id = Number(tplQuery.user_id)
if (tplQuery.status !== null && tplQuery.status !== '') params.status = tplQuery.status
const res = await getSmsTemplateList(params)
if (res.data.code === 200) {
const d = res.data.data
tplList.value = d?.data || d || []
tplTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取模板列表失败') }
finally { tplLoading.value = false }
}
//
const tplDialogVisible = ref(false)
const tplDialogType = ref('add')
const tplFormRef = ref(null)
const tplForm = reactive({ user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
const tplUserSelectorVisible = ref(false)
const tplSelectedUser = ref(null)
const openTplUserSelector = () => {
if (tplDialogType.value === 'edit') return
tplUserSelectorVisible.value = true
}
const handleTplUserSelect = (user) => {
tplForm.user_id = user.user_id
tplSelectedUser.value = user
}
const clearTplUser = () => {
tplForm.user_id = null
tplSelectedUser.value = null
}
const tplFormRules = {
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const recOptionsCache = ref([])
const handleAddTemplate = () => {
tplDialogType.value = 'add'
tplSelectedUser.value = null
Object.assign(tplForm, { user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const handleEditTemplate = (row) => {
tplDialogType.value = 'edit'
tplSelectedUser.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(tplForm, { user_id: row.user_id, name: row.name, content: row.content, paramsList: params, recommended_id: row.recommended_id || null, template_id: row.ID })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const loadRecOptionsCache = async () => {
if (!serviceId.value) return
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: 1, count: 100 })
if (res.data.code === 200) {
recOptionsCache.value = res.data.data?.data || res.data.data || []
}
} catch {}
}
const onRecommendedSelect = (id) => {
if (!id) return
const rec = recOptionsCache.value.find(r => r.id === id)
if (rec) {
tplForm.content = rec.content
tplForm.paramsList = Array.isArray(rec.params) ? rec.params.map(p => ({ ...p })) : []
}
}
const insertParam = (key) => {
tplForm.content += `{${key}}`
}
const submitTplForm = () => {
tplFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = tplForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(tplForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
user_id: tplForm.user_id,
name: tplForm.name,
content: tplForm.content
}
if (paramsJson) data.params = paramsJson
if (tplForm.recommended_id) data.recommended_id = tplForm.recommended_id
let res
if (tplDialogType.value === 'add') {
res = await createSmsTemplate(data)
} else {
data.template_id = tplForm.template_id
res = await updateSmsTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); tplDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleSubmitTpl = (row) => {
ElMessageBox.confirm(`提交模板「${row.name}」审核?`, '提交审核', { type: 'info' }).then(async () => {
try {
const res = await submitSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已提交'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleApproveTpl = (row) => {
ElMessageBox.confirm(`通过模板「${row.name}」?`, '审核通过', { type: 'success' }).then(async () => {
try {
const res = await approveSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已通过'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const currentRejectRow = ref(null)
const handleRejectTpl = (row) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmRejectTpl = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
submitting.value = true
try {
const res = await rejectSmsTemplate({ service_id: serviceId.value, template_id: currentRejectRow.value.ID, reject_reason: rejectReason.value })
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
}
const handleDeleteTpl = (row) => {
ElMessageBox.confirm(`确认删除模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTemplates() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== ==========
const recLoading = ref(false)
const recList = ref([])
const recTotal = ref(0)
const recQuery = reactive({ page: 1, count: 10 })
const fetchRecommended = async () => {
if (!serviceId.value) return
recLoading.value = true
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: recQuery.page, count: recQuery.count })
if (res.data.code === 200) {
const d = res.data.data
recList.value = d?.data || d || []
recTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取推荐模板失败') }
finally { recLoading.value = false }
}
const recDialogVisible = ref(false)
const recDialogType = ref('add')
const recFormRef = ref(null)
const recForm = reactive({ name: '', content: '', paramsList: [], category: '', template_id: null })
const recFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
const handleAddRecommended = () => {
recDialogType.value = 'add'
Object.assign(recForm, { name: '', content: '', paramsList: [], category: '', template_id: null })
recDialogVisible.value = true
}
const handleEditRecommended = (row) => {
recDialogType.value = 'edit'
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(recForm, { name: row.name, content: row.content, paramsList: params, category: row.category || '', template_id: row.id })
recDialogVisible.value = true
}
const submitRecForm = () => {
recFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = recForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(recForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
name: recForm.name,
content: recForm.content
}
if (paramsJson) data.params = paramsJson
if (recForm.category) data.category = recForm.category
let res
if (recDialogType.value === 'add') {
res = await createSmsRecommendedTemplate(data)
} else {
data.template_id = recForm.template_id
res = await updateSmsRecommendedTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); recDialogVisible.value = false; fetchRecommended() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleDeleteRecommended = (row) => {
ElMessageBox.confirm(`确认删除推荐模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsRecommendedTemplate({ service_id: serviceId.value, template_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchRecommended() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== ==========
onMounted(async () => {
await loadServices()
fetchTemplates()
fetchRecommended()
})
</script>
<style scoped>
.sms-template-page { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-info { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #ecf5ff; display: flex; align-items: center; justify-content: center; }
.header-title { margin: 0; font-size: 20px; color: #303133; }
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; }
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.tab-filters { display: flex; gap: 10px; align-items: center; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.content-cell { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; font-size: 13px; cursor: pointer; }
.reject-text { color: #f56c6c; font-size: 12px; }
.text-muted { color: #c0c4cc; }
.params-editor { width: 100%; }
.param-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.insert-btns { margin-top: 8px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.insert-label { font-size: 12px; color: #909399; }
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
.clear-icon:hover { color: #f56c6c; }
</style>
+720
View File
@@ -0,0 +1,720 @@
<template>
<div class="notice-page">
<!-- 页头 -->
<div class="page-header">
<div class="header-left">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 22c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill="#409eff"/></svg>
</div>
<div>
<h2 class="page-title">通知管理</h2>
<p class="page-desc">管理通知渠道开关与消息模板控制各业务事件的通知行为</p>
</div>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" type="border-card" class="main-tabs">
<!-- ==================== 渠道配置 ==================== -->
<el-tab-pane name="channel">
<template #label>
<span class="tab-label">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" fill="currentColor"/><path d="M8 14h8v2H8zm0-4h8v2H8z" fill="currentColor"/></svg>
渠道配置
</span>
</template>
<div class="channel-section" v-loading="channelLoading">
<!-- 短信渠道 -->
<div class="channel-group">
<div class="group-header sms-header">
<div class="group-icon sms-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/><path d="M7 9h10v2H7z" fill="currentColor"/><path d="M7 6h7v2H7z" fill="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>短信通知</h3>
<span class="group-count">{{ smsChannels.length }} 个事件</span>
</div>
<div class="group-summary">
<el-tag type="success" size="small" effect="dark" round>{{ smsChannels.filter(c => c.enabled).length }} 已启用</el-tag>
<el-tag type="info" size="small" effect="plain" round>{{ smsChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
</div>
</div>
<div class="channel-cards">
<div v-for="item in smsChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
</div>
</div>
<el-empty v-if="smsChannels.length === 0" description="暂无短信通知配置" :image-size="60" />
</div>
</div>
<!-- 邮件渠道 -->
<div class="channel-group">
<div class="group-header email-header">
<div class="group-icon email-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>邮件通知</h3>
<span class="group-count">{{ emailChannels.length }} 个事件</span>
</div>
<div class="group-summary">
<el-tag type="success" size="small" effect="dark" round>{{ emailChannels.filter(c => c.enabled).length }} 已启用</el-tag>
<el-tag type="info" size="small" effect="plain" round>{{ emailChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
</div>
</div>
<div class="channel-cards">
<div v-for="item in emailChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
</div>
</div>
<el-empty v-if="emailChannels.length === 0" description="暂无邮件通知配置" :image-size="60" />
</div>
</div>
<!-- 其他渠道 -->
<div class="channel-group" v-if="otherChannels.length > 0">
<div class="group-header other-header">
<div class="group-icon other-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>其他渠道</h3>
<span class="group-count">{{ otherChannels.length }} 个事件</span>
</div>
</div>
<div class="channel-cards">
<div v-for="item in otherChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-tag size="small" type="info">{{ item.channel }}</el-tag>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== 模板管理 ==================== -->
<el-tab-pane name="template">
<template #label>
<span class="tab-label">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z" fill="currentColor"/><path d="M8 12h8v2H8zm0 4h5v2H8z" fill="currentColor"/></svg>
模板管理
</span>
</template>
<div class="template-section">
<div class="tpl-toolbar">
<div class="tpl-toolbar-left">
<el-radio-group v-model="tplTypeFilter" size="default" @change="filterTemplates">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="email">
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
邮件模板
</el-radio-button>
<el-radio-button value="phone">
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
短信模板
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" :icon="Plus" @click="openTplDialog()">新增模板</el-button>
</div>
<el-table :data="filteredTemplates" v-loading="tplLoading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="65" align="center" />
<el-table-column prop="name" label="模板名称" min-width="140">
<template #default="{ row }">
<span class="tpl-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="tag" label="模板标识" min-width="160">
<template #default="{ row }">
<code class="tpl-tag">{{ row.tag }}</code>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 'email' ? 'warning' : 'success'" effect="dark" size="small" round>
<span style="display:inline-flex;align-items:center;gap:3px">
<svg v-if="row.type === 'email'" viewBox="0 0 24 24" width="12" height="12"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
<svg v-else viewBox="0 0 24 24" width="12" height="12"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
{{ row.type === 'email' ? '邮件' : '短信' }}
</span>
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="模板内容" min-width="240">
<template #default="{ row }">
<div class="tpl-content-preview" :title="row.content">{{ row.content || '-' }}</div>
</template>
</el-table-column>
<el-table-column prop="args" label="参数" min-width="180">
<template #default="{ row }">
<div v-if="row.args" class="args-tags">
<el-tag v-for="arg in parseArgs(row.args)" :key="arg" size="small" type="info" effect="plain" class="arg-tag">{{ arg }}</el-tag>
</div>
<span v-else class="no-args">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><span class="note-text">{{ row.note || '-' }}</span></template>
</el-table-column>
<el-table-column label="更新时间" width="160" align="center">
<template #default="{ row }">{{ formatTime(row.UpdatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openTplDialog(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="handleDeleteTpl(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- ==================== 模板编辑弹窗 ==================== -->
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="1060px" append-to-body destroy-on-close @opened="onTplDialogOpened">
<div class="tpl-dialog-body">
<!-- 左侧表单 -->
<div class="tpl-dialog-left">
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="模板标识" prop="tag">
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-radio-group v-model="tplForm.type">
<el-radio value="email">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
邮件
</span>
</el-radio>
<el-radio value="phone">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
短信
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
<div class="args-btn-group">
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
{{ arg }}
</el-button>
</div>
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="8" placeholder="模板内容,点击上方参数按钮插入变量" />
</el-form-item>
</el-form>
</div>
<!-- 右侧渲染预览 -->
<div class="tpl-dialog-right">
<div class="preview-header">
<span class="preview-title">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/></svg>
渲染预览
</span>
<el-button size="small" link type="primary" @click="fetchPreview" :loading="previewLoading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
<div class="preview-body" v-loading="previewLoading">
<template v-if="previewData.rendered">
<div class="preview-rendered" :class="{ 'html-mode': tplForm.type === 'email' }" v-html="previewRenderedHtml"></div>
</template>
<div v-else-if="previewError" class="preview-empty">
<el-icon :size="32" color="#f56c6c"><WarningFilled /></el-icon>
<p>{{ previewError }}</p>
</div>
<div v-else class="preview-empty">
<el-icon :size="32" color="#c0c4cc"><View /></el-icon>
<p>填写模板标识和类型后自动加载预览</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, View, WarningFilled } from '@element-plus/icons-vue'
import {
getNoticeChannelList, updateNoticeChannel,
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate,
previewNoticeTemplate
} from '@/api/admin/noticeChannel'
// ==================== ====================
const activeTab = ref('channel')
const channelLoading = ref(false)
const channelList = ref([])
const smsChannels = computed(() => channelList.value.filter(c => c.channel === 'sms'))
const emailChannels = computed(() => channelList.value.filter(c => c.channel === 'email'))
const otherChannels = computed(() => channelList.value.filter(c => c.channel !== 'sms' && c.channel !== 'email'))
const loadChannels = async () => {
channelLoading.value = true
try {
const res = await getNoticeChannelList()
const body = res?.data
if (body?.code === 200) {
const list = body.data?.data || body.data || []
channelList.value = (Array.isArray(list) ? list : []).map(item => ({ ...item, _switching: false }))
}
} catch {
ElMessage.error('加载渠道配置失败')
} finally {
channelLoading.value = false
}
}
const handleToggle = async (row, val) => {
row._switching = true
try {
const fd = new FormData()
fd.append('id', row.id)
fd.append('enabled', val)
const res = await updateNoticeChannel(fd)
if (res?.data?.code === 200) {
const chName = row.channel === 'sms' ? '短信' : row.channel === 'email' ? '邮件' : row.channel
ElMessage.success(`${val ? '启用' : '关闭'} ${row.eventName} - ${chName}`)
} else {
row.enabled = !val
ElMessage.error(res?.data?.message || '操作失败')
}
} catch {
row.enabled = !val
ElMessage.error('操作失败')
} finally {
row._switching = false
}
}
// ==================== ====================
const tplLoading = ref(false)
const templateList = ref([])
const tplTypeFilter = ref('')
const filteredTemplates = computed(() => {
if (!tplTypeFilter.value) return templateList.value
return templateList.value.filter(t => t.type === tplTypeFilter.value)
})
const filterTemplates = () => {}
const loadTemplates = async () => {
tplLoading.value = true
try {
const res = await getNoticeTemplateList()
const body = res?.data
if (body?.code === 200) {
const list = body.data?.data || body.data || []
templateList.value = Array.isArray(list) ? list : []
}
} catch {
ElMessage.error('加载模板列表失败')
} finally {
tplLoading.value = false
}
}
//
const tplDialogVisible = ref(false)
const tplSubmitting = ref(false)
const tplFormRef = ref(null)
const tplForm = ref({ id: 0, name: '', tag: '', content: '', type: 'email', args: '' })
const tplRules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
tag: [{ required: true, message: '请输入模板标识', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const contentInputRef = ref(null)
const tplFormArgs = computed(() => parseArgs(tplForm.value.args))
const insertArg = (arg) => {
const snippet = `{{index . "${arg}"}}`
const textarea = contentInputRef.value?.textarea
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const before = tplForm.value.content.slice(0, start)
const after = tplForm.value.content.slice(end)
tplForm.value.content = before + snippet + after
nextTick(() => {
const pos = start + snippet.length
textarea.focus()
textarea.setSelectionRange(pos, pos)
})
} else {
tplForm.value.content += snippet
}
}
const openTplDialog = (row) => {
if (row) {
tplForm.value = { id: row.id, name: row.name, tag: row.tag, content: row.content, type: row.type, args: row.args || '' }
} else {
tplForm.value = { id: 0, name: '', tag: '', content: '', type: 'email', args: '' }
}
tplDialogVisible.value = true
}
const handleSubmitTpl = async () => {
const formEl = tplFormRef.value
if (!formEl) return
await formEl.validate()
tplSubmitting.value = true
try {
const fd = new FormData()
if (tplForm.value.id) fd.append('id', tplForm.value.id)
fd.append('name', tplForm.value.name)
fd.append('tag', tplForm.value.tag)
fd.append('content', tplForm.value.content)
fd.append('type', tplForm.value.type)
if (tplForm.value.args) fd.append('args', tplForm.value.args)
const apiFn = tplForm.value.id ? updateNoticeTemplate : addNoticeTemplate
const res = await apiFn(fd)
if (res?.data?.code === 200) {
ElMessage.success(tplForm.value.id ? '模板已更新' : '模板已添加')
tplDialogVisible.value = false
loadTemplates()
} else {
ElMessage.error(res?.data?.message || '操作失败')
}
} catch {
ElMessage.error('操作失败')
} finally {
tplSubmitting.value = false
}
}
const handleDeleteTpl = async (row) => {
try {
await ElMessageBox.confirm(`确定要删除模板「${row.name}」吗?`, '确认删除', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
const res = await deleteNoticeTemplate({ id: row.id })
if (res?.data?.code === 200) {
ElMessage.success('模板已删除')
loadTemplates()
} else {
ElMessage.error(res?.data?.message || '删除失败')
}
} catch {}
}
// ==================== ====================
const previewLoading = ref(false)
const previewError = ref('')
const previewData = ref({ rendered: '', default_args: null })
const previewRenderedHtml = computed(() => {
const text = previewData.value.rendered || ''
if (tplForm.value.type === 'email') return text
return text.replace(/\n/g, '<br>')
})
let previewTimer = null
const debouncedFetchPreview = () => {
clearTimeout(previewTimer)
previewTimer = setTimeout(() => fetchPreview(), 600)
}
const fetchPreview = async () => {
const tag = tplForm.value.tag?.trim()
const type = tplForm.value.type
const content = tplForm.value.content?.trim()
if (!content && (!tag || !type)) {
previewData.value = { rendered: '', default_args: null }
previewError.value = ''
return
}
previewLoading.value = true
previewError.value = ''
try {
const params = {}
if (content) {
params.content = content
} else {
params.tag = tag
params.type = type
}
const res = await previewNoticeTemplate(params)
const body = res?.data
if (body?.code === 200 && body.data) {
previewData.value = body.data
} else {
previewData.value = { rendered: '', default_args: null }
previewError.value = body?.message || '渲染失败'
}
} catch {
previewData.value = { rendered: '', default_args: null }
previewError.value = '请求失败'
} finally {
previewLoading.value = false
}
}
const onTplDialogOpened = () => {
if (tplForm.value.tag && tplForm.value.type) {
fetchPreview()
}
}
watch(() => tplForm.value.tag, debouncedFetchPreview)
watch(() => tplForm.value.type, debouncedFetchPreview)
watch(() => tplForm.value.content, debouncedFetchPreview)
// ==================== ====================
const formatTime = (t) => {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return t
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const parseArgs = (args) => {
if (!args) return []
return args.split('/').map(s => s.trim()).filter(Boolean)
}
// ==================== ====================
onMounted(() => {
loadChannels()
loadTemplates()
})
</script>
<style scoped>
.notice-page { padding: 20px 24px; }
/* ===== 页头 ===== */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.page-title { margin: 0 0 2px; font-size: 20px; font-weight: 700; color: #1d2129; }
.page-desc { margin: 0; font-size: 13px; color: #86909c; }
/* ===== 标签页 ===== */
.main-tabs { border-radius: 8px; }
.tab-label { display: inline-flex; align-items: center; gap: 6px; }
/* ===== 渠道配置 ===== */
.channel-section { padding: 4px 0; }
.channel-group { margin-bottom: 24px; }
.channel-group:last-child { margin-bottom: 0; }
.group-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-radius: 10px; margin-bottom: 14px; }
.sms-header { background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); }
.email-header { background: linear-gradient(135deg, #fdf6ec 0%, #faecd8 100%); }
.other-header { background: linear-gradient(135deg, #f4f4f5 0%, #e9e9eb 100%); }
.group-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sms-icon { background: #67c23a; color: #fff; }
.email-icon { background: #e6a23c; color: #fff; }
.other-icon { background: #909399; color: #fff; }
.group-title-area { flex: 1; }
.group-title-area h3 { margin: 0; font-size: 15px; font-weight: 600; color: #1d2129; }
.group-count { font-size: 12px; color: #86909c; }
.group-summary { display: flex; gap: 6px; }
.channel-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.channel-card { border: 1px solid #e4e7ed; border-radius: 8px; padding: 14px 16px; transition: all .25s; background: #fff; }
.channel-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); transform: translateY(-1px); }
.channel-card.enabled { border-left: 3px solid #67c23a; }
.channel-card.disabled { border-left: 3px solid #dcdfe6; opacity: .7; }
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-event-name { font-size: 14px; font-weight: 500; color: #1d2129; }
.card-bottom { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.card-event-type { font-size: 11px; font-family: 'Consolas','Monaco',monospace; color: #909399; background: #f5f7fa; padding: 1px 6px; border-radius: 3px; }
.card-note { font-size: 12px; color: #a8abb2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
/* ===== 模板管理 ===== */
.template-section { padding: 4px 0; }
.tpl-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
.tpl-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tpl-name { font-weight: 500; color: #1d2129; }
.tpl-tag { font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #606266; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; }
.tpl-content-preview { font-size: 12px; color: #606266; max-height: 42px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.6; }
.note-text { color: #86909c; font-size: 13px; }
.no-args { color: #c0c4cc; }
.args-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.args-btn-group { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
.arg-insert-btn { font-family: 'Consolas','Monaco',monospace; font-size: 12px; }
.arg-tag { font-family: 'Consolas','Monaco',monospace; font-size: 11px; }
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; }
.form-hint code { background: #f5f7fa; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #606266; }
/* ===== 弹窗双栏布局 ===== */
.radio-with-icon { display: inline-flex; align-items: center; gap: 4px; }
.tpl-dialog-body {
display: flex;
gap: 20px;
min-height: 380px;
}
.tpl-dialog-left {
flex: 1;
min-width: 0;
}
.tpl-dialog-right {
width: 380px;
flex-shrink: 0;
border: 1px solid #e4e7ed;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fafbfc;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}
.preview-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.preview-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.preview-rendered {
font-size: 13px;
line-height: 1.7;
color: #303133;
word-break: break-all;
white-space: pre-wrap;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
max-height: 320px;
overflow-y: auto;
}
.preview-rendered.html-mode {
white-space: normal;
transform: scale(0.75);
transform-origin: top left;
width: 133.33%;
max-height: 426px;
}
.preview-args {
margin-top: 14px;
}
.preview-args-title {
font-size: 12px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.preview-args-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-arg-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.preview-arg-key {
background: #ecf5ff;
color: #409eff;
padding: 2px 8px;
border-radius: 3px;
font-family: 'Consolas','Monaco',monospace;
flex-shrink: 0;
}
.preview-arg-val {
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #909399;
}
.preview-empty p {
margin: 10px 0 0;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -1130,13 +1130,7 @@ const goToUserDetail = () => {
const goToUserGoods = () => { const goToUserGoods = () => {
const goods = ticketInfo.value?.userGoods const goods = ticketInfo.value?.userGoods
if (!goods) return if (!goods) return
const tag = (goods.tag || goods.good?.tag || '').toLowerCase() const href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
let href
if (tag === '云服务器') {
href = router.resolve({ path: '/user-goods/vm-detail', query: { id: goods.id } }).href
} else {
href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
}
window.open(href, '_blank') window.open(href, '_blank')
} }
+105 -47
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="uvm-detail"> <div class="uvm-detail" :class="{ 'is-embedded': embedded }">
<div class="page-header"> <div class="page-header" v-if="!embedded">
<div class="header-left"> <div class="header-left">
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button> <el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
<el-divider direction="vertical" /> <el-divider direction="vertical" />
@@ -36,6 +36,7 @@
</div> </div>
<div class="overview-actions"> <div class="overview-actions">
<el-button v-if="isVmGoods" size="small" type="primary" @click="handleVnc">VNC</el-button> <el-button v-if="isVmGoods" size="small" type="primary" @click="handleVnc">VNC</el-button>
<el-button v-if="isVmGoods" size="small" type="success" @click="handleSshConnect" :disabled="!vmPublicIpList.length || isWindows">SSH连接</el-button>
<el-button v-if="isVmGoods" size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button> <el-button v-if="isVmGoods" size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
<el-button v-if="isVmGoods" size="small" type="warning" @click="handlePower('reboot')">重启</el-button> <el-button v-if="isVmGoods" size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
<el-button v-if="isVmGoods" size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button> <el-button v-if="isVmGoods" size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
@@ -402,15 +403,28 @@
<div class="section-header"> <div class="section-header">
<h3 class="section-title">监控指标</h3> <h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="monitorTimeMode" size="small" @change="loadMetricsHistory">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="monitorTimeMode === 'relative'" v-model="monitorRelativeMinutes" size="small" style="width: 120px" @change="loadMetricsHistory">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker <el-date-picker
v-else
v-model="monitorDateRange" v-model="monitorDateRange"
type="datetimerange" type="datetimerange"
range-separator="至" range-separator="至"
start-placeholder="开始时间" start-placeholder="开始"
end-placeholder="结束时间" end-placeholder="结束"
size="small" size="small"
style="width: 360px" style="width: 340px"
:shortcuts="monitorShortcuts"
@change="loadMetricsHistory" @change="loadMetricsHistory"
/> />
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span> <span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
@@ -464,13 +478,13 @@
<el-row :gutter="16" style="margin-top: 16px"> <el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 I/O</span></template> <template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘读写量</span></template>
<div ref="diskChartRef" class="chart-container"></div> <div ref="diskChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络流量</span></template> <template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络速率</span></template>
<div ref="netChartRef" class="chart-container"></div> <div ref="netChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -478,7 +492,7 @@
<el-row :gutter="16" style="margin-top: 16px"> <el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 IOPS</span></template> <template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘IO速率</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div> <div ref="diskIopsChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -516,16 +530,27 @@
<div class="section-block" style="margin-top:16px"> <div class="section-block" style="margin-top:16px">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">每小时流量</h3> <h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="trafficTimeMode" size="small" @change="loadTrafficHourly">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="trafficTimeMode === 'relative'" v-model="trafficRelativeMinutes" size="small" style="width: 120px" @change="loadTrafficHourly">
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="3天" :value="4320" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker <el-date-picker
v-else
v-model="trafficHourlyRange" v-model="trafficHourlyRange"
type="datetimerange" type="datetimerange"
range-separator="至" range-separator="至"
start-placeholder="开始时间" start-placeholder="开始"
end-placeholder="结束时间" end-placeholder="结束"
size="small" size="small"
style="width: 360px" style="width: 340px"
:shortcuts="monitorShortcuts"
@change="loadTrafficHourly" @change="loadTrafficHourly"
/> />
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button> <el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
@@ -1157,6 +1182,11 @@ import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
const props = defineProps({
embedded: { type: Boolean, default: false },
goodsId: { type: Number, default: 0 }
})
import { import {
getUserVmDetail, getUserVmVnc, getUserVmHostImages, getUserVmDetail, getUserVmVnc, getUserVmHostImages,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm, startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
@@ -1186,7 +1216,7 @@ import * as echarts from 'echarts'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userGoodsId = ref(parseInt(route.query.id) || 0) const userGoodsId = ref(props.embedded ? props.goodsId : (parseInt(route.query.id) || 0))
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
@@ -1294,7 +1324,7 @@ const vmStatusType = (s) => vmStatusTypeUtil(s)
const vmStatusLabel = (s) => vmStatusLabelUtil(s) const vmStatusLabel = (s) => vmStatusLabelUtil(s)
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info') const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
const goBack = () => router.back() const goBack = () => { if (!props.embedded) router.back() }
const loadDetail = async () => { const loadDetail = async () => {
if (!userGoodsId.value) return if (!userGoodsId.value) return
@@ -1383,6 +1413,15 @@ const loadSgLockInfo = async () => {
const vncVisible = ref(false) const vncVisible = ref(false)
const vncLoading = ref(false) const vncLoading = ref(false)
const vncResult = ref(null) const vncResult = ref(null)
const handleSshConnect = () => {
const host = vmPublicIpList.value[0]
if (!host) return ElMessage.warning('无可用公网 IP')
const username = isWindows.value ? 'Administrator' : 'root'
const password = vm.value?.root_password || ''
const encodedPwd = btoa(password)
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
}
const handleVnc = async () => { const handleVnc = async () => {
vncVisible.value = true; vncLoading.value = true; vncResult.value = null vncVisible.value = true; vncLoading.value = true; vncResult.value = null
try { try {
@@ -2259,24 +2298,35 @@ const submitAddTraffic = async () => {
} }
// ---- ---- // ---- ----
const trafficTimeMode = ref('relative')
const trafficRelativeMinutes = ref(1440)
const trafficHourlyRange = ref(null) const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([]) const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false) const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null) const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null let trafficHourlyChart = null
const getTrafficTimeRange = () => {
if (trafficTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - trafficRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!trafficHourlyRange.value || trafficHourlyRange.value.length < 2) return null
return { startTime: new Date(trafficHourlyRange.value[0]), endTime: new Date(trafficHourlyRange.value[1]) }
}
}
const loadTrafficHourly = async () => { const loadTrafficHourly = async () => {
if (!userGoodsId.value) return if (!userGoodsId.value) return
if (!trafficHourlyRange.value) { const range = getTrafficTimeRange()
const now = new Date() if (!range) return
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
}
trafficHourlyLoading.value = true trafficHourlyLoading.value = true
try { try {
const res = await getUserVmTrafficHourly({ const res = await getUserVmTrafficHourly({
user_goods_id: userGoodsId.value, user_goods_id: userGoodsId.value,
start: new Date(trafficHourlyRange.value[0]).toISOString(), start: range.startTime.toISOString(),
end_time: new Date(trafficHourlyRange.value[1]).toISOString() end_time: range.endTime.toISOString()
}) })
const raw = res?.data?.data?.data const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : []) trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
@@ -2372,21 +2422,20 @@ let trafficUsedChart = null
const metricsData = ref(null) const metricsData = ref(null)
const metricsLoading = ref(false) const metricsLoading = ref(false)
const makeDefaultRange = () => { const monitorTimeMode = ref('relative')
const now = new Date() const monitorRelativeMinutes = ref(10)
return [new Date(now.getTime() - 10 * 60 * 1000), now] const monitorDateRange = ref(null)
}
const monitorDateRange = ref(makeDefaultRange())
const monitorShortcuts = [ const getMonitorTimeRange = () => {
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } }, if (monitorTimeMode.value === 'relative') {
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } }, const endTime = new Date()
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } }, const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000)
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } }, return { startTime, endTime }
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } }, } else {
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } }, if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } }, return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) }
] }
}
function calcInterval(startTime, endTime) { function calcInterval(startTime, endTime) {
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000 const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
@@ -2404,8 +2453,9 @@ function calcInterval(startTime, endTime) {
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' } const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
const currentIntervalLabel = computed(() => { const currentIntervalLabel = computed(() => {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-' const range = getMonitorTimeRange()
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1])) if (!range) return '-'
const iv = calcInterval(range.startTime, range.endTime)
return intervalLabelMap[iv] || iv return intervalLabelMap[iv] || iv
}) })
@@ -2450,11 +2500,11 @@ const vmMemPercent = (m) => {
const loadMetricsHistory = async () => { const loadMetricsHistory = async () => {
if (!userGoodsId.value) return if (!userGoodsId.value) return
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return const range = getMonitorTimeRange()
if (!range) return
metricsLoading.value = true metricsLoading.value = true
try { try {
const startTime = new Date(monitorDateRange.value[0]) const { startTime, endTime } = range
const endTime = new Date(monitorDateRange.value[1])
const interval = calcInterval(startTime, endTime) const interval = calcInterval(startTime, endTime)
const params = { const params = {
user_goods_id: userGoodsId.value, user_goods_id: userGoodsId.value,
@@ -2482,7 +2532,8 @@ const renderMetricsCharts = () => {
const metrics = metricsData.value const metrics = metricsData.value
if (!Array.isArray(metrics) || !metrics.length) return if (!Array.isArray(metrics) || !metrics.length) return
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 600000 const range = getMonitorTimeRange()
const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 600000
const showDate = spanMs >= 86400000 const showDate = spanMs >= 86400000
const symbolType = metrics.length < 30 ? 'circle' : 'none' const symbolType = metrics.length < 30 ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0 const labelRotate = showDate ? 45 : 0
@@ -2627,16 +2678,22 @@ const reloadWithNewId = (newId) => {
loadDetail() loadDetail()
} }
watch(() => route.query.id, (newVal) => { if (props.embedded) {
if (route.path === '/user-goods/vm-detail') { watch(() => props.goodsId, (newVal) => {
reloadWithNewId(newVal || 0)
})
} else {
watch(() => route.query.id, (newVal) => {
reloadWithNewId(parseInt(newVal) || 0) reloadWithNewId(parseInt(newVal) || 0)
} })
}) }
onMounted(() => { loadDetail() }) onMounted(() => { loadDetail() })
onActivated(() => { onActivated(() => {
reloadWithNewId(parseInt(route.query.id) || 0) if (!props.embedded) {
reloadWithNewId(parseInt(route.query.id) || 0)
}
}) })
onBeforeUnmount(() => { disposeCharts() }) onBeforeUnmount(() => { disposeCharts() })
@@ -2644,6 +2701,7 @@ onBeforeUnmount(() => { disposeCharts() })
<style scoped> <style scoped>
.uvm-detail { padding: 0; } .uvm-detail { padding: 0; }
.uvm-detail.is-embedded .main-content { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; } .page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; } .header-left { display: flex; align-items: center; gap: 0; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; } .page-title { font-size: 16px; font-weight: 600; color: #303133; }
+29 -3
View File
@@ -28,11 +28,20 @@
<el-option label="未绑定" :value="false" /> <el-option label="未绑定" :value="false" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
<el-option label="全部" value="all" />
<el-option label="正常" value="normal" />
<el-option label="已到期" value="expired" />
<el-option label="待开通" value="pending" />
<el-option label="已删除" value="deleted" />
</el-select>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
</el-button> </el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button> <el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="action-bar"> <div class="action-bar">
@@ -73,6 +82,15 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${deletedTimeText(row)}`" placement="top">
<el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip>
<el-tag v-else-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="续费价格" width="100"> <el-table-column label="续费价格" width="100">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span> <span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
@@ -939,7 +957,7 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const list = ref([]) const list = ref([])
const total = ref(0) const total = ref(0)
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '' }) const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '', status: 'all' })
const filterUserName = ref('') const filterUserName = ref('')
const filterGoodName = ref('') const filterGoodName = ref('')
const showFilterUserSelector = ref(false) const showFilterUserSelector = ref(false)
@@ -961,6 +979,13 @@ const formatExpireTime = (t) => {
return d.format('YYYY-MM-DD HH:mm') return d.format('YYYY-MM-DD HH:mm')
} }
// deleteAt
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
const deletedTimeText = (row) => {
const t = row?.deleteAt || row?.DeleteAt || row?.deleted_at
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
}
const loadGoods = async () => { const loadGoods = async () => {
// //
loadList() loadList()
@@ -976,6 +1001,7 @@ const loadList = async () => {
if (query.user_id) params.user_id = query.user_id if (query.user_id) params.user_id = query.user_id
if (query.good_id) params.good_id = query.good_id if (query.good_id) params.good_id = query.good_id
if (query.key) params.key = query.key if (query.key) params.key = query.key
if (query.status && query.status !== 'all') params.status = query.status
const res = await getUserGoodsList(params) const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data const d = res.data.data
@@ -1001,7 +1027,7 @@ const handleSearch = () => { query.page = 1; loadList() }
const goDetail = (row) => { const goDetail = (row) => {
if (!row.id) return if (!row.id) return
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } }) router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
} }
// ---- ---- // ---- ----
+363 -18
View File
@@ -181,18 +181,29 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="订单列表" name="3"> <el-tab-pane label="订单列表" name="3">
<el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%"> <el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%">
<el-table-column prop="id" label="订单ID" width="100" /> <el-table-column prop="id" label="订单ID" width="80" />
<el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip /> <el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="price" label="金额" width="100"> <el-table-column label="类型" width="80">
<template #default="{row}">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="getOrderStatusType(row.status)" size="small">{{ getOrderStatusText(row.status) }}</el-tag> <el-tag :type="getOrderTypeTag(row.type)" size="small">{{ getOrderTypeText(row.type) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160"> <el-table-column label="金额" width="120">
<template #default="{row}">{{ formatDate(row.created_at) }}</template> <template #default="{row}">
<span>¥{{ (row.price / 100).toFixed(2) }}</span>
<div v-if="row.renewPrice" style="font-size:12px;color:#909399">续费价: ¥{{ (row.renewPrice / 100).toFixed(2) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{row}">
<el-tag :type="getOrderStatusType(row.state)" size="small">{{ getOrderStatusText(row.state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{row}">{{ row.expireTime ? formatDate(row.expireTime) : '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" fixed="right"> <el-table-column label="操作" width="80" fixed="right">
<template #default="scope"> <template #default="scope">
@@ -256,6 +267,13 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" min-width="90">
<template #default="{row}">
<el-tag v-if="isGoodsDeleted(row)" size="small" type="danger">已删除</el-tag>
<el-tag v-else-if="isGoodsExpired(row)" size="small" type="warning">已到期</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" min-width="140"> <el-table-column label="到期时间" min-width="140">
<template #default="{row}">{{ formatDate(row.expireTime) }}</template> <template #default="{row}">{{ formatDate(row.expireTime) }}</template>
</el-table-column> </el-table-column>
@@ -276,6 +294,64 @@
</div> </div>
<el-empty v-else description="暂无已购商品" :image-size="100" /> <el-empty v-else description="暂无已购商品" :image-size="100" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="代金券" name="6">
<div class="voucher-action-bar">
<el-button type="primary" size="small" @click="handleAddVoucher">
<el-icon><Plus /></el-icon>添加代金券
</el-button>
<el-button type="success" size="small" @click="fetchUserVoucherListData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table :data="userVoucherList" v-loading="voucherListLoading" stripe style="width: 100%">
<el-table-column prop="Id" label="ID" width="70" />
<el-table-column label="代金券名称" min-width="150" show-overflow-tooltip>
<template #default="{row}">{{ row.discount?.name || '-' }}</template>
</el-table-column>
<el-table-column label="面额" min-width="100">
<template #default="{row}">
<span class="voucher-amount">¥{{ row.discount?.amount ? (row.discount.amount / 100).toFixed(2) : '0.00' }}</span>
</template>
</el-table-column>
<el-table-column label="已使用/最大" min-width="100">
<template #default="{row}">
{{ row.useTimes || 0 }} / {{ row.maxUseTimes || '∞' }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="80">
<template #default="{row}">
<el-tag :type="getVoucherStatusType(row)" size="small">
{{ getVoucherStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="140">
<template #default="{row}">{{ formatVoucherDate(row.expireAt) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="140">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{row}">
<el-button type="warning" link size="small" @click="handleEditVoucher(row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button type="danger" link size="small" @click="handleDeleteVoucher(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="voucherListTotal > 0">
<el-pagination
v-model:current-page="voucherListPage"
v-model:page-size="voucherListPageSize"
:total="voucherListTotal"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@size-change="handleVoucherListSizeChange"
@current-change="handleVoucherListPageChange"
/>
</div>
<el-empty v-if="!voucherListLoading && !userVoucherList.length" description="暂无代金券" :image-size="100" />
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
</div> </div>
@@ -451,7 +527,7 @@
<el-option label="正式环境" value="production"> <el-option label="正式环境" value="production">
<div class="env-option"> <div class="env-option">
<span>正式环境</span> <span>正式环境</span>
<span class="env-url">www.007yjs.com</span> <span class="env-url">console.007yjs.com</span>
</div> </div>
</el-option> </el-option>
<el-option label="测试环境" value="test"> <el-option label="测试环境" value="test">
@@ -478,6 +554,49 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 代金券添加/编辑对话框 -->
<el-dialog
v-model="voucherDialogVisible"
:title="voucherDialogType === 'add' ? '添加代金券' : '编辑代金券'"
width="500px"
append-to-body
>
<el-form ref="voucherFormRef" :model="voucherForm" :rules="voucherFormRules" label-width="120px">
<el-form-item label="代金券" prop="discount_id" v-if="voucherDialogType === 'add'">
<el-select v-model="voucherForm.discount_id" placeholder="请选择代金券" filterable style="width: 100%">
<el-option
v-for="item in discountCouponOptions"
:key="item.id"
:label="`${item.name} (¥${(item.amount/100).toFixed(2)})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="已使用次数" prop="use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.use_times" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="最大使用次数" prop="max_use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.max_use_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="过期时间" prop="expire_at" v-if="voucherDialogType === 'edit'">
<el-date-picker
v-model="voucherForm.expire_at"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="X"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="voucherDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitVoucherForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 管理员权限管理对话框 --> <!-- 管理员权限管理对话框 -->
<el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body> <el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body>
<el-form :model="adminForm" label-width="100px"> <el-form :model="adminForm" label-width="100px">
@@ -518,7 +637,7 @@ import UserListSelector from '@/components/admin/UserListSelector.vue'
import { import {
ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock, ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock,
UserFilled, Document, Clock, List, Switch, User, Camera, Upload, UserFilled, Document, Clock, List, Switch, User, Camera, Upload,
UploadFilled, Key, Monitor, Setting, Close UploadFilled, Key, Monitor, Setting, Close, Plus
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user' import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
import { getFileDetail } from '@/api/admin/file' import { getFileDetail } from '@/api/admin/file'
@@ -531,6 +650,13 @@ import { getAdminGroupList } from '@/api/admin/group'
import { getOrderList } from '@/api/admin/order' import { getOrderList } from '@/api/admin/order'
import { getTickerList } from '@/api/ticket' import { getTickerList } from '@/api/ticket'
import { getUserGoodsList } from '@/api/admin/product' import { getUserGoodsList } from '@/api/admin/product'
import {
getUserVoucherList,
allocateVoucher,
updateUserVoucher,
deleteUserVoucher,
getDiscountCodeList
} from '@/api/admin/discount'
const Edit = EditIcon const Edit = EditIcon
const route = useRoute() const route = useRoute()
@@ -578,11 +704,41 @@ const ticketListPageSize = ref(10)
// //
const userGoodsList = ref([]) const userGoodsList = ref([])
// deleteAt
const isGoodsDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
//
const isGoodsExpired = (row) => {
const t = row?.expireTime || row?.expire_time
if (!t) return false
const d = new Date(t)
return d.getFullYear() >= 2000 && d.getTime() < Date.now()
}
const goodsListLoading = ref(false) const goodsListLoading = ref(false)
const goodsListTotal = ref(0) const goodsListTotal = ref(0)
const goodsListPage = ref(1) const goodsListPage = ref(1)
const goodsListPageSize = ref(10) const goodsListPageSize = ref(10)
//
const userVoucherList = ref([])
const voucherListLoading = ref(false)
const voucherListTotal = ref(0)
const voucherListPage = ref(1)
const voucherListPageSize = ref(10)
const voucherDialogVisible = ref(false)
const voucherDialogType = ref('add')
const voucherFormRef = ref(null)
const discountCouponOptions = ref([])
const voucherForm = reactive({
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
const voucherFormRules = {
discount_id: [{ required: true, message: '请选择代金券', trigger: 'change' }]
}
// //
const userBalance = ref({ const userBalance = ref({
balance: 0, balance: 0,
@@ -680,7 +836,7 @@ const selectedEnvironment = ref('')
// //
const environments = { const environments = {
production: 'https://www.007yjs.com', production: 'https://console.007yjs.com',
test: 'https://apiserver.s1f.ren', test: 'https://apiserver.s1f.ren',
local: 'http://localhost:5173' local: 'http://localhost:5173'
} }
@@ -707,6 +863,8 @@ const handleTabClick = (tab) => {
fetchUserTicketList() fetchUserTicketList()
} else if (tab.props.name === '5') { } else if (tab.props.name === '5') {
fetchUserGoodsList() fetchUserGoodsList()
} else if (tab.props.name === '6') {
fetchUserVoucherListData()
} }
}; };
@@ -1017,7 +1175,6 @@ const fetchUserOrderList = async () => {
page: orderListPage.value, page: orderListPage.value,
count: orderListPageSize.value count: orderListPageSize.value
}) })
console.log('111',res)
if (res.data.code === 200) { if (res.data.code === 200) {
userOrderList.value = res.data.data.list || [] userOrderList.value = res.data.data.list || []
orderListTotal.value = res.data.data.all_count || 0 orderListTotal.value = res.data.data.all_count || 0
@@ -1100,6 +1257,169 @@ const handleGoodsListPageChange = (page) => {
fetchUserGoodsList() fetchUserGoodsList()
} }
//
const fetchUserVoucherListData = async () => {
if (!route.query.user_id) return
voucherListLoading.value = true
try {
const res = await getUserVoucherList({
user_id: route.query.user_id,
page: voucherListPage.value,
count: voucherListPageSize.value
})
if (res.data.code === 200) {
userVoucherList.value = res.data.data?.data || []
voucherListTotal.value = res.data.data?.all_count || 0
}
} catch (error) {
ElMessage.error('获取代金券列表失败')
} finally {
voucherListLoading.value = false
}
}
const handleVoucherListSizeChange = (size) => {
voucherListPageSize.value = size
voucherListPage.value = 1
fetchUserVoucherListData()
}
const handleVoucherListPageChange = (page) => {
voucherListPage.value = page
fetchUserVoucherListData()
}
//
const getVoucherStatusText = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return '已用完'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return '已过期'
return '可使用'
}
const getVoucherStatusType = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return 'info'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return 'warning'
return 'success'
}
//
const formatVoucherDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (isNaN(date.getTime()) || date.getFullYear() === 1970 || date.getFullYear() === 1) return '-'
return formatDate(dateStr)
}
//
const fetchDiscountCouponOptions = async () => {
try {
const res = await getDiscountCodeList({ discount_type: 'coupon', page: 1, count: 100 })
if (res.data.code === 200) {
discountCouponOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取代金券选项失败:', error)
}
}
//
const handleAddVoucher = () => {
voucherDialogType.value = 'add'
voucherDialogVisible.value = true
Object.assign(voucherForm, {
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
voucherFormRef.value?.resetFields()
fetchDiscountCouponOptions()
}
//
const handleEditVoucher = (row) => {
voucherDialogType.value = 'edit'
voucherDialogVisible.value = true
let expireTime = undefined
if (row.expireAt && row.expireAt !== '0001-01-01T00:00:00Z') {
const date = new Date(row.expireAt)
if (!isNaN(date.getTime()) && date.getFullYear() !== 1970) {
expireTime = Math.floor(date.getTime() / 1000)
}
}
Object.assign(voucherForm, {
discount_id: row.discountId,
id: row.Id,
use_times: row.useTimes || 0,
max_use_times: row.maxUseTimes || 0,
expire_at: expireTime
})
}
//
const handleDeleteVoucher = (row) => {
const name = row.discount?.name || '该代金券'
ElMessageBox.confirm(`确认删除代金券「${name}」吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserVoucher({
user_id: route.query.user_id,
id: row.Id
})
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitVoucherForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (!valid) return
try {
let res
if (voucherDialogType.value === 'add') {
res = await allocateVoucher({
user_id: route.query.user_id,
code_id: voucherForm.discount_id
})
} else {
res = await updateUserVoucher({
user_id: route.query.user_id,
id: voucherForm.id,
discount_id: voucherForm.discount_id,
use_times: voucherForm.use_times,
max_use_times: voucherForm.max_use_times,
expire_at: voucherForm.expire_at
})
}
if (res.data.code === 200) {
ElMessage.success(voucherDialogType.value === 'add' ? '添加成功' : '修改成功')
voucherDialogVisible.value = false
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// //
const fetchUserBalance = async () => { const fetchUserBalance = async () => {
if (!route.query.user_id) return if (!route.query.user_id) return
@@ -1119,14 +1439,25 @@ const fetchUserBalance = async () => {
} }
// //
const getOrderStatusText = (status) => { const getOrderStatusText = (state) => {
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' } const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' }
return map[status] || '未知' return map[state] || '未知'
} }
const getOrderStatusType = (status) => { const getOrderStatusType = (state) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' } const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' }
return map[status] || 'info' return map[state] || 'info'
}
//
const getOrderTypeText = (type) => {
const map = { create: '新购', renew: '续费', upgrade: '升级' }
return map[type] || type || '未知'
}
const getOrderTypeTag = (type) => {
const map = { create: 'primary', renew: 'success', upgrade: 'warning' }
return map[type] || 'info'
} }
// //
@@ -1144,7 +1475,7 @@ const getTicketStatusType = (status) => {
const handleViewOrder = (row) => { const handleViewOrder = (row) => {
router.push({ router.push({
path: '/order/list', path: '/order/list',
query: { order_id: row.order_id } query: { order_id: row.id }
}) })
} }
@@ -1413,6 +1744,7 @@ const fetchActiveTabData = () => {
else if (tab === '3') fetchUserOrderList() else if (tab === '3') fetchUserOrderList()
else if (tab === '4') fetchUserTicketList() else if (tab === '4') fetchUserTicketList()
else if (tab === '5') fetchUserGoodsList() else if (tab === '5') fetchUserGoodsList()
else if (tab === '6') fetchUserVoucherListData()
} }
const loadUserData = async () => { const loadUserData = async () => {
@@ -1831,4 +2163,17 @@ onActivated(() => {
min-width: 60px; min-width: 60px;
} }
} }
/* 代金券操作栏 */
.voucher-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.voucher-amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
</style> </style>
+541 -3
View File
@@ -85,7 +85,7 @@
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }} {{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)"> <el-button type="primary" link @click="handleEdit(row)">
@@ -94,6 +94,9 @@
<el-button type="success" link @click="handleViewMembers(row)"> <el-button type="success" link @click="handleViewMembers(row)">
<el-icon><User /></el-icon>成员 <el-icon><User /></el-icon>成员
</el-button> </el-button>
<el-button type="warning" link @click="handleViewDiscount(row)">
<el-icon><Present /></el-icon>优惠
</el-button>
<el-button type="danger" link @click="handleDelete(row)"> <el-button type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除 <el-icon><Delete /></el-icon>删除
</el-button> </el-button>
@@ -288,13 +291,181 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 用户组优惠管理对话框 -->
<el-dialog
v-model="discountDialogVisible"
:title="`优惠管理 - ${currentGroupName}`"
width="900px"
append-to-body
class="custom-dialog"
>
<div class="discount-action-bar">
<el-button type="primary" @click="handleAddDiscount">
<el-icon><Plus /></el-icon>新增优惠绑定
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table
v-loading="discountLoading"
:data="discountList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="绑定对象" min-width="220">
<template #default="{ row }">
<el-tag v-if="row.good && row.good.id" type="primary" effect="plain">
商品{{ row.good.name }} (ID:{{ row.good.id }})
</el-tag>
<el-tag v-else-if="row.goodGroup && row.goodGroup.id" type="warning" effect="plain">
商品组{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }})
</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="固定抵扣额" width="130">
<template #default="{ row }">
<span v-if="row.amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="百分比抵扣" width="120">
<template #default="{ row }">
<span v-if="row.percentage">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditDiscount(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteDiscount(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无优惠绑定" :image-size="80" />
</template>
</el-table>
<el-pagination
v-if="discountTotal > 0"
v-model:current-page="discountPage"
v-model:page-size="discountPageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="discountTotal"
@size-change="handleDiscountSizeChange"
@current-change="handleDiscountPageChange"
background
class="discount-pagination"
/>
</el-dialog>
<!-- 用户组优惠 新增/编辑 对话框 -->
<el-dialog
v-model="discountFormVisible"
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
width="620px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountFormRules"
label-width="120px"
>
<!-- 新增模式折叠层级选择器 -->
<el-form-item v-if="discountFormType === 'add'" label="选择商品">
<div class="goods-tree-wrapper">
<div class="goods-tree-toolbar">
<span class="tree-tip">勾选商品组或商品仅可选一个</span>
<div class="tree-summary">
已选: <b>{{ discountTreeChecked.name || '无' }}</b>
</div>
</div>
<el-tree
ref="discountTreeRef"
:props="discountTreeProps"
:load="loadDiscountTreeNode"
lazy
show-checkbox
check-strictly
node-key="key"
class="goods-tree"
@check="handleDiscountTreeCheck"
>
<template #default="{ data }">
<span class="tree-node">
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
{{ data.nodeType === 'group' ? '组' : '品' }}
</el-tag>
<span class="tree-node-label">{{ data.label }}</span>
<span class="tree-node-id">ID: {{ data.rawId }}</span>
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
¥{{ (data.price / 100).toFixed(2) }}
</span>
</span>
</template>
</el-tree>
</div>
</el-form-item>
<!-- 编辑模式保留选择器 -->
<template v-if="discountFormType === 'edit'">
<el-form-item label="绑定类型" prop="bind_type">
<el-radio-group v-model="discountForm.bind_type" @change="handleBindTypeChange">
<el-radio label="good">商品</el-radio>
<el-radio label="good_group">商品组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.bind_type === 'good'" label="选择商品" prop="good_id">
<el-select v-model="discountForm.good_id" placeholder="请选择商品" filterable style="width: 100%">
<el-option
v-for="item in productOptions"
:key="item.id"
:label="`${item.name} (ID:${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="选择商品组" prop="good_group_id">
<el-select v-model="discountForm.good_group_id" placeholder="请选择商品组" filterable style="width: 100%">
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID:${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="固定抵扣额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="百分比抵扣" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="0表示不使用" style="width: 100%" />
<div style="color:#909399;font-size:12px;margin-top:4px">固定抵扣额与百分比抵扣二选一填写</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="discountFormVisible = false">取消</el-button>
<el-button type="primary" @click="submitDiscountForm">确定</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue' import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
import { import {
getUserGroupList, getUserGroupList,
getUserGroupMemberList, getUserGroupMemberList,
@@ -303,6 +474,13 @@ import {
deleteUserGroup, deleteUserGroup,
addUserGroupMember addUserGroupMember
} from '@/api/admin/user' } from '@/api/admin/user'
import {
getUserGroupDiscountList,
addUserGroupDiscount,
updateUserGroupDiscount,
deleteUserGroupDiscount
} from '@/api/admin/userGroupDiscount'
import { getProductList, getProductGroupList } from '@/api/admin/product'
import { formatTime } from '@/utils/tool' import { formatTime } from '@/utils/tool'
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue' import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
@@ -624,6 +802,301 @@ const clearHigherGroup = () => {
selectedHigherGroupInfo.value = null selectedHigherGroupInfo.value = null
} }
/** -------------------- 用户组优惠管理 -------------------- */
const discountDialogVisible = ref(false)
const discountFormVisible = ref(false)
const discountLoading = ref(false)
const discountList = ref([])
const discountTotal = ref(0)
const discountPage = ref(1)
const discountPageSize = ref(10)
const discountFormType = ref('add')
const discountFormRef = ref(null)
const currentGroupId = ref(undefined)
const currentGroupName = ref('')
const productOptions = ref([])
const productGroupOptions = ref([])
const discountForm = reactive({
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
const discountFormRules = {
bind_type: [{ required: true, message: '请选择绑定类型', trigger: 'change' }],
good_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
good_group_id: [{ required: true, message: '请选择商品组', trigger: 'change' }]
}
//
const discountTreeRef = ref(null)
const discountTreeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const discountTreeChecked = reactive({ name: '', nodeType: '', rawId: undefined })
//
const loadDiscountTreeNode = async (node, resolve) => {
try {
if (node.level === 0) {
const res = await getProductGroupList({ level: 1 })
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildDiscountGroupNode))
}
return resolve([])
}
if (node.data?.nodeType === 'group') {
const groupId = node.data.rawId
const childLevel = (node.data.level || 1) + 1
const tasks = [
getProductList({ good_group_id: groupId, delete: false })
]
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productNodes = (results[0].data.code === 200 ? (results[0].data.data?.data || []) : [])
.map(buildDiscountProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildDiscountGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
return resolve([])
}
}
const buildDiscountGroupNode = (group) => ({
key: `group_${group.id}`,
rawId: group.id,
nodeType: 'group',
label: group.name,
level: group.level || 1,
existSub: group.existSub || false,
isLeaf: false
})
const buildDiscountProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
//
const handleDiscountTreeCheck = (data, { checkedKeys }) => {
if (checkedKeys.length > 1) {
discountTreeRef.value?.setCheckedKeys([data.key])
}
const nodes = discountTreeRef.value?.getCheckedNodes() || []
if (nodes.length > 0) {
const node = nodes[0]
discountTreeChecked.name = node.label
discountTreeChecked.nodeType = node.nodeType
discountTreeChecked.rawId = node.rawId
} else {
discountTreeChecked.name = ''
discountTreeChecked.nodeType = ''
discountTreeChecked.rawId = undefined
}
}
// /
const loadProductOptions = async () => {
try {
const [pRes, gRes] = await Promise.all([
getProductList({ page: 1, count: 100 }),
getProductGroupList({ page: 1, count: 100 })
])
if (pRes.data.code === 200) {
productOptions.value = pRes.data.data?.data || pRes.data.data || []
}
if (gRes.data.code === 200) {
productGroupOptions.value = gRes.data.data?.data || gRes.data.data || []
}
} catch (error) {
console.error('加载商品选项失败:', error)
}
}
//
const fetchDiscountList = async () => {
if (!currentGroupId.value) return
discountLoading.value = true
try {
const res = await getUserGroupDiscountList({
user_group_id: currentGroupId.value,
page: discountPage.value,
count: discountPageSize.value
})
if (res.data.code === 200) {
const resData = res.data.data
if (Array.isArray(resData)) {
discountList.value = resData
discountTotal.value = res.data.all_count || resData.length
} else {
discountList.value = resData?.data || []
discountTotal.value = resData?.all_count || discountList.value.length
}
}
} catch (error) {
console.error('获取用户组优惠列表失败:', error)
ElMessage.error('获取用户组优惠列表失败')
} finally {
discountLoading.value = false
}
}
const handleDiscountSizeChange = (size) => {
discountPageSize.value = size
discountPage.value = 1
fetchDiscountList()
}
const handleDiscountPageChange = (page) => {
discountPage.value = page
fetchDiscountList()
}
//
const handleViewDiscount = (row) => {
currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id
currentGroupName.value = row.group_name || row.name || row.Name || ''
discountDialogVisible.value = true
discountPage.value = 1
loadProductOptions()
fetchDiscountList()
}
//
const handleBindTypeChange = () => {
discountForm.good_id = undefined
discountForm.good_group_id = undefined
}
//
const handleAddDiscount = () => {
discountFormType.value = 'add'
discountFormVisible.value = true
Object.assign(discountForm, {
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
Object.assign(discountTreeChecked, { name: '', nodeType: '', rawId: undefined })
discountFormRef.value?.resetFields()
nextTick(() => {
discountTreeRef.value?.setCheckedKeys([])
})
}
//
const handleEditDiscount = (row) => {
discountFormType.value = 'edit'
discountFormVisible.value = true
const isGroup = !!(row.goodGroup && row.goodGroup.id)
Object.assign(discountForm, {
id: row.id,
bind_type: isGroup ? 'good_group' : 'good',
good_id: (row.good && row.good.id) ? row.good.id : undefined,
good_group_id: (row.goodGroup && row.goodGroup.id) ? row.goodGroup.id : undefined,
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0
})
loadProductOptions()
}
//
const handleDeleteDiscount = (row) => {
ElMessageBox.confirm('确认删除该优惠绑定吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserGroupDiscount({ discount_id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitDiscountForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (!valid && discountFormType.value === 'edit') return
try {
const params = new URLSearchParams()
params.append('user_group_id', currentGroupId.value)
if (discountFormType.value === 'add') {
//
if (!discountTreeChecked.rawId) {
ElMessage.warning('请选择一个商品或商品组')
return
}
if (discountTreeChecked.nodeType === 'group') {
params.append('good_group_id', discountTreeChecked.rawId)
} else {
params.append('good_id', discountTreeChecked.rawId)
}
} else {
//
if (discountForm.bind_type === 'good') {
params.append('good_id', discountForm.good_id)
} else {
params.append('good_group_id', discountForm.good_group_id)
}
params.append('id', discountForm.id)
}
params.append('amount', Math.round((discountForm.amount || 0) * 100))
params.append('percentage', Math.round((discountForm.percentage || 0) * 100))
let res
if (discountFormType.value === 'add') {
res = await addUserGroupDiscount(params)
} else {
res = await updateUserGroupDiscount(params)
}
if (res.data.code === 200) {
ElMessage.success(discountFormType.value === 'add' ? '新增成功' : '修改成功')
discountFormVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// //
onMounted(() => { onMounted(() => {
fetchGroupList() fetchGroupList()
@@ -696,6 +1169,71 @@ onMounted(() => {
padding: 0; padding: 0;
} }
.discount-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.discount-pagination {
margin-top: 16px;
justify-content: flex-end;
}
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
/* 表格样式优化 */ /* 表格样式优化 */
:deep(.el-table) { :deep(.el-table) {
border: none; border: none;
+11 -12
View File
@@ -15,17 +15,21 @@
class="search-input" class="search-input"
/> />
</div> </div>
<div class="search-group">
<span class="search-label">身份</span>
<el-select v-model="queryParams.is_admin" placeholder="全部" clearable class="search-input-small" style="width: 110px">
<el-option label="管理员" :value="true" />
<el-option label="普通用户" :value="false" />
</el-select>
</div>
<div class="search-group"> <div class="search-group">
<span class="search-label">用户ID</span> <span class="search-label">用户ID</span>
<el-input <el-input
:model-value="jumpUserName || (jumpUserId ? jumpUserId : '')" v-model="jumpUserId"
placeholder="输入ID跳转" placeholder="输入ID跳转"
readonly
clearable clearable
class="search-input-small" class="search-input-small"
style="cursor:pointer" @keyup.enter="handleJumpToUser"
@click="showJumpUserSelector = true"
@clear="jumpUserId = ''; jumpUserName = ''"
/> />
</div> </div>
<div class="search-buttons"> <div class="search-buttons">
@@ -334,11 +338,6 @@
:current-user-id="userForm.recommend_id" :current-user-id="userForm.recommend_id"
@confirm="handleRecommendUserConfirm" @confirm="handleRecommendUserConfirm"
/> />
<!-- 筛选用户ID选择器 -->
<UserListSelector
v-model="showJumpUserSelector"
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
/>
<!-- 修改头像对话框 --> <!-- 修改头像对话框 -->
<el-dialog <el-dialog
@@ -663,12 +662,11 @@ const router = useRouter()
// ID // ID
const jumpUserId = ref('') const jumpUserId = ref('')
const jumpUserName = ref('')
const showJumpUserSelector = ref(false)
// //
const queryParams = reactive({ const queryParams = reactive({
key: '', key: '',
is_admin: undefined,
page: 1, page: 1,
count: 10 count: 10
}) })
@@ -872,6 +870,7 @@ const handleQuery = () => {
// //
const resetQuery = () => { const resetQuery = () => {
queryParams.key = '' queryParams.key = ''
queryParams.is_admin = undefined
queryParams.page = 1 queryParams.page = 1
fetchUserList() fetchUserList()
} }
+433 -30
View File
@@ -54,6 +54,18 @@
<span class="status-label">带宽</span> <span class="status-label">带宽</span>
<span class="status-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span> <span class="status-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span>
</div> </div>
<div class="status-item" v-if="loadAvg">
<span class="status-label">负载</span>
<span class="status-value">
<span :style="{ color: loadColor(loadAvg['1min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['1min']?.toFixed(2) }}</span> /
<span :style="{ color: loadColor(loadAvg['5min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['5min']?.toFixed(2) }}</span> /
<span :style="{ color: loadColor(loadAvg['15min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['15min']?.toFixed(2) }}</span>
</span>
</div>
<div class="status-item" v-if="hostMetrics?.internet_speed">
<span class="status-label">实时网速</span>
<span class="status-value">{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} / {{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }}</span>
</div>
<div class="status-item"> <div class="status-item">
<span class="status-label">创建时间</span> <span class="status-label">创建时间</span>
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span> <span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
@@ -131,6 +143,187 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 实时监控概览 -->
<div class="section-block" v-loading="hostMetricsLoading">
<div class="section-header">
<h3 class="section-title"><svg class="sec-icon" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#67c23a"/></svg> 实时监控</h3>
<el-button size="small" :icon="Refresh" @click="loadHostMetrics" :loading="hostMetricsLoading">刷新</el-button>
</div>
<div class="rt-grid" v-if="hostMetrics">
<!-- CPU -->
<div class="rt-card" v-if="hostMetrics.cpu">
<div class="rt-card-icon cpu-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M15 3H9v2H7v2H5v2H3v6h2v2h2v2h2v2h6v-2h2v-2h2v-2h2V9h-2V7h-2V5h-2V3zm0 2v2h2v2h2v6h-2v2h-2v2H9v-2H7v-2H5V9h2V7h2V5h6z" fill="currentColor"/><rect x="10" y="10" width="4" height="4" rx="0.5" fill="currentColor"/></svg>
</div>
<div class="rt-card-body">
<span class="rt-label">CPU</span>
<span class="rt-value" :style="{ color: quotaColor(hostMetrics.cpu.cpu_usage_percent ?? 0) }">{{ hostMetrics.cpu.cpu_usage_percent?.toFixed(1) }}<small>%</small></span>
<el-progress :percentage="Math.min(100, hostMetrics.cpu.cpu_usage_percent ?? 0)" :show-text="false" :stroke-width="6" :color="quotaColor(hostMetrics.cpu.cpu_usage_percent ?? 0)" />
<span class="rt-sub">{{ hostMetrics.cpu.cpu_count }} 逻辑核心</span>
</div>
</div>
<!-- 内存 -->
<div class="rt-card" v-if="hostMetrics.memory">
<div class="rt-card-icon mem-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm1 2v10h14V7H5zm2 2h2v6H7V9zm4 0h2v6h-2V9zm4 2h2v4h-2v-4z" fill="currentColor"/></svg>
</div>
<div class="rt-card-body">
<span class="rt-label">内存</span>
<span class="rt-value" :style="{ color: quotaColor(hostMetrics.memory.percent ?? 0) }">{{ hostMetrics.memory.percent?.toFixed(1) }}<small>%</small></span>
<el-progress :percentage="Math.min(100, hostMetrics.memory.percent ?? 0)" :show-text="false" :stroke-width="6" :color="quotaColor(hostMetrics.memory.percent ?? 0)" />
<span class="rt-sub">{{ formatBytesAuto(hostMetrics.memory.used) }} / {{ formatBytesAuto(hostMetrics.memory.total) }}</span>
</div>
</div>
<!-- 负载 -->
<div class="rt-card" v-if="loadAvg">
<div class="rt-card-icon load-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 13h2v8H3v-8zm4-4h2v12H7V9zm4-4h2v16h-2V5zm4 6h2v10h-2V11zm4-2h2v12h-2V9z" fill="currentColor"/></svg>
</div>
<div class="rt-card-body">
<span class="rt-label">系统负载</span>
<div class="load-pills">
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['1min'], hostMetrics?.cpu?.cpu_count) }">
<em>1m</em>{{ loadAvg['1min']?.toFixed(2) }}
</span>
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['5min'], hostMetrics?.cpu?.cpu_count) }">
<em>5m</em>{{ loadAvg['5min']?.toFixed(2) }}
</span>
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['15min'], hostMetrics?.cpu?.cpu_count) }">
<em>15m</em>{{ loadAvg['15min']?.toFixed(2) }}
</span>
</div>
<span class="rt-sub">核心数 {{ hostMetrics?.cpu?.cpu_count ?? '-' }}满载阈值 {{ hostMetrics?.cpu?.cpu_count ?? '-' }}.00</span>
</div>
</div>
<!-- 网络 -->
<div class="rt-card" v-if="hostMetrics.internet_speed">
<div class="rt-card-icon net-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="currentColor"/></svg>
</div>
<div class="rt-card-body">
<span class="rt-label">网络带宽</span>
<div class="net-bw-row">
<span class="net-bw-item rx"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M6 2v8M3 7l3 3 3-3" stroke="#67c23a" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }}</span>
<span class="net-bw-item tx"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M6 10V2M3 5l3-3 3 3" stroke="#409eff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }}</span>
</div>
<span class="rt-sub" v-if="hostMetrics.network">累计 {{ formatBytesAuto(hostMetrics.network.rx_bytes) }} {{ formatBytesAuto(hostMetrics.network.tx_bytes) }}</span>
</div>
</div>
</div>
<!-- 磁盘使用 -->
<template v-if="metricsDisks.length">
<h4 class="rt-subtitle"><svg viewBox="0 0 24 24" width="16" height="16" style="vertical-align:-2px"><path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v12h16V6H4zm2 8h2v2H6v-2zm4 0h8v2h-8v-2z" fill="#909399"/></svg> 磁盘挂载</h4>
<div class="disk-list">
<div class="disk-item" v-for="disk in metricsDisks" :key="disk.path">
<div class="disk-head">
<code class="disk-path">{{ disk.path }}</code>
<span class="disk-detail">{{ formatBytesAuto(disk.used) }} / {{ formatBytesAuto(disk.total) }}</span>
<span class="disk-pct" :style="{ color: quotaColor(disk.percent) }">{{ disk.percent.toFixed(1) }}%</span>
</div>
<el-progress :percentage="Math.min(100, disk.percent)" :show-text="false" :stroke-width="6" :color="quotaColor(disk.percent)" />
</div>
</div>
</template>
<el-empty v-if="!hostMetrics && !hostMetricsLoading" description="暂无实时监控数据" :image-size="60" />
</div>
<!-- 硬件与系统信息 -->
<div class="section-block" v-if="hardwareInfo">
<h3 class="section-title clickable" @click="showHardwareInfo = !showHardwareInfo">
<svg class="sec-icon" viewBox="0 0 24 24"><path d="M20 8h-3V6c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v10h20V10c0-1.1-.9-2-2-2zM9 6h6v2H9V6zm11 12H4v-6h16v6zm0-8H4v-0c0 0 0 0 0 0h16v0z" fill="#606266"/></svg>
硬件与系统
<el-icon class="section-arrow" :class="{ expanded: showHardwareInfo }"><ArrowRight /></el-icon>
</h3>
<div v-show="showHardwareInfo">
<!-- 系统概览 -->
<div class="hw-overview" v-if="hardwareInfo.system_info">
<div class="hw-ov-item">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-.22-13h-.06c-.4 0-.72.32-.72.72v4.72c0 .35.18.68.49.86l4.15 2.49c.34.2.78.1.98-.24.21-.34.1-.79-.25-.99l-3.87-2.3V7.72c0-.4-.32-.72-.72-.72z" fill="#409eff"/></svg>
<div><em>运行</em>{{ formatUptime(hardwareInfo.system_info.uptime_seconds) }}</div>
</div>
<div class="hw-ov-item">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H1v2h22v-2h-3zM4 6h16v10H4V6z" fill="#67c23a"/></svg>
<div><em>主机</em>{{ hardwareInfo.system_info.hostname }}</div>
</div>
<div class="hw-ov-item">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" fill="#e6a23c"/></svg>
<div><em>系统</em>{{ hardwareInfo.system_info.distro || hardwareInfo.system_info.os }}</div>
</div>
<div class="hw-ov-item">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="#909399"/></svg>
<div><em>内核</em><span class="mono-text" style="font-size:12px">{{ hardwareInfo.system_info.os }}</span></div>
</div>
</div>
<!-- CPU / 内存 -->
<div class="hw-spec-grid">
<div class="hw-spec-card">
<div class="hw-spec-icon"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M15 3H9v2H7v2H5v2H3v6h2v2h2v2h2v2h6v-2h2v-2h2v-2h2V9h-2V7h-2V5h-2V3zm0 2v2h2v2h2v6h-2v2h-2v2H9v-2H7v-2H5V9h2V7h2V5h6z" fill="#409eff"/><rect x="10" y="10" width="4" height="4" rx="0.5" fill="#409eff"/></svg></div>
<div class="hw-spec-body">
<span class="hw-spec-label">处理器</span>
<span class="hw-spec-main">{{ hardwareInfo.cpu_model }}</span>
<span class="hw-spec-detail">{{ hardwareInfo.cpu_physical_cores }}C / {{ hardwareInfo.cpu_logical_cores }}T · {{ formatCpuFreq(hardwareInfo.cpu_freq) }}</span>
</div>
</div>
<div class="hw-spec-card">
<div class="hw-spec-icon"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm1 2v10h14V7H5zm2 2h2v6H7V9zm4 0h2v6h-2V9zm4 2h2v4h-2v-4z" fill="#67c23a"/></svg></div>
<div class="hw-spec-body">
<span class="hw-spec-label">内存</span>
<span class="hw-spec-main">{{ formatBytesAuto(hardwareInfo.memory_total) }}</span>
<span class="hw-spec-detail">Swap {{ hardwareInfo.swap_total ? formatBytesAuto(hardwareInfo.swap_total) : '无' }} · {{ hardwareInfo.system_info?.arch || '-' }}</span>
</div>
</div>
</div>
<!-- 磁盘设备 -->
<template v-if="hardwareInfo.disk_devices && hardwareInfo.disk_devices.length">
<h4 class="hw-subtitle"><svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-1px"><path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v12h16V6H4zm2 8h2v2H6v-2zm4 0h8v2h-8v-2z" fill="#606266"/></svg> 存储设备</h4>
<div class="hw-disk-cards">
<div class="hw-disk-card" v-for="dev in hardwareInfo.disk_devices" :key="dev.name">
<div class="hw-disk-icon">
<svg v-if="dev.type === 'ssd'" viewBox="0 0 24 24" width="28" height="28"><rect x="3" y="6" width="18" height="12" rx="2" stroke="#67c23a" stroke-width="1.5" fill="none"/><path d="M8 10l2 2-2 2M12 14h4" stroke="#67c23a" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg v-else viewBox="0 0 24 24" width="28" height="28"><circle cx="12" cy="12" r="9" stroke="#909399" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="#909399" stroke-width="1.5" fill="none"/><line x1="12" y1="3" x2="12" y2="6" stroke="#909399" stroke-width="1.5"/></svg>
</div>
<div class="hw-disk-info">
<span class="hw-disk-name">/dev/{{ dev.name }}</span>
<span class="hw-disk-model">{{ dev.model || '-' }}</span>
</div>
<div class="hw-disk-meta">
<el-tag :type="dev.type === 'ssd' ? 'success' : 'info'" size="small" effect="plain">{{ (dev.type || 'HDD').toUpperCase() }}</el-tag>
<span class="hw-disk-size">{{ formatBytesAuto(dev.size) }}</span>
</div>
</div>
</div>
</template>
<!-- 网卡 -->
<template v-if="filteredNics.length">
<h4 class="hw-subtitle"><svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-1px"><path d="M20 2H4a2 2 0 00-2 2v16a2 2 0 002 2h16a2 2 0 002-2V4a2 2 0 00-2-2zM8 18H6v-4h2v4zm4 0h-2V8h2v10zm4 0h-2v-6h2v6z" fill="#606266"/></svg> 网卡</h4>
<div class="nic-cards">
<div class="nic-card" v-for="nic in filteredNics" :key="nic.name" :class="{ 'nic-down': !nic.is_up }">
<div class="nic-head">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" :fill="nic.is_up ? '#67c23a' : '#c0c4cc'"/></svg>
<span class="nic-name">{{ nic.name }}</span>
<el-tag :type="nic.is_up ? 'success' : 'info'" size="small" effect="plain" class="nic-tag">{{ nic.is_up ? 'UP' : 'DOWN' }}</el-tag>
<span class="nic-speed" v-if="nic.speed_mbps">{{ nic.speed_mbps >= 1000 ? (nic.speed_mbps/1000)+'G' : nic.speed_mbps+'M' }}</span>
</div>
<div class="nic-body">
<div class="nic-row" v-if="nic._ipv4List.length">
<span class="nic-k">IPv4</span>
<div class="nic-addr-list"><span class="nic-addr" v-for="(ip, i) in nic._ipv4List" :key="i">{{ ip }}</span></div>
</div>
<div class="nic-row" v-if="nic._ipv6List.length">
<span class="nic-k">IPv6</span>
<div class="nic-addr-list"><span class="nic-addr v6" v-for="(ip, i) in nic._ipv6List" :key="i">{{ ip }}</span></div>
</div>
<div class="nic-row" v-if="nic._mac">
<span class="nic-k">MAC</span>
<code class="nic-mac">{{ nic._mac }}</code>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="section-block"> <div class="section-block">
<h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo"> <h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo">
硬盘 IO 限制 硬盘 IO 限制
@@ -259,15 +452,28 @@
<div class="section-header"> <div class="section-header">
<h3 class="section-title">监控指标</h3> <h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="monitorTimeMode" size="small" @change="loadHistoricalMetrics">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="monitorTimeMode === 'relative'" v-model="monitorRelativeMinutes" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker <el-date-picker
v-else
v-model="monitorDateRange" v-model="monitorDateRange"
type="datetimerange" type="datetimerange"
range-separator="至" range-separator="至"
start-placeholder="开始时间" start-placeholder="开始"
end-placeholder="结束时间" end-placeholder="结束"
size="small" size="small"
style="width: 360px" style="width: 340px"
:shortcuts="monitorShortcuts"
@change="loadHistoricalMetrics" @change="loadHistoricalMetrics"
/> />
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span> <span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
@@ -350,6 +556,12 @@
<el-tab-pane label="备份管理" name="backup"> <el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" /> <BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="回收站" name="recycleBin">
<RecycleBinManage v-if="hostTabLoaded['recycleBin']" ref="recycleBinManageRef" />
</el-tab-pane>
<el-tab-pane label="虚拟机监控" name="vmMonitor">
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
</el-tab-pane>
<el-tab-pane label="组网管理" name="networking"> <el-tab-pane label="组网管理" name="networking">
<div class="section-block"> <div class="section-block">
@@ -730,7 +942,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
import { import {
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost, getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork, assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory, getHostQuotaStats, createHostToken, getMetricsHistory, getHostQuotaStats,
@@ -745,6 +957,8 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue'
import VmManage from '@/views/virtualization/VmManage.vue' import VmManage from '@/views/virtualization/VmManage.vue'
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue' import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
import BackupManage from '@/views/virtualization/BackupManage.vue' import BackupManage from '@/views/virtualization/BackupManage.vue'
import RecycleBinManage from '@/views/virtualization/RecycleBinManage.vue'
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
import { useTagsViewStore } from '@/store/tagsViewStore' import { useTagsViewStore } from '@/store/tagsViewStore'
import UserListSelector from '@/components/admin/UserListSelector.vue' import UserListSelector from '@/components/admin/UserListSelector.vue'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
@@ -759,7 +973,7 @@ const serviceName = computed(() => route.query.service_name || '')
const hostId = computed(() => parseInt(route.query.id) || 0) const hostId = computed(() => parseInt(route.query.id) || 0)
const activeTab = ref('info') const activeTab = ref('info')
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, networking: false }) const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, recycleBin: false, vmMonitor: false, networking: false })
const imageManageRef = ref(null) const imageManageRef = ref(null)
const networkManageRef = ref(null) const networkManageRef = ref(null)
@@ -767,7 +981,9 @@ const volumeManageRef = ref(null)
const vmManageRef = ref(null) const vmManageRef = ref(null)
const snapshotManageRef = ref(null) const snapshotManageRef = ref(null)
const backupManageRef = ref(null) const backupManageRef = ref(null)
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef } const recycleBinManageRef = ref(null)
const vmMonitorRef = ref(null)
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, recycleBin: recycleBinManageRef, vmMonitor: vmMonitorRef }
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
if (!['info', 'monitor', 'networking'].includes(tab)) { if (!['info', 'monitor', 'networking'].includes(tab)) {
@@ -869,6 +1085,7 @@ const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBw
const showDiskIoSection = ref(false) const showDiskIoSection = ref(false)
const showTokenDiskIo = ref(false) const showTokenDiskIo = ref(false)
const showDetailDiskIo = ref(false) const showDetailDiskIo = ref(false)
const showHardwareInfo = ref(false)
const formData = reactive({ const formData = reactive({
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
@@ -944,6 +1161,111 @@ const loadDetail = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
} }
// ---- ----
const hostMetrics = ref(null)
const hostMetricsLoading = ref(false)
const hardwareInfo = ref(null)
const loadAvg = ref(null)
const loadHostMetrics = async () => {
if (!hostId.value) return
hostMetricsLoading.value = true
try {
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
const body = res?.data
if (body?.code === 200 && body?.data) {
const raw = body.data.data ?? body.data
hostMetrics.value = raw
if (raw.hardware_json) {
try { hardwareInfo.value = JSON.parse(raw.hardware_json) } catch { hardwareInfo.value = null }
}
if (raw.load_avg_json) {
try { loadAvg.value = JSON.parse(raw.load_avg_json) } catch { loadAvg.value = null }
}
if (raw.ksm && !ksmStats.value) {
ksmStats.value = raw.ksm
}
}
} catch (e) {
console.warn('加载实时指标失败:', e)
} finally {
hostMetricsLoading.value = false
}
}
const metricsDisks = computed(() => {
if (!hostMetrics.value?.disk) return []
return Object.entries(hostMetrics.value.disk).map(([path, info]) => {
const pct = info.percent ?? (info.total ? ((info.used / info.total) * 100) : 0)
return { path, ...info, percent: pct }
})
})
const formatBytesAuto = (val) => {
if (!val && val !== 0) return '-'
val = Number(val)
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
return val + ' B'
}
const formatSpeedAuto = (val) => {
if (!val && val !== 0) return '0 B/s'
val = Number(val)
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' GB/s'
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' MB/s'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB/s'
return val + ' B/s'
}
const formatCpuFreq = (freq) => {
if (!freq) return '-'
if (typeof freq === 'object') {
const cur = freq.current ? (freq.current >= 1000 ? (freq.current / 1000).toFixed(2) + ' GHz' : freq.current.toFixed(0) + ' MHz') : ''
const max = freq.max ? (freq.max >= 1000 ? (freq.max / 1000).toFixed(1) + ' GHz' : freq.max + ' MHz') : ''
if (cur && max) return `${cur}(最高 ${max}`
return cur || max || '-'
}
const v = Number(freq)
return v >= 1000 ? (v / 1000).toFixed(2) + ' GHz' : v + ' MHz'
}
const formatUptime = (seconds) => {
if (!seconds) return '-'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const parts = []
if (d > 0) parts.push(`${d}`)
if (h > 0) parts.push(`${h} 小时`)
if (m > 0 && d === 0) parts.push(`${m} 分钟`)
return parts.join(' ') || '< 1 分钟'
}
const loadColor = (val, cores) => {
if (!cores) return '#909399'
const ratio = val / cores
if (ratio >= 1) return '#f56c6c'
if (ratio >= 0.7) return '#e6a23c'
return '#67c23a'
}
const filteredNics = computed(() => {
if (!hardwareInfo.value?.nic_info) return []
const skipPrefixes = ['vnet', 'veth', 'ovs-', 'br-int', 'tap']
return hardwareInfo.value.nic_info
.filter(nic => !skipPrefixes.some(p => nic.name.startsWith(p)))
.map(nic => {
const addrs = nic.addresses || []
const ipv4List = addrs.filter(a => a.family?.includes('AF_INET') && !a.family?.includes('AF_INET6')).map(a => a.address)
const ipv6List = addrs.filter(a => a.family?.includes('AF_INET6')).map(a => a.address)
const macEntry = addrs.find(a => a.family?.includes('AF_PACKET'))
return { ...nic, _ipv4List: ipv4List, _ipv6List: ipv6List, _mac: macEntry?.address || '' }
})
})
// ---- ---- // ---- ----
const quotaStats = ref(null) const quotaStats = ref(null)
const quotaStatsLoading = ref(false) const quotaStatsLoading = ref(false)
@@ -1104,21 +1426,20 @@ const latestMetrics = computed(() => {
return arr[arr.length - 1] return arr[arr.length - 1]
}) })
const makeDefaultRange = () => { const monitorTimeMode = ref('relative')
const now = new Date() const monitorRelativeMinutes = ref(10)
return [new Date(now.getTime() - 10 * 60 * 1000), now] const monitorDateRange = ref(null)
}
const monitorDateRange = ref(makeDefaultRange())
const monitorShortcuts = [ const getMonitorTimeRange = () => {
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } }, if (monitorTimeMode.value === 'relative') {
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } }, const endTime = new Date()
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } }, const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000)
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } }, return { startTime, endTime }
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } }, } else {
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } }, if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } }, return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) }
] }
}
function calcInterval(startTime, endTime) { function calcInterval(startTime, endTime) {
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000 const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
@@ -1136,18 +1457,19 @@ function calcInterval(startTime, endTime) {
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' } const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
const currentIntervalLabel = computed(() => { const currentIntervalLabel = computed(() => {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-' const range = getMonitorTimeRange()
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1])) if (!range) return '-'
const iv = calcInterval(range.startTime, range.endTime)
return intervalLabelMap[iv] || iv return intervalLabelMap[iv] || iv
}) })
const loadHistoricalMetrics = async () => { const loadHistoricalMetrics = async () => {
if (!serviceId.value || !hostId.value) return if (!serviceId.value || !hostId.value) return
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return const range = getMonitorTimeRange()
if (!range) return
historicalMetricsLoading.value = true historicalMetricsLoading.value = true
try { try {
const startTime = new Date(monitorDateRange.value[0]) const { startTime, endTime } = range
const endTime = new Date(monitorDateRange.value[1])
const interval = calcInterval(startTime, endTime) const interval = calcInterval(startTime, endTime)
const params = { const params = {
service_id: serviceId.value, service_id: serviceId.value,
@@ -1187,7 +1509,8 @@ const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return if (!Array.isArray(metrics) || !metrics.length) return
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 0 const range = getMonitorTimeRange()
const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 0
const showDate = spanMs >= 12 * 3600 * 1000 const showDate = spanMs >= 12 * 3600 * 1000
const symbolType = spanMs >= 7 * 86400 * 1000 ? 'circle' : 'none' const symbolType = spanMs >= 7 * 86400 * 1000 ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0 const labelRotate = showDate ? 45 : 0
@@ -1618,13 +1941,17 @@ const initPage = () => {
historicalMetricsData.value = null historicalMetricsData.value = null
disposeCharts() disposeCharts()
loadDetail() loadDetail()
loadHostMetrics()
if (activeTab.value === 'monitor') loadHistoricalMetrics() if (activeTab.value === 'monitor') loadHistoricalMetrics()
} }
watch(hostId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtHostDetail'
onActivated(() => {
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedHostId !== hostId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
}) })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
@@ -1726,4 +2053,80 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
.quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; } .quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; }
.quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; } .quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; }
/* 实时监控 */
.sec-icon { width: 18px; height: 18px; vertical-align: -3px; margin-right: 4px; }
.rt-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.rt-card { display: flex; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 18px 20px; transition: box-shadow .2s, border-color .2s; }
.rt-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.06); border-color: #d9ecff; }
.rt-card-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; }
.cpu-icon { background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); }
.mem-icon { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
.load-icon { background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%); }
.net-icon { background: linear-gradient(135deg, #909399 0%, #b1b3b8 100%); }
.rt-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.rt-label { font-size: 12px; color: #86909c; font-weight: 500; }
.rt-value { font-size: 26px; font-weight: 700; line-height: 1.1; }
.rt-value small { font-size: 14px; font-weight: 500; margin-left: 1px; }
.rt-sub { font-size: 11px; color: #a8abb2; margin-top: 2px; }
.load-pills { display: flex; gap: 8px; margin: 4px 0 2px; }
.load-pill { display: inline-flex; align-items: center; gap: 4px; background: #f7f8fa; border: 1px solid #ebeef5; border-radius: 6px; padding: 4px 10px; font-size: 15px; font-weight: 700; color: var(--lc, #1d2129); }
.load-pill em { font-style: normal; font-size: 10px; font-weight: 500; color: #a8abb2; text-transform: uppercase; }
.net-bw-row { display: flex; gap: 16px; margin: 6px 0 2px; }
.net-bw-item { display: inline-flex; align-items: center; gap: 5px; font-size: 16px; font-weight: 700; color: #1d2129; }
.rt-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 20px 0 10px; display: flex; align-items: center; gap: 6px; }
.disk-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.disk-item { background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 12px 16px; transition: box-shadow .2s; }
.disk-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,.04); }
.disk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.disk-path { font-size: 13px; font-family: 'Consolas','Monaco',monospace; color: #1d2129; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; }
.disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; }
.disk-pct { font-size: 13px; font-weight: 700; min-width: 44px; text-align: right; }
/* 硬件信息 */
.hw-overview { display: flex; flex-wrap: wrap; gap: 0; background: #f7f8fa; border-radius: 8px; border: 1px solid #ebeef5; margin-bottom: 16px; overflow: hidden; }
.hw-ov-item { flex: 1; min-width: 180px; display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-right: 1px solid #ebeef5; }
.hw-ov-item:last-child { border-right: none; }
.hw-ov-item div { display: flex; flex-direction: column; min-width: 0; }
.hw-ov-item em { font-style: normal; font-size: 11px; color: #a8abb2; line-height: 1; margin-bottom: 2px; }
.hw-ov-item div { font-size: 13px; color: #1d2129; font-weight: 500; word-break: break-all; }
.hw-spec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; margin-bottom: 16px; }
.hw-spec-card { display: flex; gap: 14px; align-items: flex-start; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 16px 20px; }
.hw-spec-icon { flex-shrink: 0; width: 40px; height: 40px; background: #f0f7ff; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
.hw-spec-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.hw-spec-label { font-size: 11px; color: #a8abb2; }
.hw-spec-main { font-size: 14px; font-weight: 600; color: #1d2129; word-break: break-word; }
.hw-spec-detail { font-size: 12px; color: #86909c; }
.hw-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 16px 0 10px; display: flex; align-items: center; gap: 6px; }
.hw-disk-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
.hw-disk-card { display: flex; align-items: center; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; }
.hw-disk-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); }
.hw-disk-icon { flex-shrink: 0; width: 44px; height: 44px; background: #f0f7ff; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.hw-disk-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.hw-disk-name { font-size: 14px; font-weight: 600; color: #1d2129; font-family: 'Consolas','Monaco',monospace; }
.hw-disk-model { font-size: 12px; color: #86909c; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hw-disk-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
.hw-disk-size { font-size: 15px; font-weight: 700; color: #1d2129; }
.nic-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px; }
.nic-card { background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; }
.nic-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); }
.nic-card.nic-down { opacity: .55; }
.nic-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.nic-name { font-size: 14px; font-weight: 600; color: #1d2129; }
.nic-tag { margin-left: auto; }
.nic-speed { font-size: 12px; color: #86909c; font-weight: 500; }
.nic-body { display: flex; flex-direction: column; gap: 4px; }
.nic-row { display: flex; align-items: center; gap: 8px; }
.nic-k { font-size: 11px; color: #a8abb2; min-width: 32px; font-weight: 500; }
.nic-addr-list { display: flex; flex-wrap: wrap; gap: 4px; }
.nic-addr { display: inline-block; background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 4px; padding: 1px 8px; font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #409eff; }
.nic-addr.v6 { background: #fdf6ec; border-color: #faecd8; color: #e6a23c; font-size: 11px; }
.nic-mac { font-size: 12px; color: #606266; }
</style> </style>
+22 -2
View File
@@ -22,8 +22,10 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"> :header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">
<el-table-column label="名称" min-width="280"> <el-table-column label="名称" min-width="280">
<template #default="{ row }"> <template #default="{ row }">
<div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }"> <div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }"
<span v-if="row._isGroup" class="expand-icon" @click="toggleExpand(row)"> :class="{ 'clickable-group': row._isGroup, 'clickable-host': row._isHost }"
@click="row._isGroup ? toggleExpand(row) : (row._isHost ? handleGoHostDetail(row) : null)">
<span v-if="row._isGroup" class="expand-icon">
<el-icon v-if="row._loading"><Loading /></el-icon> <el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon> <el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span> </span>
@@ -924,6 +926,24 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 0;
border-radius: 4px;
transition: background 0.15s;
}
.tree-name-cell.clickable-group {
cursor: pointer;
}
.tree-name-cell.clickable-group:hover {
background: #f5f7fa;
}
.tree-name-cell.clickable-host {
cursor: pointer;
}
.tree-name-cell.clickable-host:hover {
background: #ecf5ff;
}
.tree-name-cell.clickable-host:hover .row-name {
color: #409eff;
} }
.expand-icon { .expand-icon {
cursor: pointer; cursor: pointer;
+18 -6
View File
@@ -32,8 +32,13 @@
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag> <el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="模式">
<el-tag :type="detail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ detail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item> <el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item> <el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="下载URL" :span="2"><span class="mono-text">{{ detail.url || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="来源地址" :span="2"><span class="mono-text">{{ detail.source || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item> <el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
@@ -79,6 +84,11 @@
<el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" /> <el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="镜像模式">
<el-select v-model="formData.mode" style="width: 100%">
<el-option label="本地模式" value="local" /><el-option label="远端模式" value="remote" />
</el-select>
</el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="formData.status" style="width: 100%"> <el-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" /> <el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" />
@@ -127,7 +137,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
@@ -160,7 +170,7 @@ const syncHostId = ref('')
const reloadHostId = ref('') const reloadHostId = ref('')
const formRef = ref(null) const formRef = ref(null)
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' }) const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', mode: 'local' })
const formRules = { const formRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入路径', trigger: 'blur' }] path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
@@ -232,7 +242,7 @@ const loadHostStatus = async () => {
const handleEdit = () => { const handleEdit = () => {
if (!detail.value) return if (!detail.value) return
const d = detail.value const d = detail.value
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '' }) Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '', mode: d.mode || 'local' })
editDialogVisible.value = true editDialogVisible.value = true
} }
@@ -241,7 +251,7 @@ const handleSubmitEdit = () => {
if (!valid) return if (!valid) return
submitLoading.value = true submitLoading.value = true
try { try {
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined } const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined, mode: formData.mode }
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] }) Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
const res = await updateImage(payload) const res = await updateImage(payload)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
@@ -319,8 +329,10 @@ const initPage = async () => {
loadHostStatus() loadHostStatus()
} }
watch(imageId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtImageDetail'
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>
+29 -5
View File
@@ -54,6 +54,11 @@
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag> <el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="模式" width="90">
<template #default="{ row }">
<el-tag :type="row.mode === 'remote' ? 'warning' : 'success'" size="small">{{ row.mode === 'remote' ? '远端' : '本地' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="主控状态" width="100"> <el-table-column label="主控状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
@@ -109,6 +114,13 @@
<el-option label="数据镜像" value="data" /> <el-option label="数据镜像" value="data" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="镜像模式" prop="mode">
<el-select v-model="formData.mode" style="width: 100%">
<el-option label="本地模式(下载到本地提供服务)" value="local" />
<el-option label="远端模式(直接使用远端URL" value="remote" />
</el-select>
<div style="color:#909399;font-size:12px;margin-top:4px">本地模式会下载镜像到主控远端模式宿主机直接从远端URL下载</div>
</el-form-item>
<el-form-item label="介绍"> <el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" /> <el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
</el-form-item> </el-form-item>
@@ -148,10 +160,19 @@
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag> <el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="模式">
<el-tag :type="currentDetail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ currentDetail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item> <el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"> <el-descriptions-item label="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span> <span class="mono-text">{{ currentDetail.path || '-' }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="下载URL" :span="2">
<span class="mono-text">{{ currentDetail.url || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="来源地址" :span="2">
<span class="mono-text">{{ currentDetail.source || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item> <el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
@@ -291,7 +312,7 @@ const currentHostLabel = computed(() => {
const formData = reactive({ const formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: '' description: '', status: '', size: 0, image_name: '', mode: 'local'
}) })
const formRules = { const formRules = {
@@ -400,7 +421,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 }) Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0, mode: 'local' })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -409,7 +430,8 @@ const handleEdit = (row) => {
Object.assign(formData, { Object.assign(formData, {
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '', image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
os_type: row.os_type || 'linux', type: row.type || 'system', os_type: row.os_type || 'linux', type: row.type || 'system',
description: row.description || '', status: row.status || '', size: row.size || 0 description: row.description || '', status: row.status || '', size: row.size || 0,
mode: row.mode || 'local'
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -423,7 +445,8 @@ const handleSubmit = () => {
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
res = await createImage({ res = await createImage({
service_id: serviceId.value, name: formData.name, path: formData.path, service_id: serviceId.value, name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, description: formData.description || undefined os_type: formData.os_type, type: formData.type, description: formData.description || undefined,
mode: formData.mode
}) })
} else { } else {
const payload = { const payload = {
@@ -431,7 +454,8 @@ const handleSubmit = () => {
image_name: formData.name, path: formData.path, image_name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, os_type: formData.os_type, type: formData.type,
description: formData.description || undefined, description: formData.description || undefined,
status: formData.status || undefined, size: formData.size || undefined status: formData.status || undefined, size: formData.size || undefined,
mode: formData.mode
} }
// undefined // undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] }) Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
+93 -4
View File
@@ -33,6 +33,14 @@
<el-option label="IPv4" value="ipv4" /> <el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" /> <el-option label="IPv6" value="ipv6" />
</el-select> </el-select>
<el-select v-model="filterUsed" placeholder="使用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已用" value="true" />
<el-option label="未用" value="false" />
</el-select>
<el-select v-model="filterDisable" placeholder="禁用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已禁用" value="true" />
<el-option label="已启用" value="false" />
</el-select>
</div> </div>
<!-- 网络列表 --> <!-- 网络列表 -->
@@ -54,10 +62,34 @@
<el-table-column label="宿主机" width="140"> <el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template> <template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="使用状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="isNetworkUsed(row) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(row) ? '已用' : '未用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="虚拟机" width="120">
<template #default="{ row }">
<el-button v-if="isNetworkUsed(row)" link type="primary" @click="goToVmDetail(row.vm_id)">
{{ row.vm_id }}
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="禁用" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">已禁用</el-tag>
<el-tag v-else type="success" size="small">启用</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button> <el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link :type="row.disable ? 'success' : 'warning'" @click="handleToggleDisable(row)">
{{ row.disable ? '启用' : '禁用' }}
</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -113,6 +145,10 @@
<el-form-item label="逻辑端口名"> <el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" /> <el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</el-form-item> </el-form-item>
<el-form-item label="禁用网络" v-if="dialogType === 'edit'">
<el-switch v-model="formData.disable" active-text="禁用" inactive-text="启用" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">禁用后该网络不参与自动分配</div>
</el-form-item>
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -142,6 +178,27 @@
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item> <el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用状态">
<el-tag :type="isNetworkUsed(currentDetail) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(currentDetail) ? '已用' : '未用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="虚拟机">
<el-button v-if="isNetworkUsed(currentDetail)" link type="primary" @click="goToVmDetail(currentDetail.vm_id)">
{{ currentDetail.vm_id }}
</el-button>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="主网卡">
<el-tag :type="currentDetail.is_primary ? 'warning' : 'info'" size="small">
{{ currentDetail.is_primary ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="禁用状态">
<el-tag :type="currentDetail.disable ? 'danger' : 'success'" size="small">
{{ currentDetail.disable ? '已禁用' : '启用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template> <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog> </el-dialog>
@@ -221,6 +278,15 @@ const total = ref(0)
const keyword = ref('') const keyword = ref('')
const filterType = ref('') const filterType = ref('')
const filterIpVersion = ref('') const filterIpVersion = ref('')
const filterUsed = ref('')
const filterDisable = ref('')
const isNetworkUsed = (row) => !!row?.vm_id
const goToVmDetail = (vmId) => {
if (!vmId) return
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: vmId } })
}
const hostIdInput = ref(0) const hostIdInput = ref(0)
const hostOptions = ref([]) const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 }) const queryParams = reactive({ page: 1, page_size: 10 })
@@ -254,7 +320,8 @@ const currentDetail = ref(null)
const formData = reactive({ const formData = reactive({
id: undefined, name: '', address: '', gateway: '', nameservers: '', id: undefined, name: '', address: '', gateway: '', nameservers: '',
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
disable: false
}) })
const formRules = { const formRules = {
@@ -275,6 +342,8 @@ const loadList = async () => {
if (keyword.value) params.key = keyword.value if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value if (filterIpVersion.value) params.ip_version = filterIpVersion.value
if (filterUsed.value !== '') params.used = filterUsed.value
if (filterDisable.value !== '') params.disable = filterDisable.value
const res = await getNetworkList(params) const res = await getNetworkList(params)
const body = res?.data const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
@@ -295,7 +364,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 }) Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0, disable: false })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -305,7 +374,7 @@ const handleEdit = (row) => {
id: row.id, name: row.name, address: row.address, gateway: row.gateway, id: row.id, name: row.name, address: row.address, gateway: row.gateway,
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '', nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
ls_name: row.ls_name || '', host_id: row.host_id ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -332,6 +401,7 @@ const handleSubmit = () => {
res = await createNetwork(fd) res = await createNetwork(fd)
} else { } else {
fd.append('id', formData.id) fd.append('id', formData.id)
fd.append('disable', formData.disable)
res = await updateNetwork(fd) res = await updateNetwork(fd)
} }
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
@@ -359,6 +429,25 @@ const handleViewDetail = async (row) => {
} catch { /* fallback */ } } catch { /* fallback */ }
} }
const handleToggleDisable = (row) => {
const nextState = !row.disable
const actionText = nextState ? '禁用' : '启用'
ElMessageBox.confirm(`确定要${actionText}网络「${row.name}」吗?${nextState ? '禁用后该网络不参与自动分配。' : ''}`, `${actionText}确认`, {
confirmButtonText: `确定${actionText}`, cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', row.id)
fd.append('host_id', row.host_id)
fd.append('disable', nextState)
const res = await updateNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success(`${actionText}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${actionText}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${actionText}失败`)) }
}).catch(() => {})
}
const handleDelete = (row) => { const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', { ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
@@ -0,0 +1,374 @@
<template>
<div class="recycle-bin-manage">
<div class="toolbar">
<el-input v-model="keyword" placeholder="按虚拟机名称搜索" style="width: 200px" size="small" clearable
@clear="loadList" @keyup.enter="loadList" />
<el-select v-model="filterStatus" placeholder="按状态过滤" style="width: 140px" size="small" clearable
@change="() => { currentPage = 1; loadList() }">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-button size="small" :icon="Search" @click="loadList">搜索</el-button>
<el-button size="small" :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-dropdown trigger="click" @command="handleClean" style="margin-left: auto">
<el-button size="small" type="danger">
清空回收站<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="expired">清理已到期</el-dropdown-item>
<el-dropdown-item command="all">清空全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-table :data="list" v-loading="loading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="vm_name" label="虚拟机名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="vm_id" label="原虚拟机ID" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" width="160">
<template #default="{ row }">
<span class="mono-text">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="170">
<template #default="{ row }">
<span :style="isExpired(row.expire_at) ? 'color: #f56c6c' : ''">{{ formatTs(row.expire_at) }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTs(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handleRestore(row)"
:disabled="!canRestore(row.status)">恢复</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)"
:disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" description="回收站为空" :image-size="60" />
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
@current-change="p => { currentPage = p; loadList() }" />
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="回收站记录详情" width="720px" destroy-on-close>
<div v-loading="detailLoading">
<el-descriptions :column="2" border size="small" v-if="detailData?.recycle" style="margin-bottom: 16px">
<el-descriptions-item label="记录ID">{{ detailData.recycle.id }}</el-descriptions-item>
<el-descriptions-item label="原虚拟机ID">{{ detailData.recycle.vm_id }}</el-descriptions-item>
<el-descriptions-item label="虚拟机名称">{{ detailData.recycle.vm_name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detailData.recycle.status)" size="small">{{ statusLabel(detailData.recycle.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="宿主机ID">{{ detailData.recycle.host_id }}</el-descriptions-item>
<el-descriptions-item label="归档目录">
<span class="mono-text">{{ detailData.recycle.recycle_dir || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ formatTs(detailData.recycle.expire_at) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTs(detailData.recycle.created_at) }}</el-descriptions-item>
</el-descriptions>
<template v-if="detailData">
<div v-for="snap in snapshotSections" :key="snap.key">
<div class="snapshot-title">{{ snap.label }}</div>
<el-input type="textarea" :model-value="formatSnapshot(detailData[snap.key])" :rows="6" readonly
style="margin-bottom: 12px; font-family: Consolas, Monaco, monospace; font-size: 12px" />
</div>
</template>
<el-empty v-if="!detailData" description="暂无详情数据" />
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 恢复弹窗可选指定网络 -->
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="520px" destroy-on-close>
<el-form label-width="120px">
<el-form-item label="虚拟机名称">
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
</el-form-item>
<el-form-item label="外网网络">
<div class="net-select-area">
<div v-if="selectedBridgeNetwork" class="net-selected-item">
<el-tag closable @close="selectedBridgeNetwork = null">
{{ selectedBridgeNetwork.name }} ({{ selectedBridgeNetwork.address }})
</el-tag>
</div>
<el-button size="small" @click="showBridgeNetSelector = true">
{{ selectedBridgeNetwork ? '更换' : '选择外网' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="内网网络">
<div class="net-select-area">
<div v-if="selectedNatNetworks.length" class="net-selected-list">
<el-tag v-for="net in selectedNatNetworks" :key="net.id" closable
@close="removeNatNetwork(net.id)" style="margin-right: 4px; margin-bottom: 4px">
{{ net.name }} ({{ net.address }})
</el-tag>
</div>
<el-button size="small" @click="showNatNetSelector = true">添加内网</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="restoreVisible = false">取消</el-button>
<el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button>
</template>
</el-dialog>
<!-- 恢复 - 外网选择器 -->
<NetworkSelectorPopup v-model="showBridgeNetSelector"
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
filter-type="bridge" filter-used="false"
@confirm="handleBridgeNetConfirm" />
<!-- 恢复 - 内网选择器 -->
<NetworkSelectorPopup v-model="showNatNetSelector"
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
filter-type="nat" filter-used="false"
@confirm="handleNatNetConfirm" />
</div>
</template>
<script setup>
import { ref, reactive, inject, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import {
getRecycleBinList, getRecycleBinDetail,
restoreRecycleBin, deleteRecycleBin, cleanRecycleBin
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
const serviceId = inject('serviceId')
const hostId = inject('hostId')
const loading = ref(false)
const list = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const filterStatus = ref('')
const statusOptions = [
{ value: 'pending', label: '等待归档' },
{ value: 'archiving', label: '归档中' },
{ value: 'ready', label: '就绪' },
{ value: 'archived', label: '已归档' },
{ value: 'restoring', label: '恢复中' },
{ value: 'purging', label: '清理中' }
]
const statusLabelMap = {
pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
}
const statusLabel = (s) => statusLabelMap[s] || s || '-'
const statusTagType = (s) => ({
ready: 'success', archived: 'success', pending: 'info', archiving: 'warning',
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
}[s] || 'info')
// ready archived
const canRestore = (status) => status === 'ready' || status === 'archived'
const formatTs = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
if (typeof ts === 'string' || typeof ts === 'number') {
const d = new Date(ts)
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
}
return '-'
}
const isExpired = (ts) => {
if (!ts) return false
let t
if (typeof ts === 'object' && ts.seconds) t = Number(ts.seconds) * 1000
else t = new Date(ts).getTime()
return !isNaN(t) && t < Date.now()
}
const loadList = async () => {
loading.value = true
try {
const params = {
service_id: serviceId.value,
host_id: hostId.value,
page: currentPage.value,
count: pageSize.value
}
if (keyword.value) params.keyword = keyword.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getRecycleBinList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
/* ---- 详情 ---- */
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref(null)
const snapshotSections = [
{ key: 'vm_snapshot', label: '虚拟机快照' },
{ key: 'volumes_snapshot', label: '磁盘快照' },
{ key: 'networks_snapshot', label: '网络快照' },
{ key: 'traffic_policy_snapshot', label: '流量策略快照' }
]
const formatSnapshot = (raw) => {
if (!raw) return '(无)'
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
}
const handleDetail = async (row) => {
detailData.value = null
detailVisible.value = true
detailLoading.value = true
try {
const res = await getRecycleBinDetail({
service_id: serviceId.value,
host_id: hostId.value,
recycle_id: row.id
})
if (res?.data?.code === 200 && res?.data?.data) {
detailData.value = res.data.data.data ?? res.data.data
} else { ElMessage.warning('获取详情失败') }
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '获取详情失败'))
} finally { detailLoading.value = false }
}
/* ---- 恢复 ---- */
const restoreVisible = ref(false)
const restoreLoading = ref(false)
const restoreRow = ref(null)
//
const showBridgeNetSelector = ref(false)
const showNatNetSelector = ref(false)
const selectedBridgeNetwork = ref(null)
const selectedNatNetworks = ref([])
const handleRestore = (row) => {
restoreRow.value = row
selectedBridgeNetwork.value = null
selectedNatNetworks.value = []
restoreVisible.value = true
}
const handleBridgeNetConfirm = (net) => { selectedBridgeNetwork.value = net }
const handleNatNetConfirm = (net) => {
if (!selectedNatNetworks.value.find(n => n.id === net.id)) {
selectedNatNetworks.value.push(net)
}
}
const removeNatNetwork = (id) => {
selectedNatNetworks.value = selectedNatNetworks.value.filter(n => n.id !== id)
}
const submitRestore = async () => {
restoreLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('host_id', hostId.value)
fd.append('recycle_id', restoreRow.value.id)
if (selectedNatNetworks.value.length) {
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
}
if (selectedBridgeNetwork.value) {
fd.append('internet_network_id', selectedBridgeNetwork.value.id)
}
const res = await restoreRecycleBin(fd)
if (res?.data?.code === 200) {
ElMessage.success('恢复任务已提交')
restoreVisible.value = false
loadList()
} else { ElMessage.error(extractApiError(res?.data, '恢复失败')) }
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '恢复失败'))
} finally { restoreLoading.value = false }
}
/* ---- 永久删除 ---- */
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要永久删除「${row.vm_name}」的回收站记录吗?此操作不可恢复!`,
'永久删除确认',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await deleteRecycleBin({
service_id: serviceId.value,
host_id: hostId.value,
recycle_id: row.id
})
if (res?.data?.code === 200) { ElMessage.success('删除任务已提交'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
/* ---- 清空回收站 ---- */
const handleClean = (command) => {
const isAll = command === 'all'
const msg = isAll ? '确定要清空全部回收站记录吗?此操作不可恢复!' : '确定要清理所有已到期的回收站记录吗?'
ElMessageBox.confirm(msg, '清空回收站', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await cleanRecycleBin({
service_id: serviceId.value,
host_id: hostId.value,
all: isAll
})
if (res?.data?.code === 200) {
const purged = res.data.data?.purged ?? 0
ElMessage.success(`已提交清理任务,清理 ${purged} 条记录`)
loadList()
} else { ElMessage.error(extractApiError(res?.data, '清空失败')) }
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '清空失败')) }
}).catch(() => {})
}
onMounted(() => { loadList() })
defineExpose({ loadList })
</script>
<style scoped>
.recycle-bin-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; align-items: center; margin-top: 12px; margin-bottom: 12px; }
.mono-text { font-family: Consolas, Monaco, monospace; font-size: 12px; }
.snapshot-title { font-size: 13px; font-weight: 600; color: #606266; margin: 12px 0 6px; }
.pagination-wrapper { margin-top: 12px; display: flex; justify-content: flex-end; }
.net-select-area { display: flex; flex-direction: column; gap: 6px; }
.net-selected-list { display: flex; flex-wrap: wrap; gap: 4px; }
</style>
+90 -41
View File
@@ -18,6 +18,7 @@
</div> </div>
<div class="overview-actions"> <div class="overview-actions">
<el-button type="primary" @click="handleGetVnc">远程连接</el-button> <el-button type="primary" @click="handleGetVnc">远程连接</el-button>
<el-button type="success" @click="handleSshConnect" :disabled="!publicIpList.length || isWindows">SSH连接</el-button>
<el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop' || isMigrating">关机</el-button> <el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop' || isMigrating">关机</el-button>
<el-dropdown trigger="click" @command="handleMoreCommand"> <el-dropdown trigger="click" @command="handleMoreCommand">
<el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button> <el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
@@ -594,15 +595,28 @@
<div class="section-header"> <div class="section-header">
<h3 class="section-title">监控指标</h3> <h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="monitorTimeMode" size="small" @change="loadHistoricalMetrics">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="monitorTimeMode === 'relative'" v-model="monitorRelativeMinutes" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker <el-date-picker
v-else
v-model="monitorDateRange" v-model="monitorDateRange"
type="datetimerange" type="datetimerange"
range-separator="至" range-separator="至"
start-placeholder="开始时间" start-placeholder="开始"
end-placeholder="结束时间" end-placeholder="结束"
size="small" size="small"
style="width: 360px" style="width: 340px"
:shortcuts="monitorShortcuts"
@change="loadHistoricalMetrics" @change="loadHistoricalMetrics"
/> />
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span> <span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
@@ -656,13 +670,13 @@
<el-row :gutter="16" style="margin-top: 16px"> <el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 I/O</span></template> <template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘读写量</span></template>
<div ref="diskChartRef" class="chart-container"></div> <div ref="diskChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络流量</span></template> <template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络速率</span></template>
<div ref="netChartRef" class="chart-container"></div> <div ref="netChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -670,7 +684,7 @@
<el-row :gutter="16" style="margin-top: 16px"> <el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 IOPS</span></template> <template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘IO速率</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div> <div ref="diskIopsChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -708,16 +722,27 @@
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">每小时流量</h3> <h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="trafficTimeMode" size="small" @change="loadTrafficHourly">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="trafficTimeMode === 'relative'" v-model="trafficRelativeMinutes" size="small" style="width: 120px" @change="loadTrafficHourly">
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="3天" :value="4320" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker <el-date-picker
v-else
v-model="trafficHourlyRange" v-model="trafficHourlyRange"
type="datetimerange" type="datetimerange"
range-separator="至" range-separator="至"
start-placeholder="开始时间" start-placeholder="开始"
end-placeholder="结束时间" end-placeholder="结束"
size="small" size="small"
style="width: 360px" style="width: 340px"
:shortcuts="monitorShortcuts"
@change="loadTrafficHourly" @change="loadTrafficHourly"
/> />
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button> <el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
@@ -1911,21 +1936,20 @@ let isPageActive = false
const historicalMetricsData = ref(null) const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false) const historicalMetricsLoading = ref(false)
const makeDefaultRange = () => { const monitorTimeMode = ref('relative')
const now = new Date() const monitorRelativeMinutes = ref(10)
return [new Date(now.getTime() - 10 * 60 * 1000), now] const monitorDateRange = ref(null)
}
const monitorDateRange = ref(makeDefaultRange())
const monitorShortcuts = [ const getMonitorTimeRange = () => {
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } }, if (monitorTimeMode.value === 'relative') {
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } }, const endTime = new Date()
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } }, const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000)
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } }, return { startTime, endTime }
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } }, } else {
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } }, if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } }, return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) }
] }
}
function calcInterval(startTime, endTime) { function calcInterval(startTime, endTime) {
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000 const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
@@ -1943,8 +1967,9 @@ function calcInterval(startTime, endTime) {
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' } const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
const currentIntervalLabel = computed(() => { const currentIntervalLabel = computed(() => {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-' const range = getMonitorTimeRange()
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1])) if (!range) return '-'
const iv = calcInterval(range.startTime, range.endTime)
return intervalLabelMap[iv] || iv return intervalLabelMap[iv] || iv
}) })
@@ -1987,11 +2012,11 @@ const vmMemPercent = (m) => {
const loadHistoricalMetrics = async () => { const loadHistoricalMetrics = async () => {
if (!serviceId.value || !detail.value?.name) return if (!serviceId.value || !detail.value?.name) return
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return const range = getMonitorTimeRange()
if (!range) return
historicalMetricsLoading.value = true historicalMetricsLoading.value = true
try { try {
const startTime = new Date(monitorDateRange.value[0]) const { startTime, endTime } = range
const endTime = new Date(monitorDateRange.value[1])
const interval = calcInterval(startTime, endTime) const interval = calcInterval(startTime, endTime)
const params = { const params = {
service_id: serviceId.value, service_id: serviceId.value,
@@ -2021,7 +2046,8 @@ const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return if (!Array.isArray(metrics) || !metrics.length) return
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 0 const range = getMonitorTimeRange()
const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 0
const showDate = spanMs >= 12 * 3600 * 1000 const showDate = spanMs >= 12 * 3600 * 1000
const symbolType = showDate ? 'circle' : 'none' const symbolType = showDate ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0 const labelRotate = showDate ? 45 : 0
@@ -2758,6 +2784,15 @@ const loadVncNodes = async () => {
} catch { /* */ } } catch { /* */ }
} }
const handleSshConnect = () => {
const host = publicIpList.value[0]
if (!host) return ElMessage.warning('无可用公网 IP')
const username = isWindows.value ? 'Administrator' : 'root'
const password = detail.value?.root_password || ''
const encodedPwd = btoa(password)
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
}
const handleGetVnc = async () => { const handleGetVnc = async () => {
vncNodeId.value = null vncNodeId.value = null
vncResult.value = null vncResult.value = null
@@ -3003,26 +3038,37 @@ const handleConnectNetwork = () => {
} }
// ---- ---- // ---- ----
const trafficTimeMode = ref('relative')
const trafficRelativeMinutes = ref(1440)
const trafficHourlyRange = ref(null) const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([]) const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false) const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null) const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null let trafficHourlyChart = null
const getTrafficTimeRange = () => {
if (trafficTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - trafficRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!trafficHourlyRange.value || trafficHourlyRange.value.length < 2) return null
return { startTime: new Date(trafficHourlyRange.value[0]), endTime: new Date(trafficHourlyRange.value[1]) }
}
}
const loadTrafficHourly = async () => { const loadTrafficHourly = async () => {
if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return
if (!trafficHourlyRange.value) { const range = getTrafficTimeRange()
const now = new Date() if (!range) return
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
}
trafficHourlyLoading.value = true trafficHourlyLoading.value = true
try { try {
const res = await getVmTrafficHourly({ const res = await getVmTrafficHourly({
service_id: serviceId.value, service_id: serviceId.value,
host_id: detail.value.host_id, host_id: detail.value.host_id,
vm_name: detail.value.name, vm_name: detail.value.name,
start: new Date(trafficHourlyRange.value[0]).toISOString(), start: range.startTime.toISOString(),
end_time: new Date(trafficHourlyRange.value[1]).toISOString() end_time: range.endTime.toISOString()
}) })
const raw = res?.data?.data?.data const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : []) trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
@@ -3910,11 +3956,14 @@ const loadSgLockInfo = async () => {
} }
} }
watch(vmId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVmDetail'
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) }) watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(() => { onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedVmId !== vmId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
}) })
onDeactivated(() => { isPageActive = false; stopMigratePolling() }) onDeactivated(() => { isPageActive = false; stopMigratePolling() })
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() }) onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
+106 -2
View File
@@ -28,8 +28,24 @@
</el-select> </el-select>
</div> </div>
<!-- 批量操作栏 -->
<div class="batch-bar" v-if="selectedVms.length">
<span class="batch-info">已选择 <strong>{{ selectedVms.length }}</strong> 台虚拟机</span>
<el-button type="success" size="small" @click="handleBatchPower('start')" :loading="batchLoading">
<el-icon><VideoPlay /></el-icon>批量开机
</el-button>
<el-button type="warning" size="small" @click="handleBatchPower('stop')" :loading="batchLoading">
<el-icon><SwitchButton /></el-icon>批量关机
</el-button>
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
<el-icon><Delete /></el-icon>批量删除
</el-button>
<el-button size="small" @click="clearSelection">取消选择</el-button>
</div>
<!-- 虚拟机列表 --> <!-- 虚拟机列表 -->
<el-table :data="vmList" v-loading="loading" stripe> <el-table ref="vmTableRef" :data="vmList" v-loading="loading" stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip /> <el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="配置" min-width="200"> <el-table-column label="配置" min-width="200">
@@ -433,7 +449,7 @@
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue' import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue'
import { import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getRemoteHostList, getVmList, getVmDetail, getVmStatus,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm, createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
@@ -466,6 +482,7 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const batchLoading = ref(false)
const vmList = ref([]) const vmList = ref([])
const total = ref(0) const total = ref(0)
const keyword = ref('') const keyword = ref('')
@@ -473,6 +490,12 @@ const filterStatus = ref('')
const hostOptions = ref([]) const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 }) const queryParams = reactive({ page: 1, page_size: 10 })
//
const vmTableRef = ref(null)
const selectedVms = ref([])
const handleSelectionChange = (selection) => { selectedVms.value = selection }
const clearSelection = () => { vmTableRef.value?.clearSelection() }
// //
const showCreateImageSelector = ref(false) const showCreateImageSelector = ref(false)
const showRebuildImageSelector = ref(false) const showRebuildImageSelector = ref(false)
@@ -989,6 +1012,72 @@ const handleDelete = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
// /
const handleBatchPower = (action) => {
const label = action === 'start' ? '开机' : '关机'
const targets = selectedVms.value
const skipped = targets.filter(v =>
action === 'start' ? v.status === 'running' : (v.status === 'stopped' || v.status === 'stop')
)
const toOperate = targets.filter(v => !skipped.includes(v))
if (!toOperate.length) {
ElMessage.warning(`所选虚拟机均已处于目标状态,无需${label}`)
return
}
const msg = skipped.length
? `将对 ${toOperate.length} 台虚拟机执行${label}${skipped.length} 台已跳过),是否继续?`
: `确定要对 ${toOperate.length} 台虚拟机执行批量${label}吗?`
ElMessageBox.confirm(msg, `批量${label}`, {
confirmButtonText: '确定', cancelButtonText: '取消',
type: action === 'stop' ? 'warning' : 'info'
}).then(async () => {
batchLoading.value = true
const api = action === 'start' ? startVm : stopVm
let success = 0, fail = 0
await Promise.allSettled(toOperate.map(async (vm) => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vm.id)
const res = await api(fd)
if (res?.data?.code === 200) success++
else fail++
} catch { fail++ }
}))
batchLoading.value = false
clearSelection()
ElMessage[fail ? 'warning' : 'success'](`批量${label}完成:成功 ${success},失败 ${fail}`)
loadList()
}).catch(() => {})
}
//
const handleBatchDelete = () => {
const count = selectedVms.value.length
ElMessageBox.confirm(
`确定要删除选中的 ${count} 台虚拟机吗?此操作不可恢复。`,
'批量删除',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
batchLoading.value = true
let success = 0, fail = 0
await Promise.allSettled(selectedVms.value.map(async (vm) => {
try {
const res = await deleteVm({ service_id: serviceId.value, vm_id: vm.id })
if (res?.data?.code === 200) success++
else fail++
} catch { fail++ }
}))
batchLoading.value = false
clearSelection()
ElMessage[fail ? 'warning' : 'success'](`批量删除完成:成功 ${success},失败 ${fail}`)
loadList()
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') } const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => { onMounted(async () => {
@@ -1007,4 +1096,19 @@ defineExpose({ loadList })
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; } .migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; } .migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; } .migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
.batch-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
margin-bottom: 12px;
background: #ecf5ff;
border: 1px solid #d9ecff;
border-radius: 6px;
}
.batch-info {
font-size: 13px;
color: #409eff;
margin-right: 4px;
}
</style> </style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="vm-monitor-container">
<div class="monitor-toolbar">
<div class="toolbar-row">
<div class="toolbar-item">
<span class="toolbar-label">虚拟机</span>
<el-select
v-model="selectedVms"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择要监控的虚拟机"
style="width: 360px"
filterable
:loading="vmListLoading"
>
<el-option v-for="vm in vmOptions" :key="vm.name" :label="`${vm.name} (ID:${vm.id})`" :value="vm.name" />
</el-select>
<el-button link type="primary" @click="selectAllVms" v-if="vmOptions.length > 0">全选</el-button>
<el-button link @click="selectedVms = []" v-if="selectedVms.length > 0">清空</el-button>
</div>
<div class="toolbar-item">
<span class="toolbar-label">指标</span>
<el-checkbox-group v-model="selectedMetrics">
<el-checkbox label="cpu">CPU</el-checkbox>
<el-checkbox label="memory">内存</el-checkbox>
<el-checkbox label="disk">磁盘IO</el-checkbox>
<el-checkbox label="network">网络</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="toolbar-row">
<div class="toolbar-item">
<span class="toolbar-label">时间</span>
<el-radio-group v-model="timeMode" size="small" @change="handleRefresh">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="timeMode === 'relative'" v-model="timeRange" style="width: 130px" @change="handleRefresh">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="3小时" :value="180" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="24小时" :value="1440" />
</el-select>
<el-date-picker
v-else
v-model="fixedDateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 340px"
@change="handleRefresh"
/>
</div>
<div class="toolbar-item">
<span class="toolbar-label">自动刷新</span>
<el-select v-model="autoRefreshInterval" style="width: 110px" @change="resetAutoRefresh">
<el-option label="关闭" :value="0" />
<el-option label="10秒" :value="10" />
<el-option label="30秒" :value="30" />
<el-option label="1分钟" :value="60" />
<el-option label="5分钟" :value="300" />
</el-select>
</div>
<div class="toolbar-item">
<el-button type="primary" @click="handleRefresh" :loading="metricsLoading" :disabled="selectedVms.length === 0">
刷新
</el-button>
</div>
</div>
</div>
<div class="charts-area" v-if="hasData">
<div class="chart-section" v-for="metric in selectedMetrics" :key="metric">
<h3 class="chart-section-title">{{ metricLabels[metric] }}</h3>
<div class="chart-wrapper">
<div class="chart-box" :ref="el => setChartRef(metric, el)"></div>
</div>
</div>
</div>
<el-empty v-else-if="!metricsLoading && selectedVms.length === 0" description="请选择要监控的虚拟机" :image-size="80" />
<el-empty v-else-if="!metricsLoading && loaded && !hasData" description="暂无监控数据" :image-size="80" />
</div>
</template>
<script setup>
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getVmList, getMetricsHistory } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts'
const serviceId = inject('serviceId')
const hostId = inject('hostId')
const vmListLoading = ref(false)
const vmOptions = ref([])
const selectedVms = ref([])
const selectedMetrics = ref(['cpu', 'memory'])
const metricsLoading = ref(false)
const loaded = ref(false)
const metricsDataMap = ref({})
const timeMode = ref('relative')
const timeRange = ref(60)
const fixedDateRange = ref(null)
const autoRefreshInterval = ref(0)
let autoRefreshTimer = null
const metricLabels = {
cpu: 'CPU 使用率',
memory: '内存使用',
disk: '磁盘IO速率',
network: '网络速率'
}
const vmColors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#b37feb', '#36cfc9', '#ff85c0', '#ffc53d', '#597ef7']
const hasData = computed(() => Object.keys(metricsDataMap.value).length > 0)
const chartInstances = {}
const chartElements = {}
const setChartRef = (metric, el) => {
if (el) {
chartElements[metric] = el
nextTick(() => {
if (!chartInstances[metric]) {
chartInstances[metric] = echarts.init(el)
}
})
}
}
const selectAllVms = () => { selectedVms.value = vmOptions.value.map(v => v.name) }
const calcInterval = (ms) => {
if (ms <= 10 * 60 * 1000) return '1m'
if (ms <= 30 * 60 * 1000) return '3m'
if (ms <= 60 * 60 * 1000) return '5m'
if (ms <= 3 * 3600 * 1000) return '10m'
if (ms <= 6 * 3600 * 1000) return '20m'
if (ms <= 12 * 3600 * 1000) return '30m'
if (ms <= 24 * 3600 * 1000) return '1h'
return '3h'
}
const formatBytes = (val) => {
if (!val && val !== 0) return '0 B'
val = Math.abs(Number(val))
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
return val.toFixed(0) + ' B'
}
const formatBytesPerSec = (val) => formatBytes(val) + '/s'
const formatMemKiB = (kib) => {
if (!kib && kib !== 0) return '0'
kib = Math.abs(Number(kib))
if (kib >= 1048576) return (kib / 1048576).toFixed(1) + ' GB'
if (kib >= 1024) return (kib / 1024).toFixed(0) + ' MB'
return kib.toFixed(0) + ' KB'
}
const loadVmList = async () => {
if (!serviceId.value || !hostId.value) return
vmListLoading.value = true
try {
const res = await getVmList({ service_id: serviceId.value, host_id: hostId.value, page: 1, count: 500 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
vmOptions.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败'))
} finally {
vmListLoading.value = false
}
}
const getTimeRange = () => {
if (timeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - timeRange.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!fixedDateRange.value || fixedDateRange.value.length < 2) return null
return { startTime: new Date(fixedDateRange.value[0]), endTime: new Date(fixedDateRange.value[1]) }
}
}
const handleRefresh = async () => {
if (!selectedVms.value.length) return
const range = getTimeRange()
if (!range) { ElMessage.warning('请选择时间范围'); return }
metricsLoading.value = true
const { startTime, endTime } = range
const interval = calcInterval(endTime - startTime)
const dataMap = {}
try {
await Promise.all(selectedVms.value.map(async (vmName) => {
try {
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: hostId.value,
vm_name: vmName,
start: startTime.toISOString(),
end_time: endTime.toISOString(),
interval
})
const body = res?.data
if (body?.code === 200 && body?.data) {
dataMap[vmName] = Array.isArray(body.data) ? body.data : (body.data.data || [])
}
} catch (e) {
console.warn(`获取 ${vmName} 监控数据失败:`, e)
}
}))
metricsDataMap.value = dataMap
loaded.value = true
await nextTick()
renderCharts()
} catch (e) {
ElMessage.error('加载监控数据失败')
} finally {
metricsLoading.value = false
}
}
const renderCharts = () => {
const range = getTimeRange()
const spanMs = range ? (range.endTime - range.startTime) : 0
const showDate = spanMs >= 12 * 3600 * 1000
const labelRotate = showDate ? 30 : 0
for (const metric of selectedMetrics.value) {
const chart = chartInstances[metric]
if (!chart) continue
const allTimes = getUnifiedTimeline()
if (!allTimes.length) continue
const timeLabels = allTimes.map(t => {
const date = new Date(t)
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
})
const seriesList = []
selectedVms.value.forEach((vmName, idx) => {
const metrics = metricsDataMap.value[vmName]
if (!metrics || !metrics.length) return
const color = vmColors[idx % vmColors.length]
const vmData = buildMetricData(metric, metrics, allTimes)
seriesList.push({ name: vmName, data: vmData, color })
})
if (!seriesList.length) continue
let yAxisFormatter, tooltipUnit
if (metric === 'cpu') {
yAxisFormatter = v => v.toFixed(0) + '%'
tooltipUnit = '%'
} else if (metric === 'memory') {
yAxisFormatter = v => formatMemKiB(v)
tooltipUnit = 'KiB'
} else if (metric === 'disk') {
yAxisFormatter = v => formatBytesPerSec(v)
tooltipUnit = '/s'
} else {
yAxisFormatter = v => formatBytesPerSec(v)
tooltipUnit = '/s'
}
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params) => {
let s = params[0]?.axisValue || ''
params.forEach(p => {
let val
if (metric === 'cpu') val = (p.value ?? 0).toFixed(1) + '%'
else if (metric === 'memory') val = formatMemKiB(p.value)
else val = formatBytesPerSec(p.value)
s += `<br/>${p.marker} ${p.seriesName}: ${val}`
})
return s
}
},
legend: { top: 4, right: 8, textStyle: { fontSize: 11 }, type: 'scroll' },
grid: { top: 40, left: 70, right: 16, bottom: labelRotate > 0 ? 55 : 35 },
xAxis: {
type: 'category', data: timeLabels, boundaryGap: false,
axisLabel: { fontSize: 10, rotate: labelRotate, color: '#86909c' },
axisLine: { lineStyle: { color: '#e8e8e8' } }
},
yAxis: {
type: 'value', min: 0,
max: metric === 'cpu' ? 100 : undefined,
axisLabel: { fontSize: 10, formatter: yAxisFormatter, color: '#86909c' },
splitLine: { lineStyle: { color: '#f0f0f0' } }
},
series: seriesList.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
symbol: 'none',
lineStyle: { width: 1.5 },
itemStyle: { color: s.color },
areaStyle: seriesList.length <= 3
? { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: s.color + '20' }, { offset: 1, color: s.color + '02' }]) }
: undefined
}))
}, true)
chart.resize()
}
}
const getUnifiedTimeline = () => {
const timeSet = new Set()
for (const vmName of selectedVms.value) {
const data = metricsDataMap.value[vmName]
if (data) data.forEach(m => timeSet.add(m.bucket))
}
return Array.from(timeSet).sort()
}
const buildMetricData = (metric, metrics, allTimes) => {
const timeMap = new Map()
metrics.forEach((m, i) => timeMap.set(m.bucket, { ...m, _idx: i }))
return allTimes.map((t, tIdx) => {
const m = timeMap.get(t)
if (!m) return null
if (metric === 'cpu') {
return +(m.cpu_usage ?? 0).toFixed(1)
} else if (metric === 'memory') {
return +(m.mem_used ?? 0)
} else if (metric === 'disk') {
// disk_read/disk_write are cumulative Bytes, compute rate via diff
if (m._idx === 0) return 0
const prev = metrics[m._idx - 1]
const dt = (new Date(m.bucket) - new Date(prev.bucket)) / 1000
if (dt <= 0) return 0
const readRate = Math.max(0, ((m.disk_read ?? 0) - (prev.disk_read ?? 0)) / dt)
const writeRate = Math.max(0, ((m.disk_write ?? 0) - (prev.disk_write ?? 0)) / dt)
return +(readRate + writeRate).toFixed(0)
} else if (metric === 'network') {
// net_rx/net_tx are already Bytes/s
return +((m.net_rx ?? 0) + (m.net_tx ?? 0)).toFixed(0)
}
return 0
})
}
const resetAutoRefresh = () => {
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null }
if (autoRefreshInterval.value > 0) {
autoRefreshTimer = setInterval(() => handleRefresh(), autoRefreshInterval.value * 1000)
}
}
const disposeCharts = () => {
Object.values(chartInstances).forEach(c => { try { c.dispose() } catch {} })
Object.keys(chartInstances).forEach(k => delete chartInstances[k])
}
const handleResize = () => {
Object.values(chartInstances).forEach(c => { try { c.resize() } catch {} })
}
onMounted(() => {
loadVmList()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
disposeCharts()
window.removeEventListener('resize', handleResize)
})
defineExpose({ loadVmList, loadList: loadVmList })
</script>
<style scoped>
.vm-monitor-container { padding: 0; }
.monitor-toolbar {
background: #f7f8fa;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 20px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.toolbar-row + .toolbar-row { margin-top: 12px; }
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 13px;
color: #606266;
font-weight: 500;
white-space: nowrap;
}
.charts-area { margin-top: 8px; }
.chart-section { margin-bottom: 20px; }
.chart-section-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin: 0 0 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
.chart-wrapper {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px 16px;
}
.chart-box {
width: 100%;
height: 260px;
}
</style>
+5 -3
View File
@@ -110,7 +110,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue' import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -273,8 +273,10 @@ const initPage = () => {
loadDetail() loadDetail()
} }
watch(volumeId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>