style: 优化布局和交互(Loading/空状态/骨架屏)
This commit is contained in:
@@ -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>
|
||||
@@ -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 对象、时间戳等)
|
||||
|
||||
+61
-1378
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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}失败`))
|
||||
|
||||
@@ -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
@@ -26,9 +26,9 @@
|
||||
|
||||
> **Prompt:**
|
||||
> 基于上一步的分析结果,我们需要进行代码落地。请遵循以下工程化标准:
|
||||
> 1. **请求实现:** 按照我现有项目的请求风格(例如 `axios` + `ts-interface`),补全缺失的接口请求函数。
|
||||
> 1. **请求实现:** 按照我现有项目的请求风格,补全缺失的接口请求函数。
|
||||
> 2. **组件化拆分:** 在实现业务页面时,请评估哪些逻辑可以抽离为公共组件(例如:商品详情预览框、批量操作栏、规格选择器)。如果某个功能在多个页面有重复逻辑,请将其提取为独立的 Component,并说明该组件的 Props 定义。
|
||||
> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 `ant-design` (或你使用的框架) 的组件来实现这种交互,并保证良好的用户体验。
|
||||
> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 (当前使用的框架) 的组件来实现这种交互,并保证良好的用户体验。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.vue(3281行)是当前最大的单体文件,集中了 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
|
||||
|
||||
Reference in New Issue
Block a user