feat(admin): 新增短信平台管理功能
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 35s

- 新增短信主控服务和额度商品的API接口
- 添加短信平台管理菜单项,包含主控服务管理和额度商品管理子菜单
- 实现短信平台管理相关路由配置
- 创建短信额度商品管理页面,支持额度类型配置、商品管理等功
This commit is contained in:
shiran
2026-06-07 18:25:13 +08:00
parent 84769954c4
commit 86794145f1
5 changed files with 1366 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { http2 } from '@/utils/request.js'
// ========== 短信主控服务 ==========
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 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' }
})
}
+15
View File
@@ -149,6 +149,21 @@ export const menus = [
}
]
},
{
path: '/sms',
title: '短信平台管理',
icon: 'ChatDotRound',
children: [
{
path: '/sms/service',
title: '主控服务管理'
},
{
path: '/sms/goods',
title: '额度商品管理'
}
]
},
{
title: '虚拟化平台管理',
icon: 'Platform',
+24
View File
@@ -609,6 +609,30 @@ 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: 'audit',
+892
View File
@@ -0,0 +1,892 @@
<template>
<div class="sms-goods-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#e6a23c" stroke-width="1.8">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div>
<h2 class="header-title">短信额度商品管理</h2>
<p class="header-desc">
管理短信平台的额度商品配置
<template v-if="filterServiceName">
当前筛选<el-tag size="small" type="warning">{{ filterServiceName }}</el-tag>
</template>
</p>
</div>
</div>
<div class="header-actions">
<el-button @click="router.push('/sms/service')">
<el-icon><Back /></el-icon> 返回服务列表
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增商品
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select
v-model="queryParams.service_id"
placeholder="筛选主控服务"
clearable
style="width: 220px"
@change="handleSearch"
>
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-input
v-model="queryParams.key"
placeholder="搜索商品名称"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="关联服务" min-width="140">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goToService(row.serviceId)">
{{ resolveServiceName(row.serviceId) }}
</el-button>
</template>
</el-table-column>
<el-table-column label="关联商品" min-width="150">
<template #default="{ row }">
<span class="goods-name">{{ row.good?.name || `商品#${row.goodId}` }}</span>
</template>
</el-table-column>
<el-table-column label="额度类型" width="120" align="center">
<template #default="{ row }">
<el-tag :type="quotaTypeTag(row.quotaType).type" effect="dark" size="small">
{{ quotaTypeTag(row.quotaType).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="有效期/周期配置" min-width="200">
<template #default="{ row }">
<template v-if="row.quotaType === 2">
<span class="config-label">有效期模式</span>
<el-tag size="small" type="info">{{ row.expireMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
</template>
<template v-else-if="row.quotaType === 3">
<span class="config-label">周期模式</span>
<el-tag size="small" type="info">{{ row.cycleMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
<template v-if="row.cycleMode === 'fixed'">
<span class="config-detail">{{ row.cycleValue || 1 }}{{ cycleUnitLabel(row.cycleUnit) }}</span>
</template>
</template>
<span v-else class="config-detail">永久有效</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确认删除该商品绑定?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增弹窗 -->
<el-dialog
v-model="addDialogVisible"
title="新增短信额度商品"
width="680px"
destroy-on-close
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="130px">
<el-divider content-position="left">基本信息</el-divider>
<el-form-item label="关联主控服务" prop="service_id">
<el-select v-model="addForm.service_id" placeholder="选择短信主控服务" style="width: 100%">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="商品分组" prop="good_group_id">
<el-tree-select
v-model="addForm.good_group_id"
:data="groupTreeData"
:props="groupTreeProps"
lazy
:load="loadGroupChildren"
node-key="id"
placeholder="选择商品分组"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="商品名称" prop="name">
<el-input v-model="addForm.name" placeholder="如:短信1000条包" />
</el-form-item>
<el-form-item label="商品介绍">
<el-input v-model="addForm.content" type="textarea" :rows="2" placeholder="商品介绍(可选,系统自动追加配额说明)" />
</el-form-item>
<el-form-item label="管理备注">
<el-input v-model="addForm.note" placeholder="管理备注(可选)" />
</el-form-item>
<el-divider content-position="left">额度配置</el-divider>
<el-form-item label="额度类型" prop="quota_type">
<div class="quota-type-cards">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: addForm.quota_type === qt.value }"
@click="addForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
<div class="qt-desc">{{ qt.desc }}</div>
</div>
</div>
</el-form-item>
<el-form-item label="额度数量模式" prop="quota_value_type">
<el-radio-group v-model="addForm.quota_value_type">
<el-radio value="number">数值范围用户输入数值</el-radio>
<el-radio value="select">固定选项用户选择档位</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="addForm.quota_value_type === 'number'">
<el-form-item label="最小值">
<el-input-number v-model="addForm.quota_min" :min="1" :step="100" />
</el-form-item>
<el-form-item label="最大值">
<el-input-number v-model="addForm.quota_max" :min="1" :step="100" />
</el-form-item>
<el-form-item label="步长">
<el-input-number v-model="addForm.quota_step" :min="1" :step="10" />
</el-form-item>
<el-form-item label="单条价格" prop="quota_unit_price">
<el-input-number v-model="addForm.quota_unit_price" :min="0" :step="0.01" :precision="2" controls-position="right" />
<span class="opt-unit">/</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="额度选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.quota_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="数量" controls-position="right" class="opt-value" />
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-input-number v-model="item.price" :min="0" :step="0.01" :precision="2" placeholder="价格" controls-position="right" class="opt-price" />
<span class="opt-unit"></span>
<el-button link type="danger" @click="addForm.quota_options_list.splice(idx, 1)" :disabled="addForm.quota_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.quota_options_list.push({ value: null, label: '', price: null })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 短期额度配置 -->
<template v-if="addForm.quota_type === 2">
<el-divider content-position="left">有效期配置</el-divider>
<el-form-item label="有效期模式" prop="expire_mode">
<el-radio-group v-model="addForm.expire_mode">
<el-radio value="fixed">固定天数管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'fixed'" label="有效天数">
<el-input-number v-model="addForm.expire_fixed" :min="1" />
<span style="margin-left: 8px; color: #909399"></span>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'select'" label="有效期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.expire_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="天数" controls-position="right" class="opt-value" />
<span class="opt-unit"></span>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.expire_options_list.splice(idx, 1)" :disabled="addForm.expire_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.expire_options_list.push({ value: null, label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 周期额度配置 -->
<template v-if="addForm.quota_type === 3">
<el-divider content-position="left">周期配置</el-divider>
<el-form-item label="周期单位模式" prop="cycle_mode">
<el-radio-group v-model="addForm.cycle_mode">
<el-radio value="fixed">固定单位管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="addForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'select'" label="周期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.cycle_options_list" :key="idx" class="option-row">
<el-select v-model="item.value" placeholder="周期单位" class="opt-cycle-unit">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.cycle_options_list.splice(idx, 1)" :disabled="addForm.cycle_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.cycle_options_list.push({ value: '', label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
<el-form-item label="周期数值" prop="cycle_value">
<el-input-number v-model="addForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitAdd">确定</el-button>
</template>
</el-dialog>
<!-- 编辑弹窗仅可修改 SmsGoods 自身字段 -->
<el-dialog
v-model="editDialogVisible"
title="编辑短信额度商品"
width="580px"
destroy-on-close
>
<el-form ref="editFormRef" :model="editForm" label-width="130px">
<el-form-item label="管理备注">
<el-input v-model="editForm.note" placeholder="管理备注" />
</el-form-item>
<el-form-item label="额度类型">
<div class="quota-type-cards small">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: editForm.quota_type === qt.value }"
@click="editForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
</div>
</div>
</el-form-item>
<template v-if="editForm.quota_type === 2">
<el-form-item label="有效期模式">
<el-radio-group v-model="editForm.expire_mode">
<el-radio value="fixed">固定天数</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-if="editForm.quota_type === 3">
<el-form-item label="周期单位模式">
<el-radio-group v-model="editForm.cycle_mode">
<el-radio value="fixed">固定单位</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="editForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="editForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item label="周期数值">
<el-input-number v-model="editForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitEdit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Search, Back, Delete } from '@element-plus/icons-vue'
import {
getSmsServiceList,
getSmsGoodsList,
createSmsGoods,
updateSmsGoods,
deleteSmsGoods
} from '@/api/admin/smsService.js'
import { getProductGroupList } from '@/api/admin/product'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const serviceOptions = ref([])
const serviceMap = ref({})
const addDialogVisible = ref(false)
const editDialogVisible = ref(false)
const addFormRef = ref(null)
const editFormRef = ref(null)
const groupTreeData = ref([])
const groupTreeProps = { label: 'name', children: 'children', isLeaf: (data) => !data.existSub }
const filterServiceName = computed(() => route.query.service_name || '')
const queryParams = reactive({
page: 1,
count: 10,
service_id: route.query.service_id ? parseInt(route.query.service_id) : undefined,
key: ''
})
const quotaTypes = [
{
value: 1, label: '长期',
desc: '永久有效,一次购买持续可用',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
},
{
value: 2, label: '短期',
desc: '限时有效,到期后额度失效',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'
},
{
value: 3, label: '周期',
desc: '按周期自动重置额度',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
}
]
const defaultAddForm = () => ({
service_id: queryParams.service_id || null,
good_group_id: null,
name: '',
content: '',
note: '',
quota_type: 1,
quota_value_type: 'number',
quota_options_list: [{ value: null, label: '', price: null }],
quota_min: 100,
quota_max: 100000,
quota_step: 100,
quota_unit_price: 0.05,
expire_mode: 'fixed',
expire_fixed: 30,
expire_options_list: [{ value: null, label: '' }],
cycle_mode: 'fixed',
cycle_unit: 'month',
cycle_value: 1,
cycle_options_list: [{ value: '', label: '' }]
})
const serializeOptions = (list, withPrice = false) => {
return list
.filter(item => item.value !== null && item.value !== '' && item.label)
.map(item => withPrice ? `${item.value}:${item.label}:${item.price ?? 0}` : `${item.value}:${item.label}`)
.join(',')
}
const addForm = ref(defaultAddForm())
const editForm = ref({
id: null,
note: '',
quota_type: 1,
expire_mode: '',
cycle_mode: '',
cycle_unit: '',
cycle_value: 1
})
const addRules = {
service_id: [{ required: true, message: '请选择主控服务', trigger: 'change' }],
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const quotaTypeTag = (type) => {
const map = { 1: { label: '长期', type: 'success' }, 2: { label: '短期', type: 'warning' }, 3: { label: '周期', type: '' } }
return map[type] || { label: '未知', type: 'info' }
}
const cycleUnitLabel = (unit) => {
const map = { day: '天', week: '周', month: '月', year: '年' }
return map[unit] || unit
}
const resolveServiceName = (id) => {
return serviceMap.value[id] || `服务#${id}`
}
const goToService = (serviceId) => {
router.push({ path: '/sms/service' })
}
const loadGroupOptions = async () => {
try {
const res = await getProductGroupList({ level: 1, count: 199 })
if (res.data.code === 200) {
groupTreeData.value = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
}
} catch (e) {
console.error(e)
}
}
const loadGroupChildren = async (node, resolve) => {
if (node.level === 0) return resolve(groupTreeData.value)
try {
const res = await getProductGroupList({ parent_id: node.data.id, level: node.data.level + 1, count: 199 })
if (res.data.code === 200) {
const children = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
resolve(children)
} else {
resolve([])
}
} catch (e) {
resolve([])
}
}
const loadServiceOptions = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
const body = res.data
if (body.code === 200) {
const list = body.data?.data || body.data || []
serviceOptions.value = Array.isArray(list) ? list : []
const map = {}
serviceOptions.value.forEach(s => { map[s.id] = s.name })
serviceMap.value = map
}
} catch (e) {
console.error(e)
}
}
const fetchList = async () => {
loading.value = true
try {
const params = { ...queryParams }
if (!params.service_id) delete params.service_id
const res = await getSmsGoodsList(params)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = Array.isArray(d) ? d : []
total.value = body.data?.all_count || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.service_id = undefined
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
addForm.value = defaultAddForm()
addDialogVisible.value = true
}
const handleSubmitAdd = async () => {
await addFormRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
params.append('service_id', addForm.value.service_id)
if (addForm.value.good_group_id) params.append('good_group_id', addForm.value.good_group_id)
params.append('name', addForm.value.name)
if (addForm.value.content) params.append('content', addForm.value.content)
if (addForm.value.note) params.append('note', addForm.value.note)
params.append('quota_type', addForm.value.quota_type)
params.append('quota_value_type', addForm.value.quota_value_type)
if (addForm.value.quota_value_type === 'number') {
params.append('quota_min', addForm.value.quota_min)
params.append('quota_max', addForm.value.quota_max)
params.append('quota_step', addForm.value.quota_step)
params.append('quota_unit_price', addForm.value.quota_unit_price)
} else {
params.append('quota_options', serializeOptions(addForm.value.quota_options_list, true))
}
if (addForm.value.quota_type === 2) {
params.append('expire_mode', addForm.value.expire_mode)
if (addForm.value.expire_mode === 'fixed') {
params.append('expire_fixed', addForm.value.expire_fixed)
} else {
params.append('expire_options', serializeOptions(addForm.value.expire_options_list))
}
}
if (addForm.value.quota_type === 3) {
params.append('cycle_mode', addForm.value.cycle_mode)
params.append('cycle_value', addForm.value.cycle_value)
if (addForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', addForm.value.cycle_unit)
} else {
params.append('cycle_options', serializeOptions(addForm.value.cycle_options_list))
}
}
const res = await createSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('创建成功')
addDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '创建失败')
}
} catch (e) {
ElMessage.error('创建失败')
} finally {
submitting.value = false
}
}
const handleEdit = (row) => {
editForm.value = {
id: row.id,
note: row.note || '',
quota_type: row.quotaType,
expire_mode: row.expireMode || 'fixed',
cycle_mode: row.cycleMode || 'fixed',
cycle_unit: row.cycleUnit || 'month',
cycle_value: row.cycleValue || 1
}
editDialogVisible.value = true
}
const handleSubmitEdit = async () => {
submitting.value = true
try {
const params = new URLSearchParams()
params.append('id', editForm.value.id)
if (editForm.value.note) params.append('note', editForm.value.note)
params.append('quota_type', editForm.value.quota_type)
if (editForm.value.quota_type === 2) {
params.append('expire_mode', editForm.value.expire_mode)
}
if (editForm.value.quota_type === 3) {
params.append('cycle_mode', editForm.value.cycle_mode)
params.append('cycle_value', editForm.value.cycle_value)
if (editForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', editForm.value.cycle_unit)
}
}
const res = await updateSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('更新成功')
editDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '更新失败')
}
} catch (e) {
ElMessage.error('更新失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(async () => {
await Promise.all([loadServiceOptions(), loadGroupOptions()])
fetchList()
})
</script>
<style scoped>
.sms-goods-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #fef9f0 0%, #fdf2e4 100%);
border-radius: 12px;
border: 1px solid #f0dfc8;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.15);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
}
.header-desc {
font-size: 13px;
color: #909399;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.goods-name {
font-weight: 500;
color: #303133;
}
.config-label {
color: #909399;
font-size: 12px;
margin-right: 4px;
}
.config-detail {
color: #606266;
font-size: 13px;
margin-left: 6px;
}
.note-text {
color: #909399;
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* 额度类型卡片 */
.quota-type-cards {
display: flex;
gap: 12px;
}
.quota-type-cards.small .qt-card {
padding: 10px 16px;
}
.qt-card {
flex: 1;
padding: 16px;
border: 2px solid #ebeef5;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
background: #fafbfc;
}
.qt-card:hover {
border-color: #c0c4cc;
background: #fff;
}
.qt-card.active {
border-color: #409eff;
background: #f0f7ff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.qt-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
color: #909399;
}
.qt-card.active .qt-icon {
color: #409eff;
}
.qt-label {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.qt-desc {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.form-hint {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
/* 动态选项列表 */
.dynamic-options {
width: 100%;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.option-row:last-of-type {
margin-bottom: 10px;
}
.opt-value {
width: 140px;
flex-shrink: 0;
}
.opt-unit {
font-size: 13px;
color: #909399;
flex-shrink: 0;
}
.opt-label {
flex: 1;
min-width: 0;
}
.opt-price {
width: 120px;
flex-shrink: 0;
}
.opt-cycle-unit {
width: 120px;
flex-shrink: 0;
}
</style>
+384
View File
@@ -0,0 +1,384 @@
<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>
</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="200" align="center" fixed="right">
<template #default="{ row }">
<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="确认删除该服务?" @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="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
} 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 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>