feat: add AWS and MF consumption management
Build and Deploy Vue3 / build (push) Successful in 2m28s
Build and Deploy Vue3 / deploy (push) Successful in 1m1s

This commit is contained in:
shiran
2026-08-24 20:54:13 +08:00
parent a71351ce3c
commit edc1118a52
9 changed files with 583 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import { http2 } from '@/utils/request.js'
const base = '/api/v1/admin/server/aws_service'
export const getAwsServiceList = (params) => http2.get(`${base}/list`, { params })
export const getAwsServiceDetail = (id) => http2.get(`${base}/detail`, { params: { id } })
export const createAwsService = (data) => http2.post(`${base}/create`, data)
export const updateAwsService = (data) => http2.post(`${base}/update`, data)
export const testAwsService = (id) => http2.post(`${base}/test`, null, { params: { id } })
export const deleteAwsService = (id) => http2.delete(`${base}/delete`, { params: { id } })
export const getAwsGoodsList = (params) => http2.get(`${base}/goods/list`, { params })
export const getAwsGoodsDetail = (id) => http2.get(`${base}/goods/detail`, { params: { id } })
export const createAwsGoods = (data) => http2.post(`${base}/goods/create`, data)
export const updateAwsGoods = (data) => http2.post(`${base}/goods/update`, data)
export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { params: { id } })
export const getAwsInstanceList = (params) => http2.get(`${base}/instances/list`, { params })
export const retryAwsInstance = (instanceId) => http2.post(`${base}/instances/retry`, null, { params: { instance_id: instanceId } })
+19
View File
@@ -0,0 +1,19 @@
import { http2 } from '@/utils/request.js'
const base = '/api/v1/admin/activity/mf_consumption'
/** 获取魔方特殊消费活动列表 */
export const getMFConsumptionPromotionList = (params) =>
http2.get(`${base}/list`, { params })
/** 创建魔方特殊消费活动 */
export const createMFConsumptionPromotion = (data) =>
http2.post(`${base}/create`, data)
/** 软删除魔方特殊消费活动 */
export const deleteMFConsumptionPromotion = (id) =>
http2.delete(`${base}/delete`, { params: { id } })
/** 永久清理指定时间范围内的 MF 扣减型消费记录 */
export const clearMFConsumptionHistories = (data) =>
http2.delete(`${base}/histories/clear`, { data })
+14
View File
@@ -91,6 +91,10 @@ export const menus = [
{
path: '/activity/groupbuy',
title: '拼团管理'
},
{
path: '/activity/mf-consumption',
title: '魔方特殊消费'
}
]
},
@@ -176,6 +180,16 @@ export const menus = [
}
]
},
{
path: '/aws',
title: 'AWS 服务管理',
icon: 'Cloudy',
children: [
{ path: '/aws/services', title: '服务配置' },
{ path: '/aws/goods', title: '商品绑定' },
{ path: '/aws/instances', title: '履约实例' }
]
},
{
title: '虚拟化平台管理',
icon: 'Platform',
+35
View File
@@ -374,6 +374,14 @@ const routes = [
meta: {
title: '拼团管理'
}
},
{
path: 'mf-consumption',
name: 'MFConsumptionPromotion',
component: () => import('../views/activity/MFConsumptionPromotion.vue'),
meta: {
title: '魔方特殊消费'
}
}
]
},
@@ -663,6 +671,33 @@ const routes = [
}
]
},
// AWS 远端商品管理
{
path: 'aws',
name: 'Aws',
meta: { title: 'AWS 服务管理', icon: 'Cloudy' },
redirect: '/aws/services',
children: [
{
path: 'services',
name: 'AwsServices',
component: () => import('../views/aws/AwsServices.vue'),
meta: { title: '服务配置' }
},
{
path: 'goods',
name: 'AwsGoods',
component: () => import('../views/aws/AwsGoods.vue'),
meta: { title: '商品绑定' }
},
{
path: 'instances',
name: 'AwsInstances',
component: () => import('../views/aws/AwsInstances.vue'),
meta: { title: '履约实例' }
}
]
},
// 站点审计路由
{
path: 'audit',
@@ -0,0 +1,321 @@
<template>
<div class="mf-consumption-page">
<div class="page-header">
<div>
<h2>魔方特殊消费</h2>
<p>设置订单消费计入用户组累计消费的特殊倍率活动时间按左闭右开区间生效</p>
</div>
<div class="header-actions">
<el-button type="danger" plain @click="openClearDialog">
<el-icon><Delete /></el-icon>清理消费记录
</el-button>
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新增活动
</el-button>
</div>
</div>
<el-alert
class="description-alert"
type="info"
:closable="false"
show-icon
title="倍率只影响消费记录计入用户组升级的累计值,不直接修改订单应付金额。"
/>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="活动名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="tag" label="活动标签" min-width="220" show-overflow-tooltip />
<el-table-column label="消费倍率" width="110" align="center">
<template #default="{ row }">
<el-tag type="warning" effect="plain">{{ row.multiplier }} </el-tag>
</template>
</el-table-column>
<el-table-column label="活动状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="promotionStatus(row).type">{{ promotionStatus(row).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.startTime ?? row.start_time) }}</template>
</el-table-column>
<el-table-column label="结束时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.endTime ?? row.end_time) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.CreatedAt ?? row.createdAt ?? row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removePromotion(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无特殊消费活动" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadPromotions"
/>
</div>
</el-card>
<el-dialog v-model="createDialogVisible" title="新增魔方特殊消费活动" width="620px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="105px">
<el-form-item label="活动名称" prop="name">
<el-input v-model="createForm.name" maxlength="120" show-word-limit placeholder="例如:MF 周年消费三倍活动" />
</el-form-item>
<el-form-item label="活动标签" prop="tag">
<el-input v-model="createForm.tag" maxlength="120" show-word-limit placeholder="必须唯一,例如:20260824-20260831/Price*3" />
<div class="form-tip">标签会写入消费记录创建后不可修改</div>
</el-form-item>
<el-form-item label="活动时间" prop="timeRange">
<el-date-picker
v-model="createForm.timeRange"
type="datetimerange"
start-placeholder="开始时间(包含)"
end-placeholder="结束时间(不包含)"
range-separator="至"
:default-time="defaultTimes"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="消费倍率" prop="multiplier">
<el-input-number v-model="createForm.multiplier" :min="1" :step="1" step-strictly style="width: 180px" />
<span class="multiplier-suffix"></span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">创建活动</el-button>
</template>
</el-dialog>
<el-dialog v-model="clearDialogVisible" title="清理 MF 消费记录" width="620px" destroy-on-close>
<el-alert
type="error"
:closable="false"
show-icon
title="危险操作:记录将被永久删除且无法恢复"
description="只清理 source=MF、非余额变更且位于所选时间范围内的扣减型消费记录;退款与补偿记录不会删除。清理后后端会重新计算受影响用户的消费用户组。"
/>
<el-form ref="clearFormRef" :model="clearForm" :rules="clearRules" label-width="105px" class="clear-form">
<el-form-item label="清理范围" prop="timeRange">
<el-date-picker
v-model="clearForm.timeRange"
type="datetimerange"
start-placeholder="开始时间(包含)"
end-placeholder="结束时间(不包含)"
range-separator="至"
:default-time="defaultTimes"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="确认文字" prop="confirmation">
<el-input v-model="clearForm.confirmation" autocomplete="off" placeholder="请输入:确认清理" />
<div class="form-tip">输入确认清理后才能提交</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="clearDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="clearing" :disabled="clearForm.confirmation !== '确认清理'" @click="submitClear">
永久清理
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
clearMFConsumptionHistories,
createMFConsumptionPromotion,
deleteMFConsumptionPromotion,
getMFConsumptionPromotionList
} from '@/api/admin/mfConsumption.js'
const loading = ref(false)
const creating = ref(false)
const clearing = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 20 })
const createDialogVisible = ref(false)
const clearDialogVisible = ref(false)
const createFormRef = ref()
const clearFormRef = ref()
const defaultTimes = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
const newCreateForm = () => ({ name: '', tag: '', timeRange: [], multiplier: 1 })
const createForm = reactive(newCreateForm())
const clearForm = reactive({ timeRange: [], confirmation: '' })
const validateTimeRange = (_rule, value, callback) => {
if (!Array.isArray(value) || value.length !== 2) return callback(new Error('请选择完整的开始和结束时间'))
if (new Date(value[1]).getTime() <= new Date(value[0]).getTime()) return callback(new Error('结束时间必须晚于开始时间'))
callback()
}
const createRules = {
name: [{ required: true, whitespace: true, message: '请输入活动名称', trigger: 'blur' }],
tag: [{ required: true, whitespace: true, message: '请输入唯一活动标签', trigger: 'blur' }],
timeRange: [{ required: true, validator: validateTimeRange, trigger: 'change' }],
multiplier: [{ required: true, type: 'integer', min: 1, message: '倍率必须是大于等于 1 的整数', trigger: 'change' }]
}
const clearRules = {
timeRange: [{ required: true, validator: validateTimeRange, trigger: 'change' }],
confirmation: [{ validator: (_rule, value, callback) => value === '确认清理' ? callback() : callback(new Error('请输入“确认清理”')), trigger: 'change' }]
}
const responseBody = (response) => response?.data || {}
const toUnixSeconds = (value) => Math.floor(new Date(value).getTime() / 1000)
function formatTime(value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(date)
}
function promotionStatus(row) {
const now = Date.now()
const start = new Date(row.startTime ?? row.start_time).getTime()
const end = new Date(row.endTime ?? row.end_time).getTime()
if (Number.isFinite(start) && now < start) return { label: '未开始', type: 'info' }
if (Number.isFinite(end) && now >= end) return { label: '已结束', type: 'info' }
return { label: '进行中', type: 'success' }
}
async function loadPromotions() {
loading.value = true
try {
const body = responseBody(await getMFConsumptionPromotionList(query))
if (body.code !== 200) return ElMessage.error(body.message || '获取活动列表失败')
rows.value = body.data?.data || body.data?.list || []
total.value = body.data?.all_count ?? rows.value.length
} catch (error) {
ElMessage.error(error?.response?.data?.message || '获取活动列表失败')
} finally {
loading.value = false
}
}
function handleSizeChange() {
query.page = 1
loadPromotions()
}
function openCreateDialog() {
Object.assign(createForm, newCreateForm())
createDialogVisible.value = true
}
async function submitCreate() {
await createFormRef.value?.validate()
creating.value = true
try {
const body = responseBody(await createMFConsumptionPromotion({
name: createForm.name.trim(),
tag: createForm.tag.trim(),
start_time: toUnixSeconds(createForm.timeRange[0]),
end_time: toUnixSeconds(createForm.timeRange[1]),
multiplier: createForm.multiplier
}))
if (body.code !== 200) return ElMessage.error(body.message || '创建活动失败')
ElMessage.success('活动创建成功')
createDialogVisible.value = false
query.page = 1
loadPromotions()
} catch (error) {
ElMessage.error(error?.response?.data?.message || '创建活动失败,请检查活动时间是否重叠、标签是否唯一')
} finally {
creating.value = false
}
}
async function removePromotion(row) {
try {
await ElMessageBox.confirm(
`确定删除活动“${row.name}”吗?已导入的消费记录不会回溯修改。`,
'删除活动',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
const body = responseBody(await deleteMFConsumptionPromotion(row.id))
if (body.code !== 200) return ElMessage.error(body.message || '删除活动失败')
ElMessage.success('活动已删除')
if (rows.value.length === 1 && query.page > 1) query.page--
loadPromotions()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.response?.data?.message || '删除活动失败')
}
}
function openClearDialog() {
Object.assign(clearForm, { timeRange: [], confirmation: '' })
clearDialogVisible.value = true
}
async function submitClear() {
await clearFormRef.value?.validate()
try {
await ElMessageBox.confirm(
`即将永久清理 ${formatTime(clearForm.timeRange[0])}${formatTime(clearForm.timeRange[1])} 的 MF 消费记录,是否继续?`,
'最后确认',
{ type: 'error', confirmButtonText: '永久清理', cancelButtonText: '取消', distinguishCancelAndClose: true }
)
} catch {
return
}
clearing.value = true
try {
const body = responseBody(await clearMFConsumptionHistories({
start_time: toUnixSeconds(clearForm.timeRange[0]),
end_time: toUnixSeconds(clearForm.timeRange[1]),
confirm: true
}))
if (body.code !== 200) return ElMessage.error(body.message || '清理消费记录失败')
const result = body.data || {}
ElMessage.success(`清理完成:删除 ${result.deleted_count ?? 0} 条记录,影响 ${result.affected_user_count ?? 0} 个用户`)
clearDialogVisible.value = false
} catch (error) {
ElMessage.error(error?.response?.data?.message || '清理消费记录失败')
} finally {
clearing.value = false
}
}
onMounted(loadPromotions)
</script>
<style scoped>
.mf-consumption-page { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 20px 24px; margin-bottom: 16px; border: 1px solid #e4e7ed; border-radius: 12px; background: linear-gradient(135deg, #fff8ed, #fff); }
.page-header h2 { margin: 0; color: #303133; }
.page-header p { margin: 7px 0 0; color: #606266; }
.header-actions { display: flex; flex-shrink: 0; }
.description-alert { margin-bottom: 16px; }
.pagination { display: flex; justify-content: flex-end; padding-top: 18px; }
.form-tip { margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.4; }
.multiplier-suffix { margin-left: 8px; color: #606266; }
.clear-form { margin-top: 20px; }
@media (max-width: 768px) {
.page-header { align-items: flex-start; flex-direction: column; }
.header-actions { width: 100%; }
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="page">
<div class="header"><div><h2>AWS 商品绑定</h2><p>配置商品可售目录默认参数和强制策略</p></div><el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新增绑定</el-button></div>
<div class="filters"><el-select v-model="query.service_id" clearable placeholder="全部 AWS 服务" @change="search"><el-option v-for="s in services" :key="s.id" :label="s.name" :value="s.id"/></el-select><el-button @click="reset">重置</el-button></div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="70"/>
<el-table-column label="商品 ID" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/product/manage',query:{good_id:row.goodId}})">{{row.goodId}}</el-link></template></el-table-column>
<el-table-column label="服务" min-width="140"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{serviceName(row.serviceId)}} (#{{row.serviceId}})</el-link></template></el-table-column>
<el-table-column label="区域" min-width="190"><template #default="{row}"><el-tag v-for="v in row.allowedRegions||[]" :key="v" size="small" class="tag">{{v}}</el-tag></template></el-table-column>
<el-table-column prop="exchangeRate" label="汇率" width="90"/>
<el-table-column prop="gracePeriodHours" label="宽限期(h)" width="100"/>
<el-table-column label="强制策略" min-width="210"><template #default="{row}"><el-tag v-if="row.forceAssociateEip" size="small">EIP</el-tag><el-tag v-if="row.forceEncryptedRootVolume" size="small" type="success">加密系统盘</el-tag><el-tag v-if="row.forceInstallAgent" size="small" type="warning">Agent</el-tag></template></el-table-column>
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</el-tag></template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-popconfirm title="确认删除无历史记录的绑定?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="820px" top="4vh" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="145px">
<el-row :gutter="18"><el-col :span="12"><el-form-item label="商品 ID" prop="good_id"><el-input-number v-model="form.good_id" :min="1" :disabled="editing" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="AWS 服务" prop="service_id"><el-select v-model="form.service_id" style="width:100%"><el-option v-for="s in services" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id"/></el-select></el-form-item></el-col></el-row>
<el-form-item label="允许区域" prop="allowed_regions"><el-select v-model="form.allowed_regions" multiple filterable allow-create default-first-option placeholder="输入 Region 后回车" style="width:100%"/></el-form-item>
<el-form-item label="镜像分类白名单"><el-select v-model="form.allowed_image_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="镜像白名单"><el-select v-model="form.allowed_images" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格分类白名单"><el-select v-model="form.allowed_type_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格白名单"><el-select v-model="form.allowed_types" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-row :gutter="18"><el-col :span="12"><el-form-item label="人民币汇率" prop="exchange_rate"><el-input-number v-model="form.exchange_rate" :min="0.000001" :precision="6" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="宽限期(小时)"><el-input-number v-model="form.grace_period_hours" :min="0" style="width:100%"/></el-form-item></el-col></el-row>
<el-form-item label="强制策略"><el-checkbox v-model="form.force_associate_eip">绑定 EIP</el-checkbox><el-checkbox v-model="form.force_encrypted_root_volume">加密系统盘</el-checkbox><el-checkbox v-model="form.force_install_agent">安装 Agent</el-checkbox><el-checkbox v-model="form.enabled">启用绑定</el-checkbox></el-form-item>
<el-form-item label="默认创建参数" prop="defaults_text"><el-input v-model="form.defaults_text" type="textarea" :rows="7" placeholder="{}" class="json"/></el-form-item>
<el-form-item label="商品约束" prop="constraints_text"><el-input v-model="form.constraints_text" type="textarea" :rows="6" placeholder="{}" class="json"/></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, updateAwsGoods } from '@/api/admin/awsService.js'
const route=useRoute(), loading=ref(false), submitting=ref(false), rows=ref([]), services=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
const query=reactive({page:1,count:20,service_id:route.query.service_id?Number(route.query.service_id):undefined})
const empty=()=>({id:null,good_id:null,service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,grace_period_hours:168,exchange_rate:1,enabled:true})
const form=reactive(empty())
const jsonRule=(_,value,cb)=>{try{const v=JSON.parse(value||'{}');if(!v||Array.isArray(v)||typeof v!=='object')throw new Error();cb()}catch{cb(new Error('请输入有效的 JSON 对象'))}}
const rules={good_id:[{required:true,message:'请输入商品 ID'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]}
const body=r=>r?.data||{}
async function loadServices(){const b=body(await getAwsServiceList({page:1,count:100}));if(b.code===200)services.value=b.data?.data||b.data?.list||[]}
async function load(){loading.value=true;try{const params={...query};if(!params.service_id)delete params.service_id;const b=body(await getAwsGoodsList(params));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
const serviceName=id=>services.value.find(v=>v.id===id)?.name||'服务'
function search(){query.page=1;load()} function reset(){query.service_id=undefined;search()}
function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true}
async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true}
function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}}
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else data.good_id=form.good_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
async function remove(row){const b=body(await deleteAwsGoods(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')}
onMounted(()=>{loadServices();load()})
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:260px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.tag{margin:2px}.json :deep(textarea){font-family:Consolas,Monaco,monospace}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="page">
<div class="header"><div><h2>AWS 履约实例</h2><p>查看远端实例快照并审核失败任务</p></div><el-button @click="load">刷新</el-button></div>
<div class="filters"><el-select v-model="query.status" clearable placeholder="全部履约状态" @change="search"><el-option v-for="s in statuses" :key="s" :label="statusText(s)" :value="s"/></el-select><el-button @click="reset">重置</el-button></div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="实例 ID" width="90"/>
<el-table-column label="用户商品" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push(`/user-goods/detail/${row.userGoodsId}`)">{{row.userGoodsId}}</el-link></template></el-table-column>
<el-table-column label="订单" width="100"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/order/list',query:{order_id:row.orderId}})">{{row.orderId}}</el-link></template></el-table-column>
<el-table-column label="服务" width="95"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{row.serviceId}}</el-link></template></el-table-column>
<el-table-column prop="region" label="Region" min-width="125"/>
<el-table-column prop="remoteAwsInstanceId" label="AWS 实例" min-width="160" show-overflow-tooltip/>
<el-table-column label="履约状态" width="110"><template #default="{row}"><el-tag :type="statusType(row.provisionStatus)">{{statusText(row.provisionStatus)}}</el-tag></template></el-table-column>
<el-table-column prop="pendingAction" label="待执行" width="90"/>
<el-table-column prop="retryCount" label="重试" width="65"/>
<el-table-column prop="lastError" label="最近错误" min-width="180" show-overflow-tooltip/>
<el-table-column label="最近同步" width="170"><template #default="{row}">{{formatTime(row.lastSyncedAt)}}</template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row)">详情</el-button><el-popconfirm v-if="row.provisionStatus==='failed'" title="确认审核并重试该履约?" @confirm="retry(row)"><template #reference><el-button link type="warning" :loading="retryingId===row.id">重试</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-drawer v-model="drawer" title="AWS 履约详情" size="600px">
<template v-if="current"><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">{{current.id}}</el-descriptions-item><el-descriptions-item label="状态">{{statusText(current.provisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端实例">{{current.remoteAwsInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="任务 ID">{{current.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="错误代码">{{current.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="下次重试">{{formatTime(current.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{current.lastError||'-'}}</el-descriptions-item></el-descriptions><h4>资源快照</h4><pre class="snapshot">{{snapshotText}}</pre></template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAwsInstanceList, retryAwsInstance } from '@/api/admin/awsService.js'
const statuses=['pending','submitting','provisioning','active','failed','stopping','stopped','terminating','terminated']
const labels={pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'}
const loading=ref(false), retryingId=ref(null), rows=ref([]), total=ref(0), drawer=ref(false), current=ref(null)
const query=reactive({page:1,count:20,status:undefined})
const body=r=>r?.data||{}
const statusText=s=>labels[s]||s||'-'
const statusType=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info'
const formatTime=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-'
const snapshotText=computed(()=>{const raw=current.value?.resourceSnapshot;if(!raw)return '暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}})
async function load(){loading.value=true;try{const params={...query};if(!params.status)delete params.status;const b=body(await getAwsInstanceList(params));if(b.code===200){rows.value=b.data?.list||b.data?.data||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
function search(){query.page=1;load()} function reset(){query.status=undefined;search()} function showDetail(row){current.value=row;drawer.value=true}
async function retry(row){retryingId.value=row.id;try{const b=body(await retryAwsInstance(row.id));if(b.code===200){ElMessage.success('已接受重试');load()}else ElMessage.error(b.message||'重试失败')}finally{retryingId.value=null}}
onMounted(load)
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:230px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.snapshot{padding:16px;border-radius:8px;background:#f5f7fa;white-space:pre-wrap;word-break:break-all;max-height:55vh;overflow:auto}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="page">
<div class="header"><div><h2>AWS 服务配置</h2><p>管理 aws-server-sdk 连接和目录缓存策略</p></div><el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新增服务</el-button></div>
<div class="filters"><el-input v-model="query.key" clearable placeholder="搜索名称、地址或备注" @keyup.enter="search" @clear="search"/><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="70"/>
<el-table-column prop="name" label="名称" min-width="140"/>
<el-table-column prop="baseUrl" label="服务地址" min-width="220" show-overflow-tooltip/>
<el-table-column label="Token" width="150"><template #default="{ row }"><el-tag :type="row.tokenConfigured ? 'success' : 'danger'">{{ row.tokenConfigured ? (row.tokenPrefix || '已配置') : '未配置' }}</el-tag></template></el-table-column>
<el-table-column prop="timeoutSec" label="超时(秒)" width="95"/>
<el-table-column prop="catalogTtl" label="目录TTL" width="95"/>
<el-table-column label="状态" width="85"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip/>
<el-table-column label="操作" width="235" fixed="right"><template #default="{ row }"><el-button link type="success" :loading="testingId===row.id" @click="test(row)">测试</el-button><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link @click="$router.push({path:'/aws/goods',query:{service_id:row.id}})">商品</el-button><el-popconfirm title="确认删除未被绑定的服务?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-dialog v-model="dialog" :title="editing ? '编辑 AWS 服务' : '新增 AWS 服务'" width="600px" 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"/></el-form-item>
<el-form-item label="服务地址" prop="base_url"><el-input v-model="form.base_url" placeholder="https://aws.example.com"/></el-form-item>
<el-form-item label="App Token" prop="app_token"><el-input v-model="form.app_token" show-password :placeholder="editing ? '留空保留原 Token' : 'awsapp_...'"/></el-form-item>
<el-form-item label="请求超时" prop="timeout_sec"><el-input-number v-model="form.timeout_sec" :min="1" :max="300"/></el-form-item>
<el-form-item label="目录缓存" prop="catalog_ttl"><el-input-number v-model="form.catalog_ttl" :min="0"/></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled"/></el-form-item>
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" :rows="3"/></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createAwsService, deleteAwsService, getAwsServiceList, testAwsService, updateAwsService } from '@/api/admin/awsService.js'
const loading=ref(false), submitting=ref(false), testingId=ref(null), rows=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
const query=reactive({page:1,count:20,key:''})
const empty=()=>({id:null,name:'',base_url:'',app_token:'',enabled:true,timeout_sec:30,catalog_ttl:60,note:''})
const form=reactive(empty())
const rules={name:[{required:true,message:'请输入名称'}],base_url:[{required:true,message:'请输入服务地址'},{type:'url',message:'请输入有效 URL'}],app_token:[{validator:(_,v,cb)=>{if(!editing.value&&!v)return cb(new Error('请输入 App Token'));if(v&&!v.startsWith('awsapp_'))return cb(new Error('Token 必须以 awsapp_ 开头'));cb()}}]}
const body=r=>r?.data||{}
async function load(){loading.value=true;try{const b=body(await getAwsServiceList(query));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
function search(){query.page=1;load()} function reset(){query.key='';search()}
function openCreate(){editing.value=false;Object.assign(form,empty());dialog.value=true}
function openEdit(row){editing.value=true;Object.assign(form,{id:row.id,name:row.name||'',base_url:row.baseUrl||row.base_url||'',app_token:'',enabled:!!row.enabled,timeout_sec:row.timeoutSec??30,catalog_ttl:row.catalogTtl??60,note:row.note||''});dialog.value=true}
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data={name:form.name.trim(),base_url:form.base_url.trim(),enabled:form.enabled,timeout_sec:form.timeout_sec,catalog_ttl:form.catalog_ttl,note:form.note};if(form.app_token)data.app_token=form.app_token; if(editing.value)data.id=form.id;const b=body(await (editing.value?updateAwsService(data):createAwsService(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
async function test(row){testingId.value=row.id;try{const b=body(await testAwsService(row.id));b.code===200?ElMessage.success(`连接成功${b.data?.latency_ms?`${b.data.latency_ms}ms`:''}`):ElMessage.error(b.message||'连接失败')}finally{testingId.value=null}}
async function remove(row){const b=body(await deleteAwsService(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')}
onMounted(load)
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0;color:#303133}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-input{width:320px}.pager{display:flex;justify-content:flex-end;padding-top:16px}
</style>
+13
View File
@@ -162,6 +162,7 @@
<span v-if="row.isGroup">{{ row.note || '-' }}</span>
<div v-else-if="row.isProduct" class="product-info-inline">
<span class="product-price">¥{{ (row.data.price / 100).toFixed(2) }}</span>
<el-tag v-if="row.data.remoteGoods || row.data.remote_goods" type="primary" size="small">远端商品</el-tag>
<el-tag v-if="row.data.inventoryControl" type="info" size="small">库存: {{ row.data.inventory }}</el-tag>
<el-tag v-if="row.data.recommend" type="warning" size="small">推荐</el-tag>
<el-tag v-if="row.data.expireTime && row.data.expireTime > 0" type="info" size="small">有效期: {{ row.data.expireTime }}</el-tag>
@@ -682,6 +683,14 @@
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
<el-form-item label="远端商品" prop="remote_goods">
<el-switch
v-model="productForm.remote_goods"
active-text="远端计价/履约"
inactive-text="普通商品"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">AWS 商品请同时将标签设为 aws保存后到 AWS 商品绑定页配置目录规则</div>
</el-form-item>
</div>
</el-tab-pane>
@@ -960,6 +969,7 @@ const productForm = reactive({
arg_type: 'all',
can_renew: true,
require_real_name: false,
remote_goods: false,
sold_out: false,
max_per_user: 0,
max_first_purchase_duration: 0,
@@ -1621,6 +1631,7 @@ const handleAddProduct = () => {
recommend_rebate: 0,
arg_type: 'all',
require_real_name: false,
remote_goods: false,
max_per_user: 0,
max_first_purchase_duration: 0,
recommend_words: ''
@@ -1658,6 +1669,7 @@ const handleEditProduct = (product, parentGroupId) => {
arg_type: product.argType || 'all',
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,
remote_goods: product.remoteGoods ?? product.remote_goods ?? false,
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
@@ -1691,6 +1703,7 @@ const submitProductForm = () => {
arg_type: productForm.arg_type,
can_renew: productForm.can_renew,
require_real_name: productForm.require_real_name,
remote_goods: productForm.remote_goods === true,
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,