style: 优化布局和交互(Loading/空状态/骨架屏)
Build and Deploy Vue3 / build (push) Successful in 1m51s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s

This commit is contained in:
2026-04-07 16:51:12 +08:00
parent f0e89695f4
commit 2f06aa9f5f
15 changed files with 2216 additions and 1681 deletions
+3 -2
View File
@@ -6,10 +6,11 @@ const GOODS_BASE = '/api/v1/admin/good/user_goods'
const fd = (data) => {
const f = new FormData()
Object.entries(data).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
// 数组类型逐个 append(如 network_ids
if (v === undefined || v === null) return
if (Array.isArray(v)) {
v.forEach(item => f.append(k, item))
} else if (typeof v === 'boolean') {
f.append(k, v ? 'true' : 'false')
} else {
f.append(k, v)
}
@@ -0,0 +1,51 @@
<template>
<div>
<div class="selector-field-row">
<el-input
:model-value="displayText"
readonly
:placeholder="placeholder"
style="flex:1"
/>
<el-button
type="primary"
:disabled="disabled"
style="margin-left:8px"
@click="$emit('select')"
>{{ buttonText }}</el-button>
<el-button
v-if="clearable && modelValue"
style="margin-left:4px"
@click="$emit('update:modelValue', null); $emit('clear')"
>清除</el-button>
</div>
<div v-if="hint" :style="{ fontSize: '12px', color: hintType === 'disabled' ? '#c0c4cc' : '#909399', marginTop: '4px' }">
{{ hint }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: [Number, String, Object], default: null },
displayText: { type: String, default: '' },
placeholder: { type: String, default: '请选择' },
buttonText: { type: String, default: '选择' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
hint: { type: String, default: '' },
hintType: { type: String, default: 'normal' }
})
defineEmits(['select', 'clear', 'update:modelValue'])
</script>
<style scoped>
.selector-field-row {
display: flex;
align-items: center;
width: 100%;
}
</style>
+27
View File
@@ -58,6 +58,33 @@ export function timeToTimestamp(time) {
return num / 100
}
/**
* 分转元显示(返回 ¥xx.xx 或 '-'
*/
export function formatPrice(fen, fallback = '-') {
if (!fen && fen !== 0) return fallback
return '¥' + (fen / 100).toFixed(2)
}
/**
* 元转分(四舍五入取整)
*/
export function yuanToFen(yuan) {
return Math.round((yuan || 0) * 100)
}
/**
* 格式化到期时间(year < 2000 视为永久)
*/
export function formatExpireTime(t) {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return '-'
if (d.getFullYear() < 2000) return '永久'
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 将 ISO 格式时间字符串转换为毫秒级时间戳(用于时间选择器)
* @param {string|Date|number} time - 输入时间(支持 ISO 格式字符串如 '2023-11-08T01:10:00+08:00'、Date 对象、时间戳等)
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -74,7 +74,13 @@
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" />
<el-table-column prop="table" label="商品所属表" width="150" />
<el-table-column label="标签" width="100">
<template #default="{ row }">
<el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag>
<span v-else style="color:#c0c4cc;font-size:12px">-</span>
</template>
</el-table-column>
<el-table-column prop="table" label="所属表" width="130" />
<el-table-column label="价格" width="120">
<template #default="{ row }">
<span class="price">¥{{ (row.price / 100).toFixed(2) }}</span>
@@ -104,6 +110,10 @@
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无商品数据" :image-size="120" />
</template>
</el-table>
<!-- 分页 -->
@@ -126,7 +136,6 @@
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增商品' : '编辑商品'"
width="700px"
style="margin-top: 300px;"
>
<el-form
ref="productFormRef"
@@ -299,6 +308,9 @@
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无参数数据" :image-size="80" />
</template>
</el-table>
</el-dialog>
@@ -416,6 +428,9 @@
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无参数值" :image-size="60" />
</template>
</el-table>
</el-dialog>
@@ -539,6 +554,9 @@
<el-button type="danger" link @click="handleDeletePlan(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无套餐数据" :image-size="80" />
</template>
</el-table>
</div>
</el-dialog>
@@ -817,6 +835,9 @@
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无分组数据" :image-size="60" />
</template>
</el-table>
</div>
<template #footer>
+184 -28
View File
@@ -15,8 +15,14 @@
</div>
</div>
<div class="main-content" v-loading="loading">
<el-card class="profile-card" shadow="never" v-if="detail">
<!-- 空状态 -->
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
<el-button type="primary" @click="loadDetail">重新加载</el-button>
</el-empty>
<el-card class="profile-card" shadow="hover" v-if="detail">
<div class="profile-header">
<div class="profile-basic">
<div class="icon-wrapper">
@@ -59,14 +65,16 @@
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" v-if="detail" style="margin-top:20px">
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133">关联信息</h3>
<el-card shadow="hover" v-if="detail" class="related-card">
<template #header>
<span class="card-title">关联信息</span>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
@@ -224,28 +232,176 @@ watch(goodsId, (newId, oldId) => {
</script>
<style scoped>
.goods-detail-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px; }
.profile-card { margin-bottom: 0; }
.profile-header { display: flex; justify-content: space-between; align-items: flex-start; }
.profile-basic { display: flex; align-items: center; gap: 20px; }
.icon-wrapper { width: 80px; height: 80px; border-radius: 12px; background: linear-gradient(135deg, #e8f4fd, #d6eaff); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.identity { display: flex; flex-direction: column; gap: 8px; }
.name-row { display: flex; align-items: center; gap: 10px; }
.name { font-size: 22px; font-weight: 600; color: #303133; margin: 0; }
.id-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #909399; flex-wrap: wrap; }
.id-row .label { color: #909399; }
.id-row .value { color: #606266; font-weight: 500; }
.profile-stats { display: flex; gap: 32px; flex-shrink: 0; }
.stat-item { text-align: center; min-width: 80px; }
.stat-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
.stat-value { font-size: 14px; font-weight: 600; color: #303133; }
.note-value { font-weight: 400; font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-row { display: flex; align-items: center; width: 100%; }
.goods-detail-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e1e8ed;
}
.header-left {
display: flex;
align-items: center;
gap: 0;
}
.back-btn {
font-size: 14px;
color: #606266;
}
.back-btn:hover {
color: #409eff;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
gap: 8px;
}
.main-content {
padding: 20px;
min-height: 300px;
}
.profile-card {
margin-bottom: 0;
border: 1px solid #e1e8ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.profile-basic {
display: flex;
align-items: center;
gap: 20px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.identity {
display: flex;
flex-direction: column;
gap: 8px;
}
.name-row {
display: flex;
align-items: center;
gap: 10px;
}
.name {
font-size: 22px;
font-weight: 600;
color: #303133;
margin: 0;
}
.id-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
flex-wrap: wrap;
}
.id-row .label {
color: #909399;
}
.id-row .value {
color: #606266;
font-weight: 500;
}
.profile-stats {
display: flex;
gap: 24px;
flex-shrink: 0;
}
.stat-item {
text-align: center;
min-width: 80px;
padding: 10px 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.note-value {
font-weight: 400;
font-size: 13px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-card {
margin-top: 20px;
border: 1px solid #e1e8ed;
border-radius: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.selector-row {
display: flex;
align-items: center;
width: 100%;
}
:deep(.el-descriptions__label) {
font-weight: 500;
color: #606266;
}
</style>
+508 -172
View File
@@ -1,60 +1,132 @@
<template>
<div class="user-goods-list">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" :icon="Plus" @click="handleCreate">新增用户商品</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
<el-card class="main-container" shadow="never">
<!-- 筛选与操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" class="search-form">
<el-form-item label="用户ID">
<el-input v-model="query.user_id" placeholder="筛选用户" clearable style="width:120px"
@keyup.enter="handleSearch" @clear="handleSearch" />
</el-form-item>
<el-form-item label="商品ID">
<el-input v-model="query.good_id" placeholder="筛选商品" clearable style="width:120px"
@keyup.enter="handleSearch" @clear="handleSearch" />
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; handleSearch()">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>新增用户商品
</el-button>
<el-button type="success" @click="loadList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<div class="toolbar-right">
<el-input v-model="query.key" placeholder="搜索商品名称" clearable style="width:200px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<!-- 数据表格 -->
<div class="table-section">
<!-- 骨架屏 -->
<div v-if="loading && list.length === 0" class="skeleton-container">
<div v-for="i in 5" :key="i" class="skeleton-row">
<div class="skeleton-cell" style="width:60px"></div>
<div class="skeleton-cell" style="width:140px"></div>
<div class="skeleton-cell" style="flex:1;min-width:120px"></div>
<div class="skeleton-cell" style="width:80px"></div>
<div class="skeleton-cell" style="width:100px"></div>
<div class="skeleton-cell" style="width:160px"></div>
<div class="skeleton-cell" style="width:140px;height:32px"></div>
</div>
</div>
<el-table
v-else
:data="list"
v-loading="loading"
stripe
style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600, fontSize: '13px' }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="140">
<template #default="{ row }">
<div class="user-cell">
<span class="user-name">{{ row.user?.UserName || row.user?.username || '-' }}</span>
<span class="user-id">({{ row.userId || row.user_id || '-' }})</span>
</div>
</template>
</el-table-column>
<el-table-column label="商品" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
</el-table-column>
<el-table-column label="标签" width="100">
<template #default="{ row }">
<el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="套餐ID" width="90">
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
</el-table-column>
<el-table-column label="订单" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }}</template>
</el-table-column>
<el-table-column label="续费价格" width="110">
<template #default="{ row }">
<span v-if="row.renewPrice || row.renew_price" class="price-text">¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="170">
<template #default="{ row }">
<span :class="{ 'expire-permanent': formatExpireTime(row.expireTime || row.expire_time) === '永久' }">
{{ formatExpireTime(row.expireTime || row.expire_time) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无用户商品数据" :image-size="120" />
</template>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.count"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }"
background
class="pagination"
/>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="140">
<template #default="{ row }">
<span>{{ row.user?.UserName || row.user?.username || '-' }}</span>
<span style="color:#909399;font-size:12px"> ({{ row.userId || row.user_id || '-' }})</span>
</template>
</el-table-column>
<el-table-column label="商品" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
</el-table-column>
<el-table-column label="套餐ID" width="90">
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
</el-table-column>
<el-table-column label="订单" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }}</template>
</el-table-column>
<el-table-column label="续费价格" width="110">
<template #default="{ row }">
<span v-if="row.renewPrice || row.renew_price">¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="170">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.count"
:page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }" />
</div>
</el-card>
<!-- 新增弹窗 -->
<el-dialog v-model="createVisible" title="新增用户商品" width="600px" destroy-on-close class="scrollable-dialog">
@@ -100,7 +172,7 @@
<el-button type="primary" @click="openArgsDialog" :disabled="!createForm.good_id" style="margin-left:8px">配置</el-button>
<el-button v-if="createForm.order_args" @click="createForm.order_args = ''; clearArgsConfig()" style="margin-left:4px">清除</el-button>
</div>
<div v-if="!createForm.good_id" style="font-size:12px;color:#c0c4cc;margin-top:4px">请先选择商品</div>
<div v-if="!createForm.good_id" class="form-hint disabled">请先选择商品</div>
</el-form-item>
<el-form-item label="归属项">
@@ -112,9 +184,9 @@
</el-button>
<el-button v-if="createForm.item_id" @click="createForm.item_id = 0; createForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div v-if="!createForm.good_id" style="font-size:12px;color:#c0c4cc;margin-top:4px">请先选择商品</div>
<div v-else-if="createForm._goodTag === '云服务器'" style="font-size:12px;color:#909399;margin-top:4px">云服务器商品,点击选择用户虚拟机作为归属项</div>
<div v-else style="font-size:12px;color:#909399;margin-top:4px">普通商品,点击将商品ID赋值为归属项</div>
<div v-if="!createForm.good_id" class="form-hint disabled">请先选择商品</div>
<div v-else-if="createForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
</el-form-item>
<el-form-item label="续费价格()">
@@ -132,14 +204,28 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
<div class="dialog-footer">
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑用户商品" width="480px" destroy-on-close>
<el-dialog v-model="editVisible" title="编辑用户商品" width="600px" destroy-on-close class="scrollable-dialog">
<el-form :model="editForm" label-width="110px">
<el-form-item label="归属项">
<div class="selector-row">
<el-input :model-value="editForm._itemName || (editForm.item_id ? `#${editForm.item_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="handleEditItemSelect" :disabled="!editForm._goodId" style="margin-left:8px">
{{ editForm._goodTag === '云服务器' ? '选择虚拟机' : '使用商品ID' }}
</el-button>
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div v-if="editForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
</el-form-item>
<el-form-item label="续费价格()">
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
@@ -155,8 +241,10 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitEdit">保存</el-button>
<div class="dialog-footer">
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitEdit">保存</el-button>
</div>
</template>
</el-dialog>
@@ -165,9 +253,9 @@
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id" @confirm="handlePlanSelectedForCreate" />
<!-- 用户虚拟机选择弹窗item_id 为云服务器时使用) -->
<!-- 用户虚拟机选择弹窗 -->
<el-dialog v-model="showVmListDialog" title="选择用户虚拟机" width="800px" append-to-body destroy-on-close>
<div class="filter-section" style="margin-bottom:12px">
<div style="margin-bottom:12px">
<el-form :inline="true" size="default">
<el-form-item label="关键词">
<el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px"
@@ -179,7 +267,8 @@
</el-form>
</div>
<el-table :data="vmListForItem" v-loading="vmListLoading" highlight-current-row
@current-change="r => vmListSelected = r" :height="350" style="width:100%">
@current-change="r => vmListSelected = r" :height="350" style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="120">
<template #default="{ row }">{{ row.user?.UserName || row.user?.username || '-' }}</template>
@@ -193,26 +282,31 @@
<el-table-column label="到期时间" width="170">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
<template #empty>
<el-empty description="暂无虚拟机数据" :image-size="80" />
</template>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<el-pagination v-model:current-page="vmListQuery.page" v-model:page-size="vmListQuery.count"
:total="vmListTotal" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next"
:total="vmListTotal" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" background
@size-change="s => { vmListQuery.count = s; vmListQuery.page = 1; loadVmListForItem() }"
@current-change="p => { vmListQuery.page = p; loadVmListForItem() }" />
</div>
<template #footer>
<el-button @click="showVmListDialog = false">取消</el-button>
<el-button type="primary" :disabled="!vmListSelected" @click="confirmVmForItem">确定选择</el-button>
<div class="dialog-footer">
<el-button @click="showVmListDialog = false">取消</el-button>
<el-button type="primary" :disabled="!vmListSelected" @click="confirmVmForItem">确定选择</el-button>
</div>
</template>
</el-dialog>
<!-- 订单参数配置弹窗 -->
<el-dialog v-model="showArgsDialog" title="配置订单参数" width="600px" append-to-body destroy-on-close class="scrollable-dialog">
<div v-if="argsSpecLoading" style="text-align:center;padding:20px;color:#909399">加载参数中...</div>
<div v-else-if="argsSpecList.length === 0" style="text-align:center;padding:20px;color:#909399">该商品暂无参数配置</div>
<div v-if="argsSpecLoading" v-loading="true" style="min-height:120px"></div>
<el-empty v-else-if="argsSpecList.length === 0" description="该商品暂无参数配置" :image-size="80" />
<div v-else>
<div v-for="spec in argsSpecList" :key="spec.id" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid #f0f0f0">
<div style="font-size:14px;font-weight:500;color:#303133;margin-bottom:8px">
<div v-for="spec in argsSpecList" :key="spec.id" class="args-spec-item">
<div class="args-spec-label">
{{ spec.name }}
<el-tag v-if="spec.must" size="small" type="danger" style="margin-left:6px">必填</el-tag>
</div>
@@ -224,21 +318,23 @@
<template v-else-if="spec.type === 'number'">
<div style="display:flex;align-items:center;gap:12px">
<el-input-number v-model="argsValues[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" @change="buildArgsJson" style="width:200px" />
<span style="font-size:12px;color:#909399">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span>
<span class="form-hint" style="margin-top:0">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span>
</div>
</template>
<template v-else>
<el-input v-model="argsValues[spec.id]" placeholder="请输入值" style="width:200px" @input="buildArgsJson" />
</template>
</div>
<div v-if="createForm.order_args" style="margin-top:8px">
<div style="font-size:12px;color:#909399;margin-bottom:6px">生成的参数 JSON</div>
<div v-if="createForm.order_args" style="margin-top:16px">
<div class="form-hint" style="margin-bottom:6px;margin-top:0">生成的参数 JSON</div>
<el-input v-model="createForm.order_args" type="textarea" :rows="4" readonly style="font-family:monospace;font-size:12px" />
</div>
</div>
<template #footer>
<el-button @click="showArgsDialog = false">取消</el-button>
<el-button type="primary" @click="showArgsDialog = false">确定</el-button>
<div class="dialog-footer">
<el-button @click="showArgsDialog = false">取消</el-button>
<el-button type="primary" @click="showArgsDialog = false">确定</el-button>
</div>
</template>
</el-dialog>
</div>
@@ -263,11 +359,10 @@ const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '' })
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
// 过期时间为 0001-01-01 时视为无到期时间
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
@@ -280,11 +375,12 @@ const loadList = async () => {
try {
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
console.log("用户商品列表",list.value)
total.value = d.all_count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
@@ -321,7 +417,6 @@ const loadArgsSpec = async (goodId) => {
const res = await getProductParameterList({ good_id: goodId })
if (res?.data?.code === 200) {
argsSpecList.value = res.data.data || []
// 初始化 number 类型的默认值
for (const spec of argsSpecList.value) {
if (spec.type === 'number' && argsValues[spec.id] === undefined) {
argsValues[spec.id] = spec.min || 0
@@ -332,93 +427,99 @@ const loadArgsSpec = async (goodId) => {
}
const buildArgsJson = () => {
const result = []
const argsArray = []
for (const spec of argsSpecList.value) {
const val = argsValues[spec.id]
if (val === undefined || val === null || val === '') continue
if (val === undefined || val === '') continue
if (spec.type === 'select') {
const attr = spec.attrs?.find(a => a.id === val)
if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0 })
if (attr) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value || '' })
} else if (spec.type === 'number') {
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val })
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, number: Number(val) })
} else {
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0 })
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val) })
}
}
createForm.order_args = result.length > 0 ? JSON.stringify(result) : ''
}
// 选择套餐后自动填入参数
const handlePlanSelectedForCreate = async (plan) => {
createForm.good_plan_id = plan.id
createForm._planName = plan.name
// 解析套餐的 args 字段自动填入参数值
if (plan.args) {
try {
const planArgs = typeof plan.args === 'string' ? JSON.parse(plan.args) : plan.args
if (Array.isArray(planArgs)) {
for (const arg of planArgs) {
if (arg.arg_id) {
if (arg.attr_id) argsValues[arg.arg_id] = arg.attr_id
else if (arg.number !== undefined) argsValues[arg.arg_id] = arg.number
else if (arg.value) argsValues[arg.arg_id] = arg.value
}
}
buildArgsJson()
}
} catch { /* */ }
}
}
// 选择商品后加载参数
// watch 在 createForm 声明后定义,见下方
const handleDetail = (row) => {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
createForm.order_args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
}
// ---- 新增 ----
const createVisible = ref(false)
const submitLoading = ref(false)
const createFormRef = ref(null)
const submitLoading = ref(false)
const showProductSelector = ref(false)
const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showPlanSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '', good_plan_id: 0, _planName: '',
good_id: 0, _goodName: '', _goodTag: '',
user_id: 0, _userName: '',
order_id: 0, _orderName: '',
good_plan_id: 0, _planName: '',
order_args: '',
item_id: 0, _itemName: '',
_renewYuan: 0, _baseYuan: 0, note: '', expire_time: '',
order_args: ''
_renewYuan: 0, _baseYuan: 0,
expire_time: '', note: ''
})
const createRules = {
good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }],
user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }]
}
// 商品选择确认处理
const handleCreate = () => {
Object.assign(createForm, {
good_id: 0, _goodName: '', _goodTag: '',
user_id: 0, _userName: '',
order_id: 0, _orderName: '',
good_plan_id: 0, _planName: '',
order_args: '',
item_id: 0, _itemName: '',
_renewYuan: 0, _baseYuan: 0,
expire_time: '', note: ''
})
clearArgsConfig()
createVisible.value = true
}
const handleProductSelected = (p) => {
createForm.good_id = p.id
createForm._goodName = p.name
createForm._goodName = p.name || ''
createForm._goodTag = p.tag || ''
createForm.item_id = 0
createForm._itemName = ''
clearArgsConfig()
}
// ---- 归属项选择(item_id ----
const handlePlanSelectedForCreate = async (plan) => {
createForm.good_plan_id = plan.id
createForm._planName = plan.name || `套餐 #${plan.id}`
if (plan.args) {
createForm.order_args = typeof plan.args === 'string' ? plan.args : JSON.stringify(plan.args)
}
if (plan.id && createForm.good_id) {
try {
const res = await getProductPlanDetail({ good_id: String(createForm.good_id), plan_id: String(plan.id) })
if (res?.data?.code === 200 && res.data.data?.args) {
createForm.order_args = typeof res.data.data.args === 'string' ? res.data.data.args : JSON.stringify(res.data.data.args)
}
} catch { /* keep what we have */ }
}
}
// ---- item_id 选择 ----
const vmItemTarget = ref('create')
const showVmListDialog = ref(false)
const vmListForItem = ref([])
const vmListLoading = ref(false)
const vmListTotal = ref(0)
const vmListSelected = ref(null)
const vmListTotal = ref(0)
const vmListQuery = reactive({ page: 1, count: 10, key: '' })
const handleItemSelect = () => {
if (!createForm.good_id) return
if (createForm._goodTag === '云服务器') {
vmListSelected.value = null
vmListQuery.page = 1
vmListQuery.key = ''
vmItemTarget.value = 'create'
showVmListDialog.value = true
loadVmListForItem()
} else {
@@ -438,73 +539,94 @@ const loadVmListForItem = async () => {
const d = res.data.data
vmListForItem.value = d.data || (Array.isArray(d) ? d : [])
vmListTotal.value = d.all_count ?? d.total ?? vmListForItem.value.length
} else { vmListForItem.value = []; vmListTotal.value = 0 }
} catch { vmListForItem.value = []; vmListTotal.value = 0 } finally { vmListLoading.value = false }
}
} catch { vmListForItem.value = [] } finally { vmListLoading.value = false }
}
const confirmVmForItem = () => {
if (!vmListSelected.value) return
const vm = vmListSelected.value
createForm.item_id = vm.id
createForm._itemName = `虚拟机 #${vm.id}${vm.good?.name ? ` (${vm.good.name})` : ''}`
if (vmItemTarget.value === 'edit') {
editForm.item_id = vm.id
editForm._itemName = `虚拟机 #${vm.id}`
} else {
createForm.item_id = vm.id
createForm._itemName = `虚拟机 #${vm.id}`
}
showVmListDialog.value = false
vmListSelected.value = null
ElMessage.success('已选择虚拟机')
}
// 选择商品后加载参数
watch(() => createForm.good_id, (id) => {
if (id) loadArgsSpec(id)
else clearArgsConfig()
})
const handleCreate = () => {
Object.assign(createForm, { good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', good_plan_id: 0, _planName: '', item_id: 0, _itemName: '', _renewYuan: 0, _baseYuan: 0, note: '', expire_time: '', order_args: '' })
clearArgsConfig()
createVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
good_id: createForm.good_id, user_id: createForm.user_id,
order_id: createForm.order_id, good_plan_id: createForm.good_plan_id,
item_id: createForm.item_id || 0,
note: createForm.note,
renew_price: Math.round((createForm._renewYuan || 0) * 100),
base_price: Math.round((createForm._baseYuan || 0) * 100)
}
if (createForm.order_args) payload.order_args = createForm.order_args
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
const res = await createUserGoods(payload)
if (res?.data?.code === 200) { ElMessage.success('新增成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '新增失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '新增失败')) } finally { submitLoading.value = false }
})
const submitCreate = async () => {
try { await createFormRef.value?.validate() } catch { return }
submitLoading.value = true
try {
const payload = {
good_id: createForm.good_id,
user_id: createForm.user_id,
item_id: createForm.item_id || 0,
renew_price: Math.round((createForm._renewYuan || 0) * 100),
base_price: Math.round((createForm._baseYuan || 0) * 100),
note: createForm.note || ''
}
if (createForm.order_id) payload.order_id = createForm.order_id
if (createForm.good_plan_id) payload.good_plan_id = createForm.good_plan_id
if (createForm.order_args) payload.order_args = createForm.order_args
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
const res = await createUserGoods(payload)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
}
// ---- 编辑 ----
const editVisible = ref(false)
const editForm = reactive({ id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '' })
const editForm = reactive({
id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '',
item_id: 0, _itemName: '', _goodTag: '', _goodId: 0
})
const handleEdit = (row) => {
const goodTag = row.tag || row.good?.tag || ''
const goodId = row.good?.id || row.goodId || 0
const itemId = row.itemId || row.item_id || 0
Object.assign(editForm, {
id: row.id,
note: row.note || '',
_renewYuan: ((row.renewPrice || row.renew_price || 0) / 100),
_baseYuan: ((row.basePrice || row.base_price || 0) / 100),
expire_time: row.expireTime || row.expire_time || ''
expire_time: row.expireTime || row.expire_time || '',
item_id: itemId,
_goodTag: goodTag,
_goodId: goodId,
_itemName: itemId
? (goodTag === '云服务器' ? `虚拟机 #${itemId}` : `商品 #${itemId}`)
: ''
})
editVisible.value = true
}
const handleEditItemSelect = () => {
if (editForm._goodTag === '云服务器') {
vmItemTarget.value = 'edit'
showVmListDialog.value = true
loadVmListForItem()
} else {
editForm.item_id = editForm._goodId
editForm._itemName = `商品 #${editForm._goodId}`
ElMessage.success('已将商品ID赋值为归属项')
}
}
const submitEdit = async () => {
submitLoading.value = true
try {
const payload = {
id: editForm.id,
note: editForm.note,
item_id: editForm.item_id || 0,
renew_price: Math.round((editForm._renewYuan || 0) * 100),
base_price: Math.round((editForm._baseYuan || 0) * 100)
}
@@ -515,7 +637,9 @@ const submitEdit = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { submitLoading.value = false }
}
// ---- 删除 ----
// ---- 详情 / 删除 ----
const handleDetail = (row) => { router.push({ name: 'UserGoodsDetail', params: { id: row.id } }) }
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
.then(async () => {
@@ -531,11 +655,200 @@ onMounted(loadList)
</script>
<style scoped>
.user-goods-list { padding: 20px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.selector-row { display: flex; align-items: center; width: 100%; }
.user-goods-list {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
:deep(.el-card__body) {
padding: 0;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 0;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 0;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
}
.table-section {
padding: 0;
}
:deep(.el-table) {
border: none;
color: #2c3e50;
}
: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;
}
.user-cell {
display: flex;
align-items: center;
gap: 4px;
}
.user-name {
font-weight: 500;
color: #303133;
}
.user-id {
color: #909399;
font-size: 12px;
}
.text-muted {
color: #c0c4cc;
font-size: 12px;
}
.price-text {
color: #e74c3c;
font-weight: 600;
font-size: 14px;
}
.expire-permanent {
color: #67c23a;
font-weight: 500;
}
.action-buttons {
display: flex;
gap: 4px;
align-items: center;
flex-wrap: nowrap;
}
.action-buttons .el-button {
padding: 4px 8px;
}
.pagination {
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;
}
.selector-row {
display: flex;
align-items: center;
width: 100%;
}
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.form-hint.disabled {
color: #c0c4cc;
}
.args-spec-item {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.args-spec-item:last-child {
border-bottom: none;
}
.args-spec-label {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 8px;
}
/* 骨架屏 */
.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;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
:global(.scrollable-dialog .el-dialog__body) {
max-height: 70vh;
@@ -543,7 +856,30 @@ onMounted(loadList)
overflow-x: hidden;
scrollbar-width: none;
}
:global(.scrollable-dialog .el-dialog__body::-webkit-scrollbar) {
display: none;
}
@media (max-width: 768px) {
.filter-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.search-form {
flex-direction: column;
width: 100%;
}
.search-form :deep(.el-form-item) {
width: 100%;
}
.action-bar {
width: 100%;
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,204 @@
<template>
<div>
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="tagQueryParams" class="search-form">
<el-form-item label="关键词">
<el-input v-model="tagQueryParams.key" placeholder="搜索标签名称" clearable style="width: 180px" @keyup.enter="fetchTagList" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchTagList">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetTagQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAddTag">
<el-icon><Plus /></el-icon>新增标签
</el-button>
<el-button type="success" @click="fetchTagList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<div class="table-section">
<el-table
v-loading="tagLoading"
:data="tagList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="标签ID" width="100" />
<el-table-column prop="name" label="标签名称" min-width="200" />
<el-table-column prop="index" label="排序" width="100" sortable />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEditTag(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteTag(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无标签数据" :image-size="80" />
</template>
</el-table>
<el-pagination
v-model:current-page="tagQueryParams.page"
v-model:page-size="tagQueryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="tagTotal"
@size-change="handleTagSizeChange"
@current-change="handleTagCurrentChange"
background
class="pagination"
/>
</div>
<!-- 分组标签表单对话框 -->
<el-dialog
v-model="tagDialogVisible"
:title="tagDialogType === 'add' ? '新增分组标签' : '编辑分组标签'"
width="500px"
append-to-body
>
<el-form
ref="tagFormRef"
:model="tagForm"
:rules="tagRules"
label-width="100px"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="tagForm.name" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="排序索引" prop="index">
<el-input-number v-model="tagForm.index" :min="0" :max="9999" placeholder="数值越小越靠前" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="tagDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitTagForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
getProductGroupTagList,
createProductGroupTag,
updateProductGroupTag,
deleteProductGroupTag
} from '@/api/admin/product'
const tagQueryParams = reactive({ page: 1, count: 10, key: '' })
const tagLoading = ref(false)
const tagList = ref([])
const tagTotal = ref(0)
const tagDialogVisible = ref(false)
const tagDialogType = ref('add')
const tagFormRef = ref(null)
const tagForm = reactive({ id: undefined, name: '', index: 0 })
const tagRules = { name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }] }
const fetchTagList = async () => {
tagLoading.value = true
try {
const params = { ...tagQueryParams }
if (!params.key) delete params.key
const res = await getProductGroupTagList(params)
if (res.data.code === 200) {
const data = res.data.data
if (Array.isArray(data)) {
tagList.value = data
tagTotal.value = data.length
} else if (data && data.list) {
tagList.value = data.list
tagTotal.value = data.all_count || data.total || data.list.length
} else if (data && data.data) {
tagList.value = data.data
tagTotal.value = data.total || data.data.length
} else {
tagList.value = []
tagTotal.value = 0
}
}
} catch (error) {
console.error('获取标签列表失败:', error)
ElMessage.error('获取标签列表失败')
} finally {
tagLoading.value = false
}
}
const resetTagQuery = () => { tagQueryParams.key = ''; tagQueryParams.page = 1; fetchTagList() }
const handleTagSizeChange = (size) => { tagQueryParams.count = size; fetchTagList() }
const handleTagCurrentChange = (page) => { tagQueryParams.page = page; fetchTagList() }
const handleAddTag = () => {
tagDialogType.value = 'add'
tagDialogVisible.value = true
Object.assign(tagForm, { id: undefined, name: '', index: 0 })
tagFormRef.value?.resetFields()
}
const handleEditTag = (row) => {
tagDialogType.value = 'edit'
tagDialogVisible.value = true
Object.assign(tagForm, { id: row.id, name: row.name, index: row.index || 0 })
}
const handleDeleteTag = (row) => {
ElMessageBox.confirm(`确认删除标签 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductGroupTag({ id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTagList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const submitTagForm = () => {
tagFormRef.value?.validate(async (valid) => {
if (valid) {
try {
let res
if (tagDialogType.value === 'add') {
res = await createProductGroupTag({ name: tagForm.name, index: tagForm.index })
} else {
res = await updateProductGroupTag({ id: tagForm.id, name: tagForm.name, index: tagForm.index })
}
if (res.data.code === 200) {
ElMessage.success(tagDialogType.value === 'add' ? '新增成功' : '修改成功')
tagDialogVisible.value = false
fetchTagList()
}
} catch (error) { ElMessage.error('操作失败') }
}
})
}
defineExpose({ fetchTagList })
</script>
<style scoped>
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
.filter-content { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
.search-form { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0; }
.search-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 0; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.table-section { padding: 0; }
.action-buttons { display: flex; gap: 4px; align-items: center; flex-wrap: nowrap; }
.action-buttons .el-button { padding: 4px 8px; }
.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; }
</style>
@@ -0,0 +1,402 @@
<template>
<!-- 商品参数管理主对话框 -->
<el-dialog
:model-value="visible"
title="商品参数管理"
width="900px"
@update:model-value="$emit('update:visible', $event)"
>
<div class="filter-section" style="border: none; padding: 0 0 16px 0;">
<div class="action-bar">
<el-button type="primary" @click="handleAddParameter">
<el-icon><Plus /></el-icon>新增参数
</el-button>
<el-button type="success" @click="fetchParameterList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<el-table
v-loading="paramLoading"
:data="parameterList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="参数ID" width="80" />
<el-table-column prop="name" label="参数名称" min-width="120" />
<el-table-column prop="type" label="参数类型" width="100">
<template #default="{ row }">
<el-tag :type="getArgTypeTag(row.type)">{{ getArgTypeText(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="数值配置" min-width="180">
<template #default="{ row }">
<template v-if="row.type === 'number'">
<span class="number-config">步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}</span>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEditParameter(row)">编辑</el-button>
<el-button type="success" link @click="handleViewParamValues(row)">查看参数值</el-button>
<el-button type="danger" link @click="handleDeleteParameter(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无参数数据" :image-size="80" />
</template>
</el-table>
</el-dialog>
<!-- 商品参数表单对话框 -->
<el-dialog
v-model="paramFormDialogVisible"
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
width="600px"
append-to-body
>
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="120px">
<el-form-item label="参数名称" prop="arg_name">
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
</el-form-item>
<el-form-item label="参数类型" prop="arg_type">
<el-radio-group v-model="paramForm.arg_type">
<el-radio label="string">字符串</el-radio>
<el-radio label="number">数字</el-radio>
<el-radio label="select">选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否必选" prop="must">
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
</el-form-item>
<el-divider content-position="left">权限控制</el-divider>
<el-form-item label="允许单独购买">
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
</el-form-item>
<el-form-item label="用户组优惠">
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
</el-form-item>
<el-form-item label="用户优惠">
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠代金券与优惠码</div>
</el-form-item>
<template v-if="paramForm.arg_type === 'number'">
<el-divider content-position="left">数值参数配置</el-divider>
<el-form-item label="步进值" prop="arg_step">
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
</el-form-item>
<el-form-item label="最小值" prop="arg_min">
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
</el-form-item>
<el-form-item label="最大值" prop="arg_max">
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="paramFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 参数值管理对话框 -->
<el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body>
<div class="values-header">
<span>参数{{ currentParam?.name }}</span>
<el-button type="primary" @click="handleAddParamValue">
<el-icon><Plus /></el-icon>添加参数值
</el-button>
</div>
<el-table
v-loading="paramValuesLoading"
:data="paramValueList"
style="width: 100%; margin-top: 20px"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="值ID" width="80" />
<el-table-column prop="name" label="值名称" min-width="120" />
<el-table-column label="值/范围" min-width="150">
<template #default="{ row }">
<template v-if="currentParam?.type === 'select'">{{ row.value || '-' }}</template>
<template v-else-if="currentParam?.type === 'number'">
<el-tag size="small" type="info">{{ getRangeTypeText(row.rangeType) }} {{ row.range }}</el-tag>
</template>
<template v-else>{{ row.value || '-' }}</template>
</template>
</el-table-column>
<el-table-column prop="index" label="排序" width="80" />
<el-table-column label="价格" width="100">
<template #default="{ row }">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEditParamValue(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteParamValue(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无参数值" :image-size="60" />
</template>
</el-table>
</el-dialog>
<!-- 参数值表单对话框 -->
<el-dialog
v-model="paramValueFormDialogVisible"
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
width="550px"
append-to-body
>
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="120px">
<el-form-item label="值名称" prop="attr_name">
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
</el-form-item>
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
</el-form-item>
<template v-if="currentParam?.type === 'number'">
<el-divider content-position="left">数值范围配置phase</el-divider>
<el-form-item label="范围类型" prop="range_type">
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
<el-option label="小于等于 (before)" value="before" />
<el-option label="大于等于 (after)" value="after" />
<el-option label="等于 (equal)" value="equal" />
</el-select>
<div class="form-tip">before: 数值 phase 时匹配 | after: 数值 phase 时匹配</div>
</el-form-item>
<el-form-item label="阈值" prop="attr_range">
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
</el-form-item>
</template>
<el-form-item label="排序索引" prop="index">
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
</el-form-item>
<el-form-item label="价格" prop="attr_price">
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import {
getProductParameterList,
getProductParameterDetail,
createProductParameter,
updateProductParameter,
deleteProductParameter,
addProductParameterValue,
updateProductParameterValue,
deleteProductParameterValue
} from '@/api/admin/product'
const props = defineProps({
visible: { type: Boolean, default: false },
goodId: { type: Number, default: null }
})
const emit = defineEmits(['update:visible'])
const paramLoading = ref(false)
const parameterList = ref([])
const paramFormDialogVisible = ref(false)
const paramFormType = ref('add')
const paramFormRef = ref(null)
const paramForm = reactive({
arg_id: undefined,
arg_name: '',
arg_type: 'string',
must: false,
arg_step: 1,
arg_min: 0,
arg_max: 100,
user_add: false,
use_user_group_discount: false,
use_user_discount: false
})
const paramRules = {
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
arg_type: [{ required: true, message: '请选择参数类型', trigger: 'change' }]
}
const paramValuesDialogVisible = ref(false)
const paramValuesLoading = ref(false)
const paramValueList = ref([])
const currentParam = ref(null)
const paramValueFormDialogVisible = ref(false)
const paramValueFormType = ref('add')
const paramValueFormRef = ref(null)
const paramValueForm = reactive({
attr_id: undefined,
attr_name: '',
attr_value: '',
attr_price: 0,
index: 0,
attr_range: 0,
range_type: 'equal'
})
const paramValueRules = {
attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }]
}
const getArgTypeText = (type) => {
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
return typeMap[type] || '未知'
}
const getArgTypeTag = (type) => {
const tagMap = { 'string': 'primary', 'number': 'success', 'select': 'warning' }
return tagMap[type] || 'info'
}
const getRangeTypeText = (type) => {
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
return typeMap[type] || type || '-'
}
const fetchParameterList = async () => {
if (!props.goodId) return
paramLoading.value = true
try {
const res = await getProductParameterList({ good_id: props.goodId })
if (res.data.code === 200) {
parameterList.value = res.data.data || []
}
} catch (error) {
ElMessage.error('获取参数列表失败')
} finally {
paramLoading.value = false
}
}
const handleAddParameter = () => {
paramFormType.value = 'add'
paramFormDialogVisible.value = true
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100, user_add: false, use_user_group_discount: false, use_user_discount: false })
nextTick(() => { paramFormRef.value?.resetFields() })
}
const handleEditParameter = (row) => {
paramFormType.value = 'edit'
paramFormDialogVisible.value = true
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100, user_add: row.userAdd ?? row.user_add ?? false, use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false, use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false })
}
const handleDeleteParameter = (row) => {
ElMessageBox.confirm(`确认删除参数 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductParameter({ good_id: props.goodId, arg_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchParameterList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const submitParamForm = () => {
paramFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = { good_id: Number(props.goodId), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true, user_add: paramForm.user_add === true, use_user_group_discount: paramForm.use_user_group_discount === true, use_user_discount: paramForm.use_user_discount === true }
if (paramForm.arg_type === 'number') {
submitData.arg_step = Number(paramForm.arg_step)
submitData.arg_min = Number(paramForm.arg_min)
submitData.arg_max = Number(paramForm.arg_max)
}
if (paramFormType.value === 'edit') submitData.arg_id = paramForm.arg_id
const res = paramFormType.value === 'add' ? await createProductParameter(submitData) : await updateProductParameter(submitData)
if (res.data.code === 200) { ElMessage.success(paramFormType.value === 'add' ? '新增成功' : '修改成功'); paramFormDialogVisible.value = false; fetchParameterList() }
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
}
})
}
const handleViewParamValues = (row) => {
currentParam.value = row
paramValuesDialogVisible.value = true
fetchParamValuesList()
}
const fetchParamValuesList = async () => {
if (!props.goodId || !currentParam.value) return
paramValuesLoading.value = true
try {
const res = await getProductParameterDetail({ good_id: props.goodId, arg_id: currentParam.value.id })
if (res.data.code === 200) paramValueList.value = res.data.data.attrs || []
} catch (error) { ElMessage.error('获取参数值列表失败') }
finally { paramValuesLoading.value = false }
}
const handleAddParamValue = () => {
paramValueFormType.value = 'add'
paramValueFormDialogVisible.value = true
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, range_type: 'equal' })
nextTick(() => { paramValueFormRef.value?.resetFields() })
}
const handleEditParamValue = (row) => {
paramValueFormType.value = 'edit'
paramValueFormDialogVisible.value = true
Object.assign(paramValueForm, { attr_id: row.id, attr_name: row.name, attr_value: row.value || '', attr_price: row.price / 100 || 0, index: row.index || 0, attr_range: row.phase || 0, range_type: row.rangeType || 'equal' })
}
const handleDeleteParamValue = (row) => {
ElMessageBox.confirm(`确认删除参数值 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductParameterValue({ good_id: props.goodId, attr_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchParamValuesList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const submitParamValueForm = () => {
paramValueFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = { good_id: Number(props.goodId), arg_id: Number(currentParam.value.id), attr_name: paramValueForm.attr_name, index: Number(paramValueForm.index), attr_price: Number(paramValueForm.attr_price) }
if (currentParam.value.type === 'select') submitData.attr_value = paramValueForm.attr_value
if (currentParam.value.type === 'number') { submitData.attr_range = Number(paramValueForm.attr_range); submitData.range_type = paramValueForm.range_type }
if (paramValueFormType.value === 'edit') submitData.attr_id = paramValueForm.attr_id
const res = paramValueFormType.value === 'add' ? await addProductParameterValue(submitData) : await updateProductParameterValue(submitData)
if (res.data.code === 200) { ElMessage.success(paramValueFormType.value === 'add' ? '添加成功' : '修改成功'); paramValueFormDialogVisible.value = false; fetchParamValuesList() }
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
}
})
}
watch(() => props.visible, (val) => {
if (val && props.goodId) fetchParameterList()
})
</script>
<style scoped>
.filter-section { padding: 0; background: transparent; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.action-buttons { display: flex; gap: 4px; align-items: center; flex-wrap: nowrap; }
.action-buttons .el-button { padding: 4px 8px; }
.text-muted { color: #c0c4cc; font-size: 12px; }
.number-config { color: #909399; font-size: 13px; }
.values-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,512 @@
<template>
<!-- 商品套餐管理主对话框 -->
<el-dialog
:model-value="visible"
:title="`商品套餐管理 - ${goodName}`"
width="900px"
append-to-body
@update:model-value="$emit('update:visible', $event)"
>
<div class="plan-management">
<div class="plan-header">
<el-button type="primary" @click="handleAddPlan">
<el-icon><Plus /></el-icon>新增套餐
</el-button>
<el-button type="success" @click="fetchPlanList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table
v-loading="planLoading"
:data="planList"
style="width: 100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" min-width="120" />
<el-table-column label="参数配置" min-width="200">
<template #default="{ row }">
<div v-if="row.argsParsed && row.argsParsed.length > 0" class="args-list">
<el-tag v-for="(arg, index) in row.argsParsed" :key="index" size="small" type="info" style="margin-right: 4px; margin-bottom: 4px;">
{{ arg.name || arg.value || `参数${arg.arg_id}` }}
</el-tag>
</div>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="150" show-overflow-tooltip />
<el-table-column prop="index" label="排序" width="70" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">{{ row.disable ? '禁用' : '启用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="首页展示" width="90">
<template #default="{ row }">
<el-tag :type="row.showHome || row.show_home ? 'success' : 'info'" size="small">
{{ row.showHome || row.show_home ? '展示' : '不展示' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
<el-button :type="row.disable ? 'success' : 'warning'" link @click="handleTogglePlanStatus(row)">
{{ row.disable ? '启用' : '禁用' }}
</el-button>
<el-button type="danger" link @click="handleDeletePlan(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无套餐数据" :image-size="80" />
</template>
</el-table>
</div>
</el-dialog>
<!-- 套餐表单对话框 -->
<el-dialog
v-model="planFormDialogVisible"
:title="planFormType === 'add' ? '新增套餐' : '编辑套餐'"
width="700px"
append-to-body
class="plan-form-dialog"
>
<div class="plan-form-content">
<el-form ref="planFormRef" :model="planForm" :rules="planFormRules" label-width="100px">
<el-form-item label="套餐名称" prop="name">
<el-input v-model="planForm.name" placeholder="请输入套餐名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
</el-form-item>
<el-form-item label="参数配置" prop="args">
<div class="args-config-container">
<div class="args-select-row">
<el-select v-model="selectedArgIds" multiple placeholder="请选择需要配置的参数" style="width: 100%" @change="onSelectedArgsChange">
<el-option v-for="spec in planSpecList" :key="spec.id" :label="spec.name" :value="spec.id" />
</el-select>
</div>
<div v-if="selectedArgSpecs.length > 0" class="args-selector">
<div v-for="spec in selectedArgSpecs" :key="spec.id" class="spec-item">
<div class="spec-label">{{ spec.name }}</div>
<div class="spec-values">
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
<el-radio-group v-model="selectedArgs[spec.id]" size="small" @change="updateArgsJson">
<el-radio-button v-for="attr in spec.attrs" :key="attr.id" :value="attr.id">{{ attr.name }}</el-radio-button>
</el-radio-group>
</template>
<template v-else-if="spec.type === 'number'">
<div class="number-input-wrapper">
<el-input-number v-model="selectedArgs[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" size="small" @change="updateArgsJson" />
<span class="number-range">(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }}步长: {{ spec.step || 1 }})</span>
</div>
<div v-if="spec.attrs && spec.attrs.length > 0 && selectedArgs[spec.id]" class="matched-attr-info">
<el-tag type="success" size="small">匹配区间: {{ getMatchedAttrName(spec, selectedArgs[spec.id]) }}</el-tag>
</div>
</template>
<template v-else>
<el-input v-model="selectedArgs[spec.id]" placeholder="请输入值" size="small" style="width: 200px" @input="updateArgsJson" />
</template>
</div>
</div>
</div>
<el-empty v-else-if="planSpecList.length > 0" description="请先选择需要配置的参数" :image-size="60" />
<el-empty v-else description="暂无参数配置,请先为商品添加参数" :image-size="60" />
<div class="args-actions" v-if="selectedArgSpecs.length > 0">
<el-button type="info" plain size="small" @click="showArgsPreview = true">
<el-icon><View /></el-icon>查看配置JSON
</el-button>
<el-button type="warning" plain size="small" @click="clearArgsSelection">
<el-icon><Delete /></el-icon>清空选择
</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="额外参数">
<div class="args-config-container">
<div class="form-tip" style="margin-bottom: 8px;">选择参数配置中未选择的参数作为额外参数</div>
<el-select v-model="selectedExtraArgIds" multiple placeholder="请选择额外参数" style="width: 100%" @change="onSelectedExtraArgsChange">
<el-option v-for="spec in extraSpecList" :key="spec.id" :label="`${spec.name} (ID: ${spec.id})`" :value="spec.id" />
</el-select>
<el-empty v-if="extraSpecList.length === 0" description="所有参数已在参数配置中选择" :image-size="40" />
</div>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<el-input-number v-model="planForm.inventory" :min="0" style="width: 100%" placeholder="库存数量" />
<div class="form-tip">0 表示没有库存</div>
</el-form-item>
<el-form-item label="启用固定价格" prop="enable_fixed_price">
<el-switch v-model="planForm.enable_fixed_price" :active-value="true" :inactive-value="false" active-text="启用" inactive-text="禁用" :loading="fixedPriceLoading" @change="handleFixedPriceChange" />
<div class="form-tip">启用后套餐价格将使用固定价格不再根据参数计算</div>
</el-form-item>
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="width: 100%" placeholder="请输入固定价格(元)" />
</el-form-item>
<el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="disable" v-if="planFormType === 'edit'">
<el-radio-group v-model="planForm.disable">
<el-radio :value="false">启用</el-radio>
<el-radio :value="true">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="首页展示" prop="show_home">
<el-switch v-model="planForm.show_home" active-text="展示" inactive-text="不展示" />
<div class="form-tip">控制商品套餐是否在首页显示</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="planFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitPlanForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 参数配置预览对话框 -->
<el-dialog v-model="showArgsPreview" title="参数配置预览" width="500px" append-to-body>
<div class="args-preview">
<div class="preview-header">
<span>已选择 {{ Object.keys(selectedArgs).filter(k => selectedArgs[k] !== undefined && selectedArgs[k] !== '').length }} 个参数</span>
<el-tag :type="isArgsValid ? 'success' : 'warning'" size="small">{{ isArgsValid ? '配置有效' : '部分参数未选择' }}</el-tag>
</div>
<el-divider />
<div class="preview-list">
<div v-for="spec in planSpecList" :key="spec.id" class="preview-item">
<span class="preview-label">{{ spec.name }}:</span>
<span class="preview-value" :class="{ 'not-selected': !getSelectedValueDisplay(spec) }">{{ getSelectedValueDisplay(spec) || '未选择' }}</span>
</div>
</div>
<el-divider content-position="left">JSON 数据</el-divider>
<pre class="json-preview">{{ formatArgsJsonPreview() }}</pre>
</div>
<template #footer>
<el-button @click="showArgsPreview = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Delete, View } from '@element-plus/icons-vue'
import {
getProductParameterList,
getProductPlanList,
getProductPlanDetail,
createProductPlan,
updateProductPlan,
deleteProductPlan,
disableProductPlan,
enableProductPlan,
disablePlanFixedPrice,
enablePlanFixedPrice
} from '@/api/admin/product'
const props = defineProps({
visible: { type: Boolean, default: false },
goodId: { type: Number, default: null },
goodName: { type: String, default: '' }
})
const emit = defineEmits(['update:visible'])
const planLoading = ref(false)
const planList = ref([])
const fixedPriceLoading = ref(false)
const planFormDialogVisible = ref(false)
const planFormType = ref('add')
const planFormRef = ref(null)
const planForm = reactive({
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
})
const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
}
const planSpecList = ref([])
const selectedArgIds = ref([])
const selectedArgs = reactive({})
const showArgsPreview = ref(false)
const selectedExtraArgIds = ref([])
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
const parseArgs = (argsStr) => {
if (!argsStr) return []
try { const parsed = JSON.parse(argsStr); return Array.isArray(parsed) ? parsed : [] }
catch (e) { return [] }
}
const fetchPlanSpecList = async () => {
if (!props.goodId) return
try {
const res = await getProductParameterList({ good_id: props.goodId })
if (res.data.code === 200) planSpecList.value = res.data.data || []
} catch (error) { planSpecList.value = [] }
}
const onSelectedArgsChange = () => {
for (const key in selectedArgs) { if (!selectedArgIds.value.includes(Number(key))) delete selectedArgs[key] }
selectedExtraArgIds.value = selectedExtraArgIds.value.filter(id => !selectedArgIds.value.includes(id))
updateArgsJson()
updateExtraArgIds()
}
const onSelectedExtraArgsChange = () => { updateExtraArgIds() }
const updateExtraArgIds = () => { planForm.extra_arg_ids = selectedExtraArgIds.value.join(','); planForm.extra_arg_ids_array = [...selectedExtraArgIds.value] }
const updateArgsJson = () => {
const argsArray = []
for (const specId of selectedArgIds.value) {
const spec = planSpecList.value.find(s => s.id === specId)
if (!spec) continue
const selectedValue = selectedArgs[spec.id]
if (selectedValue === undefined || selectedValue === '') continue
if (spec.type === 'select') {
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
if (attrObj) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attrObj.id, value: attrObj.value || '' })
} else if (spec.type === 'number') {
const numValue = Number(selectedValue)
const matchedAttr = findMatchingNumberAttr(spec, numValue)
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: matchedAttr ? matchedAttr.id : 0, number: numValue })
} else {
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(selectedValue) })
}
}
planForm.args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
}
const findMatchingNumberAttr = (spec, numValue) => {
if (!spec.attrs || spec.attrs.length === 0) return null
const sortedAttrs = [...spec.attrs].sort((a, b) => (a.index || 0) - (b.index || 0))
for (const attr of sortedAttrs) {
const phase = attr.phase || 0
const rangeType = attr.rangeType || 'before'
if (rangeType === 'before' && numValue <= phase) return attr
else if (rangeType === 'after' && numValue >= phase) return attr
else if (rangeType === 'equal' && numValue === phase) return attr
}
return sortedAttrs[sortedAttrs.length - 1]
}
const getMatchedAttrName = (spec, numValue) => {
const matchedAttr = findMatchingNumberAttr(spec, Number(numValue))
if (matchedAttr) { const priceText = matchedAttr.price ? ` (+¥${(matchedAttr.price / 100).toFixed(2)})` : ''; return `${matchedAttr.name}${priceText}` }
return '无匹配区间'
}
const clearArgsSelection = () => {
selectedArgIds.value = []
for (const key in selectedArgs) delete selectedArgs[key]
selectedExtraArgIds.value = []
planForm.args = ''
planForm.extra_arg_ids = ''
planForm.extra_arg_ids_array = []
}
const getSelectedValueDisplay = (spec) => {
const selectedValue = selectedArgs[spec.id]
if (selectedValue === undefined || selectedValue === '') return null
if (spec.type === 'select') { const attrObj = spec.attrs?.find(a => a.id === selectedValue); return attrObj ? attrObj.name : null }
return String(selectedValue)
}
const isArgsValid = computed(() => Object.keys(selectedArgs).some(k => selectedArgs[k] !== undefined && selectedArgs[k] !== ''))
const formatArgsJsonPreview = () => {
if (!planForm.args) return '[]'
try { return JSON.stringify(JSON.parse(planForm.args), null, 2) } catch { return planForm.args }
}
const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
clearArgsSelection()
const argsParamIds = []
if (argsJson) {
try {
const argsArray = typeof argsJson === 'string' ? JSON.parse(argsJson) : argsJson
if (Array.isArray(argsArray)) {
for (const arg of argsArray) {
const spec = planSpecList.value.find(s => s.id === arg.arg_id)
if (!spec) continue
argsParamIds.push(spec.id)
if (spec.type === 'select') {
if (arg.attr_id) selectedArgs[spec.id] = arg.attr_id
else if (arg.id) selectedArgs[spec.id] = arg.id
else { const attrObj = spec.attrs?.find(a => a.value === arg.value || a.name === arg.name); if (attrObj) selectedArgs[spec.id] = attrObj.id }
} else if (spec.type === 'number') { selectedArgs[spec.id] = Number(arg.number !== undefined ? arg.number : arg.value) }
else { selectedArgs[spec.id] = arg.value }
}
}
} catch (e) { console.error('解析args失败:', e) }
}
selectedArgIds.value = argsParamIds
if (extraArgIds && extraArgIds.length > 0) selectedExtraArgIds.value = extraArgIds.filter(id => !argsParamIds.includes(id))
updateArgsJson()
updateExtraArgIds()
}
const fetchPlanList = async () => {
if (!props.goodId) return
planLoading.value = true
try {
const res = await getProductPlanList({ good_id: String(props.goodId) })
if (res.data.code === 200) {
let data = res.data.data
if (Array.isArray(data)) planList.value = data.map(item => ({ ...item, argsParsed: parseArgs(item.args) }))
else if (data && data.list) planList.value = data.list.map(item => ({ ...item, argsParsed: parseArgs(item.args) }))
else if (data && data.data) planList.value = data.data.map(item => ({ ...item, argsParsed: parseArgs(item.args) }))
else planList.value = []
}
} catch (error) { ElMessage.error('获取套餐列表失败') }
finally { planLoading.value = false }
}
const handleAddPlan = async () => {
planFormType.value = 'add'
await fetchPlanSpecList()
clearArgsSelection()
selectedArgIds.value = planSpecList.value.map(spec => spec.id)
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 })
planFormDialogVisible.value = true
nextTick(() => { planFormRef.value?.resetFields() })
}
const handleEditPlan = async (row) => {
planFormType.value = 'edit'
await fetchPlanSpecList()
try {
const res = await getProductPlanDetail({ good_id: String(props.goodId), plan_id: String(row.id) })
if (res.data.code === 200) {
const data = res.data.data
let extraArgIdsArray = []
const extraArgIdsRaw = data.extraArgIds || data.extra_arg_ids
const extraArgsRaw = data.extraArgs || data.extra_args
if (extraArgIdsRaw) {
if (Array.isArray(extraArgIdsRaw)) extraArgIdsArray = extraArgIdsRaw.map(Number)
else if (typeof extraArgIdsRaw === 'string' && extraArgIdsRaw.trim()) extraArgIdsArray = extraArgIdsRaw.split(',').filter(Boolean).map(Number)
} else if (extraArgsRaw && Array.isArray(extraArgsRaw)) {
extraArgIdsArray = extraArgsRaw.map(arg => arg.id || arg.arg_id || arg.argId).filter(Boolean).map(Number)
}
Object.assign(planForm, {
plan_id: data.id, name: data.name || '', note: data.note || '', args: data.args || '',
extra_arg_ids: extraArgIdsArray.join(','), extra_arg_ids_array: extraArgIdsArray,
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),
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home)
})
initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true
}
} catch (error) { ElMessage.error('获取套餐详情失败') }
}
const handleDeletePlan = (row) => {
ElMessageBox.confirm(`确认删除套餐 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductPlan({ good_id: String(props.goodId), plan_id: String(row.id) })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchPlanList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const handleTogglePlanStatus = async (row) => {
try {
const res = row.disable
? await enableProductPlan({ good_id: String(props.goodId), plan_id: String(row.id) })
: await disableProductPlan({ good_id: String(props.goodId), plan_id: String(row.id) })
if (res.data.code === 200) { ElMessage.success(row.disable ? '启用成功' : '禁用成功'); fetchPlanList() }
} catch (error) { ElMessage.error('操作失败') }
}
const handleFixedPriceChange = async (value) => {
if (planFormType.value !== 'edit' || !planForm.plan_id) return
fixedPriceLoading.value = true
try {
const data = { good_id: String(props.goodId), plan_id: String(planForm.plan_id) }
const res = value === true ? await enablePlanFixedPrice(data) : await disablePlanFixedPrice(data)
if (res.data.code === 200) ElMessage.success(value ? '已启用固定价格' : '已禁用固定价格')
else { planForm.enable_fixed_price = !value; ElMessage.error(res.data.message || '操作失败') }
} catch (error) { planForm.enable_fixed_price = !value; ElMessage.error('操作失败') }
finally { fixedPriceLoading.value = false }
}
const submitPlanForm = () => {
planFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const extraArgIdsStr = selectedExtraArgIds.value.join(',')
const submitData = {
good_id: String(props.goodId), name: planForm.name, note: planForm.note || '',
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,
index: Number(planForm.index) || 0, show_home: planForm.show_home === true
}
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res
if (planFormType.value === 'add') res = await createProductPlan(submitData)
else { submitData.plan_id = String(planForm.plan_id); submitData.disable = planForm.disable; res = await updateProductPlan(submitData) }
if (res.data.code === 200) { ElMessage.success(planFormType.value === 'add' ? '新增成功' : '修改成功'); planFormDialogVisible.value = false; fetchPlanList() }
else ElMessage.error(res.data.message || '操作失败')
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
}
})
}
watch(() => props.visible, (val) => {
if (val && props.goodId) fetchPlanList()
})
</script>
<style scoped>
.text-muted { color: #c0c4cc; font-size: 12px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
.plan-management { padding: 0; }
.plan-header { display: flex; gap: 12px; margin-bottom: 16px; }
.args-list { display: flex; flex-wrap: wrap; gap: 4px; }
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
.plan-form-content::-webkit-scrollbar { width: 6px; }
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
.plan-form-content::-webkit-scrollbar-thumb { background-color: transparent; border-radius: 3px; transition: background-color 0.3s; }
.plan-form-content:hover::-webkit-scrollbar-thumb { background-color: rgba(144, 147, 153, 0.3); }
.args-config-container { width: 100%; }
.args-select-row { margin-bottom: 12px; }
.args-selector { border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; background: #fafafa; max-height: 300px; overflow-y: auto; }
.spec-item { display: flex; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #e4e7ed; }
.spec-item:last-child { border-bottom: none; }
.spec-label { width: 100px; flex-shrink: 0; font-weight: 500; color: #606266; padding-top: 4px; }
.spec-values { flex: 1; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.spec-values :deep(.el-radio-group) { display: flex; flex-wrap: wrap; gap: 6px; }
.spec-values :deep(.el-radio-button__inner) { padding: 6px 12px; }
.number-input-wrapper { display: flex; align-items: center; gap: 8px; }
.number-range { color: #909399; font-size: 12px; }
.matched-attr-info { margin-top: 6px; }
.args-actions { margin-top: 12px; display: flex; gap: 8px; }
.args-preview { padding: 0; }
.preview-header { display: flex; justify-content: space-between; align-items: center; }
.preview-list { max-height: 200px; overflow-y: auto; }
.preview-item { display: flex; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.preview-item:last-child { border-bottom: none; }
.preview-label { width: 120px; flex-shrink: 0; color: #606266; font-weight: 500; }
.preview-value { color: #409eff; font-weight: 500; }
.preview-value.not-selected { color: #c0c4cc; font-style: italic; }
.json-preview { background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; color: #606266; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-all; margin: 0; }
</style>
+146 -91
View File
@@ -78,33 +78,36 @@
</div>
<!-- 新建虚拟机弹窗 -->
<el-dialog v-model="createVisible" title="新建用户虚拟机" width="940px" destroy-on-close class="scrollable-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px" v-loading="createLoading">
<el-row :gutter="16">
<el-dialog v-model="createVisible" title="新建用户虚拟机" width="900px" destroy-on-close class="scrollable-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px" v-loading="createLoading">
<!-- 行1: 商品 + 用户 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="createForm._goodName || (createForm.good_id ? `商品 #${createForm.good_id}` : '')" readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showProductSelector = true" style="margin-left:8px">选择</el-button>
<el-input :model-value="createForm._goodName || (createForm.good_id ? `商品 #${createForm.good_id}` : '')" readonly placeholder="请选择商品" />
<el-button type="primary" @click="showProductSelector = true">选择</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="createForm._userName || (createForm.user_id ? `用户 #${createForm.user_id}` : '')" readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left:8px">选择</el-button>
<el-input :model-value="createForm._userName || (createForm.user_id ? `用户 #${createForm.user_id}` : '')" readonly placeholder="请选择用户" />
<el-button type="primary" @click="showUserSelector = true">选择</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行2: 虚拟机名称 -->
<el-form-item label="虚拟机名称" prop="name">
<el-input v-model="createForm.name" placeholder="虚拟机名称" />
</el-form-item>
<el-row :gutter="16">
<!-- 行3: 内存 + vCPU -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="内存(MB)" prop="memory">
<el-input-number v-model="createForm._memoryMB" :min="0" controls-position="right" style="width:100%" placeholder="MB" />
<el-input-number v-model="createForm._memoryMB" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -113,95 +116,136 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<!-- 行4: 系统盘 + 主控服务 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="系统盘(GB)" prop="system_size">
<el-input-number v-model="createForm.system_size" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="镜像ID" prop="image_id">
<div style="display:flex;flex-direction:column;gap:6px;width:100%">
<div class="selector-row">
<el-input :model-value="createForm._serviceName || (createForm._serviceId ? `主控 #${createForm._serviceId}` : '')"
readonly placeholder="1. 选择主控服务" style="flex:1" />
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm._serviceId" @click="createForm._serviceId = 0; createForm._serviceName = ''; createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
<div class="selector-row">
<el-input :model-value="createForm._imageName || (createForm.image_id ? `镜像 #${createForm.image_id}` : '')"
readonly placeholder="2. 选择镜像" style="flex:1" />
<el-button type="primary" @click="showImageSelector = true" :disabled="!createForm._serviceId" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
<el-form-item label="主控服务">
<div class="selector-row">
<el-input :model-value="createForm._serviceName || (createForm._serviceId ? `主控 #${createForm._serviceId}` : '')" readonly placeholder="选择主控服务" />
<el-button type="primary" @click="showServiceSelector = true">选择</el-button>
<el-button v-if="createForm._serviceId" @click="createForm._serviceId = 0; createForm._serviceName = ''; createForm.image_id = 0; createForm._imageName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<!-- 行5: 下行带宽 + 镜像 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)" width="220px">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)" width="220px">
<el-form-item label="镜像" prop="image_id">
<div class="selector-row">
<el-input :model-value="createForm._imageName || (createForm.image_id ? `镜像 #${createForm.image_id}` : '')" readonly placeholder="选择镜像" />
<el-button type="primary" @click="showImageSelector = true" :disabled="!createForm._serviceId">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行6: 上行带宽 + IPv4 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="IPv4数量" label-width="90px">
<el-form-item label="IPv4数量">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 行7: IPv6 + 快照上限 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="IPv6数量" label-width="90px">
<el-form-item label="IPv6数量">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="快照上限">
<el-input-number v-model="createForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 行8: 备份上限 + 续费价格 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="备份上限">
<el-input-number v-model="createForm.backup_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="续费价格()">
<el-input-number v-model="createForm._renewPriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 行9: 基础价格 + 订单 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="基础价格()">
<el-input-number v-model="createForm._basePriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="订单">
<div class="selector-row">
<el-input :model-value="createForm._orderName || (createForm.order_id ? `订单 #${createForm.order_id}` : '')" readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showOrderSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''" style="margin-left:4px">清除</el-button>
<el-input :model-value="createForm._orderName || (createForm.order_id ? `订单 #${createForm.order_id}` : '')" readonly placeholder="可选" />
<el-button type="primary" @click="showOrderSelector = true">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行10: 到期时间 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="到期时间">
<el-date-picker v-model="createForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="指定宿主机">
<div class="selector-row">
<el-input :model-value="createForm._hostName || (createForm.host_id ? `宿主机 #${createForm.host_id}` : '')" readonly placeholder="可选不选则自动分配" />
<el-button type="primary" @click="showHostSelector = true" :disabled="!createForm._serviceId">选择</el-button>
<el-button v-if="createForm.host_id" @click="createForm.host_id = 0; createForm._hostName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行11: 宿主机组 + 网络 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指定宿主机组">
<div class="selector-row">
<el-input :model-value="createForm._hostGroupName || (createForm.host_group_id ? `主机组 #${createForm.host_group_id}` : '')" readonly placeholder="可选不选则用商品绑定的" />
<el-button type="primary" @click="showHostGroupSelector = true" :disabled="!createForm._serviceId">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._hostGroupName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="网络">
<div class="selector-row">
<el-input :model-value="createForm._networkNames || ''" readonly placeholder="可选可多选" />
<el-button type="primary" @click="showNetworkSelector = true" :disabled="!createForm._serviceId || !createForm.host_id">选择</el-button>
<el-button v-if="createForm.network_ids.length" @click="createForm.network_ids = []; createForm._networkNames = ''">清除</el-button>
</div>
<div v-if="!createForm.host_id && createForm._serviceId" style="font-size:12px;color:#c0c4cc;margin-top:2px">请先选择宿主机</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行12: 备注 -->
<el-form-item label="备注">
<el-input v-model="createForm.note" type="textarea" :rows="2" />
</el-form-item>
@@ -212,7 +256,7 @@
</template>
</el-dialog>
<!-- 编辑虚拟机弹窗(对接 /user_vm/update -->
<!-- 编辑虚拟机弹窗 -->
<el-dialog v-model="editVisible" title="编辑虚拟机配置" width="560px" destroy-on-close class="scrollable-dialog">
<el-form :model="editForm" label-width="130px" v-loading="editLoading">
<el-row :gutter="16">
@@ -251,17 +295,15 @@
</el-row>
<el-form-item label="安全组">
<div class="selector-row">
<el-input :model-value="editForm._sgName || (editForm.port_group_id ? `安全组 #${editForm.port_group_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-input :model-value="editForm._sgName || (editForm.port_group_id ? `安全组 #${editForm.port_group_id}` : '')" readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showSgSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.port_group_id" @click="editForm.port_group_id = 0; editForm._sgName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="公网网络">
<div class="selector-row">
<el-input :model-value="editForm._networkName || (editForm.internet_network_id ? `网络 #${editForm.internet_network_id}` : '')"
readonly placeholder="可选仅网桥类型" style="flex:1" />
<el-button type="primary" @click="showNetworkSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-input :model-value="editForm._networkName || (editForm.internet_network_id ? `网络 #${editForm.internet_network_id}` : '')" readonly placeholder="可选仅网桥类型" style="flex:1" />
<el-button type="primary" @click="showEditNetworkSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.internet_network_id" @click="editForm.internet_network_id = 0; editForm._networkName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
@@ -272,22 +314,20 @@
</template>
</el-dialog>
<!-- 商品选择器 -->
<!-- 选择器组件 -->
<ProductSelector v-model="showProductSelector" @confirm="p => { createForm.good_id = p.id; createForm._goodName = p.name }" />
<!-- 用户选择器 -->
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
<!-- 订单选择器 -->
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<!-- 主控服务选择器(镜像用) -->
<KvmServiceSelector v-model="showServiceSelector" @confirm="s => { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = '' }" />
<!-- 镜像选择器 -->
<KvmServiceSelector v-model="showServiceSelector" @confirm="s => { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = ''; createForm.host_id = 0; createForm._hostName = ''; createForm.host_group_id = 0; createForm._hostGroupName = ''; createForm.network_ids = []; createForm._networkNames = '' }" />
<ImageSelectorPopup v-model="showImageSelector" :service-id="createForm._serviceId || 0" @confirm="img => { createForm.image_id = img.id; createForm._imageName = img.name }" />
<!-- 编辑用安全组选择器 -->
<UserVmSecurityGroupSelector v-model="showSgSelector" :user-goods-id="editForm.id"
@confirm="sg => { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" />
<!-- 编辑用公网网络选择器 -->
<UserVmNetworkSelector v-model="showNetworkSelector" :user-goods-id="editForm.id"
@confirm="net => { editForm.internet_network_id = net.id; editForm._networkName = net.name }" />
<!-- 宿主机选择器 -->
<HostSelectorPopup v-model="showHostSelector" :service-id="createForm._serviceId || 0" @confirm="h => { createForm.host_id = h.id; createForm._hostName = h.name || h.ip; createForm.network_ids = []; createForm._networkNames = '' }" />
<!-- 宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="createForm._serviceId || 0" @confirm="g => { createForm.host_group_id = g.id; createForm._hostGroupName = g.name }" />
<!-- 网络选择器(多选用弹窗内表格) -->
<NetworkSelectorPopup v-model="showNetworkSelector" :service-id="createForm._serviceId || 0" :host-id="createForm.host_id || 0" @confirm="n => addNetwork(n)" />
<UserVmSecurityGroupSelector v-model="showSgSelector" :user-goods-id="editForm.id" @confirm="sg => { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" />
<UserVmNetworkSelector v-model="showEditNetworkSelector" :user-goods-id="editForm.id" @confirm="net => { editForm.internet_network_id = net.id; editForm._networkName = net.name }" />
</div>
</template>
@@ -304,6 +344,9 @@ import UserSelector from '@/components/UserSelector/index.vue'
import OrderSelector from '@/components/admin/OrderSelector.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import HostSelectorPopup from '@/components/admin/HostSelectorPopup.vue'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import dayjs from 'dayjs'
@@ -314,7 +357,6 @@ const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', bound: null })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
@@ -352,28 +394,41 @@ const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showServiceSelector = ref(false)
const showImageSelector = ref(false)
const showHostSelector = ref(false)
const showHostGroupSelector = ref(false)
const showNetworkSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '',
name: '',
_memoryMB: 0,
vcpu: 0, system_size: 0,
order_id: 0, _orderName: '', name: '',
_memoryMB: 0, vcpu: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '',
network_ids: [], _networkNames: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0,
note: '', expire_time: ''
})
const createRules = {
good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }],
user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }],
vcpu: [{ required: true, message: '请填写vCPU', trigger: 'blur' }],
system_size: [{ required: true, message: '请填写系统盘大小', trigger: 'blur' }],
image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写镜像ID')), trigger: 'blur' }]
image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择镜像')), trigger: 'blur' }]
}
const handleCreate = () => {
Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '', _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' })
Object.assign(createForm, {
good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '',
_memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0,
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '',
network_ids: [], _networkNames: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: ''
})
createVisible.value = true
}
@@ -386,7 +441,7 @@ const submitCreate = () => {
good_id: createForm.good_id,
user_id: createForm.user_id,
name: createForm.name,
memory: Math.round((createForm._memoryMB || 0) * 1024), // MB → KB
memory: Math.round((createForm._memoryMB || 0) * 1024),
vcpu: createForm.vcpu,
system_size: createForm.system_size,
rx_bandwidth: createForm.rx_bandwidth,
@@ -396,12 +451,15 @@ const submitCreate = () => {
ipv6_num: createForm.ipv6_num,
snapshot_num: createForm.snapshot_num,
backup_num: createForm.backup_num,
renew_price: Math.round(createForm._renewPriceYuan || 0 ),
base_price: Math.round(createForm._basePriceYuan || 0 ),
renew_price: Math.round((createForm._renewPriceYuan || 0) * 100),
base_price: Math.round((createForm._basePriceYuan || 0) * 100),
note: createForm.note
}
if (createForm.order_id) payload.order_id = createForm.order_id
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
if (createForm.host_id) payload.host_id = createForm.host_id
if (createForm.host_group_id) payload.host_group_id = createForm.host_group_id
if (createForm.network_ids.length) payload.network_ids = createForm.network_ids
const res = await createUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
@@ -409,28 +467,32 @@ const submitCreate = () => {
})
}
// 网络多选:每次选择追加(不重复)
const addNetwork = (n) => {
if (!createForm.network_ids.includes(n.id)) {
createForm.network_ids.push(n.id)
const names = createForm._networkNames ? createForm._networkNames + ', ' + (n.name || n.address || `#${n.id}`) : (n.name || n.address || `#${n.id}`)
createForm._networkNames = names
}
}
// ---- 编辑 ----
const editVisible = ref(false)
const editLoading = ref(false)
const showSgSelector = ref(false)
const showNetworkSelector = ref(false)
const showEditNetworkSelector = ref(false)
const editForm = reactive({
id: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
id: 0, rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
})
const handleEdit = async (row) => {
// 先重置
Object.assign(editForm, {
id: row.id,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
id: row.id, rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
@@ -449,20 +511,12 @@ const handleEdit = async (row) => {
editForm.snapshot_num = vm.snapshot_num || 0
editForm.backup_num = vm.backup_num || 0
}
// 回填入站安全组
const inSg = d.vm?.in_port_group
if (inSg) {
editForm.port_group_id = inSg.id
editForm._sgName = inSg.name
}
// 回填公网网络(取第一个 bridge 类型)
if (inSg) { editForm.port_group_id = inSg.id; editForm._sgName = inSg.name }
const bridgeNet = (d.vm?.networks || []).find(n => n.type === 'bridge')
if (bridgeNet) {
editForm.internet_network_id = bridgeNet.id
editForm._networkName = bridgeNet.name || bridgeNet.address
}
if (bridgeNet) { editForm.internet_network_id = bridgeNet.id; editForm._networkName = bridgeNet.name || bridgeNet.address }
}
} catch { /* 回填失败不影响编辑 */ } finally { editLoading.value = false }
} catch { } finally { editLoading.value = false }
}
const submitEdit = async () => {
@@ -485,7 +539,7 @@ const submitEdit = async () => {
// ---- 删除 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!`, '删除确认', { type: 'error' })
ElMessageBox.confirm('确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!', '删除确认', { type: 'error' })
.then(async () => {
try {
const res = await deleteUserVm({ user_goods_id: row.id })
@@ -503,7 +557,8 @@ onMounted(loadList)
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.selector-row { display: flex; align-items: center; width: 100%; }
.selector-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.selector-row .el-input { flex: 1; }
:global(.scrollable-dialog .el-dialog__body) {
max-height: 65vh;
@@ -279,8 +279,8 @@ const submitEdit = () => {
fd.append('id', sgId.value)
fd.append('name', editForm.name)
fd.append('direction', editForm.direction)
fd.append('lock', editForm.lock)
fd.append('drop_all', editForm.drop_all)
fd.append('lock', editForm.lock ? 'true' : 'false')
fd.append('drop_all', editForm.drop_all ? 'true' : 'false')
const res = await updateSecurityGroup(fd)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
@@ -362,7 +362,7 @@ const handleSetShared = (shared) => {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', sgId.value)
fd.append('shared', shared)
fd.append('shared', shared ? 'true' : 'false')
const res = await setSecurityGroupShared(fd)
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadDetail() }
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
+2 -2
View File
@@ -2306,8 +2306,8 @@ const submitSgCreate = () => {
fd.append('name', sgCreateForm.name)
fd.append('host_id', sgCreateForm.host_id)
fd.append('direction', sgCreateForm.direction)
if (sgCreateForm.lock) fd.append('lock', true)
if (sgCreateForm.drop_all) fd.append('drop_all', true)
fd.append('lock', sgCreateForm.lock ? 'true' : 'false')
fd.append('drop_all', sgCreateForm.drop_all ? 'true' : 'false')
const res = await createSecurityGroup(fd)
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
+2 -2
View File
@@ -26,9 +26,9 @@
> **Prompt:**
> 基于上一步的分析结果,我们需要进行代码落地。请遵循以下工程化标准:
> 1. **请求实现:** 按照我现有项目的请求风格(例如 `axios` + `ts-interface`,补全缺失的接口请求函数。
> 1. **请求实现:** 按照我现有项目的请求风格,补全缺失的接口请求函数。
> 2. **组件化拆分:** 在实现业务页面时,请评估哪些逻辑可以抽离为公共组件(例如:商品详情预览框、批量操作栏、规格选择器)。如果某个功能在多个页面有重复逻辑,请将其提取为独立的 Component,并说明该组件的 Props 定义。
> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 `ant-design` (或你使用的框架) 的组件来实现这种交互,并保证良好的用户体验。
> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 (当前使用的框架) 的组件来实现这种交互,并保证良好的用户体验。
---
+88 -1
View File
@@ -1,9 +1,96 @@
✅已完成、⚠️部分完成、❌未完成这样显示
-----------------------------------------------------------------------------------------------需要解决
1.新增用户商品点击选择用户,点击确定选择并没有将数据返回到弹窗中,带有例如订单ID,套餐ID的这种都需要变为选择组件选择,里面是列表展示,并且带有分页,和刷新按钮
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
.../snapshot/progress GET 快照进度 是 getUserVmSnapshotProgress task_id 类型为 string,前端需确保传字符串而非整数
.../snapshot/count GET 快照数量 是 getUserVmSnapshotCount 无
.../snapshot/set_limit POST 设置快照上限 是 setUserVmSnapshotLimit 无
五、备份 (Backup) — 7 个接口
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
.../backup/list GET 备份列表 是 getUserVmBackupList 同快照 list,无分页参数
.../backup/create POST 创建备份 是 createUserVmBackup 无
.../backup/restore POST 恢复备份 是 restoreUserVmBackup 无
.../backup/delete POST 删除备份 是 deleteUserVmBackup 同快照 delete,用 POST 而非 DELETE
.../backup/progress GET 备份进度 是 getUserVmBackupProgress task_id 类型为 string
.../backup/count GET 备份数量 是 getUserVmBackupCount 无
.../backup/set_limit POST 设置备份上限 是 setUserVmBackupLimit 无
六、安全组 (PostGroup) — 15 个接口
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
.../post_group/list GET 安全组列表 是 getUserVmPostGroupList 无分页参数,仅有 keyword 搜索
.../post_group/detail GET 安全组详情 是 getUserVmPostGroupDetail 无
.../post_group/user_list GET 用户安全组列表 是 getUserVmPostGroupUserList 注意:分页参数名为 page_size(非 count),与其他接口不一致
.../post_group/create POST 创建安全组 是 createUserVmPostGroup lock/drop_all 为 boolean,但 FormData 会序列化为字符串 "true"/"false",需后端兼容
.../post_group/update POST 修改安全组 是 updateUserVmPostGroup 同上 boolean 问题
.../post_group/bind POST 绑定安全组 是 bindUserVmPostGroup 无
.../post_group/unbind POST 解绑安全组 是 unbindUserVmPostGroup 无
.../post_group/apply POST 应用安全组 是 applyUserVmPostGroup 无
.../post_group/set_shared POST 设置共享 是 setSharedUserVmPostGroup shared 为 boolean,同上 FormData 序列化问题
.../post_group/delete DELETE 删除安全组 是 deleteUserVmPostGroup 无
.../post_group/enable_whitelist POST 启用白名单 是 enableUserVmPostGroupWhitelist 无
.../post_group/disable_whitelist POST 禁用白名单 是 disableUserVmPostGroupWhitelist 无
.../post_group/create_rule POST 创建规则 是 createUserVmPostGroupRule 无
.../post_group/update_rule POST 修改规则 是 updateUserVmPostGroupRule 无
.../post_group/delete_rule DELETE 删除规则 是 deleteUserVmPostGroupRule 无
七、网络 & 组网 (Network / Networking) — 7 个接口
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
.../network/list GET 网络列表 是 getUserVmNetworkList 无
.../network/detail GET 网络详情 是 getUserVmNetworkDetail OpenAPI 有可选参数 host_id,前端需视情况传递
.../networking/list GET 组网列表 是 getUserVmNetworkingList 无
.../networking/detail GET 组网详情 是 getUserVmNetworkingDetail 无
.../networking/create POST 创建组网 是 createUserVmNetworking 无
.../networking/assign POST 分配组网 IP 是 assignUserVmNetworking 无
.../networking/remove_network POST 移除组网网络 是 removeUserVmNetworkingNetwork 无
.../networking/delete DELETE 删除组网 是 deleteUserVmNetworking 无
八、总结
接口完整性
OpenAPI 定义的 68 个接口全部已在 src/api/admin/userVm.js 中实现,HTTP 方法和路径均一致,无缺失接口。
需要关注的潜在风险(按严重程度排序)
优先级 风险点 涉及位置 说明 状态
✅高 user_goods/create 缺少 item_id UserGoodsList.vue 新增/编辑表单 已添加 item_id 归属项字段,普通商品直接赋值商品ID,云服务器弹出虚拟机列表选择 ✅已完成
✅高 user_goods/list 缺少筛选参数 UserGoodsList.vue 已添加 user_id 和 good_id 筛选输入框,支持按用户ID和商品ID过滤 ✅已完成
✅中 post_group/user_list 分页参数名不一致 UserVmSecurityGroupSelector.vue 前端调用处已正确使用 page_size 参数名 ✅已完成(无需修改)
✅中 Boolean 字段通过 FormData 传递 安全组 create/update/set_shared fd() 已增加 boolean 类型显式转换为 "true"/"false"SecurityGroupDetail.vue 和 VmDetail.vue 手动 append 处也已修复 ✅已完成
⚠️中 snapshot/list 和 backup/list 无分页 快照/备份相关页面 OpenAPI 未定义分页参数,如数据量大可能一次返回全部 ⚠️后端限制,待后端支持分页
✅低 task_id 类型为 string 快照/备份进度查询 所有调用处已使用 String() 确保传字符串 ✅已完成(无需修改)
✅低 UserGoodsDetail.vue 基础价格显示用了 renewPrice UserGoodsDetail.vue 第 62 行 已修复为 basePrice ✅已完成
✅低 fd() 过滤空字符串 userVm.js fd() 函数 已移除 v === '' 过滤条件,允许空字符串传递(用于清除字段) ✅已完成
第二阶段:功能开发与组件化审查结果
一、请求实现
✅ OpenAPI 定义的 69 个接口(user_vm 64 + user_goods 5)全部在 src/api/admin/userVm.js 中实现
✅ 商品管理 33 个接口全部在 src/api/admin/product.js 中实现(group 6 + goods 5 + spec 8 + plan 9 + group_tag 5
✅ 无缺失接口
二、组件化拆分
已创建的公共组件:
| 组件 | 路径 | Props | 复用场景 |
|------|------|-------|----------|
| FormSelectorField | src/components/common/FormSelectorField.vue | modelValue, displayText, placeholder, buttonText, disabled, clearable, hint, hintType | 所有"只读输入框+选择按钮+清除按钮"的选择器行(50+处) |
已补充的工具函数(src/utils/tool.js):
| 函数 | 用途 | 复用场景 |
|------|------|----------|
| formatPrice(fen) | 分→元显示 ¥xx.xx | 25+个文件的价格展示 |
| yuanToFen(yuan) | 元→分转换 | 所有提交价格的表单 |
| formatExpireTime(t) | 到期时间格式化(<2000年显示永久) | 28+个文件 |
三、嵌套与快捷入口评估
⚠️ ProductGroup.vue3281行)是当前最大的单体文件,集中了 5 个 CRUD 模块和 11 个弹窗:
1. 商品分组管理(树形/列表 + CRUD)
2. 分组标签管理(列表 + CRUD)
3. 商品表单(新增/编辑弹窗)
4. 商品参数管理(参数列表 + 参数值管理 2级嵌套弹窗)
5. 商品套餐管理(套餐列表 + 套餐表单 + 参数配置预览)
推荐拆分方案(待确认后执行):
❌ ProductParameterManager.vue - 提取参数管理弹窗(参数列表+参数值管理+参数表单,约 300 行模板 + 400 行逻辑)
❌ ProductPlanManager.vue - 提取套餐管理弹窗(套餐列表+套餐表单+参数配置,约 250 行模板 + 500 行逻辑)
❌ GroupTagManager.vue - 提取分组标签Tab页(标签列表+标签表单,约 80 行模板 + 150 行逻辑)
拆分后 ProductGroup.vue 可从 3281 行降至约 1600 行
-----------------------------------------------------------------------------------------------需要解决
1.请求接口的带有page-size或者是count参数的都只能是10