docs(readme): 更新文档说明
- 添加项目使用指南 - 完善API接口描述 - 修正错误的配置示例
This commit is contained in:
Generated
+949
-896
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,10 @@
|
||||
* Dynamic Unit System
|
||||
*
|
||||
* Handles dynamic unit conversion and display for product parameters.
|
||||
* Base units: memory=KB, storage=GB, bandwidth=Mbps, cpu=Core
|
||||
* Base units: storage=GB, bandwidth=Mbps, cpu=Core
|
||||
*/
|
||||
|
||||
const UNIT_CONVERSIONS = {
|
||||
memory: { KB: 1, MB: 1024, GB: 1024 * 1024, TB: 1024 * 1024 * 1024 },
|
||||
cpu: { Core: 1 },
|
||||
bandwidth_up: { Mbps: 1, Gbps: 1000 },
|
||||
bandwidth_down: { Mbps: 1, Gbps: 1000 },
|
||||
@@ -17,7 +16,6 @@ const UNIT_CONVERSIONS = {
|
||||
}
|
||||
|
||||
const BASE_UNITS = {
|
||||
memory: 'KB',
|
||||
cpu: 'Core',
|
||||
bandwidth_up: 'Mbps',
|
||||
bandwidth_down: 'Mbps',
|
||||
@@ -28,7 +26,6 @@ const BASE_UNITS = {
|
||||
}
|
||||
|
||||
const DEFAULT_DISPLAY_UNITS = {
|
||||
memory: 'MB',
|
||||
cpu: 'Core',
|
||||
bandwidth_up: 'Mbps',
|
||||
bandwidth_down: 'Mbps',
|
||||
@@ -39,7 +36,6 @@ const DEFAULT_DISPLAY_UNITS = {
|
||||
}
|
||||
|
||||
const ARG_KEY_OPTIONS = [
|
||||
{ label: '内存 (memory)', value: 'memory' },
|
||||
{ label: 'CPU (cpu)', value: 'cpu' },
|
||||
{ label: 'IPv4', value: 'ipv4' },
|
||||
{ label: 'IPv6', value: 'ipv6' },
|
||||
@@ -54,7 +50,7 @@ const ARG_KEY_OPTIONS = [
|
||||
* @param {number} value
|
||||
* @param {string} fromUnit
|
||||
* @param {string} toUnit
|
||||
* @param {string} argKey - e.g. 'memory', 'storage'
|
||||
* @param {string} argKey - e.g. 'storage', 'bandwidth_up'
|
||||
*/
|
||||
export function convertUnit(value, fromUnit, toUnit, argKey) {
|
||||
if (value === null || value === undefined || fromUnit === toUnit) return value
|
||||
|
||||
+524
-135
@@ -91,12 +91,16 @@
|
||||
>
|
||||
<el-table-column prop="name" label="名称" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="group-name-cell" :style="{ paddingLeft: row.isProduct ? ((row.level - 1) * 24 + 24) + 'px' : (row.level - 1) * 24 + 'px' }">
|
||||
<div
|
||||
class="group-name-cell"
|
||||
:class="{ 'is-clickable': row.isGroup || row.isProduct }"
|
||||
:style="{ paddingLeft: row.isProduct ? ((row.level - 1) * 24 + 24) + 'px' : (row.level - 1) * 24 + 'px' }"
|
||||
@click="handleNameCellClick(row)"
|
||||
>
|
||||
<!-- 分组的展开/收起按钮 -->
|
||||
<span
|
||||
v-if="row.isGroup"
|
||||
class="expand-icon"
|
||||
@click="toggleExpand(row)"
|
||||
>
|
||||
<el-icon v-if="row._loading"><Loading /></el-icon>
|
||||
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
|
||||
@@ -270,120 +274,135 @@
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
width="860px"
|
||||
append-to-body
|
||||
class="product-form-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="groupFormRef"
|
||||
:model="groupForm"
|
||||
:rules="groupRules"
|
||||
label-width="100px"
|
||||
label-position="top"
|
||||
class="product-form"
|
||||
>
|
||||
<!-- 顶部:封面 + 基本信息 -->
|
||||
<div class="product-form-header">
|
||||
<!-- 左侧封面 -->
|
||||
<div class="cover-uploader" @click="showCoverSelector = true">
|
||||
<img v-if="groupForm.cover_url" :src="groupForm.cover_url" class="cover-image" alt="分组封面" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon class="cover-placeholder-icon"><Folder /></el-icon>
|
||||
<span class="cover-placeholder-text">点击选择封面</span>
|
||||
</div>
|
||||
<div class="cover-mask">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>更换封面</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="groupForm.cover_id"
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
class="cover-clear-btn"
|
||||
@click.stop="clearGroupCover"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧基本信息 -->
|
||||
<div class="basic-fields">
|
||||
<el-form-item label="分组名称" prop="name">
|
||||
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分组" prop="parent_id">
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="selectedParentName"
|
||||
:placeholder="isAddingChild ? '已自动设置父级分组' : '无父级(顶级分组)'"
|
||||
readonly
|
||||
:disabled="isAddingChild"
|
||||
<div
|
||||
class="group-picker"
|
||||
:class="{ 'is-empty': !selectedParentName, 'is-disabled': isAddingChild }"
|
||||
@click="!isAddingChild && (showParentSelector = true)"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="!isAddingChild && (showParentSelector = true)" :disabled="isAddingChild">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-icon class="group-picker-icon"><Folder /></el-icon>
|
||||
<span class="group-picker-text">
|
||||
{{ selectedParentName || (isAddingChild ? '已自动设置父级分组' : '无父级(顶级分组)') }}
|
||||
</span>
|
||||
<el-button
|
||||
v-if="groupForm.parent_id && !isAddingChild"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearParent"
|
||||
class="clear-btn"
|
||||
size="small"
|
||||
class="group-picker-clear"
|
||||
@click.stop="clearParent"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
<el-icon v-if="!isAddingChild" class="group-picker-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div v-if="isAddingChild" class="form-tip">
|
||||
添加子级时自动继承父级分组,不可修改
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组层级" prop="level">
|
||||
<el-input :model-value="`${groupForm.level}级`" disabled style="width: 100%" />
|
||||
<div class="form-tip">层级根据父级分组自动计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组封面" prop="cover_id">
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="groupForm.cover_id ? `文件ID: ${groupForm.cover_id}` : ''"
|
||||
placeholder="点击选择封面图片"
|
||||
readonly
|
||||
@click="showCoverSelector = true"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="showCoverSelector = true">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
v-if="groupForm.cover_id"
|
||||
type="danger"
|
||||
link
|
||||
@click="groupForm.cover_id = undefined"
|
||||
class="clear-btn"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组标签" prop="tag_id">
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="selectedTagName"
|
||||
placeholder="点击选择分组标签"
|
||||
readonly
|
||||
<div
|
||||
class="group-picker"
|
||||
:class="{ 'is-empty': !selectedTagName }"
|
||||
@click="showTagSelector = true"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="showTagSelector = true">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-icon class="group-picker-icon"><CollectionTag /></el-icon>
|
||||
<span class="group-picker-text">{{ selectedTagName || '点击选择分组标签' }}</span>
|
||||
<el-button
|
||||
v-if="groupForm.tag_id"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearTag"
|
||||
class="clear-btn"
|
||||
size="small"
|
||||
class="group-picker-clear"
|
||||
@click.stop="clearTag"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="groupForm.note" type="textarea" :rows="4" placeholder="请输入备注" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本属性(层级 / 排序 / 状态) -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">基本属性</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="分组层级" prop="level">
|
||||
<el-input :model-value="`${groupForm.level} 级`" disabled style="width: 100%" />
|
||||
<div class="form-tip">层级根据父级分组自动计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number
|
||||
v-model="groupForm.index"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
placeholder="请输入排序索引,数值越小排序越靠前"
|
||||
controls-position="right"
|
||||
placeholder="数值越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">数值越小排序越靠前,相同索引按创建时间排序</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="disable">
|
||||
<el-form-item label="状态" prop="disable" class="group-status-item">
|
||||
<el-radio-group v-model="groupForm.disable">
|
||||
<el-radio :value="false">启用</el-radio>
|
||||
<el-radio :value="true">禁用</el-radio>
|
||||
<el-radio-button :value="false">启用</el-radio-button>
|
||||
<el-radio-button :value="true">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<el-form-item prop="note" label="备注" class="note-form-item">
|
||||
<el-input
|
||||
v-model="groupForm.note"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入备注(可选)"
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -486,70 +505,79 @@
|
||||
<el-dialog
|
||||
v-model="productDialogVisible"
|
||||
:title="productDialogType === 'add' ? '新增商品' : '编辑商品'"
|
||||
width="800px"
|
||||
width="860px"
|
||||
append-to-body
|
||||
class="product-form-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="productFormRef"
|
||||
:model="productForm"
|
||||
:rules="productRules"
|
||||
label-width="120px"
|
||||
label-position="top"
|
||||
class="product-form"
|
||||
>
|
||||
<!-- 顶部:封面 + 基本信息 -->
|
||||
<div class="product-form-header">
|
||||
<!-- 左侧封面 -->
|
||||
<div class="cover-uploader" @click="coverSelectorVisible = true">
|
||||
<img v-if="productForm.cover_url" :src="productForm.cover_url" class="cover-image" alt="商品封面" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon class="cover-placeholder-icon"><Picture /></el-icon>
|
||||
<span class="cover-placeholder-text">点击选择封面</span>
|
||||
</div>
|
||||
<div class="cover-mask">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>更换封面</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="productForm.cover_id"
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
class="cover-clear-btn"
|
||||
@click.stop="clearProductCover"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧基本信息 -->
|
||||
<div class="basic-fields">
|
||||
<el-form-item label="商品名称" prop="name">
|
||||
<el-input v-model="productForm.name" placeholder="请输入商品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品所属表" prop="table">
|
||||
<el-input v-model="productForm.table" placeholder="请输入商品所属表" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品分组" prop="good_group_id">
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="selectedGroupName"
|
||||
placeholder="请选择商品分组"
|
||||
readonly
|
||||
<el-form-item label="所属分组" prop="good_group_id">
|
||||
<div
|
||||
class="group-picker"
|
||||
:class="{ 'is-empty': !selectedGroupName }"
|
||||
@click="showProductGroupSelector = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-icon class="group-picker-icon"><Folder /></el-icon>
|
||||
<span class="group-picker-text">{{ selectedGroupName || '点击选择商品分组' }}</span>
|
||||
<el-button
|
||||
v-if="productForm.good_group_id"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearProductGroup"
|
||||
class="clear-btn"
|
||||
size="small"
|
||||
class="group-picker-clear"
|
||||
@click.stop="clearProductGroup"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品内容" prop="content">
|
||||
<el-input v-model="productForm.content" type="textarea" :rows="4" placeholder="请输入商品内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品封面" prop="cover_id">
|
||||
<div class="recommend-user-selector">
|
||||
<el-form-item label="商品介绍" prop="content">
|
||||
<el-input
|
||||
:model-value="productForm.cover_id ? `文件ID: ${productForm.cover_id}` : ''"
|
||||
placeholder="点击选择封面图片"
|
||||
readonly
|
||||
@click="coverSelectorVisible = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Picture /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
v-if="productForm.cover_id"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearProductCover"
|
||||
class="clear-btn"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
v-model="productForm.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入商品介绍"
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 封面选择器弹窗 -->
|
||||
<AvatarSelector
|
||||
@@ -560,44 +588,118 @@
|
||||
@confirm="handleProductCoverSelect"
|
||||
/>
|
||||
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
<el-switch v-model="productForm.inventory_control" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
<!-- 价格与销售 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">价格与销售</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item prop="price">
|
||||
<template #label>
|
||||
<span>商品价格<span class="unit-suffix">(元)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
placeholder="请输入价格"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
|
||||
<el-input-number
|
||||
v-model="productForm.pay_num"
|
||||
:min="1"
|
||||
placeholder="请输入数量"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="expire_time">
|
||||
<template #label>
|
||||
<span>有效期<span class="unit-suffix">(天,0 为永久)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.expire_time"
|
||||
:min="0"
|
||||
placeholder="请输入有效期"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许续费" prop="can_renew">
|
||||
<el-switch
|
||||
v-model="productForm.can_renew"
|
||||
active-text="允许"
|
||||
inactive-text="禁止"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" prop="expire_time">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 库存管理 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">库存管理</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
<el-switch
|
||||
v-model="productForm.inventory_control"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number
|
||||
v-model="productForm.inventory"
|
||||
:min="0"
|
||||
:disabled="!productForm.inventory_control"
|
||||
placeholder="请输入库存"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐与参数 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">推荐与参数</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
|
||||
<el-switch
|
||||
v-model="productForm.recommend"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐返还(%)" prop="recommend_rebate">
|
||||
<el-input-number v-model="productForm.recommend_rebate" :min="0" :max="100" placeholder="请输入返还百分比" style="width: 100%" />
|
||||
<el-form-item prop="recommend_rebate">
|
||||
<template #label>
|
||||
<span>推荐返还<span class="unit-suffix">(%)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.recommend_rebate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="!productForm.recommend"
|
||||
placeholder="返还百分比"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-select v-model="productForm.arg_type" placeholder="请选择参数类型" style="width: 100%">
|
||||
<el-form-item label="参数类型" prop="arg_type" class="arg-type-item">
|
||||
<el-select
|
||||
v-model="productForm.arg_type"
|
||||
placeholder="请选择参数类型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="所有参数" value="all" />
|
||||
<el-option label="套餐" value="plan" />
|
||||
<el-option label="自定义" value="customize" />
|
||||
</el-select>
|
||||
<div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许续费" prop="can_renew">
|
||||
<el-switch v-model="productForm.can_renew" active-text="允许" inactive-text="禁止" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -657,7 +759,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductGroupList,
|
||||
createProductGroup,
|
||||
@@ -712,6 +814,7 @@ const groupForm = reactive({
|
||||
level: 1,
|
||||
parent_id: undefined,
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
})
|
||||
@@ -752,6 +855,7 @@ const productForm = reactive({
|
||||
tag: '',
|
||||
content: '',
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
good_group_id: undefined,
|
||||
inventory_control: false,
|
||||
inventory: 0,
|
||||
@@ -768,9 +872,6 @@ const productRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入商品名称', trigger: 'blur' }
|
||||
],
|
||||
table: [
|
||||
{ required: true, message: '请输入商品所属表', trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入商品内容', trigger: 'blur' }
|
||||
],
|
||||
@@ -928,6 +1029,14 @@ const loadProductsForGroup = async (groupId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameCellClick = (row) => {
|
||||
if (row.isGroup) {
|
||||
toggleExpand(row)
|
||||
} else if (row.isProduct) {
|
||||
handleEditProduct(row.data, row.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = async (row) => {
|
||||
const group = row._origRef || findGroupById(row.id)
|
||||
if (!group || group._loading) return
|
||||
@@ -1300,6 +1409,7 @@ const handleAdd = (parentRow) => {
|
||||
level: parentRow.level + 1,
|
||||
parent_id: parentRow.id,
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: parentTagId || undefined,
|
||||
index: 0
|
||||
})
|
||||
@@ -1314,6 +1424,7 @@ const handleAdd = (parentRow) => {
|
||||
level: 1,
|
||||
parent_id: undefined,
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
})
|
||||
@@ -1334,6 +1445,7 @@ const handleEdit = (row) => {
|
||||
level: row.level || 1,
|
||||
parent_id: row.parentId || undefined,
|
||||
cover_id: row.coverId || undefined,
|
||||
cover_url: row.cover || '',
|
||||
tag_id: row.tag?.id || row.tagId || undefined,
|
||||
index: row.index || 0
|
||||
})
|
||||
@@ -1393,6 +1505,7 @@ const handleAddProduct = () => {
|
||||
tag: '',
|
||||
content: '',
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
good_group_id: undefined,
|
||||
inventory_control: false,
|
||||
inventory: 0,
|
||||
@@ -1424,6 +1537,7 @@ const handleEditProduct = (product, parentGroupId) => {
|
||||
tag: typeof product.tag === 'string' ? product.tag : '',
|
||||
content: product.content || '',
|
||||
cover_id: product.coverId,
|
||||
cover_url: product.cover || '',
|
||||
good_group_id: groupId,
|
||||
inventory_control: product.inventoryControl,
|
||||
inventory: product.inventory,
|
||||
@@ -1506,11 +1620,13 @@ const clearProductGroup = () => {
|
||||
|
||||
const handleProductCoverSelect = (file) => {
|
||||
productForm.cover_id = file.cover_id
|
||||
productForm.cover_url = file.url || ''
|
||||
coverSelectorVisible.value = false
|
||||
}
|
||||
|
||||
const clearProductCover = () => {
|
||||
productForm.cover_id = undefined
|
||||
productForm.cover_url = ''
|
||||
}
|
||||
|
||||
const clearParent = () => {
|
||||
@@ -1533,6 +1649,12 @@ const confirmParentSelect = () => {
|
||||
|
||||
const handleCoverSelect = (file) => {
|
||||
groupForm.cover_id = file.cover_id
|
||||
groupForm.cover_url = file.url || ''
|
||||
}
|
||||
|
||||
const clearGroupCover = () => {
|
||||
groupForm.cover_id = undefined
|
||||
groupForm.cover_url = ''
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row, disable) => {
|
||||
@@ -1755,6 +1877,21 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.group-name-cell.is-clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group-name-cell.is-clickable:hover {
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.group-name-cell.is-clickable:hover .expand-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
@@ -1847,6 +1984,258 @@ onMounted(() => {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
/* 商品表单优化布局 */
|
||||
.product-form-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.product-form {
|
||||
--section-gap: 20px;
|
||||
}
|
||||
|
||||
.product-form-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--section-gap);
|
||||
}
|
||||
|
||||
.cover-uploader {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.cover-uploader:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.cover-placeholder-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.cover-placeholder-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cover-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cover-mask .el-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.cover-uploader:hover .cover-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cover-clear-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.cover-uploader:hover .cover-clear-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.basic-fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.basic-fields :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.basic-fields :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.group-picker:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.group-picker.is-empty .group-picker-text {
|
||||
color: #a8abb2;
|
||||
}
|
||||
|
||||
.group-picker.is-disabled {
|
||||
background: #f5f7fa;
|
||||
cursor: not-allowed;
|
||||
color: #a8abb2;
|
||||
}
|
||||
|
||||
.group-picker.is-disabled:hover {
|
||||
border-color: #dcdfe6;
|
||||
}
|
||||
|
||||
.group-picker.is-disabled .group-picker-text,
|
||||
.group-picker.is-disabled .group-picker-icon {
|
||||
color: #a8abb2;
|
||||
}
|
||||
|
||||
.note-form-item {
|
||||
margin-bottom: 0 !important;
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.group-status-item :deep(.el-radio-button__inner) {
|
||||
padding: 6px 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-picker-icon {
|
||||
color: #909399;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-picker-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-picker-clear {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.group-picker-arrow {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: var(--section-gap);
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #409eff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 24px;
|
||||
}
|
||||
|
||||
.form-grid :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid .arg-type-item {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.unit-suffix {
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.product-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px !important;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.product-form-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid .arg-type-item {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="商品参数管理"
|
||||
width="900px"
|
||||
width="1000px"
|
||||
top="6vh"
|
||||
class="param-manager-dialog"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
>
|
||||
<div class="filter-section" style="border: none; padding: 0 0 16px 0;">
|
||||
<div class="param-manager-toolbar">
|
||||
<div class="toolbar-info">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>共 {{ parameterList.length }} 个参数</span>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="handleAddParameter">
|
||||
<el-icon><Plus /></el-icon>新增参数
|
||||
@@ -16,48 +22,131 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="paramLoading"
|
||||
:data="parameterList"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||
|
||||
<div v-loading="paramLoading" class="parameter-cards-wrap">
|
||||
<div v-if="!parameterList.length && !paramLoading" class="param-empty-state">
|
||||
<el-empty description="暂无参数数据,点击右上角新增参数" :image-size="80" />
|
||||
</div>
|
||||
<div v-else class="parameter-cards-grid">
|
||||
<div v-for="param in parameterList" :key="param.id" class="param-card">
|
||||
<!-- 卡片头 -->
|
||||
<div class="param-card-header">
|
||||
<div class="param-card-title">
|
||||
<el-tag :type="getArgTypeTag(param.type)" effect="dark" size="small" class="title-type-tag">
|
||||
{{ getArgTypeText(param.type) }}
|
||||
</el-tag>
|
||||
<span class="param-name" :title="param.name">{{ param.name }}</span>
|
||||
<el-tag v-if="param.must" type="danger" size="small" effect="plain" class="title-must-tag">必选</el-tag>
|
||||
<span class="param-id">#{{ param.id }}</span>
|
||||
</div>
|
||||
<div class="param-card-actions">
|
||||
<el-button type="primary" link @click="handleEditParameter(param)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDeleteParameter(param)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权限 / 配置 元信息 -->
|
||||
<div class="param-card-meta">
|
||||
<el-tag size="small" :type="param.userAdd ? 'success' : 'info'" effect="plain" class="meta-tag">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
{{ param.userAdd ? '允许单独购买' : '不可单独购买' }}
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="param.useUserGroupDiscount ? 'success' : 'info'" effect="plain" class="meta-tag">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ param.useUserGroupDiscount ? '用户组优惠' : '无用户组优惠' }}
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="param.useUserDiscount ? 'success' : 'info'" effect="plain" class="meta-tag">
|
||||
<el-icon><Ticket /></el-icon>
|
||||
{{ param.useUserDiscount ? '用户优惠' : '无用户优惠' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- number 类型配置 -->
|
||||
<div v-if="param.type === 'number'" class="param-card-number-config">
|
||||
<div class="number-config-item">
|
||||
<span class="config-label">步进</span>
|
||||
<span class="config-value">{{ param.step || '-' }}</span>
|
||||
</div>
|
||||
<div class="number-config-item">
|
||||
<span class="config-label">范围</span>
|
||||
<span class="config-value">{{ param.min ?? '-' }} ~ {{ param.max ?? '-' }}</span>
|
||||
</div>
|
||||
<div v-if="hasUnit(param)" class="number-config-item">
|
||||
<span class="config-label">单位</span>
|
||||
<el-tag size="small" type="success" effect="plain">{{ getArgKey(param) }}</el-tag>
|
||||
<el-tag size="small" type="warning" effect="plain" style="margin-left: 4px">
|
||||
{{ getBaseUnit(getArgKey(param)) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数值列表 -->
|
||||
<div class="param-card-values">
|
||||
<div class="values-header">
|
||||
<span class="values-title">
|
||||
<el-icon><List /></el-icon>
|
||||
参数值({{ (param.attrs || []).length }} 项)
|
||||
<span v-if="(param.attrs || []).length > 1" class="drag-tip-inline">
|
||||
<el-icon><Rank /></el-icon>可拖动排序
|
||||
</span>
|
||||
</span>
|
||||
<el-button type="primary" size="small" @click="handleAddParamValue(param)">
|
||||
<el-icon><Plus /></el-icon>添加参数值
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="!(param.attrs || []).length" class="values-empty">
|
||||
暂无参数值
|
||||
</div>
|
||||
<div v-else class="values-list">
|
||||
<div
|
||||
v-for="(attr, idx) in (param.attrs || [])"
|
||||
:key="attr.id"
|
||||
class="value-row"
|
||||
:class="[
|
||||
isDraggingAttr(param.id, idx) ? 'is-dragging' : '',
|
||||
dragOverClass(param.id, idx)
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="onAttrDragStart($event, param, idx)"
|
||||
@dragover="onAttrDragOver($event, param, idx)"
|
||||
@dragleave="onAttrDragLeave($event, param, idx)"
|
||||
@drop="onAttrDrop($event, param, idx)"
|
||||
@dragend="onAttrDragEnd"
|
||||
>
|
||||
<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="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === 'number'">
|
||||
<div class="number-config">
|
||||
<div>步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}
|
||||
<template v-if="hasUnit(row)"> ({{ getBaseUnit(getArgKey(row)) }})</template>
|
||||
</div>
|
||||
<div v-if="hasUnit(row)" class="unit-info">
|
||||
<el-tag size="small" type="success">{{ getArgKey(row) }}</el-tag>
|
||||
<el-tag size="small" type="warning">{{ getParamDefaultUnit(row) }}</el-tag>
|
||||
<el-icon class="value-drag-handle"><Rank /></el-icon>
|
||||
<span class="value-order">#{{ idx }}</span>
|
||||
<span class="value-name" :title="attr.name">{{ attr.name }}</span>
|
||||
<span class="value-range">
|
||||
<el-tag v-if="param.type === 'number'" size="small" type="info">
|
||||
{{ getRangeTypeText(attr.rangeType) }}
|
||||
{{ (attr.phase != null && attr.phase !== '') ? attr.phase : 0 }}
|
||||
<template v-if="hasUnit(param)"> {{ getBaseUnit(getArgKey(param)) }}</template>
|
||||
</el-tag>
|
||||
<span v-else-if="param.type === 'select'" class="value-raw">{{ attr.value || '-' }}</span>
|
||||
<span v-else class="value-raw">{{ attr.value || '-' }}</span>
|
||||
</span>
|
||||
<span class="value-price">
|
||||
<el-icon><Money /></el-icon>
|
||||
¥{{ (attr.price / 100).toFixed(2) }}
|
||||
</span>
|
||||
<div class="value-actions">
|
||||
<el-button type="primary" link @click="handleEditParamValue(attr, param)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDeleteParamValue(attr, param)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 商品参数表单对话框 -->
|
||||
@@ -154,56 +243,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参数值管理对话框 -->
|
||||
<el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body>
|
||||
<div class="values-header">
|
||||
<div>
|
||||
<span>参数:{{ currentParam?.name }}</span>
|
||||
<el-tag v-if="hasUnit(currentParam)" size="small" type="success" style="margin-left: 8px">
|
||||
{{ getArgKey(currentParam) }} · 基础单位: {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<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="180">
|
||||
<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.phase != null && row.phase !== '') ? row.phase : 0 }}
|
||||
</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"
|
||||
@@ -216,56 +255,67 @@
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">参数值信息</div>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
<div class="name-input-row">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" style="flex: 1" />
|
||||
<el-button
|
||||
v-if="currentParam?.type === 'number'"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!canGenerateValueName"
|
||||
@click="generateValueName"
|
||||
>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span style="margin-left: 4px">生成</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<template v-if="currentParam?.type === 'number'">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值范围配置</div>
|
||||
<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-form-item v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range">
|
||||
<div class="range-config-row">
|
||||
<el-select v-model="paramValueForm.range_type" class="range-type-select">
|
||||
<el-option label="<" value="before">
|
||||
<span class="range-opt-symbol"><</span>
|
||||
<span class="range-opt-desc">小于</span>
|
||||
</el-option>
|
||||
<el-option label=">" value="after">
|
||||
<span class="range-opt-symbol">></span>
|
||||
<span class="range-opt-desc">大于</span>
|
||||
</el-option>
|
||||
<el-option label="=" value="equal">
|
||||
<span class="range-opt-symbol">=</span>
|
||||
<span class="range-opt-desc">等于</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 < phase 时匹配 | after: 数值 > phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number
|
||||
v-model="paramValueForm.attr_range_display"
|
||||
:min="valueDisplayMin"
|
||||
:max="valueDisplayMax"
|
||||
:step="valueDisplayStep"
|
||||
:step-strictly="true"
|
||||
placeholder="范围阈值"
|
||||
style="flex: 1"
|
||||
placeholder="数值"
|
||||
controls-position="right"
|
||||
class="range-value-input"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(currentParam)"
|
||||
v-model="paramValueForm.display_unit"
|
||||
style="width: 100px"
|
||||
class="range-unit-select"
|
||||
@change="onValueUnitChange"
|
||||
>
|
||||
<el-option v-for="u in currentParamUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="hasUnit(currentParam)" class="form-tip">
|
||||
实际存储值: {{ computedBaseValue }} {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
实际存储: {{ computedBaseValue }} {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
(范围: {{ currentParam?.min ?? 0 }} ~ {{ currentParam?.max ?? '-' }} {{ getBaseUnit(getArgKey(currentParam)) }},步长: {{ currentParam?.step || 1 }})
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="tk-dialog-footer">
|
||||
@@ -279,7 +329,11 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Plus, Refresh, Rank, MagicStick,
|
||||
Edit, Delete, Collection, List, Money,
|
||||
ShoppingCart, User, Ticket
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductParameterList,
|
||||
getProductParameterDetail,
|
||||
@@ -293,7 +347,7 @@ import {
|
||||
import {
|
||||
getAvailableUnits, getArgKeyOptions, hasUnit, getArgKey,
|
||||
getBaseUnit, getDefaultDisplayUnit, getParamDefaultUnit, getParamUnits,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
toBaseUnit, fromBaseUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -382,9 +436,6 @@ const onEnableUnitChange = (val) => {
|
||||
}
|
||||
}
|
||||
|
||||
const paramValuesDialogVisible = ref(false)
|
||||
const paramValuesLoading = ref(false)
|
||||
const paramValueList = ref([])
|
||||
const currentParam = ref(null)
|
||||
|
||||
const paramValueFormDialogVisible = ref(false)
|
||||
@@ -436,19 +487,145 @@ const computedBaseValue = computed(() => {
|
||||
return Math.round(toBaseUnit(paramValueForm.attr_range_display || 0, paramValueForm.display_unit, argKey))
|
||||
})
|
||||
|
||||
const formatPhaseDisplay = (phaseValue) => {
|
||||
if (phaseValue === undefined || phaseValue === null) return '-'
|
||||
if (!hasUnit(currentParam.value)) return String(phaseValue)
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
const displayVal = fromBaseUnit(phaseValue, displayUnit, argKey)
|
||||
return formatValueWithUnit(displayVal, displayUnit)
|
||||
}
|
||||
|
||||
const onValueUnitChange = () => {
|
||||
// Recalculate: keep the base value, recalculate display value
|
||||
}
|
||||
|
||||
const canGenerateValueName = computed(() => {
|
||||
if (currentParam.value?.type !== 'number') return false
|
||||
const v = paramValueForm.attr_range_display
|
||||
return v !== null && v !== undefined && v !== ''
|
||||
})
|
||||
|
||||
const generateValueName = () => {
|
||||
if (!canGenerateValueName.value) return
|
||||
const val = paramValueForm.attr_range_display
|
||||
const unit = hasUnit(currentParam.value) ? (paramValueForm.display_unit || '') : ''
|
||||
const numText = `${val}${unit}`
|
||||
let name = ''
|
||||
switch (paramValueForm.range_type) {
|
||||
case 'before':
|
||||
name = `${numText} 以下`
|
||||
break
|
||||
case 'after':
|
||||
name = `${numText} 以上`
|
||||
break
|
||||
case 'equal':
|
||||
default:
|
||||
name = numText
|
||||
break
|
||||
}
|
||||
paramValueForm.attr_name = name
|
||||
}
|
||||
|
||||
const dragSrc = ref({ paramId: null, index: null })
|
||||
const dragOver = ref({ paramId: null, index: null, position: null })
|
||||
const isReordering = ref(false)
|
||||
|
||||
const isDraggingAttr = (paramId, idx) => {
|
||||
return dragSrc.value.paramId === paramId && dragSrc.value.index === idx
|
||||
}
|
||||
const dragOverClass = (paramId, idx) => {
|
||||
if (dragOver.value.paramId !== paramId || dragOver.value.index !== idx) return ''
|
||||
return dragOver.value.position === 'top' ? 'drag-over-top' : 'drag-over-bottom'
|
||||
}
|
||||
|
||||
const onAttrDragStart = (e, param, idx) => {
|
||||
dragSrc.value = { paramId: param.id, index: idx }
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
const onAttrDragOver = (e, param, idx) => {
|
||||
if (dragSrc.value.paramId !== param.id) return
|
||||
if (dragSrc.value.index === idx) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const position = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom'
|
||||
if (
|
||||
dragOver.value.paramId !== param.id ||
|
||||
dragOver.value.index !== idx ||
|
||||
dragOver.value.position !== position
|
||||
) {
|
||||
dragOver.value = { paramId: param.id, index: idx, position }
|
||||
}
|
||||
}
|
||||
|
||||
const onAttrDragLeave = (e, param, idx) => {
|
||||
if (
|
||||
dragOver.value.paramId === param.id &&
|
||||
dragOver.value.index === idx &&
|
||||
!e.currentTarget.contains(e.relatedTarget)
|
||||
) {
|
||||
dragOver.value = { paramId: null, index: null, position: null }
|
||||
}
|
||||
}
|
||||
|
||||
const onAttrDrop = async (e, param, idx) => {
|
||||
e.preventDefault()
|
||||
if (dragSrc.value.paramId !== param.id) return
|
||||
const src = dragSrc.value.index
|
||||
const position = dragOver.value.position
|
||||
let dst = idx
|
||||
if (position === 'bottom') dst = idx + 1
|
||||
dragSrc.value = { paramId: null, index: null }
|
||||
dragOver.value = { paramId: null, index: null, position: null }
|
||||
if (src === null || src === dst || src + 1 === dst) return
|
||||
await reorderAttrs(param, src, dst)
|
||||
}
|
||||
|
||||
const onAttrDragEnd = () => {
|
||||
dragSrc.value = { paramId: null, index: null }
|
||||
dragOver.value = { paramId: null, index: null, position: null }
|
||||
}
|
||||
|
||||
const reorderAttrs = async (param, src, dst) => {
|
||||
if (isReordering.value) return
|
||||
isReordering.value = true
|
||||
const newAttrs = [...(param.attrs || [])]
|
||||
const [moved] = newAttrs.splice(src, 1)
|
||||
const insertAt = dst > src ? dst - 1 : dst
|
||||
newAttrs.splice(insertAt, 0, moved)
|
||||
param.attrs = newAttrs
|
||||
const updates = []
|
||||
newAttrs.forEach((item, i) => {
|
||||
if (Number(item.index) !== i) {
|
||||
updates.push({ item, newIndex: i })
|
||||
}
|
||||
})
|
||||
if (!updates.length) {
|
||||
isReordering.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await Promise.all(updates.map(({ item, newIndex }) => {
|
||||
const payload = {
|
||||
good_id: Number(props.goodId),
|
||||
arg_id: Number(param.id),
|
||||
attr_id: item.id,
|
||||
attr_name: item.name,
|
||||
attr_value: item.value || '',
|
||||
attr_price: Number(item.price) || 0,
|
||||
index: newIndex
|
||||
}
|
||||
if (param?.type === 'number') {
|
||||
payload.attr_range = item.phase ?? 0
|
||||
payload.range_type = item.rangeType || 'equal'
|
||||
}
|
||||
return updateProductParameterValue(payload)
|
||||
}))
|
||||
ElMessage.success('排序已更新')
|
||||
} catch (err) {
|
||||
ElMessage.error('排序更新失败')
|
||||
} finally {
|
||||
isReordering.value = false
|
||||
refreshParamAttrs(param)
|
||||
}
|
||||
}
|
||||
|
||||
const getArgTypeText = (type) => {
|
||||
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
|
||||
return typeMap[type] || '未知'
|
||||
@@ -468,7 +645,22 @@ const fetchParameterList = async () => {
|
||||
try {
|
||||
const res = await getProductParameterList({ good_id: props.goodId })
|
||||
if (res.data.code === 200) {
|
||||
parameterList.value = res.data.data || []
|
||||
const list = res.data.data || []
|
||||
await Promise.all(list.map(async (param) => {
|
||||
try {
|
||||
const detail = await getProductParameterDetail({ good_id: props.goodId, arg_id: param.id })
|
||||
if (detail.data.code === 200) {
|
||||
const attrs = detail.data.data.attrs || []
|
||||
attrs.sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0))
|
||||
param.attrs = attrs
|
||||
} else {
|
||||
param.attrs = []
|
||||
}
|
||||
} catch (_) {
|
||||
param.attrs = []
|
||||
}
|
||||
}))
|
||||
parameterList.value = list
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取参数列表失败')
|
||||
@@ -477,6 +669,18 @@ const fetchParameterList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshParamAttrs = async (param) => {
|
||||
if (!param) return
|
||||
try {
|
||||
const res = await getProductParameterDetail({ good_id: props.goodId, arg_id: param.id })
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.attrs || []
|
||||
list.sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0))
|
||||
param.attrs = list
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const handleAddParameter = () => {
|
||||
paramFormType.value = 'add'
|
||||
paramFormDialogVisible.value = true
|
||||
@@ -566,41 +770,31 @@ const submitParamForm = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const handleAddParamValue = (param) => {
|
||||
currentParam.value = param
|
||||
paramValueFormType.value = 'add'
|
||||
paramValueFormDialogVisible.value = true
|
||||
const defaultUnit = hasUnit(currentParam.value) ? getParamDefaultUnit(currentParam.value) : ''
|
||||
const defaultUnit = hasUnit(param) ? getParamDefaultUnit(param) : ''
|
||||
const attrs = param.attrs || []
|
||||
const nextIndex = attrs.length
|
||||
? Math.max(...attrs.map((it) => Number(it.index) || 0)) + 1
|
||||
: 0
|
||||
nextTick(() => {
|
||||
paramValueFormRef.value?.resetFields()
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: nextIndex, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditParamValue = (row) => {
|
||||
const handleEditParamValue = (row, param) => {
|
||||
currentParam.value = param
|
||||
paramValueFormType.value = 'edit'
|
||||
paramValueFormDialogVisible.value = true
|
||||
const baseValue = row.phase || 0
|
||||
let displayValue = baseValue
|
||||
let displayUnit = ''
|
||||
if (hasUnit(currentParam.value)) {
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
if (hasUnit(param)) {
|
||||
const argKey = getArgKey(param)
|
||||
displayUnit = getParamDefaultUnit(param)
|
||||
displayValue = fromBaseUnit(baseValue, displayUnit, argKey)
|
||||
}
|
||||
Object.assign(paramValueForm, {
|
||||
@@ -611,13 +805,13 @@ const handleEditParamValue = (row) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParamValue = (row) => {
|
||||
const handleDeleteParamValue = (row, param) => {
|
||||
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() }
|
||||
if (res.data.code === 200) { ElMessage.success('删除成功'); refreshParamAttrs(param) }
|
||||
} catch (error) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
@@ -651,7 +845,11 @@ const submitParamValueForm = () => {
|
||||
}
|
||||
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() }
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(paramValueFormType.value === 'add' ? '添加成功' : '修改成功')
|
||||
paramValueFormDialogVisible.value = false
|
||||
refreshParamAttrs(currentParam.value)
|
||||
}
|
||||
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
|
||||
}
|
||||
})
|
||||
@@ -663,17 +861,309 @@ watch(() => props.visible, (val) => {
|
||||
</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; }
|
||||
.number-config div { line-height: 1.4; }
|
||||
.unit-info { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.unit-text { font-size: 12px; color: #666; }
|
||||
.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; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.param-manager-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0 16px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toolbar-info .el-icon { color: #409eff; font-size: 16px; }
|
||||
|
||||
/* 参数卡片网格 */
|
||||
.parameter-cards-wrap {
|
||||
min-height: 200px;
|
||||
max-height: 68vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.parameter-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.param-empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 参数卡片 */
|
||||
.param-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.param-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
border-color: #dcdfe6;
|
||||
}
|
||||
|
||||
.param-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.param-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.title-type-tag { flex-shrink: 0; }
|
||||
.param-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.title-must-tag { flex-shrink: 0; }
|
||||
.param-id {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.param-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 元信息标签行 */
|
||||
.param-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.meta-tag {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.meta-tag .el-icon { font-size: 12px; }
|
||||
|
||||
/* number 类型配置信息 */
|
||||
.param-card-number-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
.number-config-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.config-label {
|
||||
color: #909399;
|
||||
}
|
||||
.config-value {
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 参数值列表 */
|
||||
.param-card-values {
|
||||
border-top: 1px dashed #ebeef5;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.values-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.values-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
.values-title .el-icon { color: #409eff; }
|
||||
.drag-tip-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
}
|
||||
.drag-tip-inline .el-icon { color: #909399; font-size: 12px; }
|
||||
|
||||
.values-empty {
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #e4e7ed;
|
||||
}
|
||||
|
||||
.values-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.value-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 40px 1.4fr 1.4fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #fafafa;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.value-row:hover {
|
||||
background: #ecf5ff;
|
||||
border-color: #d9ecff;
|
||||
}
|
||||
.value-row:active { cursor: grabbing; }
|
||||
.value-row.is-dragging {
|
||||
opacity: 0.45;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.value-row.drag-over-top {
|
||||
box-shadow: inset 0 2px 0 0 #409eff;
|
||||
}
|
||||
.value-row.drag-over-bottom {
|
||||
box-shadow: inset 0 -2px 0 0 #409eff;
|
||||
}
|
||||
|
||||
.value-drag-handle {
|
||||
color: #c0c4cc;
|
||||
font-size: 15px;
|
||||
cursor: grab;
|
||||
}
|
||||
.value-row:hover .value-drag-handle { color: #409eff; }
|
||||
|
||||
.value-order {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
text-align: center;
|
||||
}
|
||||
.value-name {
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.value-range { min-width: 0; }
|
||||
.value-raw {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
.value-price {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
background: #fef0f0;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.value-price .el-icon { font-size: 12px; }
|
||||
.value-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.value-actions .el-button { padding: 4px 6px; }
|
||||
|
||||
/* 值名称 + 生成按钮 */
|
||||
.name-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
|
||||
/* 数值范围单行配置 */
|
||||
.range-config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.range-config-row .range-type-select { width: 96px; flex: 0 0 96px; }
|
||||
.range-config-row .range-value-input { flex: 1; min-width: 0; }
|
||||
.range-config-row .range-unit-select { width: 100px; flex: 0 0 100px; }
|
||||
.range-config-row :deep(.el-select .el-input__inner) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
text-align: center;
|
||||
}
|
||||
.range-opt-symbol {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #409eff;
|
||||
width: 24px;
|
||||
}
|
||||
.range-opt-desc {
|
||||
margin-left: 12px;
|
||||
color: #8492a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 对话框内滚动区域自定义滚动条 */
|
||||
.parameter-cards-wrap::-webkit-scrollbar { width: 6px; }
|
||||
.parameter-cards-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||
.parameter-cards-wrap::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.parameter-cards-wrap::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
|
||||
|
||||
/* 响应式:窄屏时参数值行改为纵向 */
|
||||
@media (max-width: 768px) {
|
||||
.value-row {
|
||||
grid-template-columns: 24px 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
.value-order { display: none; }
|
||||
.value-range { grid-column: 2 / 3; }
|
||||
.value-price { grid-column: 3 / 4; grid-row: 1 / 2; }
|
||||
.value-actions { grid-column: 3 / 4; grid-row: 2 / 3; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,58 +16,133 @@
|
||||
<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 }"
|
||||
|
||||
<div v-loading="planLoading" class="plan-cards-wrap">
|
||||
<div v-if="planList.length > 0" class="plan-cards-grid">
|
||||
<div
|
||||
v-for="row in planList"
|
||||
:key="row.id"
|
||||
class="plan-card"
|
||||
:class="{ 'is-disabled': row.disable }"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="套餐名称" min-width="120" />
|
||||
<el-table-column label="参数配置" min-width="250">
|
||||
<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 }}: {{ formatArgTagDisplay(arg) }}
|
||||
<!-- 卡片头部:名称 + 状态 -->
|
||||
<div class="plan-card-header">
|
||||
<div class="plan-card-title">
|
||||
<span class="plan-card-name">{{ row.name }}</span>
|
||||
<span class="plan-card-id">#{{ row.id }}</span>
|
||||
</div>
|
||||
<el-tag :type="row.disable ? 'danger' : 'success'" size="small" effect="light">
|
||||
{{ row.disable ? '已禁用' : '启用中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
|
||||
<!-- 关键数据:价格 / 库存 / 额外参数 -->
|
||||
<div class="plan-card-stats">
|
||||
<div class="plan-stat plan-stat-price" :class="{ 'is-dynamic': !isFixedPrice(row) }">
|
||||
<div class="plan-stat-label">
|
||||
<el-icon><PriceTag /></el-icon>
|
||||
<span>{{ isFixedPrice(row) ? '固定价格' : '参数计价' }}</span>
|
||||
</div>
|
||||
<div class="plan-stat-value">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-amount">{{ formatPlanPrice(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
|
||||
<div class="plan-stat-label">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>库存</span>
|
||||
</div>
|
||||
<div class="plan-stat-value">
|
||||
<template v-if="getInventoryNum(row) > 0">
|
||||
<span class="inventory-num">{{ getInventoryNum(row) }}</span>
|
||||
<span class="inventory-unit">件</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 ? '展示' : '不展示' }}
|
||||
<span v-else class="inventory-unlimited">不限</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-stat plan-stat-extra">
|
||||
<div class="plan-stat-label">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>额外参数</span>
|
||||
</div>
|
||||
<div class="plan-stat-value">
|
||||
<span class="extra-num">{{ getExtraArgCount(row) }}</span>
|
||||
<span class="extra-unit">项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数配置:固定高度,超出滚动 -->
|
||||
<div class="plan-card-section">
|
||||
<div class="plan-card-section-title">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>参数配置</span>
|
||||
<span v-if="row.argsParsed && row.argsParsed.length > 0" class="plan-card-section-count">
|
||||
{{ row.argsParsed.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plan-card-args">
|
||||
<template v-if="row.argsParsed && row.argsParsed.length > 0">
|
||||
<el-tag
|
||||
v-for="(arg, index) in row.argsParsed"
|
||||
:key="index"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
class="plan-arg-tag"
|
||||
>
|
||||
<span class="plan-arg-name">{{ arg.name }}</span>
|
||||
<span class="plan-arg-sep">:</span>
|
||||
<span class="plan-arg-value">{{ formatArgTagDisplay(arg) }}</span>
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许升级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small">
|
||||
{{ row.canUpdate || row.can_update ? '允许' : '不允许' }}
|
||||
<div v-else class="plan-card-empty">暂无参数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 元信息:排序 / 展示 / 升级 -->
|
||||
<div class="plan-card-meta">
|
||||
<span class="plan-meta-item">
|
||||
<span class="plan-meta-label">排序</span>
|
||||
<span class="plan-meta-value">{{ row.index ?? 0 }}</span>
|
||||
</span>
|
||||
<el-tag :type="row.showHome || row.show_home ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ 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-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ row.canUpdate || row.can_update ? '允许升级' : '禁止升级' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 说明 -->
|
||||
<div v-if="row.note" class="plan-card-note" :title="row.note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span class="plan-card-note-text">{{ row.note }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="plan-card-actions">
|
||||
<el-button type="primary" link size="small" @click="handleEditPlan(row)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</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>
|
||||
<el-button
|
||||
:type="row.disable ? 'success' : 'warning'"
|
||||
link
|
||||
size="small"
|
||||
@click="handleTogglePlanStatus(row)"
|
||||
>
|
||||
<el-icon><Switch /></el-icon>{{ row.disable ? '启用' : '禁用' }}
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDeletePlan(row)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!planLoading" description="暂无套餐数据" :image-size="80" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
@@ -247,7 +322,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Delete, View, CopyDocument, DocumentAdd, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Delete, View, CopyDocument, DocumentAdd, WarningFilled, Setting, InfoFilled, Edit, Switch, PriceTag, Box, Collection } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductParameterList,
|
||||
getProductPlanList,
|
||||
@@ -354,6 +429,39 @@ const onPlanUnitChange = (spec, newUnit) => {
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const isFixedPrice = (row) => {
|
||||
return !!(row.enableFixedPrice ?? row.enable_fixed_price)
|
||||
}
|
||||
|
||||
const formatPlanPrice = (row) => {
|
||||
const raw = isFixedPrice(row)
|
||||
? (row.fixedPrice ?? row.fixed_price ?? 0)
|
||||
: (row.argsPrice ?? row.args_price ?? 0)
|
||||
return (Number(raw) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
const getInventoryNum = (row) => {
|
||||
return Number(row.inventory ?? 0) || 0
|
||||
}
|
||||
|
||||
const getInventoryClass = (row) => {
|
||||
const n = getInventoryNum(row)
|
||||
if (n === 0) return 'is-unlimited'
|
||||
if (n <= 5) return 'is-low'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getExtraArgCount = (row) => {
|
||||
const extraArgs = row.extraArgs || row.extra_args
|
||||
if (Array.isArray(extraArgs)) return extraArgs.length
|
||||
const ids = row.extraArgIds || row.extra_arg_ids
|
||||
if (Array.isArray(ids)) return ids.length
|
||||
if (typeof ids === 'string' && ids.trim()) {
|
||||
return ids.split(',').filter(Boolean).length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const formatArgTagDisplay = (arg) => {
|
||||
if (arg.number !== undefined && arg.number !== 0) {
|
||||
const spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
@@ -744,6 +852,338 @@ watch(() => props.visible, (val) => {
|
||||
.plan-management { padding: 0; }
|
||||
.plan-header { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.args-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
/* ===== 套餐卡片列表 ===== */
|
||||
.plan-cards-wrap {
|
||||
min-height: 160px;
|
||||
}
|
||||
.plan-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.plan-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s, transform 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
position: relative;
|
||||
}
|
||||
.plan-card:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.plan-card.is-disabled {
|
||||
background: #fafafa;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.plan-card.is-disabled::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 10px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0 10px,
|
||||
rgba(245, 108, 108, 0.04) 10px 11px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plan-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.plan-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.plan-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-card-id {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plan-stat {
|
||||
position: relative;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.plan-stat-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1;
|
||||
}
|
||||
.plan-stat-label .el-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.plan-stat-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-stat-price {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffeded 100%);
|
||||
border-color: #fcdcdc;
|
||||
}
|
||||
.plan-stat-price .plan-stat-label {
|
||||
color: #e47470;
|
||||
}
|
||||
.plan-stat-price .price-symbol {
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
margin-right: 1px;
|
||||
}
|
||||
.plan-stat-price .price-amount {
|
||||
font-size: 18px;
|
||||
color: #f56c6c;
|
||||
font-weight: 700;
|
||||
}
|
||||
.plan-stat-price.is-dynamic {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
|
||||
border-color: #c6e2ff;
|
||||
}
|
||||
.plan-stat-price.is-dynamic .plan-stat-label {
|
||||
color: #409eff;
|
||||
}
|
||||
.plan-stat-price.is-dynamic .price-symbol,
|
||||
.plan-stat-price.is-dynamic .price-amount {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.plan-stat-inventory .inventory-num {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #67c23a;
|
||||
}
|
||||
.plan-stat-inventory .inventory-unit {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
.plan-stat-inventory.is-low {
|
||||
background: linear-gradient(135deg, #fdf6ec 0%, #faecd8 100%);
|
||||
border-color: #f5dab1;
|
||||
}
|
||||
.plan-stat-inventory.is-low .inventory-num {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.plan-stat-inventory.is-low .plan-stat-label {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.plan-stat-inventory.is-unlimited {
|
||||
background: linear-gradient(135deg, #f4f4f5 0%, #ebeef5 100%);
|
||||
}
|
||||
.plan-stat-inventory .inventory-unlimited {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-stat-extra .extra-num {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
}
|
||||
.plan-stat-extra .extra-unit {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.plan-card-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plan-card-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.plan-card-section-title .el-icon {
|
||||
color: #409eff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.plan-card-section-count {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.plan-card-args {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
height: 108px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.plan-card-args::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.plan-card-args::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.plan-card-args::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(144, 147, 153, 0.25);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.plan-card-args::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(144, 147, 153, 0.45);
|
||||
}
|
||||
.plan-card-empty {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.plan-arg-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.plan-arg-tag :deep(.el-tag__content) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.plan-arg-name {
|
||||
color: #909399;
|
||||
}
|
||||
.plan-arg-sep {
|
||||
color: #c0c4cc;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.plan-arg-value {
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.plan-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.plan-meta-label {
|
||||
color: #909399;
|
||||
}
|
||||
.plan-meta-value {
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-card-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #fffbea;
|
||||
border: 1px solid #ffe8a6;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.plan-card-note .el-icon {
|
||||
color: #e6a23c;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.plan-card-note-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.plan-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
margin-top: auto;
|
||||
}
|
||||
.plan-card-actions .el-button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.plan-card-actions .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user