Commit 04340bf7 by renyizhao

动态规格

parent 5d3142e4
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" :width="1000">
<!-- 搜索区域 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="82px"
>
<el-form-item label="配置类别" prop="configCategory">
<el-select
v-model="queryParams.configCategory"
placeholder="请选择配置类别"
clearable
class="!w-200px"
>
<el-option
v-for="dict in categoryOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="配置选项" prop="configOption">
<el-input
v-model="queryParams.configOption"
placeholder="请输入配置选项"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
</el-form-item>
</el-form>
<!-- 规格配置列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
row-key="id"
@selection-change="handleSelectionChange"
height="400"
>
<el-table-column type="selection" width="55" :reserve-selection="true" />
<el-table-column label="配置类别" align="center" prop="configCategory" min-width="160">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMPUTE_RESOURCE_CONFIG_CATEGORY" :value="scope.row.configCategory" />
</template>
</el-table-column>
<el-table-column label="配置选项" align="center" prop="configOption" min-width="180" />
<el-table-column label="详情备注" align="center" prop="detailRemark" min-width="200" />
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-4"
/>
<!-- 底部按钮 -->
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">
确定选择 ({{ selectedCount }})
</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
import { ResourceConfigApi, ResourceConfig } from '@/api/compute/resourceconfig'
interface Props {
modelValue: boolean
title?: string
/** 已选中的规格 ID 列表(用于回显勾选状态) */
selectedIds?: number[]
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', selections: ResourceConfig[]): void
}
const props = withDefaults(defineProps<Props>(), {
title: '选择规格',
selectedIds: () => []
})
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const dialogTitle = computed(() => props.title)
// 字典选项
const categoryOptions = getStrDictOptions(DICT_TYPE.COMPUTE_RESOURCE_CONFIG_CATEGORY)
const loading = ref(false)
const list = ref<ResourceConfig[]>([])
const total = ref(0)
const tableRef = ref()
// 跨分页的全量已选规格(key: id,value: 完整规格对象)
// 用于支持多选 + 翻页 + 取消勾选场景
const allSelectedMap = reactive(new Map<number, ResourceConfig>())
const selectedCount = computed(() => allSelectedMap.size)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
configCategory: undefined,
configOption: undefined,
status: 0 // 默认只查询启用的规格
})
const queryFormRef = ref()
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ResourceConfigApi.getResourceConfigPage(queryParams)
list.value = data.list
total.value = data.total
// 数据加载完后回显已选中的项
await nextTick()
restoreSelection()
} finally {
loading.value = false
}
}
/** 回显已选中的规格(跨分页) */
const restoreSelection = () => {
if (!props.selectedIds?.length || !tableRef.value) return
const idSet = new Set(props.selectedIds)
for (const row of list.value) {
if (idSet.has(row.id)) {
tableRef.value.toggleRowSelection(row, true)
}
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.status = 0
handleQuery()
}
/** 处理表格多选变化
* el-table 的 selection-change 只会带当前页可见的已选行;
* 这里用 allSelectedMap 维护所有分页的全量已选规格。
* 策略:把当前页 id 全部从 map 中移除,再把 rows(当前页最新已选)写入 map,
* 这样能正确处理「取消勾选」和「翻页」两种场景。
*/
const handleSelectionChange = (rows: ResourceConfig[]) => {
const currentPageIds = list.value.map((r) => r.id)
const visibleSelectedIds = new Set(rows.map((r) => r.id))
for (const id of currentPageIds) {
if (!visibleSelectedIds.has(id)) {
allSelectedMap.delete(id)
}
}
for (const row of rows) {
allSelectedMap.set(row.id, row)
}
}
/** 确认选择 - 提交全量已选(跨分页) */
const handleConfirm = () => {
emit('confirm', Array.from(allSelectedMap.values()))
dialogVisible.value = false
}
/** 打开弹窗时加载数据
* 弹窗只展示启用规格,但 props.selectedIds 里可能既有启用的也有禁用的;
* 跨分页的已选规格在第一页打开时不会触发 selection-change,所以这里要主动
* 预加载所有已选的启用规格到 allSelectedMap,让底部计数从一开始就是全量。
*/
const handleOpen = async () => {
allSelectedMap.clear()
if (props.selectedIds?.length) {
const results = await Promise.all(
props.selectedIds.map((id) => ResourceConfigApi.getResourceConfig(id).catch(() => null))
)
for (const cfg of results) {
// 只统计启用规格(弹窗只展示启用规格,禁用的不在计数范围内)
if (cfg && cfg.status !== 1) {
allSelectedMap.set(cfg.id, cfg)
}
}
}
getList()
}
// 监听弹窗打开
watch(
() => props.modelValue,
(val) => {
if (val) {
handleOpen()
} else {
// 关闭时清空全量选择
allSelectedMap.clear()
}
},
{ immediate: true }
)
</script>
......@@ -59,26 +59,35 @@
</el-select>
</el-form-item>
<!-- 4. 规格配置 - 按 configCategory 分组多选 -->
<!-- 4. 规格配置 - 弹窗多选 -->
<el-form-item label="规格配置" prop="specConfigIds">
<div class="spec-config-wrapper">
<div v-for="category in specCategories" :key="category.value" class="spec-category-row">
<span class="spec-category-label">{{ category.label }}</span>
<el-checkbox-group
v-model="formData.specConfigIdsByCategory[category.value]"
:disabled="isDetailMode"
<div v-if="selectedConfigs.length" class="spec-tag-list">
<el-tag
v-for="item in selectedConfigs"
:key="item.id"
:type="isDisabledConfig(item) ? 'danger' : 'primary'"
:effect="isDisabledConfig(item) ? 'dark' : 'light'"
closable
:disable-transitions="true"
class="!mr-2 !mb-2"
@close="removeSpecConfig(item.id)"
>
<el-checkbox v-for="opt in category.options" :key="opt.id" :value="opt.id">
{{ opt.configOption }}
<el-tooltip v-if="opt.detailRemark" :content="opt.detailRemark" placement="top">
<Icon icon="ep:info-filled" class="ml-1" />
</el-tooltip>
</el-checkbox>
</el-checkbox-group>
{{ getCategoryLabel(item.configCategory) }}: {{ item.configOption }}
<el-text v-if="isDisabledConfig(item)" type="danger" size="small" class="!ml-1">
(已禁用)
</el-text>
</el-tag>
</div>
<div v-if="!specCategories.length" class="spec-empty"
>暂无规格类别,请在「系统管理 → 字典管理」配置</div
<el-text v-else type="info">暂未选择规格</el-text>
<el-button
type="primary"
plain
:disabled="isDetailMode"
@click="configDialogVisible = true"
>
<Icon icon="ep:plus" class="mr-5px" /> 选择规格
</el-button>
</div>
</el-form-item>
......@@ -181,6 +190,13 @@
</el-form-item>
</el-form>
</ContentWrap>
<!-- 规格选择弹窗 -->
<ResourceConfigSelectDialog
v-model="configDialogVisible"
:selected-ids="formData.specConfigIds || []"
@confirm="handleConfigConfirm"
/>
</template>
<script lang="ts" setup>
......@@ -188,8 +204,9 @@ import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
import { UploadImg } from '@/components/UploadFile'
import { useTagsViewStore } from '@/store/modules/tagsView'
import { ResourceSpu, ResourceSpuApi } from '@/api/compute/resourcespu'
import { ResourceConfigApi } from '@/api/compute/resourceconfig'
import { ResourceConfigApi, ResourceConfig } from '@/api/compute/resourceconfig'
import * as AreaApi from '@/api/system/area'
import ResourceConfigSelectDialog from '../ResourceConfigSelectDialog.vue'
defineOptions({ name: 'ResourceSpuAdd' })
......@@ -213,18 +230,22 @@ const formRef = ref() // 表单 Ref
const categoryList = ref<any[]>([])
const areaList = ref<any[]>([]) // 地区树数据
// 规格分组(按 configCategory 分组多选)
interface SpecOption {
id: number
configOption: string
detailRemark?: string
}
interface SpecCategory {
value: string // 字典 value(对应 configCategory)
label: string // 字典 label
options: SpecOption[]
}
const specCategories = ref<SpecCategory[]>([])
// 已选规格(仅前端用于回显 tag,需提交时只取 id 列表)
const selectedConfigs = ref<ResourceConfig[]>([])
const configDialogVisible = ref(false) // 规格选择弹窗可见性
// 类别字典:用于把 configCategory 转为中文 label
const categoryDictMap = computed(() => {
const map: Record<string, string> = {}
for (const d of getStrDictOptions(DICT_TYPE.COMPUTE_RESOURCE_CONFIG_CATEGORY)) {
map[d.value as string] = d.label
}
return map
})
const getCategoryLabel = (category?: string) =>
(category && categoryDictMap.value[category]) || category || ''
/** 判断已选规格是否已禁用 */
const isDisabledConfig = (item: ResourceConfig) => item.status === 1
const formData = ref<ResourceSpu>({
id: undefined,
......@@ -236,8 +257,9 @@ const formData = ref<ResourceSpu>({
sliderPicUrls: undefined,
sales: undefined,
status: undefined,
// 规格相关
// 规格相关(提交时使用)
specConfigIds: [],
// 前端辅助字段(不再使用分组多选,但保留以兼容类型)
specConfigIdsByCategory: {}
})
......@@ -249,53 +271,42 @@ const formRules = reactive({
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
/**
* 加载规格分组选项:
* 1. 从字典 compute_resource_config_category 读取所有类别
* 2. 对每个类别调 listSimpleConfigByCategory 拿该类别的所有规格项
/** 删除单个已选规格 */
const removeSpecConfig = (id: number) => {
selectedConfigs.value = selectedConfigs.value.filter((c) => c.id !== id)
formData.value.specConfigIds = selectedConfigs.value.map((c) => c.id)
}
/** 规格选择弹窗 - 确认回调
* dialog 只展示启用规格,selections 是「全量已选的启用规格」;
* 表单里已有的禁用规格(status===1)需要保留,避免从弹窗走一圈就丢了。
*/
const loadSpecOptions = async () => {
const categoryDicts = getStrDictOptions(DICT_TYPE.COMPUTE_RESOURCE_CONFIG_CATEGORY)
if (!categoryDicts.length) {
specCategories.value = []
return
}
const results = await Promise.all(
categoryDicts.map(async (cat) => {
const options = await ResourceConfigApi.listSimpleConfigByCategory(cat.value as string)
return {
value: cat.value as string,
label: cat.label,
options: (options || []) as SpecOption[]
}
})
)
specCategories.value = results
// 预填 specConfigIdsByCategory 键,确保 el-checkbox-group v-model 能正常绑定
for (const cat of specCategories.value) {
if (!formData.value.specConfigIdsByCategory[cat.value]) {
formData.value.specConfigIdsByCategory[cat.value] = []
const handleConfigConfirm = (selections: ResourceConfig[]) => {
const map = new Map<number, ResourceConfig>()
// 先放禁用规格(保留)
for (const c of selectedConfigs.value) {
if (isDisabledConfig(c)) {
map.set(c.id, c)
}
}
// 再用弹窗返回的启用规格覆盖(支持取消勾选、新增)
for (const c of selections) {
map.set(c.id, c)
}
selectedConfigs.value = Array.from(map.values())
formData.value.specConfigIds = selectedConfigs.value.map((c) => c.id)
}
/**
* 把后端返回的扁平 specConfigIds 按 configCategory 分布到各分组
*/
const distributeSpecConfigIds = () => {
const flat = new Set(formData.value.specConfigIds || [])
// 先清空所有分组
for (const cat of specCategories.value) {
formData.value.specConfigIdsByCategory[cat.value] = []
}
// 再按 configId 匹配回填
for (const cat of specCategories.value) {
for (const opt of cat.options) {
if (flat.has(opt.id)) {
formData.value.specConfigIdsByCategory[cat.value].push(opt.id)
}
}
/** 根据 id 列表加载完整规格对象(用于编辑模式回显) */
const loadSelectedConfigs = async (ids: number[]) => {
if (!ids?.length) {
selectedConfigs.value = []
return
}
const results = await Promise.all(
ids.map((id) => ResourceConfigApi.getResourceConfig(id).catch(() => null))
)
selectedConfigs.value = results.filter(Boolean) as ResourceConfig[]
}
/** 地区选择变化时,自动设置 location 为市级名称(第二级) */
......@@ -334,14 +345,19 @@ const emit = defineEmits(['success']) // 定义 success 事件,用于操作成
const submitForm = async () => {
await formRef.value.validate()
// 校验:至少选 1 个规格
const allSpecIds = (Object.values(formData.value.specConfigIdsByCategory || {}).flat() ||
[]) as number[]
if (!allSpecIds.length) {
if (!formData.value.specConfigIds?.length) {
message.error('请至少选择 1 个规格')
return
}
// 扁平化写入 formData.specConfigIds
formData.value.specConfigIds = allSpecIds
// 校验:所选规格不能包含已禁用的项
const disabledItems = selectedConfigs.value.filter(isDisabledConfig)
if (disabledItems.length) {
const names = disabledItems
.map((c) => `${getCategoryLabel(c.configCategory)}: ${c.configOption}`)
.join('、')
message.error(`已选规格包含已禁用的项:${names},请移除后重新选择`)
return
}
formLoading.value = true
try {
const data = { ...formData.value } as unknown as ResourceSpu
......@@ -373,11 +389,10 @@ const close = () => {
/** 初始化 */
onMounted(async () => {
try {
// 并行加载分类列表、地区树和规格分组
// 并行加载分类列表、地区树
const [categoryResponse, areaResponse] = await Promise.all([
ResourceSpuApi.listSimpleCategory(),
AreaApi.getAreaTree(),
loadSpecOptions()
AreaApi.getAreaTree()
])
categoryList.value = categoryResponse
areaList.value = areaResponse
......@@ -391,8 +406,8 @@ onMounted(async () => {
if (!formData.value.specConfigIdsByCategory) {
formData.value.specConfigIdsByCategory = {}
}
// 把 specConfigIds 分布到各分组
distributeSpecConfigIds()
// 加载已选规格的完整对象(用于回显 tag)
await loadSelectedConfigs(formData.value.specConfigIds || [])
} finally {
formLoading.value = false
}
......@@ -405,24 +420,16 @@ onMounted(async () => {
<style scoped>
.spec-config-wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.spec-category-row {
.spec-tag-list {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
align-items: center;
width: 100%;
margin-bottom: 12px;
}
.spec-category-label {
width: 80px;
text-align: right;
padding-right: 12px;
color: #606266;
line-height: 32px;
flex-shrink: 0;
}
.spec-empty {
color: #909399;
font-size: 13px;
padding: 8px 0;
}
</style>
......@@ -17,83 +17,6 @@
class="!w-240px"
/>
</el-form-item>
<el-form-item label="CPU" prop="cpu">
<el-select v-model="queryParams.cpu" placeholder="请选择CPU配置" clearable class="!w-240px">
<el-option
v-for="option in resourceConfigStore.getOptionsByType('cpu')"
:key="option.id"
:label="option.configOption"
:value="option.configOption"
/>
</el-select>
</el-form-item>
<el-form-item label="GPU" prop="gpu">
<el-select v-model="queryParams.gpu" placeholder="请选择GPU配置" clearable class="!w-240px">
<el-option
v-for="option in resourceConfigStore.getOptionsByType('gpu')"
:key="option.id"
:label="option.configOption"
:value="option.configOption"
/>
</el-select>
</el-form-item>
<el-form-item label="内存" prop="ram">
<el-select
v-model="queryParams.ram"
placeholder="请选择内存配置"
clearable
class="!w-240px"
>
<el-option
v-for="option in resourceConfigStore.getOptionsByType('ram')"
:key="option.id"
:label="option.configOption"
:value="option.configOption"
/>
</el-select>
</el-form-item>
<el-form-item label="存储" prop="storage">
<el-select
v-model="queryParams.storage"
placeholder="请选择存储配置"
clearable
class="!w-240px"
>
<el-option
v-for="option in resourceConfigStore.getOptionsByType('storage')"
:key="option.id"
:label="option.configOption"
:value="option.configOption"
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="服务器ip" prop="ip">-->
<!-- <el-input-->
<!-- v-model="queryParams.ip"-->
<!-- placeholder="请输入服务器ip"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- class="!w-240px"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="初始用户名" prop="initUsername">-->
<!-- <el-input-->
<!-- v-model="queryParams.initUsername"-->
<!-- placeholder="请输入初始用户名"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- class="!w-240px"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="初始密码" prop="initPassword">-->
<!-- <el-input-->
<!-- v-model="queryParams.initPassword"-->
<!-- placeholder="请输入初始密码"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- class="!w-240px"-->
<!-- />-->
<!-- </el-form-item>-->
<el-form-item label="商品简介" prop="intro">
<el-input
v-model="queryParams.intro"
......@@ -133,21 +56,6 @@
/>
</el-select>
</el-form-item>
<el-form-item label="服务器所在地" prop="location">
<el-select
v-model="queryParams.location"
placeholder="请选择服务器所在地"
clearable
class="!w-240px"
>
<el-option
v-for="option in resourceConfigStore.getOptionsByType('location')"
:key="option.id"
:label="option.configOption"
:value="option.configOption"
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="商品封面图" prop="picUrl">-->
<!-- <el-input-->
<!-- v-model="queryParams.picUrl"-->
......@@ -216,7 +124,7 @@
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
<!-- <el-button
type="success"
plain
@click="handleExport"
......@@ -224,7 +132,7 @@
v-hasPermi="['compute:resource-spu:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</el-button> -->
<el-button
type="danger"
plain
......@@ -268,16 +176,11 @@
</template>
</el-table-column>
<!-- 硬件配置字段已隐藏 -->
<!-- <el-table-column label="算力资源分类编号" align="center" prop="categoryId" />-->
<!-- <el-table-column label="算力资源分类编号" align="center" prop="categoryId" />-->
<el-table-column label="算力资源分类" align="center" prop="categoryName" min-width="150" />
<el-table-column label="规格" min-width="240">
<template #default="{ row }">
<el-tag
v-for="(value, key) in row.specMap"
:key="key"
size="small"
class="!mr-1"
>
<el-tag v-for="(value, key) in row.specMap" :key="key" size="small" class="!mr-1">
{{ key }}: {{ value }}
</el-tag>
</template>
......@@ -351,10 +254,8 @@ import download from '@/utils/download'
import { createImageViewer } from '@/components/ImageViewer'
import { ResourceSpuApi, ResourceSpu } from '@/api/compute/resourcespu'
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
import { useResourceConfigStore } from '@/store/modules/compute/hardwareConfig'
const { push } = useRouter()
const resourceConfigStore = useResourceConfigStore()
/** 算力资源SPU表(基础配置信息) 列表 */
defineOptions({ name: 'ResourceSpu' })
......@@ -370,20 +271,9 @@ const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
cpu: undefined,
gpu: undefined,
ram: undefined,
storage: undefined,
ip: undefined,
initUsername: undefined,
initPassword: undefined,
intro: undefined,
categoryId: undefined,
source: undefined,
location: undefined,
picUrl: undefined,
sliderPicUrls: undefined,
sales: undefined,
status: undefined,
createTime: []
})
......@@ -485,15 +375,9 @@ const handleExport = async () => {
/** 初始化 **/
onMounted(async () => {
try {
// 并行加载数据
const [categoryResponse] = await Promise.all([
ResourceSpuApi.listSimpleCategory(),
resourceConfigStore.loadConfigOptions()
])
// 加载分类列表 + 列表数据
const [categoryResponse] = await Promise.all([ResourceSpuApi.listSimpleCategory(), getList()])
categoryList.value = categoryResponse
// 加载列表数据
getList()
} catch (error) {
console.error('初始化失败:', error)
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment