17 Commits

Author SHA1 Message Date
shiran 860ca1002e Merge pull request 'feat(kvm): 宿主机管理树表点击体验优化' (#19) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m50s
Build and Deploy Vue3 / deploy (push) Successful in 35s
Reviewed-on: #19
2026-06-04 16:41:00 +08:00
shiran 60120395d5 Merge pull request 'feat: 工单系统优化 - 修复自动跳转问题并添加用户筛选功能' (#18) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m38s
Build and Deploy Vue3 / deploy (push) Successful in 32s
Reviewed-on: #18
2026-06-02 17:54:48 +08:00
shiran 8c49c74b72 Merge pull request 'master' (#17) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m57s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#17
2026-01-06 21:58:14 +08:00
shiran f8cac7e976 Merge pull request 'master' (#16) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m52s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#16
2026-01-01 03:30:09 +08:00
shiran deebef26dd Merge pull request 'master' (#15) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#15
2025-12-20 15:47:29 +08:00
shiran 5fb53a2fdd Merge pull request 'master' (#14) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m6s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#14
2025-12-16 15:36:59 +08:00
shiran 8897a62dc7 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#13) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 2m8s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#13
2025-12-12 21:19:43 +08:00
shiran baec1e3685 Merge pull request 'master' (#12) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Has been cancelled
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#12
2025-12-12 19:12:25 +08:00
shiran 4b73cb3ea0 Merge pull request 'fix:修改获取镜像套餐参数' (#9) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 17m43s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#9
2025-10-15 17:16:21 +08:00
shiran 8ba17ff6d0 Merge pull request 'fix:修改镜像展示label' (#8) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 22m3s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#8
2025-10-07 16:36:07 +08:00
wlkjyy 05ad6f8a44 Merge pull request 'fix: 修复了创建虚拟机无法获取镜像列表的BUG' (#7) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 9m0s
Reviewed-on: https://gitlab.s1f.top/lin/ApiServer-Web-admin_dashboard_pc/pulls/7
2025-10-07 00:24:13 +08:00
shiran fcfde5191e Merge pull request 'master' (#6) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 3m14s
Build and Deploy Vue3 / deploy (push) Successful in 17m10s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#6
2025-10-06 21:36:43 +08:00
shiran 41295f27f0 Merge pull request 'fix:edit' (#5) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m51s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#5
2025-10-05 15:52:12 +08:00
shiran 225228f666 Merge pull request 'feat:添加服务器新建容器' (#4) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 20m53s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#4
2025-10-04 23:25:58 +08:00
shiran 2e79be0b0f Merge pull request 'feat:添加新增虚拟机' (#3) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m53s
Build and Deploy Vue3 / deploy (push) Successful in 24m39s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#3
2025-10-01 19:01:36 +08:00
shiran 8847848d59 Merge pull request 'master' (#2) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 1m47s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#2
2025-10-01 01:17:14 +08:00
shiran 8e698c2644 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#1) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 4m13s
Build and Deploy Vue3 / deploy (push) Successful in 16m55s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#1
2025-09-29 23:21:02 +08:00
32 changed files with 6261 additions and 8208 deletions
-43
View File
@@ -1,43 +0,0 @@
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 })
}
-7
View File
@@ -39,10 +39,3 @@ export const updateNoticeTemplate = (data) => {
export const deleteNoticeTemplate = (params) => { export const deleteNoticeTemplate = (params) => {
return http2.delete('/api/v1/admin/notice_message/template/delete', { 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' }
})
}
-145
View File
@@ -1,145 +0,0 @@
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 = (params) => { export const getUserList = (data) => {
return http2.get('/api/v1/admin/user/user/list', { params }) return http2.get('/api/v1/admin/user/user/list?page=' + data.page + '&count=' + data.count + '&key=' + data.key)
} }
/**更新用户信息 */ /**更新用户信息 */
-30
View File
@@ -1,30 +0,0 @@
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 })
}
+11 -28
View File
@@ -26,10 +26,6 @@
</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>
重置 重置
@@ -122,7 +118,6 @@ const selectedUser = ref(null)
const searchParams = reactive({ const searchParams = reactive({
key: '', key: '',
is_admin: undefined,
page: 1, page: 1,
count: 10 count: 10
}) })
@@ -166,7 +161,6 @@ const handleSearch = () => {
const handleReset = () => { const handleReset = () => {
searchParams.key = '' searchParams.key = ''
searchParams.is_admin = undefined
searchParams.page = 1 searchParams.page = 1
fetchUserList() fetchUserList()
} }
@@ -197,25 +191,22 @@ const confirmSelection = () => {
<style scoped> <style scoped>
.user-selector-content { .user-selector-content {
max-height: 520px; max-height: 500px;
overflow: hidden; overflow: hidden;
padding: 4px 0;
} }
.selector-search { .selector-search {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 14px 16px; padding-bottom: 16px;
background: linear-gradient(135deg, #f7f8fa 0%, #f0f2f5 100%); border-bottom: 1px solid #ebeef5;
border-radius: 10px;
border: 1px solid #ebeef5;
margin-bottom: 16px; margin-bottom: 16px;
} }
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 0; max-width: 350px;
} }
.reset-btn { .reset-btn {
@@ -240,7 +231,7 @@ const confirmSelection = () => {
} }
.selector-pagination { .selector-pagination {
margin-top: 14px; margin-top: 16px;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -253,26 +244,19 @@ const confirmSelection = () => {
.selected-info { .selected-info {
margin-right: auto; margin-right: auto;
color: #409eff; color: #606266;
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 > td) { :deep(.el-table__row):hover {
background-color: #f0f7ff !important; background-color: #f5f7fa;
} }
:deep(.current-row > td) { :deep(.current-row) {
background-color: var(--el-color-primary-light-9) !important; background-color: var(--el-color-primary-light-9) !important;
} }
@@ -281,9 +265,8 @@ const confirmSelection = () => {
} }
:deep(.el-avatar) { :deep(.el-avatar) {
background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary)); background-color: var(--el-color-primary-light-5);
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 600;
} }
</style> </style>
+466 -172
View File
@@ -1,111 +1,166 @@
<template> <template>
<el-dialog v-model="visible" title="选择用户" width="860px" append-to-body @close="handleClose" class="user-selector-dialog"> <el-dialog
<div class="uls-root"> v-model="visible"
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="uls-tabs"> title="选择用户"
<!-- ====== 选择用户 ====== --> width="900px"
<el-tab-pane name="selectUser"> append-to-body
<template #label> @close="handleClose"
<span class="tab-lbl">
<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>
选择用户
</span>
</template>
<!-- 搜索栏 -->
<div class="uls-search">
<el-input v-model="searchParams.key" placeholder="搜索用户名 / 邮箱 / ID" clearable @keyup.enter="handleSearch" class="uls-search-input" :prefix-icon="Search" />
<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 type="primary" @click="handleSearch" :icon="Search" circle />
<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)"
> >
<div class="uls-card-check" v-if="selectedUser?.user_id === user.user_id"> <div class="user-selector">
<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-tabs v-model="activeTab" @tab-click="handleTabClick">
</div> <!-- 选择用户 -->
<el-avatar :size="42" :src="user.cover" class="uls-avatar">{{ user.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar> <el-tab-pane label="选择用户" name="selectUser">
<div class="uls-card-body"> <div class="user-list-container">
<div class="uls-card-name"> <!-- 搜索筛选区域 -->
{{ user.user_name }} <div class="filter-section">
<el-tag v-if="user.is_admin" type="warning" size="small" effect="dark" round class="admin-tag">管理员</el-tag> <el-form :inline="true" :model="searchParams" class="search-form">
</div> <el-form-item label="关键词">
<div class="uls-card-meta"> <el-input
<span class="meta-id">#{{ user.user_id }}</span> v-model="searchParams.key"
<span v-if="user.email" class="meta-email">{{ user.email }}</span> placeholder="搜索用户名或ID"
<span v-if="user.phone" class="meta-phone">{{ user.phone }}</span> clearable
</div> @keyup.enter="handleSearch"
</div> style="width: 200px"
</div> >
<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> </div>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" :image-size="80" /> <!-- 用户列表表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="user_id" label="用户ID" width="100" align="center" />
<el-table-column prop="user_name" label="用户名" min-width="120">
<template #default="{ row }">
<div class="user-info-cell">
<el-avatar :size="32" :src="row.cover">
<el-icon><User /></el-icon>
</el-avatar>
<span class="user-name">{{ row.user_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip />
<!-- <el-table-column prop="status" label="状态" width="80" align="center">
<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="uls-pagination" v-if="total > 0"> <div class="pagination-container" v-if="total > 0">
<el-pagination <el-pagination
v-model:current-page="searchParams.page" v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count" v-model:page-size="searchParams.count"
:page-sizes="[12, 24, 48]" :page-sizes="[10, 20, 50, 100]"
:total="total" :total="total"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next, jumper"
background small background
@size-change="s => { searchParams.count = s; searchParams.page = 1; fetchUserList() }" @size-change="handleSizeChange"
@current-change="p => { searchParams.page = p; fetchUserList() }" @current-change="handlePageChange"
/> />
</div> </div>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
</div>
</el-tab-pane> </el-tab-pane>
<!-- ====== 添加用户 ====== --> <!-- 添加用户 -->
<el-tab-pane name="addUser"> <el-tab-pane label="添加用户" name="addUser">
<template #label> <div class="add-user-section">
<span class="tab-lbl"> <el-form
<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> ref="addFormRef"
添加用户 :model="addForm"
</span> :rules="addFormRules"
</template> label-width="100px"
<div class="uls-add-section"> class="add-user-form"
<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 v-model="addForm.user_name" placeholder="请输入用户名" maxlength="50" show-word-limit /> <el-input
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 v-model="addForm.email" placeholder="请输入邮箱地址" type="email" /> <el-input
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 v-model="addForm.phone" placeholder="请输入手机号选填" maxlength="11" /> <el-input
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 v-model="addForm.password" placeholder="请输入密码" type="password" show-password /> <el-input
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 v-model="addForm.confirmPassword" placeholder="请再次输入密码" type="password" show-password /> <el-input
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" :icon="Plus">立即创建</el-button> <el-button type="primary" @click="handleAddUser" :loading="addLoading">
<el-button @click="resetAddForm" :icon="Refresh">重置</el-button> <el-icon><Plus /></el-icon>
立即创建
</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>
@@ -114,9 +169,16 @@
</div> </div>
<template #footer> <template #footer>
<div class="uls-footer"> <div class="dialog-footer">
<el-button @click="handleClose">取消</el-button> <el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedUser" v-if="activeTab === 'selectUser'">确定选择</el-button> <el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedUser"
v-if="activeTab === 'selectUser'"
>
确定选择
</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -128,12 +190,23 @@ 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: { type: Boolean, default: false }, modelValue: {
currentUserId: { type: [String, Number], default: '' } type: Boolean,
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)
@@ -143,131 +216,352 @@ 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({
const addForm = reactive({ user_name: '', email: '', phone: '', password: '', confirmPassword: '' }) key: '',
const validateConfirmPassword = (rule, value, callback) => { status: '',
if (!value) callback(new Error('请再次输入密码')) page: 1,
else if (value !== addForm.password) callback(new Error('两次输入密码不一致')) count: 10
else callback()
}
const addFormRules = {
user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 50, message: '2-50 个字符', trigger: 'blur' }],
email: [{ 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' }]
}
watch(() => props.modelValue, (v) => {
visible.value = v
if (v) { activeTab.value = 'selectUser'; selectedUser.value = null; searchParams.page = 1; fetchUserList() }
}) })
watch(visible, (v) => emit('update:modelValue', v))
// 添加用户表单
const addForm = reactive({
user_name: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
})
// 密码确认验证
const validateConfirmPassword = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== addForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
// 添加用户表单验证规则
const addFormRules = {
user_name: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度在 2 到 50 个字符', trigger: 'blur' }
],
email: [
{ 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, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectUser'
selectedUser.value = null
searchParams.page = 1
fetchUserList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取用户列表
const fetchUserList = async () => { const fetchUserList = async () => {
loading.value = true; userList.value = [] loading.value = true
userList.value = []
try { try {
const params = { page: searchParams.page, count: searchParams.count, key: searchParams.key || '' } const params = {
if (searchParams.is_admin !== undefined && searchParams.is_admin !== null && searchParams.is_admin !== '') params.is_admin = searchParams.is_admin page: searchParams.page,
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) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
} }
} catch { ElMessage.error('获取用户列表失败') }
finally { loading.value = false }
} }
const handleTabClick = (tab) => { if (tab.paneName === 'selectUser') fetchUserList() } // 处理标签页切换
const handleSearch = () => { searchParams.page = 1; fetchUserList() } const handleTabClick = (tab) => {
const handleReset = () => { searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; fetchUserList() } if (tab.paneName === 'selectUser') {
const handleCurrentChange = (row) => { selectedUser.value = (selectedUser.value?.user_id === row.user_id) ? null : row } fetchUserList()
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 fd = new FormData() const formData = new FormData()
fd.append('user_name', addForm.user_name); fd.append('email', addForm.email) formData.append('user_name', addForm.user_name)
if (addForm.phone) fd.append('phone', addForm.phone) formData.append('email', addForm.email)
fd.append('password', addForm.password) if (addForm.phone) {
const res = await createTask(fd) formData.append('phone', addForm.phone)
}
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 { ElMessage.error(res.data.msg || '创建失败') } } else {
} catch { ElMessage.error('创建失败') } ElMessage.error(res.data.msg || '用户创建失败')
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 handleClose = () => { visible.value = false; selectedUser.value = null; userList.value = []; searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; total.value = 0; resetAddForm() } const resetAddForm = () => {
const handleConfirm = () => { if (selectedUser.value) { emit('confirm', selectedUser.value); handleClose() } else ElMessage.warning('请选择一个用户') } addForm.user_name = ''
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>
.uls-root { min-height: 460px; } .user-selector {
min-height: 450px;
}
/* 标签页 */ .user-list-container {
.tab-lbl { display: inline-flex; align-items: center; gap: 5px; } padding: 10px 0;
:deep(.el-tabs__item) { font-size: 15px; padding: 0 20px; } }
:deep(.el-tabs__item.is-active) { font-weight: 600; }
/* 搜索栏 */ .filter-section {
.uls-search { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; } margin-bottom: 16px;
.uls-search-input { flex: 1; max-width: 320px; } padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
/* 已选提示条 */ .search-form {
.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; } display: flex;
.uls-sel-left { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #1d2129; } flex-wrap: wrap;
.uls-sel-left b { font-weight: 600; } align-items: center;
.sel-id { color: #86909c; font-size: 12px; } gap: 8px;
.sel-avatar { flex-shrink: 0; font-size: 11px; background: linear-gradient(135deg, #67c23a, #85ce61); color: #fff; } }
.fade-enter-active, .fade-leave-active { transition: all .25s ease; } .search-form :deep(.el-form-item) {
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-6px); } margin-bottom: 0;
margin-right: 12px;
}
/* 卡片网格 */ .user-info-cell {
.uls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; max-height: 340px; overflow-y: auto; padding: 2px; } display: flex;
align-items: center;
gap: 10px;
}
.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; } .user-name {
.uls-card:hover { border-color: #b3d8ff; background: #f5faff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(64,158,255,.1); } font-weight: 500;
.uls-card.active { border-color: #409eff; background: #ecf5ff; } color: #303133;
}
.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; } .pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.uls-avatar { flex-shrink: 0; font-size: 16px; font-weight: 700; background: linear-gradient(135deg, #c6e2ff, #409eff); color: #fff; } .add-user-section {
.uls-card.active .uls-avatar { background: linear-gradient(135deg, #409eff, #337ecc); } padding: 30px 60px;
}
.uls-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } .add-user-form {
.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; } max-width: 500px;
.admin-tag { transform: scale(.85); transform-origin: left center; } margin: 0 auto;
}
.uls-card-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 11px; color: #a8abb2; line-height: 1.3; } .add-user-form :deep(.el-input) {
.meta-id { color: #86909c; font-weight: 500; } width: 100%;
.meta-email, .meta-phone { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; } }
/* 分页 */ .dialog-footer {
.uls-pagination { margin-top: 14px; display: flex; justify-content: flex-end; } display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 添加用户 */ /* 表格样式 */
.uls-add-section { padding: 24px 40px; } :deep(.el-table__row) {
.uls-add-form { max-width: 460px; margin: 0 auto; } cursor: pointer;
}
/* 底部 */ :deep(.el-table__row:hover) {
.uls-footer { display: flex; justify-content: flex-end; gap: 10px; } background-color: #f5f7fa;
}
/* 滚动条美化 */ :deep(.selected-row) {
.uls-grid::-webkit-scrollbar { width: 5px; } background-color: var(--el-color-primary-light-9) !important;
.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>
+17 -36
View File
@@ -47,8 +47,7 @@ export const menus = [
title: '商品管理', title: '商品管理',
icon: 'Goods', icon: 'Goods',
children: [ children: [
{ path: '/product/manage', title: '商品管理' }, { path: '/product/manage', title: '商品管理' }
{ path: '/product/discount', title: '优惠管理' }
] ]
}, },
{ {
@@ -71,6 +70,22 @@ export const menus = [
} }
] ]
}, },
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{ {
path: '/activity', path: '/activity',
title: '活动管理', title: '活动管理',
@@ -134,40 +149,6 @@ 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',
+30 -73
View File
@@ -253,18 +253,6 @@ 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' }
] ]
@@ -322,20 +310,43 @@ const routes = [
} }
] ]
}, },
// 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接) // 优惠营销路由
{ {
path: 'marketing', path: 'marketing',
name: 'Marketing', name: 'Marketing',
meta: { meta: {
title: '优惠营销', title: '优惠营销',
icon: 'Present', icon: 'Present'
hidden: true
}, },
redirect: '/product/discount', redirect: '/marketing/discount',
children: [ children: [
{ path: 'discount', redirect: '/product/discount' }, {
{ path: 'voucher', redirect: '/product/discount' }, path: 'discount',
{ path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` } name: 'DiscountCode',
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'
}
},
] ]
}, },
// 活动管理路由 // 活动管理路由
@@ -598,60 +609,6 @@ 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
@@ -1,420 +0,0 @@
<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
@@ -0,0 +1,677 @@
<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>
+134 -210
View File
@@ -161,39 +161,47 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择关联对象" v-if="dialogType === 'add'"> <el-form-item label="选择类型" prop="select_type" v-if="dialogType === 'add'">
<div class="goods-tree-wrapper"> <el-radio-group v-model="form.select_type" @change="handleSelectTypeChange">
<div class="goods-tree-toolbar"> <el-radio value="product">商品</el-radio>
<span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span> <el-radio value="product_group">商品组</el-radio>
<div class="tree-summary"> </el-radio-group>
已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品 </el-form-item>
</div>
</div> <el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'">
<el-tree <el-select
ref="goodsTreeRef" v-model="form.selected_product"
:props="treeProps" placeholder="请选择商品"
:load="loadTreeNode" filterable
lazy clearable
show-checkbox style="width: 100%"
check-strictly @change="handleProductChange"
node-key="key"
class="goods-tree"
@check="handleTreeCheck"
> >
<template #default="{ data }"> <el-option
<span class="tree-node"> v-for="item in productOptions"
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain"> :key="item.id"
{{ data.nodeType === 'group' ? '组' : '品' }} :label="`${item.name} (ID: ${item.id})`"
</el-tag> :value="item.id"
<span class="tree-node-label">{{ data.label }}</span> />
<span class="tree-node-id">ID: {{ data.rawId }}</span> </el-select>
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price"> </el-form-item>
¥{{ (data.price / 100).toFixed(2) }}
</span> <el-form-item label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'">
</span> <el-select
</template> v-model="form.selected_group"
</el-tree> placeholder="请选择商品组"
</div> filterable
clearable
style="width: 100%"
@change="handleProductGroupChange"
>
<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>
<!-- 编辑模式显示字段 --> <!-- 编辑模式显示字段 -->
@@ -226,7 +234,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch, nextTick } from 'vue' import { ref, reactive, onMounted, watch } 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 {
@@ -268,27 +276,36 @@ 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([])
@@ -419,79 +436,33 @@ const fetchProductGroupList = async () => {
} }
} }
// 折叠层级选择器:懒加载节点 // 选择类型变化
// node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品 const handleSelectTypeChange = (type) => {
const loadTreeNode = async (node, resolve) => { form.selected_product = undefined
try { form.selected_group = undefined
// 根节点:仅加载 level=1 的顶级商品组 form.goods_id = undefined
if (node.level === 0) { form.goods_name = ''
const res = await getProductGroupList({ level: 1 }) form.goods_type = ''
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildGroupNode))
}
return resolve([])
} }
// 商品组节点:逐级加载子分组 + 下属商品 // 选择商品变化
if (node.data?.nodeType === 'group') { const handleProductChange = (productId) => {
const groupId = node.data.rawId const product = productOptions.value.find(item => item.id === productId)
const childLevel = (node.data.level || 1) + 1 if (product) {
const tasks = [ form.goods_id = product.id
getProductList({ good_group_id: groupId, delete: false }) form.goods_name = product.goodsName || product.name || ''
] 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 buildGroupNode = (group) => ({ const handleProductGroupChange = (groupId) => {
key: `group_${group.id}`, const group = productGroupOptions.value.find(item => item.id === groupId)
rawId: group.id, if (group) {
nodeType: 'group', form.goods_id = group.id
label: group.name, form.goods_name = group.name || ''
level: group.level || 1, form.goods_type = 'product_group'
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
} }
// 获取商品关联列表 // 获取商品关联列表
@@ -556,15 +527,16 @@ 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(() => { // 加载商品和商品组列表
goodsTreeRef.value?.setCheckedKeys([]) fetchProductList()
}) fetchProductGroupList()
} }
// 编辑商品关联 // 编辑商品关联
@@ -669,68 +641,69 @@ const handleBatchDelete = () => {
// 提交表单 // 提交表单
const submitForm = () => { const submitForm = () => {
// 新增模式下的额外验证
if (dialogType.value === 'add') {
if (!form.code_id) { if (!form.code_id) {
ElMessage.warning('请选择代金券') ElMessage.warning('请选择代金券')
return return
} }
if (form.select_type === 'product' && !form.selected_product) {
if (dialogType.value === 'add') { ElMessage.warning('请选择商品')
// 收集树中勾选的商品组与商品
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 return
} }
if (form.select_type === 'product_group' && !form.selected_group) {
submitAdd(goodIds, goodGroupIds) ElMessage.warning('请选择商品组')
return return
} }
if (!form.goods_id || !form.goods_name || !form.goods_type) {
ElMessage.warning('请先选择商品或商品组')
return
}
}
// 编辑模式
formRef.value?.validate(async (valid) => { formRef.value?.validate(async (valid) => {
if (!valid) return if (valid) {
try { try {
const submitData = { const submitData = {
code_id: String(form.code_id), code_id: String(form.code_id)
discount_good_id: String(form.id)
} }
if (form.goods_type === 'product_group') {
// 根据选择类型决定传 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) submitData.good_group_id = String(form.goods_id)
} else { } else {
// 其他类型默认使用 good_id
submitData.good_id = String(form.goods_id) 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) console.log('提交商品关联数据:', submitData)
const res = await addDiscountGoods(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) { if (res.data.code === 200) {
ElMessage.success('新增成功') ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false dialogVisible.value = false
fetchGoodsList() fetchGoodsList()
} }
@@ -739,6 +712,8 @@ const submitAdd = async (goodIds, goodGroupIds) => {
ElMessage.error(error.response?.data?.message || '操作失败') ElMessage.error(error.response?.data?.message || '操作失败')
} }
} }
})
}
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
@@ -823,57 +798,6 @@ 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: false, message: '请输入代金券ID', trigger: 'blur' } { required: true, message: '请输入代金券ID', trigger: 'blur' }
], ],
use_times: [ use_times: [
{ required: true, message: '请输入已使用次数', trigger: 'blur' } { required: true, message: '请输入已使用次数', trigger: 'blur' }
+569
View File
@@ -0,0 +1,569 @@
<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>
+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('/product/discount') router.push('/marketing/voucher')
} }
</script> </script>
+199 -465
View File
@@ -73,11 +73,6 @@
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column prop="id" label="订单ID" width="100" /> <el-table-column prop="id" label="订单ID" width="100" />
<el-table-column prop="name" label="订单名称" min-width="180" /> <el-table-column prop="name" label="订单名称" min-width="180" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.type === 'create' ? 'success' : row.type === 'renew' ? 'warning' : 'info'">{{ getTypeText(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="用户ID" width="100"> <el-table-column label="用户ID" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link> <el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
@@ -148,7 +143,7 @@
<div class="action-buttons"> <div class="action-buttons">
<el-button type="primary" link @click="handleView(row)">查看</el-button> <el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button> <el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleRetryOrder(row)">重试流程</el-button> <el-button v-if="row.error" type="danger" link @click="handleRetryOrder(row)">重试流程</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -179,12 +174,8 @@
<el-descriptions :column="2" border v-if="orderDetail"> <el-descriptions :column="2" border v-if="orderDetail">
<el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item> <el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ orderDetail.name }}</el-descriptions-item> <el-descriptions-item label="订单名称">{{ orderDetail.name }}</el-descriptions-item>
<el-descriptions-item label="订单类型">
<el-tag size="small">{{ getTypeText(orderDetail.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ orderDetail.userId }}</el-descriptions-item> <el-descriptions-item label="用户ID">{{ orderDetail.userId }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ orderDetail.commodityId }}</el-descriptions-item> <el-descriptions-item label="商品ID">{{ orderDetail.commodityId }}</el-descriptions-item>
<el-descriptions-item label="套餐ID">{{ orderDetail.planId || '-' }}</el-descriptions-item>
<el-descriptions-item label="表名">{{ orderDetail.table }}</el-descriptions-item> <el-descriptions-item label="表名">{{ orderDetail.table }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item> <el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
<el-descriptions-item label="订单金额">¥{{ (orderDetail.price / 100).toFixed(2) }}</el-descriptions-item> <el-descriptions-item label="订单金额">¥{{ (orderDetail.price / 100).toFixed(2) }}</el-descriptions-item>
@@ -207,231 +198,191 @@
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
<!-- 订单表单对话框分步向导 --> <!-- 订单表单对话框 -->
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:title="dialogType === 'add' ? '新增订单' : '编辑订单'" :title="dialogType === 'add' ? '新增订单' : '编辑订单'"
width="820px" width="700px"
append-to-body append-to-body
destroy-on-close
> >
<div class="wizard-container"> <el-form
<el-steps :active="currentStep" finish-status="success" align-center class="wizard-steps"> ref="orderFormRef"
<el-step title="选择用户" description="指定订单用户与类型" /> :model="orderForm"
<el-step title="商品配置" description="选择商品与套餐" /> :rules="orderRules"
<el-step title="订单详情" description="价格、支付与优惠" /> label-width="120px"
</el-steps> >
<el-form-item label="订单名称" prop="name">
<div class="wizard-body"> <el-input v-model="orderForm.name" placeholder="请输入订单名称" />
<!-- Step 1: 用户与订单类型 --> </el-form-item>
<el-form v-show="currentStep === 0" ref="step1FormRef" :model="orderForm" :rules="step1Rules" label-width="110px"> <el-form-item label="所属表" prop="table">
<el-form-item label="用户" prop="user_id"> <el-input v-model="orderForm.table" placeholder="请输入所属表" />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<el-input <el-input
v-if="selectedUserInfo" v-if="selectedUserInfo"
:model-value="`${selectedUserInfo.user_name} (ID: ${orderForm.user_id})`" :model-value="`${selectedUserInfo.user_name} (ID: ${orderForm.user_id})`"
readonly readonly
style="width: 100%"
> >
<template #suffix> <template #suffix>
<el-icon class="clear-icon" @click="clearUser"><Close /></el-icon> <el-icon class="clear-icon" @click="clearUser"><Close /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="userSelectorVisible = true"><el-icon><User /></el-icon></el-button> <el-button @click="userSelectorVisible = true">
<el-icon><User /></el-icon>
</el-button>
</template> </template>
</el-input> </el-input>
<el-input v-else placeholder="请选择用户" readonly @click="userSelectorVisible = true"> <el-input
v-else
placeholder="请选择用户"
readonly
style="width: 100%"
@click="userSelectorVisible = true"
>
<template #append> <template #append>
<el-button @click="userSelectorVisible = true"><el-icon><User /></el-icon></el-button> <el-button @click="userSelectorVisible = true">
<el-icon><User /></el-icon>
</el-button>
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="订单类型" prop="type"> <el-form-item label="商品ID" prop="commodity_id">
<el-select v-model="orderForm.type" placeholder="请选择订单类型" style="width: 100%" @change="handleTypeChange">
<el-option label="新购" value="create" />
<el-option label="续费" value="renew" />
<el-option label="升级" value="update" />
<el-option label="快照" value="snapshot" />
<el-option label="备份" value="backup" />
<el-option label="数据盘" value="data_volume" />
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</el-select>
</el-form-item>
<el-form-item label="订单名称" prop="name">
<el-input v-model="orderForm.name" placeholder="请输入订单名称" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<!-- Step 2: 商品与套餐 -->
<el-form v-show="currentStep === 1" ref="step2FormRef" :model="orderForm" :rules="step2Rules" label-width="110px">
<el-form-item label="商品" prop="commodity_id">
<el-input <el-input
v-if="selectedProductInfo" v-if="selectedProductInfo"
:model-value="`${selectedProductInfo.name} (ID: ${orderForm.commodity_id})`" :model-value="`${selectedProductInfo.name} (ID: ${orderForm.commodity_id})`"
readonly readonly
style="width: 100%"
> >
<template #suffix> <template #suffix>
<el-icon class="clear-icon" @click="clearProduct"><Close /></el-icon> <el-icon class="clear-icon" @click="clearProduct"><Close /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="productSelectorVisible = true"><el-icon><ShoppingCart /></el-icon></el-button> <el-button @click="productSelectorVisible = true">
<el-icon><ShoppingCart /></el-icon>
</el-button>
</template> </template>
</el-input> </el-input>
<el-input v-else placeholder="请选择商品" readonly @click="productSelectorVisible = true">
<template #append>
<el-button @click="productSelectorVisible = true"><el-icon><ShoppingCart /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="套餐" prop="plan_id">
<el-select v-model="orderForm.plan_id" placeholder="请选择套餐(可选)" clearable style="width: 100%" :loading="planLoading" @change="handlePlanChange">
<el-option v-for="p in planList" :key="p.id" :label="`${p.name} - ¥${(p.price / 100).toFixed(2)}`" :value="p.id" />
</el-select>
<div v-if="!planList.length && orderForm.commodity_id && !planLoading" class="form-tip">该商品暂无套餐可直接进入下一步手动设置价格</div>
</el-form-item>
<el-form-item label="所属表">
<el-input v-model="orderForm.table" placeholder="选择商品后自动填充,或手动输入" />
</el-form-item>
<!-- 动态商品参数 -->
<div v-if="productParams.length" class="args-section">
<div class="args-section-title">
<el-icon><Setting /></el-icon>商品参数配置
</div>
<el-form-item
v-for="param in productParams"
:key="param.id"
:label="param.name"
:required="param.must"
>
<!-- select 类型 -->
<el-select
v-if="param.type === 'select'"
v-model="argValues[param.id]"
:placeholder="`请选择${param.name}`"
style="width: 100%"
>
<el-option
v-for="attr in param.attrs"
:key="attr.id"
:label="`${attr.name}${attr.price ? ' (¥' + (attr.price / 100).toFixed(2) + ')' : ''}`"
:value="attr.id"
/>
</el-select>
<!-- number 类型 -->
<div v-else-if="param.type === 'number'" class="number-arg-row">
<el-input-number
v-model="argValues[param.id]"
:min="param.min || 0"
:max="param.max || 9999"
:step="param.step || 1"
style="flex: 1"
/>
<span v-if="param.arg_key" class="unit-text">{{ param.arg_key }}</span>
</div>
<!-- string 类型 -->
<el-input <el-input
v-else v-else
v-model="argValues[param.id]" placeholder="请选择商品"
:placeholder="`请输入${param.name}`" readonly
/> style="width: 100%"
@click="productSelectorVisible = true"
>
<template #append>
<el-button @click="productSelectorVisible = true">
<el-icon><ShoppingCart /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="购买数量" prop="pay_num">
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
</el-form-item> </el-form-item>
</div>
<div v-else-if="paramsLoading" class="form-tip" style="text-align: center; padding: 12px 0;">
<el-icon class="is-loading"><Loading /></el-icon> 加载商品参数中...
</div>
</el-form>
<!-- Step 3: 价格与支付 -->
<el-form v-show="currentStep === 2" ref="step3FormRef" :model="orderForm" :rules="step3Rules" label-width="110px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="价格" prop="price"> <el-form-item label="价格" prop="price">
<div class="unit-input-row"> <div class="unit-input-row">
<el-input-number v-model="orderForm.price" :min="0" :precision="0" style="flex:1" /> <el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="flex:1" />
<span class="unit-text"></span> <span class="unit-text"></span>
</div> </div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="续费价格" prop="renew_price">
<el-col :span="12">
<el-form-item label="续费价格">
<div class="unit-input-row"> <div class="unit-input-row">
<el-input-number v-model="orderForm.renew_price" :min="0" :precision="0" style="flex:1" /> <el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="flex:1" />
<span class="unit-text"></span> <span class="unit-text"></span>
</div> </div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="过期时间" prop="expire_time">
</el-row> <el-date-picker
<el-row :gutter="16"> v-model="orderForm.expire_time"
<el-col :span="12"> type="datetime"
<el-form-item label="购买数量" prop="pay_num"> placeholder="请选择过期时间"
<el-input-number v-model="orderForm.pay_num" :min="1" style="width: 100%" /> format="YYYY-MM-DD HH:mm:ss"
value-format="x"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="优惠码ID" prop="discount_code_id">
<el-col :span="12">
<el-form-item label="过期时间">
<el-date-picker v-model="orderForm.expire_time" type="datetime" placeholder="请选择" format="YYYY-MM-DD HH:mm:ss" value-format="x" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="订单状态">
<el-radio-group v-model="orderForm.state">
<el-radio :value="0">待支付</el-radio>
<el-radio :value="1">已支付</el-radio>
<el-radio :value="2">已失效</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="orderForm.pay_type" placeholder="请选择" clearable style="width: 100%">
<el-option label="默认(余额)" value="default" />
<el-option label="支付宝" value="ali" />
<el-option label="微信" value="wx" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="优惠码">
<el-input <el-input
v-if="selectedDiscountCodeInfo" v-if="selectedDiscountCodeInfo"
:model-value="`${selectedDiscountCodeInfo.name || selectedDiscountCodeInfo.code} (ID: ${orderForm.discount_code_id})`" :model-value="`${selectedDiscountCodeInfo.name || selectedDiscountCodeInfo.code} (ID: ${orderForm.discount_code_id})`"
readonly readonly
style="width: 100%"
> >
<template #suffix><el-icon class="clear-icon" @click="clearDiscountCode"><Close /></el-icon></template> <template #suffix>
<template #append><el-button @click="discountCodeSelectorVisible = true"><el-icon><Ticket /></el-icon></el-button></template> <el-icon class="clear-icon" @click="clearDiscountCode"><Close /></el-icon>
</template>
<template #append>
<el-button @click="discountCodeSelectorVisible = true">
<el-icon><Ticket /></el-icon>
</el-button>
</template>
</el-input> </el-input>
<el-input v-else placeholder="选择优惠码(可选)" readonly @click="discountCodeSelectorVisible = true"> <el-input
<template #append><el-button @click="discountCodeSelectorVisible = true"><el-icon><Ticket /></el-icon></el-button></template> v-else
placeholder="请选择优惠码(可选)"
readonly
style="width: 100%"
@click="discountCodeSelectorVisible = true"
>
<template #append>
<el-button @click="discountCodeSelectorVisible = true">
<el-icon><Ticket /></el-icon>
</el-button>
</template>
</el-input> </el-input>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="代金券ID" prop="coupon_id">
<el-col :span="12">
<el-form-item label="代金券">
<el-input <el-input
v-if="selectedVoucherInfo" v-if="selectedVoucherInfo"
:model-value="`${selectedVoucherInfo.name || selectedVoucherInfo.code} (ID: ${orderForm.user_coupon_id})`" :model-value="`${selectedVoucherInfo.name || selectedVoucherInfo.code} (ID: ${orderForm.coupon_id})`"
readonly readonly
style="width: 100%"
> >
<template #suffix><el-icon class="clear-icon" @click="clearVoucher"><Close /></el-icon></template> <template #suffix>
<template #append><el-button @click="voucherSelectorVisible = true"><el-icon><Money /></el-icon></el-button></template> <el-icon class="clear-icon" @click="clearVoucher"><Close /></el-icon>
</template>
<template #append>
<el-button @click="voucherSelectorVisible = true">
<el-icon><Money /></el-icon>
</el-button>
</template>
</el-input> </el-input>
<el-input v-else placeholder="选择代金券(可选)" readonly @click="voucherSelectorVisible = true"> <el-input
<template #append><el-button @click="voucherSelectorVisible = true"><el-icon><Money /></el-icon></el-button></template> v-else
placeholder="请选择代金券(可选)"
readonly
style="width: 100%"
@click="voucherSelectorVisible = true"
>
<template #append>
<el-button @click="voucherSelectorVisible = true">
<el-icon><Money /></el-icon>
</el-button>
</template>
</el-input> </el-input>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="订单状态" prop="state">
</el-row> <el-radio-group v-model="orderForm.state">
<el-form-item label="备注"> <el-radio :label="0">待支付</el-radio>
<el-input v-model="orderForm.note" type="textarea" :rows="2" placeholder="请输入备注(可选)" /> <el-radio :label="1">已支付</el-radio>
<el-radio :label="2">已失效</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="支付方式" prop="pay_type">
<el-input v-model="orderForm.pay_type" placeholder="请输入支付类型" />
</el-form-item>
<el-form-item label="订单参数" prop="args">
<el-input v-model="orderForm.args" placeholder="请输入订单参数" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="orderForm.note" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item> </el-form-item>
</el-form> </el-form>
</div>
</div>
<template #footer> <template #footer>
<div class="wizard-footer"> <div class="dialog-footer">
<el-button v-if="currentStep > 0" @click="currentStep--">上一步</el-button>
<el-button v-if="currentStep < 2" type="primary" @click="handleNextStep">下一步</el-button>
<el-button v-if="currentStep === 2" type="primary" :loading="submitLoading" @click="submitForm">提交订单</el-button>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -460,7 +411,7 @@
<!-- 代金券选择器 --> <!-- 代金券选择器 -->
<VoucherSelector <VoucherSelector
v-model="voucherSelectorVisible" v-model="voucherSelectorVisible"
:current-voucher-id="orderForm.user_coupon_id" :current-voucher-id="orderForm.coupon_id"
@confirm="handleVoucherSelect" @confirm="handleVoucherSelect"
/> />
</div> </div>
@@ -470,9 +421,8 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading } from '@element-plus/icons-vue' import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close } from '@element-plus/icons-vue'
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order' import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
import { getProductPlanList, getProductParameterList, getProductParameterDetail } from '@/api/admin/product'
import UserListSelector from '@/components/admin/UserListSelector.vue' import UserListSelector from '@/components/admin/UserListSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue' import ProductSelector from '@/components/admin/ProductSelector.vue'
import DiscountCodeSelector from '@/components/admin/DiscountCodeSelector.vue' import DiscountCodeSelector from '@/components/admin/DiscountCodeSelector.vue'
@@ -498,52 +448,42 @@ const orderForm = reactive({
order_id: undefined, order_id: undefined,
name: '', name: '',
table: '', table: '',
type: 'create',
user_id: undefined, user_id: undefined,
commodity_id: 0, commodity_id: 0,
plan_id: null,
pay_num: 1, pay_num: 1,
price: 0, price: 0,
renew_price: 0, renew_price: 0,
expire_time: 0, expire_time: 0,
discount_code_id: 0, discount_code_id: 0,
user_coupon_id: 0, coupon_id: 0,
state: 0, state: 0,
pay_type: 'default', pay_type: '',
payment_order_id: '',
args: '', args: '',
note: '' note: ''
}) })
// 分步验证规则 const orderRules = {
const step1Rules = { name: [
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }], { required: true, message: '请输入订单名称', trigger: 'blur' }
type: [{ required: true, message: '请选择订单类型', trigger: 'change' }], ],
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }] table: [
{ required: true, message: '请输入所属表', trigger: 'blur' }
],
user_id: [
{ required: true, message: '请输入用户ID', trigger: 'blur' },
{ type: 'number', message: '用户ID必须是数字', trigger: 'blur' }
],
coupon_id: [
{ message: '请输入代金券ID', trigger: 'blur' },
{ type: 'number', message: '代金券ID必须是数字', trigger: 'blur' }
],
pay_num: [
{ required: true, message: '请输入购买数量', trigger: 'blur' }
],
price: [
{ required: true, message: '请输入价格', trigger: 'blur' }
]
} }
const step2Rules = {
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }]
}
const step3Rules = {
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
pay_num: [{ required: true, message: '请输入数量', trigger: 'blur' }]
}
// 分步向导
const currentStep = ref(0)
const submitLoading = ref(false)
const step1FormRef = ref(null)
const step2FormRef = ref(null)
const step3FormRef = ref(null)
// 套餐相关
const planList = ref([])
const planLoading = ref(false)
// 商品参数相关
const productParams = ref([])
const paramsLoading = ref(false)
const argValues = reactive({})
// 状态数据 // 状态数据
const loading = ref(false) const loading = ref(false)
@@ -554,6 +494,7 @@ const selectedRows = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
const dialogType = ref('add') const dialogType = ref('add')
const orderFormRef = ref(null)
// 选择器弹窗状态 // 选择器弹窗状态
const userSelectorVisible = ref(false) const userSelectorVisible = ref(false)
@@ -618,15 +559,14 @@ const getStatusType = (status) => {
} }
// 获取订单状态文本 // 获取订单状态文本
// state 0:未支付 1:已支付 2:已失效
const getStatusText = (status) => { const getStatusText = (status) => {
const statusMap = { 0: '待支付', 1: '已支付', 2: '已失效' } const statusMap = {
return statusMap[status] || '未知' 0: '待支付',
1: '已支付',
2: '已失效'
} }
return statusMap[status] || '未知'
// 获取订单类型文本
const getTypeText = (type) => {
const typeMap = { create: '新购', renew: '续费', update: '升级', snapshot: '快照', backup: '备份', data_volume: '数据盘', ipv4: 'IPv4', ipv6: 'IPv6' }
return typeMap[type] || type || '-'
} }
// 查询 // 查询
@@ -665,32 +605,26 @@ const handleCurrentChange = (page) => {
// 新增订单 // 新增订单
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
currentStep.value = 0 dialogVisible.value = true
clearAllSelections() clearAllSelections()
planList.value = []
productParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
Object.assign(orderForm, { Object.assign(orderForm, {
order_id: undefined, order_id: undefined,
name: '', name: '',
table: 'good', table: '',
type: 'create',
user_id: undefined, user_id: undefined,
commodity_id: 0, commodity_id: 0,
plan_id: null,
pay_num: 1, pay_num: 1,
price: 0, price: 0,
renew_price: 0, renew_price: 0,
expire_time: 0, expire_time: 0,
discount_code_id: 0, discount_code_id: 0,
user_coupon_id: 0, coupon_id: 0,
state: 0, state: 0,
pay_type: 'default', pay_type: '',
payment_order_id: '',
args: '', args: '',
note: '' note: ''
}) })
dialogVisible.value = true orderFormRef.value?.resetFields()
} }
// 查看订单详情 // 查看订单详情
@@ -710,12 +644,10 @@ const handleView = async (row) => {
// 编辑订单 // 编辑订单
const handleEdit = (row) => { const handleEdit = (row) => {
dialogType.value = 'edit' dialogType.value = 'edit'
currentStep.value = 0 dialogVisible.value = true
clearAllSelections() clearAllSelections()
planList.value = []
productParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
// 处理过期时间:优先使用已转换的时间戳,否则转换ISO格式
let expireTimeMs = null let expireTimeMs = null
if (row._expireTimeMs !== undefined) { if (row._expireTimeMs !== undefined) {
expireTimeMs = row._expireTimeMs expireTimeMs = row._expireTimeMs
@@ -726,54 +658,29 @@ const handleEdit = (row) => {
Object.assign(orderForm, { Object.assign(orderForm, {
order_id: row.id, order_id: row.id,
name: row.name, name: row.name,
table: row.table || '', table: row.table,
type: row.type || 'create',
user_id: row.userId, user_id: row.userId,
commodity_id: row.commodityId || 0, commodity_id: row.commodityId,
plan_id: row.planId || null,
pay_num: row.payNum, pay_num: row.payNum,
price: row.price, price: row.price,
renew_price: row.renewPrice, renew_price: row.renewPrice,
expire_time: expireTimeMs, expire_time: expireTimeMs,
discount_code_id: 0, discount_code_id: 0, // 从详情接口获取
user_coupon_id: 0, coupon_id: 0, // 从详情接口获取
state: row.state, state: row.state,
pay_type: row.payType || 'default', pay_type: row.payType || '',
payment_order_id: row.paymentOrderId || '',
args: row.args || '', args: row.args || '',
note: row.note || '' note: row.note || ''
}) })
// 设置显示信息(只显示ID,名称需要从选择器中获取)
if (row.userId) { if (row.userId) {
selectedUserInfo.value = { user_id: row.userId, user_name: `用户${row.userId}` } selectedUserInfo.value = { user_id: row.userId, user_name: `用户${row.userId}` }
} }
if (row.commodityId) { if (row.commodityId) {
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` } selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
fetchPlanList(row.commodityId)
fetchProductParams(row.commodityId).then(() => {
// 从已有 args 中恢复参数值
if (row.args) {
try {
const existingArgs = JSON.parse(row.args)
if (Array.isArray(existingArgs)) {
for (const a of existingArgs) {
const param = productParams.value.find(p => p.id === a.arg_id)
if (!param) continue
if (param.type === 'select') {
argValues[param.id] = a.attr_id
} else if (param.type === 'number') {
argValues[param.id] = a.number
} else {
argValues[param.id] = a.value || ''
} }
} }
}
} catch {}
}
})
}
dialogVisible.value = true
}
// 重试订单流程 // 重试订单流程
const handleRetryOrder = (row) => { const handleRetryOrder = (row) => {
@@ -837,166 +744,46 @@ const handleBatchDelete = () => {
}).catch(() => {}) }).catch(() => {})
} }
// 订单类型变更
const handleTypeChange = (type) => {
if (type === 'create') {
orderForm.table = 'good'
}
}
// 分步导航
const handleNextStep = async () => {
if (currentStep.value === 0) {
const formEl = step1FormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
} else if (currentStep.value === 1) {
const formEl = step2FormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
}
currentStep.value++
}
// 加载套餐列表
const fetchPlanList = async (goodId) => {
if (!goodId) { planList.value = []; return }
planLoading.value = true
try {
const res = await getProductPlanList({ good_id: goodId, page: 1, count: 100 })
if (res.data.code === 200) {
planList.value = res.data.data?.list || res.data.data || []
} else {
planList.value = []
}
} catch { planList.value = [] }
finally { planLoading.value = false }
}
// 加载商品参数列表
const fetchProductParams = async (goodId) => {
if (!goodId) { productParams.value = []; return }
paramsLoading.value = true
try {
const res = await getProductParameterList({ good_id: goodId })
if (res.data.code === 200) {
const list = res.data.data || []
await Promise.all(list.map(async (param) => {
try {
const detail = await getProductParameterDetail({ good_id: goodId, arg_id: param.id })
if (detail.data.code === 200) {
const attrs = detail.data.data.attrs || []
attrs.sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0))
param.attrs = attrs
} else { param.attrs = [] }
} catch { param.attrs = [] }
}))
productParams.value = list
// 初始化默认值
for (const param of list) {
if (argValues[param.id] !== undefined) continue
if (param.type === 'select' && param.attrs?.length) {
argValues[param.id] = param.attrs[0].id
} else if (param.type === 'number') {
argValues[param.id] = param.min || 0
} else {
argValues[param.id] = ''
}
}
} else { productParams.value = [] }
} catch { productParams.value = [] }
finally { paramsLoading.value = false }
}
// 根据参数表单值构建 args JSON
const buildArgsJson = () => {
if (!productParams.value.length) return ''
const args = []
for (const param of productParams.value) {
const val = argValues[param.id]
if (val === undefined || val === null || val === '') continue
if (param.type === 'select') {
const attr = param.attrs?.find(a => a.id === val)
if (attr) {
args.push({ arg_id: param.id, name: param.name, attr_id: attr.id, value: attr.value || attr.name })
}
} else if (param.type === 'number') {
const matchedAttr = findMatchedAttr(param, val)
args.push({ arg_id: param.id, name: param.name, attr_id: matchedAttr?.id || (param.attrs?.[0]?.id || 0), number: val })
} else {
const attr = param.attrs?.[0]
args.push({ arg_id: param.id, name: param.name, attr_id: attr?.id || 0, value: val })
}
}
return args.length > 0 ? JSON.stringify(args) : ''
}
const findMatchedAttr = (param, numValue) => {
if (!param.attrs || !param.attrs.length) return null
const sorted = [...param.attrs].sort((a, b) => (a.phase || 0) - (b.phase || 0))
for (const attr of sorted) {
const phase = attr.phase || 0
if (attr.rangeType === 'before' && numValue <= phase) return attr
if (attr.rangeType === 'after' && numValue >= phase) return attr
if (attr.rangeType === 'equal' && numValue === phase) return attr
}
return sorted[sorted.length - 1]
}
// 套餐选择变更
const handlePlanChange = (planId) => {
if (!planId) return
const plan = planList.value.find(p => p.id === planId)
if (plan) {
if (plan.price) orderForm.price = plan.price
if (plan.renew_price) orderForm.renew_price = plan.renew_price
}
}
// 提交表单 // 提交表单
const submitForm = async () => { const submitForm = () => {
const formEl = step3FormRef.value orderFormRef.value?.validate(async (valid) => {
if (formEl) { if (valid) {
const valid = await formEl.validate().catch(() => false)
if (!valid) return
}
submitLoading.value = true
try { try {
let expireTimeStr = '' // 处理过期时间:将毫秒级时间戳转换为秒级时间戳
let expireTimeSeconds = 0
if (orderForm.expire_time) { if (orderForm.expire_time) {
expireTimeStr = new Date(Number(orderForm.expire_time)).toISOString() const timestamp = timeToTimestamp(new Date(orderForm.expire_time))
expireTimeSeconds = timestamp || 0
} }
const argsStr = productParams.value.length ? buildArgsJson() : orderForm.args || '' // 2026-05-12: /api/v1/admin/order/create 与 /update 入参 price / renew_price 单位由
// "分"改为"元"(后端接口变更,用户确认两端都已生效);列表与表单内部仍按"分"持有,
// 提交时统一除以 100 做"分→元"换算,避免再次入库时被当成元导致额外放大 100 倍。
// 输入框旁的"分"单位文案暂不改动(用户明确仅要求 /100),UI 一致性问题待后续单独处理。
const submitData = { const submitData = {
name: orderForm.name, name: orderForm.name,
table: orderForm.table || '', table: orderForm.table,
type: orderForm.type,
user_id: Number(orderForm.user_id), user_id: Number(orderForm.user_id),
commodity_id: Number(orderForm.commodity_id), commodity_id: Number(orderForm.commodity_id),
pay_num: Number(orderForm.pay_num), pay_num: Number(orderForm.pay_num),
price: Number(orderForm.price) / 100, price: Number(orderForm.price) / 100,
renew_price: Number(orderForm.renew_price) / 100, renew_price: Number(orderForm.renew_price) / 100,
expire_time: expireTimeStr, expire_time: expireTimeSeconds,
discount_code_id: Number(orderForm.discount_code_id),
coupon_id: Number(orderForm.coupon_id),
state: Number(orderForm.state), state: Number(orderForm.state),
pay_type: orderForm.pay_type || 'default', pay_type: orderForm.pay_type || '',
args: argsStr, args: orderForm.args || '',
note: orderForm.note || '' note: orderForm.note || ''
} }
if (orderForm.plan_id) submitData.plan_id = Number(orderForm.plan_id) // 如果是编辑,添加order_id
if (orderForm.discount_code_id) submitData.discount_code_id = Number(orderForm.discount_code_id)
if (orderForm.user_coupon_id) submitData.user_coupon_id = Number(orderForm.user_coupon_id)
if (orderForm.payment_order_id) submitData.payment_order_id = orderForm.payment_order_id
if (dialogType.value === 'edit') { if (dialogType.value === 'edit') {
submitData.order_id = Number(orderForm.order_id) submitData.order_id = Number(orderForm.order_id)
} }
console.log('提交订单数据:', submitData)
let res let res
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
res = await createOrder(submitData) res = await createOrder(submitData)
@@ -1008,15 +795,14 @@ const submitForm = async () => {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功') ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false dialogVisible.value = false
fetchOrderList() fetchOrderList()
} else {
ElMessage.error(res.data.message || '操作失败')
} }
} catch (error) { } catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败') ElMessage.error(error.response?.data?.message || '操作失败')
} finally {
submitLoading.value = false
} }
} }
})
}
// 用户选择处理 // 用户选择处理
const handleUserSelect = (user) => { const handleUserSelect = (user) => {
@@ -1033,21 +819,15 @@ const clearUser = () => {
const handleProductSelect = (product) => { const handleProductSelect = (product) => {
orderForm.commodity_id = product.id orderForm.commodity_id = product.id
selectedProductInfo.value = product selectedProductInfo.value = product
if (product.table) orderForm.table = product.table // 自动填充表名
orderForm.plan_id = null if (product.table) {
// 清空旧参数值 orderForm.table = product.table
Object.keys(argValues).forEach(k => delete argValues[k]) }
fetchPlanList(product.id)
fetchProductParams(product.id)
} }
const clearProduct = () => { const clearProduct = () => {
orderForm.commodity_id = 0 orderForm.commodity_id = 0
orderForm.plan_id = null
selectedProductInfo.value = null selectedProductInfo.value = null
planList.value = []
productParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
} }
// 优惠码选择处理 // 优惠码选择处理
@@ -1063,12 +843,12 @@ const clearDiscountCode = () => {
// 代金券选择处理 // 代金券选择处理
const handleVoucherSelect = (voucher) => { const handleVoucherSelect = (voucher) => {
orderForm.user_coupon_id = voucher.id orderForm.coupon_id = voucher.id
selectedVoucherInfo.value = voucher selectedVoucherInfo.value = voucher
} }
const clearVoucher = () => { const clearVoucher = () => {
orderForm.user_coupon_id = 0 orderForm.coupon_id = 0
selectedVoucherInfo.value = null selectedVoucherInfo.value = null
} }
@@ -1166,57 +946,11 @@ onMounted(() => {
justify-content: flex-end; justify-content: flex-end;
} }
/* 分步向导样式 */ .dialog-footer {
.wizard-container {
padding: 0 10px;
}
.wizard-steps {
margin-bottom: 30px;
}
.wizard-body {
min-height: 280px;
}
.wizard-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
} padding: 0;
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.args-section {
margin-top: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.args-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.number-arg-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
} }
/* 表格样式优化 */ /* 表格样式优化 */
File diff suppressed because it is too large Load Diff
+15 -98
View File
@@ -567,9 +567,6 @@
<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"
@@ -591,9 +588,9 @@
@confirm="handleProductCoverSelect" @confirm="handleProductCoverSelect"
/> />
<!-- Tab 栏管理 --> <!-- 价格与销售 -->
<el-tabs v-model="productFormTab" class="product-form-tabs"> <div class="form-section">
<el-tab-pane label="价格与销售" name="sales"> <div class="form-section-title">价格与销售</div>
<div class="form-grid"> <div class="form-grid">
<el-form-item prop="price"> <el-form-item prop="price">
<template #label> <template #label>
@@ -637,20 +634,6 @@
inactive-text="禁止" inactive-text="禁止"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="renew_price" v-if="productForm.can_renew">
<template #label>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
</template>
<el-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type"> <el-form-item label="购买类型" prop="arg_type">
<el-select <el-select
v-model="productForm.arg_type" v-model="productForm.arg_type"
@@ -671,9 +654,11 @@
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div> <div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item> </el-form-item>
</div> </div>
</el-tab-pane> </div>
<el-tab-pane label="库存管理" name="inventory"> <!-- 库存管理 -->
<div class="form-section">
<div class="form-section-title">库存管理</div>
<div class="form-grid"> <div class="form-grid">
<el-form-item label="库存控制" prop="inventory_control"> <el-form-item label="库存控制" prop="inventory_control">
<el-switch <el-switch
@@ -692,29 +677,12 @@
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="售罄状态" prop="sold_out">
<el-switch
v-model="productForm.sold_out"
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>
</div> </div>
</el-tab-pane> </div>
<el-tab-pane label="推荐与通知" name="promotion"> <!-- 推荐与参数 -->
<div class="form-section">
<div class="form-section-title">推荐与参数</div>
<div class="form-grid"> <div class="form-grid">
<el-form-item label="推荐" prop="recommend"> <el-form-item label="推荐" prop="recommend">
<el-switch <el-switch
@@ -737,31 +705,8 @@
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</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="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> </div>
</el-tab-pane> </div>
</el-tabs>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@@ -928,12 +873,7 @@ 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,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0
}) })
const productRules = { const productRules = {
@@ -955,7 +895,6 @@ 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)
@@ -1618,12 +1557,7 @@ 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,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
}) })
productDialogVisible.value = true productDialogVisible.value = true
@@ -1649,12 +1583,7 @@ 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,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
} }
let res let res
@@ -2256,18 +2185,6 @@ 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
@@ -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 || '-'
} }
@@ -113,9 +113,6 @@
<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>
<!-- 说明 --> <!-- 说明 -->
@@ -274,13 +271,6 @@
<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>
@@ -298,10 +288,6 @@
<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>
@@ -392,9 +378,7 @@ 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' }]
@@ -562,11 +546,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 rangeVal = attr.range ?? attr.phase ?? 0 const phase = attr.phase || 0
const rangeType = attr.rangeType || 'before' const rangeType = attr.rangeType || 'before'
if (rangeType === 'before' && numValue <= rangeVal) return attr if (rangeType === 'before' && numValue < phase) return attr
else if (rangeType === 'after' && numValue >= rangeVal) return attr else if (rangeType === 'after' && numValue > phase) return attr
else if (rangeType === 'equal' && numValue === rangeVal) return attr else if (rangeType === 'equal' && numValue === phase) return attr
} }
return sortedAttrs[sortedAttrs.length - 1] return sortedAttrs[sortedAttrs.length - 1]
} }
@@ -774,7 +758,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, max_per_user: 0 }) 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 })
planFormDialogVisible.value = true planFormDialogVisible.value = true
nextTick(() => { planFormRef.value?.resetFields() }) nextTick(() => { planFormRef.value?.resetFields() })
} }
@@ -801,9 +785,7 @@ 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
@@ -860,9 +842,7 @@ 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
-892
View File
@@ -1,892 +0,0 @@
<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
@@ -1,410 +0,0 @@
<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
@@ -1,549 +0,0 @@
<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
@@ -1,632 +0,0 @@
<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>
+6 -226
View File
@@ -193,10 +193,7 @@
</el-tabs> </el-tabs>
<!-- ==================== 模板编辑弹窗 ==================== --> <!-- ==================== 模板编辑弹窗 ==================== -->
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="1060px" append-to-body destroy-on-close @opened="onTplDialogOpened"> <el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="640px" append-to-body destroy-on-close>
<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 :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
<el-form-item label="模板名称" prop="name"> <el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit /> <el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
@@ -230,38 +227,9 @@
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div> <div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
</el-form-item> </el-form-item>
<el-form-item label="模板内容" prop="content"> <el-form-item label="模板内容" prop="content">
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="8" placeholder="模板内容,点击上方参数按钮插入变量" /> <el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="6" placeholder="模板内容,点击上方参数按钮插入变量" />
</el-form-item> </el-form-item>
</el-form> </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> <template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button> <el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button> <el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
@@ -271,13 +239,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, View, WarningFilled } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { import {
getNoticeChannelList, updateNoticeChannel, getNoticeChannelList, updateNoticeChannel,
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate, getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate
previewNoticeTemplate
} from '@/api/admin/noticeChannel' } from '@/api/admin/noticeChannel'
// ==================== 渠道配置 ==================== // ==================== 渠道配置 ====================
@@ -443,70 +410,6 @@ const handleDeleteTpl = async (row) => {
} catch {} } 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) => { const formatTime = (t) => {
if (!t) return '-' if (!t) return '-'
@@ -592,129 +495,6 @@ onMounted(() => {
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; } .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; } .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; } .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> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-10
View File
@@ -36,7 +36,6 @@
</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>
@@ -1408,15 +1407,6 @@ 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 {
+2 -298
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="340" fixed="right"> <el-table-column label="操作" width="280" 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,9 +94,6 @@
<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>
@@ -291,129 +288,13 @@
</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" type="primary" effect="plain">
商品{{ row.good.name }} (ID:{{ row.good.id }})
</el-tag>
<el-tag v-else-if="row.goodGroup" 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-dialog>
<!-- 用户组优惠 新增/编辑 对话框 -->
<el-dialog
v-model="discountFormVisible"
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
width="560px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountFormRules"
label-width="120px"
>
<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>
<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 } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue' import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue'
import { import {
getUserGroupList, getUserGroupList,
getUserGroupMemberList, getUserGroupMemberList,
@@ -422,13 +303,6 @@ 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'
@@ -750,167 +624,6 @@ const clearHigherGroup = () => {
selectedHigherGroupInfo.value = null selectedHigherGroupInfo.value = null
} }
/** -------------------- 用户组优惠管理 -------------------- */
const discountDialogVisible = ref(false)
const discountFormVisible = ref(false)
const discountLoading = ref(false)
const discountList = ref([])
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 loadProductOptions = async () => {
try {
const [pRes, gRes] = await Promise.all([
getProductList({ page: 1, count: 1000 }),
getProductGroupList({ page: 1, count: 1000 })
])
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: 1, count: 1000 })
if (res.data.code === 200) {
discountList.value = res.data.data || []
}
} catch (error) {
console.error('获取用户组优惠列表失败:', error)
ElMessage.error('获取用户组优惠列表失败')
} finally {
discountLoading.value = false
}
}
//
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
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
})
discountFormRef.value?.resetFields()
}
//
const handleEditDiscount = (row) => {
discountFormType.value = 'edit'
discountFormVisible.value = true
const isGroup = !!row.goodGroup
Object.assign(discountForm, {
id: row.id,
bind_type: isGroup ? 'good_group' : 'good',
good_id: row.good ? row.good.id : undefined,
good_group_id: row.goodGroup ? row.goodGroup.id : undefined,
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0
})
}
//
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) return
try {
const params = new URLSearchParams()
params.append('user_group_id', currentGroupId.value)
if (discountForm.bind_type === 'good') {
params.append('good_id', discountForm.good_id)
} else {
params.append('good_group_id', discountForm.good_group_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 {
params.append('id', discountForm.id)
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()
@@ -983,15 +696,6 @@ onMounted(() => {
padding: 0; padding: 0;
} }
.discount-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.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;
-9
View File
@@ -15,13 +15,6 @@
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
@@ -676,7 +669,6 @@ const showJumpUserSelector = ref(false)
// //
const queryParams = reactive({ const queryParams = reactive({
key: '', key: '',
is_admin: undefined,
page: 1, page: 1,
count: 10 count: 10
}) })
@@ -880,7 +872,6 @@ const handleQuery = () => {
// //
const resetQuery = () => { const resetQuery = () => {
queryParams.key = '' queryParams.key = ''
queryParams.is_admin = undefined
queryParams.page = 1 queryParams.page = 1
fetchUserList() fetchUserList()
} }
-10
View File
@@ -18,7 +18,6 @@
</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>
@@ -2784,15 +2783,6 @@ 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