Files
admin-ui/src/views/assets/asset/component/editAsset.vue
2910410219 0a42e700e2 更新模型配置和订阅页面
- 修改模型模块的字段名称,从 `keyword` 更改为 `modelName`,以提高一致性。
- 添加模型类型和访问类型的选择功能,增强用户交互体验。
- 移除不必要的调试日志,优化代码整洁性。
- 更新订阅页面的错误处理逻辑,确保用户在加载失败时获得清晰反馈。
2026-05-11 13:48:20 +08:00

1409 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="assets-edit-asset-container">
<el-dialog :title="isEdit ? '修改资产' : '新增资产'" v-model="isShowDialog" width="1000px" destroy-on-close>
<el-form ref="formRef" :model="ruleForm" :rules="rules" size="default" label-width="100px" v-loading="formLoading">
<!-- 基础信息 -->
<el-divider content-position="left">基础信息</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="资产名称" prop="name">
<el-input v-model="ruleForm.name" placeholder="请输入资产名称" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="资产类型" prop="type">
<el-select v-model="ruleForm.type" placeholder="请选择资产类型" class="w100" :disabled="isEdit">
<el-option label="实物" value="physical" />
<el-option label="虚拟" value="virtual" />
<el-option label="服务" value="service" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="资产分类" prop="categoryId">
<el-cascader
v-model="ruleForm.categoryId"
:options="categoryOptions"
:props="categoryProps"
placeholder="请选择资产分类"
clearable
class="w100"
:disabled="isEdit"
@change="onCategoryChange"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="上线时间">
<el-date-picker
v-model="ruleForm.onlineTime"
type="datetime"
placeholder="请选择上线时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w100"
@change="onOnlineTimeChange"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="下线时间">
<el-date-picker
v-model="ruleForm.offlineTime"
type="datetime"
placeholder="请选择下线时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w100"
:disabled-date="disabledOfflineDate"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="库存类型">
<el-radio-group v-model="ruleForm.unlimitedStock" :disabled="isEdit">
<el-radio :value="false">有限库存</el-radio>
<el-radio :value="true">无限库存</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8" v-if="tenantId == 1">
<el-form-item label="存储模式">
<el-radio-group v-model="ruleForm.stockMode" :disabled="isEdit">
<el-radio :value="1">明细模式</el-radio>
<el-radio :value="2">批次模式</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<!-- 分类属性值选择 -->
<template v-if="categoryAttrs.length > 0">
<el-divider content-position="left">分类属性</el-divider>
<el-row :gutter="24">
<el-col :span="8" v-for="(attr, index) in categoryAttrs" :key="index">
<el-form-item
:label="getAttrLabel(attr)"
:prop="'metadata.' + getAttrKey(attr)"
:rules="[{ required: !!attr.required, message: getAttrLabel(attr) + '不能为空', trigger: ['blur', 'change'] }]"
>
<!-- 单选类型 -->
<el-select
v-if="attr.type === 'select'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
:placeholder="'请选择' + getAttrLabel(attr)"
class="w100"
clearable
>
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 多选类型 -->
<el-select
v-else-if="attr.type === 'multi_select'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
:placeholder="'请选择' + getAttrLabel(attr)"
class="w100"
multiple
clearable
>
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 文本类型 -->
<el-input
v-else-if="attr.type === 'text'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
:placeholder="'请输入' + getAttrLabel(attr)"
/>
<!-- 数字类型 -->
<el-input-number v-else-if="attr.type === 'number'" v-model="ruleForm.metadata[getAttrKey(attr)]" class="w100" />
<!-- 日期类型 -->
<el-date-picker
v-else-if="attr.type === 'date'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
type="date"
:placeholder="'请选择' + getAttrLabel(attr)"
class="w100"
/>
<!-- 布尔类型 -->
<el-switch v-else-if="attr.type === 'boolean'" v-model="ruleForm.metadata[getAttrKey(attr)]" />
<!-- 图片类型 -->
<div v-else-if="attr.type === 'image'" class="w100">
<el-upload
class="attr-image-uploader"
:show-file-list="false"
:auto-upload="true"
accept="image/*"
:http-request="onAttrImageUpload(attr)"
>
<img
v-if="ruleForm.metadata[getAttrKey(attr)]"
:src="formatImageUrl(ruleForm.metadata[getAttrKey(attr)])"
class="avatar"
style="width: 80px; height: 80px"
/>
<el-icon v-else class="avatar-uploader-icon" style="width: 80px; height: 80px; line-height: 80px"><Plus /></el-icon>
</el-upload>
<el-button
v-if="ruleForm.metadata[getAttrKey(attr)]"
type="danger"
link
size="small"
@click.stop="removeAttrImage(attr)"
style="margin-top: 5px"
>
删除图片
</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 图片上传 -->
<el-divider content-position="left">图片信息</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="主图" prop="mainImage">
<div class="w100">
<el-upload class="avatar-uploader" :show-file-list="false" :auto-upload="true" :http-request="handleMainImageUpload" accept="image/*">
<img v-if="mainImagePreview" :src="mainImagePreview" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
<div v-if="mainImagePreview" style="margin-top: 5px">
<el-button type="primary" link size="small" @click.stop="previewMainImage">预览</el-button>
<el-button type="danger" link size="small" @click.stop="removeMainImage">删除</el-button>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="图片列表">
<el-upload
v-model:file-list="imageFileList"
list-type="picture-card"
:auto-upload="true"
:http-request="handleImageListUpload"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
accept="image/*"
multiple
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<!-- 资产描述 -->
<el-divider content-position="left">资产描述</el-divider>
<el-form-item label="描述内容" label-width="100px">
<div class="editor-wrapper">
<Editor v-model="ruleForm.description" height="200px" placeholder="请输入资产描述" />
</div>
</el-form-item>
<!-- 实物资产配置 -->
<template v-if="ruleForm.type === 'physical'">
<el-divider content-position="left">实物资产配置</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="配送方式">
<el-select v-model="ruleForm.physicalAssetConfig.shipping.deliveryMethod" placeholder="请选择配送方式" class="w100">
<el-option label="快递" value="express" />
<el-option label="自提" value="self_pickup" />
<el-option label="同城配送" value="city_delivery" disabled />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 虚拟资产配置 -->
<template v-if="ruleForm.type === 'virtual'">
<el-divider content-position="left">虚拟资产配置</el-divider>
<!-- 虚拟类型选择 -->
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="虚拟类型" prop="virtualAssetConfig.virtualType">
<el-select v-model="ruleForm.virtualAssetConfig.virtualType" placeholder="请选择虚拟类型" class="w100">
<el-option label="充值" value="recharge" />
<el-option label="订阅" value="subscribe" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8" v-if="ruleForm.virtualAssetConfig.virtualType === 'subscribe'">
<el-form-item label="合集价格" prop="virtualAssetConfig.collectionPrice">
<el-input-number v-model="ruleForm.virtualAssetConfig.collectionPrice" :min="0" :precision="0" class="w100" />
<span class="unit-text"></span>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="货币单位" prop="virtualAssetConfig.currency">
<el-select v-model="ruleForm.virtualAssetConfig.currency" placeholder="请选择货币单位" class="w100">
<el-option label="人民币 (CNY)" value="CNY" />
<el-option label="美元 (USD)" value="USD" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- API 配置 -->
<el-divider content-position="left">API 配置</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="请求方式" prop="virtualAssetConfig.apiConfig.method">
<el-select v-model="ruleForm.virtualAssetConfig.apiConfig.method" placeholder="请选择请求方式" class="w100">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="请求地址" prop="virtualAssetConfig.apiConfig.requestURL">
<el-input v-model="ruleForm.virtualAssetConfig.apiConfig.requestURL" placeholder="请输入请求地址" class="w100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="请求头">
<div class="config-list-container">
<div v-for="(item, index) in ruleForm.virtualAssetConfig.apiConfig.headers" :key="index" class="config-list-item">
<el-input v-model="item.key" placeholder="Key" style="width: 200px" />
<span class="separator">:</span>
<el-input v-model="item.value" placeholder="Value" style="width: 200px" />
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers, index)"
/>
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers)"
>添加请求头</el-button
>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="请求参数">
<div class="config-list-container">
<div v-for="(item, index) in ruleForm.virtualAssetConfig.apiConfig.params" :key="index" class="config-list-item">
<el-input v-model="item.key" placeholder="Key" style="width: 200px" />
<span class="separator">:</span>
<el-input v-model="item.value" placeholder="Value" style="width: 200px" />
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params, index)"
/>
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params)"
>添加请求参数</el-button
>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="认证方式" prop="virtualAssetConfig.apiConfig.authType">
<el-select v-model="ruleForm.virtualAssetConfig.apiConfig.authType" placeholder="请选择认证方式" class="w100">
<el-option label="无" value="none" />
<el-option label="API Key" value="apikey" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="OAuth" value="oauth" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16" v-if="ruleForm.virtualAssetConfig.apiConfig.authType !== 'none'">
<el-form-item label="认证配置" prop="virtualAssetConfig.apiConfig.authConfig">
<el-input
v-model="ruleForm.virtualAssetConfig.apiConfig.authConfig"
type="textarea"
:rows="2"
placeholder="请输入认证配置"
class="w100"
/>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 服务资产配置 -->
<template v-if="ruleForm.type === 'service'">
<el-divider content-position="left">服务资产配置</el-divider>
<!-- 服务类型选择 -->
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="服务类型" prop="serviceAssetConfig.serviceAssetType">
<el-select v-model="ruleForm.serviceAssetConfig.serviceAssetType" placeholder="请选择服务类型" class="w100">
<el-option label="到店" value="arrival" />
<el-option label="上门" value="visit" disabled />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 到店服务配置 -->
<template v-if="ruleForm.serviceAssetConfig.serviceAssetType === 'arrival'">
<!-- 预订配置 -->
<el-row :gutter="24">
<el-col :span="6">
<el-form-item label="最小提前" prop="serviceAssetConfig.serviceAssetArrivalConfig.booking.minAdvance">
<el-input-number v-model="ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.booking.minAdvance" :min="0" class="w100" />
<span class="unit-text">分钟</span>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="最小时长" prop="serviceAssetConfig.serviceAssetArrivalConfig.booking.minDuration">
<el-input-number v-model="ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.booking.minDuration" :min="0" class="w100" />
<span class="unit-text">分钟</span>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="取消提前" prop="serviceAssetConfig.serviceAssetArrivalConfig.booking.cancelWindow">
<el-input-number v-model="ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.booking.cancelWindow" :min="0" class="w100" />
<span class="unit-text">分钟</span>
</el-form-item>
</el-col>
</el-row>
<!-- 时间段配置 -->
<el-form-item label="服务时间" prop="serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots">
<div class="config-list-container">
<div
v-for="(slot, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots"
:key="index"
class="config-list-item"
>
<el-select v-model="slot.dayOfWeek" placeholder="星期" style="width: 100px">
<el-option v-for="d in 7" :key="d" :label="'周' + ['一', '二', '三', '四', '五', '六', '日'][d - 1]" :value="String(d)" />
</el-select>
<el-time-picker v-model="slot.startTime" format="HH:mm" value-format="HH:mm" placeholder="开始" style="width: 100px" />
<span class="separator">-</span>
<el-time-picker v-model="slot.endTime" format="HH:mm" value-format="HH:mm" placeholder="结束" style="width: 100px" />
<el-input-number v-model="slot.capacity" :min="1" placeholder="容量" style="width: 100px" controls-position="right" />
<el-button type="danger" :icon="Delete" circle size="small" @click="removeTimeSlot(index)" />
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addTimeSlot" :disabled="isTimeSlotLimitReached"> 添加时间段 </el-button>
</div>
</el-form-item>
<!-- 例外日期配置 -->
<el-form-item label="休息时间">
<div class="config-list-container">
<div
v-for="(exc, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions"
:key="index"
class="config-list-item"
>
<el-select v-model="exc.exceptionType" placeholder="类型" style="width: 100px" @change="onExceptionTypeChange(exc)">
<el-option label="指定日期" value="date" />
<el-option label="指定星期" value="dayOfWeek" />
</el-select>
<el-date-picker
v-if="exc.exceptionType === 'date'"
v-model="exc.date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="日期"
style="width: 130px"
/>
<el-select v-if="exc.exceptionType === 'dayOfWeek'" v-model="exc.dayOfWeek" placeholder="星期" style="width: 100px">
<el-option v-for="d in 7" :key="d" :label="'周' + ['一', '二', '三', '四', '五', '六', '日'][d - 1]" :value="String(d)" />
</el-select>
<el-select v-model="exc.status" placeholder="状态" style="width: 90px">
<el-option label="可用" :value="1" />
<el-option label="不可用" :value="0" />
</el-select>
<el-input v-model="exc.reason" placeholder="原因" style="width: 120px" />
<el-button type="danger" :icon="Delete" circle size="small" @click="removeException(index)" />
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addException">添加休息时间</el-button>
</div>
</el-form-item>
</template>
</template>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel" size="default"> </el-button>
<el-button type="primary" @click="onSubmit" size="default" :loading="submitLoading">{{ isEdit ? '修 改' : '添 加' }}</el-button>
</span>
</template>
</el-dialog>
<!-- 图片预览 -->
<el-dialog v-model="dialogVisible" title="图片预览">
<img :src="dialogImageUrl" style="width: 100%" />
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: 'assetsEditAsset',
};
</script>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { Plus, Delete } from '@element-plus/icons-vue';
import { getAsset, createAsset, updateAsset, uploadAssetImage } from '/@/api/assets/asset';
import { getCategoryTree, getCategory } from '/@/api/assets/category';
import { createFormDiff } from '/@/utils/diffUtils';
import { Session } from '/@/utils/storage';
import Editor from '/@/components/editor/index.vue';
import type { UploadFile, UploadUserFile, UploadRequestOptions } from 'element-plus';
// 类型定义
interface TimeSlot {
dayOfWeek: string;
startTime: string;
endTime: string;
capacity: number;
}
interface Exception {
exceptionType: 'date' | 'dayOfWeek';
date: string;
status: number;
reason: string;
dayOfWeek: string;
}
interface CategoryAttr {
name: string;
type: string;
options?: { label: string; value: string }[];
required?: boolean;
dictType?: string;
}
interface KeyValuePair {
key: string;
value: string;
}
interface RuleForm {
id: string;
name: string;
type: string;
categoryId: string;
categoryPath: string;
description: string;
onlineTime: string;
offlineTime: string;
unlimitedStock: boolean;
stockMode: number;
physicalAssetConfig: {
shipping: {
deliveryMethod: string;
deliveryTime: number;
};
};
virtualAssetConfig: {
virtualType: string;
collectionPrice: number;
currency: string;
apiConfig: {
method: string;
requestURL: string;
headers: KeyValuePair[];
params: KeyValuePair[];
authType: string;
authConfig: string;
};
};
serviceAssetConfig: {
serviceAssetType: string;
serviceAssetArrivalConfig: {
schedule: {
timeSlots: TimeSlot[];
exceptions: Exception[];
};
booking: {
minAdvance: number;
minDuration: number;
cancelWindow: number;
};
capacity: {
maxUsers: number;
};
};
};
metadata: Record<string, any>;
mainImage?: string;
}
const emit = defineEmits(['getAssetList']);
const formRef = ref<FormInstance>();
const editAssetRef = ref();
const MAX_TIME_SLOTS = 7;
const isShowDialog = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formLoading = ref(false);
const categoryOptions = ref<any[]>([]);
const categoryAttrs = ref<CategoryAttr[]>([]);
// 分类选择器配置 - 只能选择最下级节点(isLeafNode: true)
const categoryProps = {
checkStrictly: true,
emitPath: false,
value: 'id',
label: 'name',
children: 'children',
disabled: (data: any) => !data.isLeafNode,
};
const isTimeSlotLimitReached = computed(() => ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots.length >= MAX_TIME_SLOTS);
// 获取属性的key
const getAttrKey = (attr: CategoryAttr): string => {
return attr.name || `attr_${categoryAttrs.value.indexOf(attr)}`;
};
// 获取属性的显示名称
const getAttrLabel = (attr: CategoryAttr): string => {
return attr.name || '属性';
};
// 图片相关
const mainImagePreview = ref('');
const imageFileList = ref<UploadUserFile[]>([]);
const dialogVisible = ref(false);
const dialogImageUrl = ref('');
// 图片拼接
const fileAddressPrefix = ref('');
// 使用通用工具函数保存原始数据,用于最小化传参
const assetFormDiff = createFormDiff<Record<string, any>>();
// 获取租户ID
const tenantId = ref(Session.get('userInfo')?.tenantId || '');
const formatImageUrl = (url?: string) => {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
if (/^blob:/i.test(url)) return url; // 支持本地预览地址
return `${fileAddressPrefix.value || ''}${url}`;
};
const createDefaultTimeSlots = (): TimeSlot[] => {
const slots: TimeSlot[] = [];
for (let i = 1; i <= MAX_TIME_SLOTS; i++) {
slots.push({
dayOfWeek: String(i),
startTime: '09:00',
endTime: '18:00',
capacity: 100,
});
}
return slots;
};
// 初始表单数据
const getInitialForm = (): RuleForm => ({
id: '',
name: '',
type: 'physical',
categoryId: '',
categoryPath: '',
description: '',
onlineTime: '',
offlineTime: '',
unlimitedStock: false,
stockMode: 1,
physicalAssetConfig: {
shipping: {
deliveryMethod: 'express',
deliveryTime: 24,
},
},
virtualAssetConfig: {
virtualType: 'recharge',
collectionPrice: 0,
currency: 'CNY',
apiConfig: {
method: 'GET',
requestURL: '',
headers: [],
params: [],
authType: 'none',
authConfig: '',
},
},
serviceAssetConfig: {
serviceAssetType: 'arrival',
serviceAssetArrivalConfig: {
schedule: {
timeSlots: createDefaultTimeSlots(),
exceptions: [],
},
booking: {
minAdvance: 60,
minDuration: 30,
cancelWindow: 30,
},
capacity: {
maxUsers: 0,
},
},
},
metadata: {},
mainImage: '',
});
const ruleForm = reactive<RuleForm>(getInitialForm());
const validateOfflineTime = (_rule: any, value: string, callback: Function) => {
if (value && ruleForm.onlineTime && new Date(value).getTime() < new Date(ruleForm.onlineTime).getTime()) {
callback(new Error('下线时间不能早于上线时间'));
} else {
callback();
}
};
const disabledOfflineDate = (time: Date) => {
if (!ruleForm.onlineTime) return false;
return time.getTime() < new Date(ruleForm.onlineTime).setHours(0, 0, 0, 0);
};
const validateTimeSlots = (_rule: any, value: TimeSlot[], callback: Function) => {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个服务时间段'));
return;
}
for (let i = 0; i < value.length; i++) {
const slot = value[i];
if (!slot.dayOfWeek || !slot.startTime || !slot.endTime || !slot.capacity) {
callback(new Error(`${i + 1} 行服务时间配置不完整`));
return;
}
}
callback();
};
const onOnlineTimeChange = () => {
if (ruleForm.offlineTime) {
formRef.value?.validateField('offlineTime');
}
};
const rules: FormRules = {
name: [{ required: true, message: '资产名称不能为空', trigger: 'blur' }],
type: [{ required: true, message: '请选择资产类型', trigger: 'change' }],
categoryId: [{ required: true, message: '请选择资产分类', trigger: 'change' }],
offlineTime: [{ validator: validateOfflineTime, trigger: 'change' }],
mainImage: [{ required: true, message: '请上传主图', trigger: 'change' }],
'serviceAssetConfig.serviceAssetArrivalConfig.booking.minAdvance': [{ required: true, message: '请输入最小提前时间', trigger: 'blur' }],
'serviceAssetConfig.serviceAssetArrivalConfig.booking.minDuration': [{ required: true, message: '请输入最小时长', trigger: 'blur' }],
'serviceAssetConfig.serviceAssetArrivalConfig.booking.cancelWindow': [{ required: true, message: '请输入取消提前时间', trigger: 'blur' }],
'serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots': [{ validator: validateTimeSlots, trigger: 'change' }],
};
// 主图上传处理
const handleMainImageUpload = async (options: UploadRequestOptions) => {
try {
const url = await uploadImage(options.file);
if (url) {
ruleForm.mainImage = url;
mainImagePreview.value = formatImageUrl(url);
formRef.value?.validateField('mainImage');
}
} catch (error) {
ElMessage.error('主图上传失败');
}
};
// 图片列表上传处理
const handleImageListUpload = async (options: UploadRequestOptions) => {
try {
const url = await uploadImage(options.file);
if (url) {
const fileItem = imageFileList.value.find((f) => f.uid === options.file.uid);
if (fileItem) {
fileItem.url = formatImageUrl(url);
fileItem.status = 'success';
fileItem.response = url; // 存储原始 URL
}
}
} catch (error) {
ElMessage.error('上传失败');
const index = imageFileList.value.findIndex((f) => f.uid === options.file.uid);
if (index !== -1) {
imageFileList.value.splice(index, 1);
}
}
};
// 属性图片上传处理
const onAttrImageUpload = (attr: CategoryAttr) => {
return async (options: UploadRequestOptions) => {
try {
const url = await uploadImage(options.file);
if (url) {
const key = getAttrKey(attr);
ruleForm.metadata[key] = url;
formRef.value?.validateField(`metadata.${key}`);
}
} catch (error) {
ElMessage.error('图片上传失败');
}
};
};
// 主图预览
const previewMainImage = () => {
if (mainImagePreview.value) {
dialogImageUrl.value = mainImagePreview.value;
dialogVisible.value = true;
}
};
// 主图删除
const removeMainImage = () => {
mainImagePreview.value = '';
ruleForm.mainImage = '';
formRef.value?.validateField('mainImage');
};
// 图片列表预览
const handlePictureCardPreview = (file: UploadFile) => {
dialogImageUrl.value = file.url || '';
dialogVisible.value = true;
};
// 图片列表移除
const handleRemove = (file: UploadFile) => {
const index = imageFileList.value.findIndex((f) => f.uid === file.uid);
if (index > -1) {
imageFileList.value.splice(index, 1);
}
};
// 移除属性图片
const removeAttrImage = (attr: CategoryAttr) => {
const key = getAttrKey(attr);
ruleForm.metadata[key] = '';
formRef.value?.validateField(`metadata.${key}`);
};
// 时间段操作
const addTimeSlot = () => {
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule = { timeSlots: [], exceptions: [] };
}
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots = [];
}
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots.push({
dayOfWeek: '1',
startTime: '09:00',
endTime: '18:00',
capacity: 100,
});
formRef.value?.validateField('serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots');
};
const removeTimeSlot = (index: number) => {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots.splice(index, 1);
formRef.value?.validateField('serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots');
};
// 例外日期操作
const addException = () => {
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule = { timeSlots: [], exceptions: [] };
}
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions = [];
}
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions.push({
exceptionType: 'date',
date: '',
status: 1,
reason: '',
dayOfWeek: '1',
});
};
const removeException = (index: number) => {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions.splice(index, 1);
};
// 例外类型切换时清空对应字段
const onExceptionTypeChange = (exc: Exception) => {
if (exc.exceptionType === 'date') {
exc.dayOfWeek = '';
} else {
exc.date = '';
}
};
// 键值对操作
const addKeyValuePair = (list: KeyValuePair[]) => {
list.push({ key: '', value: '' });
};
const removeKeyValuePair = (list: KeyValuePair[], index: number) => {
list.splice(index, 1);
};
// 重置表单
const resetForm = () => {
const initial = getInitialForm();
Object.assign(ruleForm, initial);
mainImagePreview.value = '';
imageFileList.value = [];
categoryAttrs.value = [];
fileAddressPrefix.value = '';
};
// 获取分类数据
const fetchCategories = () => {
getCategoryTree()
.then((res: any) => {
const tree = res.data?.tree ?? [];
categoryOptions.value = tree.length > 0 && tree[0].children ? tree[0].children : tree;
})
.catch(() => {
categoryOptions.value = [];
});
};
// 递归查找分类节点
const findCategoryNode = (nodes: any[], id: string): any => {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children?.length) {
const found = findCategoryNode(node.children, id);
if (found) return found;
}
}
return null;
};
// 分类变更时获取分类属性和path
const onCategoryChange = (categoryId: string) => {
categoryAttrs.value = [];
ruleForm.metadata = {};
ruleForm.categoryPath = '';
if (!categoryId) return;
// 从分类树中查找选中节点获取path
const node = findCategoryNode(categoryOptions.value, categoryId);
if (node?.path !== undefined) {
ruleForm.categoryPath = node.path;
}
getCategory(categoryId)
.then((res: any) => {
const data = res.data;
// 如果接口返回了path优先使用接口返回的
if (data?.path !== undefined) {
ruleForm.categoryPath = data.path;
}
if (data?.attrs && Array.isArray(data.attrs)) {
categoryAttrs.value = data.attrs;
// 初始化属性值,确保 boolean 类型默认为 false
categoryAttrs.value.forEach((attr: CategoryAttr) => {
const key = getAttrKey(attr);
if (attr.type === 'boolean') {
ruleForm.metadata[key] = false;
}
});
}
})
.catch(() => {
categoryAttrs.value = [];
});
};
// 打开弹窗
const openDialog = (row?: any, edit?: boolean) => {
resetForm();
isEdit.value = edit || false;
fetchCategories();
if (row && edit) {
// 修改模式:获取详情
formLoading.value = true;
getAsset(row.id)
.then((res: any) => {
const data = res.data;
// 支持 fileAddressPrefix 和 imgAddressPrefix
fileAddressPrefix.value = data.fileAddressPrefix || data.imgAddressPrefix || '';
ruleForm.id = data.id || '';
ruleForm.name = data.name || '';
ruleForm.type = data.type || 'physical';
ruleForm.categoryId = data.categoryId || '';
ruleForm.categoryPath = data.categoryPath || '';
ruleForm.description = data.description || '';
ruleForm.onlineTime = data.onlineTime || '';
ruleForm.offlineTime = data.offlineTime || '';
ruleForm.unlimitedStock = data.unlimitedStock || false;
ruleForm.stockMode = data.stockMode || 1;
// 主图预览 (支持 imageUrl 和 fileURL)
const mainImg = data.imageUrl || data.fileURL;
if (mainImg) {
mainImagePreview.value = formatImageUrl(mainImg);
ruleForm.mainImage = mainImg;
}
// 图片列表
if (data.images && Array.isArray(data.images)) {
imageFileList.value = data.images.map((url: string, index: number) => ({
name: `image-${index}`,
url: formatImageUrl(url),
response: url, // 存储原始相对路径,供提交时使用
}));
}
// 根据类型加载配置
if (data.type === 'physical' && data.physicalAssetConfig) {
Object.assign(ruleForm.physicalAssetConfig, data.physicalAssetConfig);
}
if (data.type === 'virtual' && data.virtualAssetConfig) {
// 先处理 headers 和 params 为数组格式,再赋值
const config = { ...data.virtualAssetConfig };
// 确保 apiConfig 存在
if (!config.apiConfig) {
config.apiConfig = {
method: 'GET',
requestURL: '',
headers: [],
params: [],
authType: 'none',
authConfig: '',
};
} else {
// 处理 headers
if (config.apiConfig.headers && typeof config.apiConfig.headers === 'object' && !Array.isArray(config.apiConfig.headers)) {
config.apiConfig.headers = Object.keys(config.apiConfig.headers).map((key) => ({ key, value: config.apiConfig.headers[key] }));
} else if (!Array.isArray(config.apiConfig.headers)) {
config.apiConfig.headers = [];
}
// 处理 params
if (config.apiConfig.params && typeof config.apiConfig.params === 'object' && !Array.isArray(config.apiConfig.params)) {
config.apiConfig.params = Object.keys(config.apiConfig.params).map((key) => ({ key, value: config.apiConfig.params[key] }));
} else if (!Array.isArray(config.apiConfig.params)) {
config.apiConfig.params = [];
}
}
Object.assign(ruleForm.virtualAssetConfig, config);
}
if (data.type === 'service' && data.serviceAssetConfig) {
Object.assign(ruleForm.serviceAssetConfig, data.serviceAssetConfig);
// 确保 serviceAssetArrivalConfig 对象存在
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig = {
schedule: { timeSlots: [], exceptions: [] },
booking: { minAdvance: 60, minDuration: 30, cancelWindow: 30 },
capacity: { maxUsers: 0 },
};
}
// 确保 schedule 对象存在
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule = { timeSlots: [], exceptions: [] };
}
// 确保数组存在,防止后端返回 null 或 undefined 导致 push 报错
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions = [];
} else {
// 补充缺失的 exceptionType
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions.forEach((exc: Exception) => {
if (!exc.exceptionType) {
if (exc.date) {
exc.exceptionType = 'date';
} else if (exc.dayOfWeek) {
exc.exceptionType = 'dayOfWeek';
} else {
// 默认值
exc.exceptionType = 'date';
}
}
});
}
if (!ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots) {
ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots = [];
}
}
// 元数据
if (data.metadata) {
if (Array.isArray(data.metadata)) {
data.metadata.forEach((item: any) => {
if (item.name) {
let val = item.value;
// 修复回显问题:如果 value 不存在但 options 存在,尝试从 options 恢复值
if ((val === undefined || val === null) && item.options && item.options.length > 0) {
if (item.type === 'multi_select') {
val = item.options.map((opt: any) => opt.value);
} else if (item.type === 'select') {
val = item.options[0].value;
}
}
ruleForm.metadata[item.name] = val;
}
});
} else {
Object.assign(ruleForm.metadata, data.metadata);
}
}
// 加载分类属性
if (data.categoryId) {
getCategory(data.categoryId)
.then((catRes: any) => {
const catData = catRes.data;
if (catData?.attrs && Array.isArray(catData.attrs)) {
categoryAttrs.value = catData.attrs;
// 初始化属性值,确保 boolean 类型默认为 false
categoryAttrs.value.forEach((attr: CategoryAttr) => {
const key = getAttrKey(attr);
if (attr.type === 'boolean' && ruleForm.metadata[key] === undefined) {
ruleForm.metadata[key] = false;
}
});
}
})
.catch(() => {
categoryAttrs.value = [];
})
.finally(() => {
// 分类属性加载完成后,保存原始数据用于最小化传参
buildRequestBody().then((originalBody) => {
assetFormDiff.saveOriginal(JSON.parse(JSON.stringify(originalBody)));
});
});
} else {
// 没有分类属性,直接保存原始数据
buildRequestBody().then((originalBody) => {
assetFormDiff.saveOriginal(JSON.parse(JSON.stringify(originalBody)));
});
}
})
.finally(() => {
formLoading.value = false;
});
}
isShowDialog.value = true;
};
// 关闭弹窗
const closeDialog = () => {
isShowDialog.value = false;
};
// 取消
const onCancel = () => {
closeDialog();
};
// 上传图片并返回URL
const uploadImage = async (file: File): Promise<string> => {
const res: any = await uploadAssetImage(file);
// 1. 尝试获取并设置 fileAddressPrefix
// 优先检查顶层,再检查 data 内部
if (res.fileAddressPrefix) {
fileAddressPrefix.value = res.fileAddressPrefix;
} else if (res.data && typeof res.data === 'object' && res.data.fileAddressPrefix) {
fileAddressPrefix.value = res.data.fileAddressPrefix;
}
// 2. 尝试获取 fileURL / url
// 优先检查顶层 fileURL
if (res.fileURL) return res.fileURL;
// 检查 data 对象中的 fileURL 或 url
if (res.data && typeof res.data === 'object') {
if (res.data.fileURL) return res.data.fileURL;
if (res.data.url) return res.data.url;
}
// 3. 兼容旧逻辑 (data 直接是 url 字符串)
if (typeof res.data === 'string') return res.data;
return '';
};
// 构建请求体
const buildRequestBody = async (): Promise<any> => {
const body: any = {
name: ruleForm.name,
type: ruleForm.type,
categoryId: ruleForm.categoryId,
categoryPath: ruleForm.categoryPath || '',
description: ruleForm.description || '',
};
// 编辑模式添加 id
if (isEdit.value && ruleForm.id) {
body.id = ruleForm.id;
}
// 上下线时间
if (ruleForm.onlineTime) {
body.onlineTime = ruleForm.onlineTime;
}
if (ruleForm.offlineTime) {
body.offlineTime = ruleForm.offlineTime;
}
// 库存类型
body.unlimitedStock = ruleForm.unlimitedStock;
// 库存存储模式仅租户ID为1时提交
if (tenantId.value == 1) {
body.stockMode = ruleForm.stockMode;
}
// 主图 (已在上传时直接赋值给 ruleForm.mainImage)
if (ruleForm.mainImage) {
body.imageURL = ruleForm.mainImage;
}
// 图片列表
const imageUrls: string[] = [];
for (const file of imageFileList.value) {
let url = (file.response as string) || '';
if (!url && file.url && !file.url.startsWith('blob:')) {
url = file.url;
}
if (url) {
// 移除前缀,确保提交相对路径
if (fileAddressPrefix.value && url.startsWith(fileAddressPrefix.value)) {
url = url.substring(fileAddressPrefix.value.length);
}
imageUrls.push(url);
}
}
body.images = imageUrls;
// 根据类型添加配置
if (ruleForm.type === 'physical') {
body.physicalAssetConfig = ruleForm.physicalAssetConfig;
} else if (ruleForm.type === 'virtual') {
// 深拷贝 virtualAssetConfig 以避免修改原对象
const virtualConfig = JSON.parse(JSON.stringify(ruleForm.virtualAssetConfig));
// 将数组转换为对象
if (virtualConfig.apiConfig) {
if (Array.isArray(virtualConfig.apiConfig.headers)) {
const headersObj: Record<string, string> = {};
virtualConfig.apiConfig.headers.forEach((item: KeyValuePair) => {
if (item.key) headersObj[item.key] = item.value;
});
virtualConfig.apiConfig.headers = headersObj;
}
if (Array.isArray(virtualConfig.apiConfig.params)) {
const paramsObj: Record<string, string> = {};
virtualConfig.apiConfig.params.forEach((item: KeyValuePair) => {
if (item.key) paramsObj[item.key] = item.value;
});
virtualConfig.apiConfig.params = paramsObj;
}
}
body.virtualAssetConfig = virtualConfig;
} else if (ruleForm.type === 'service') {
body.serviceAssetConfig = ruleForm.serviceAssetConfig;
}
// 元数据(分类属性值)
if (categoryAttrs.value.length > 0) {
const metadataArray: any[] = [];
for (const attr of categoryAttrs.value) {
const key = getAttrKey(attr);
let value = ruleForm.metadata[key];
// 如果是图片类型,且值是 blob 开头(未上传成功的情况),置为空
if (attr.type === 'image' && typeof value === 'string' && value.startsWith('blob:')) {
value = '';
}
const metaItem: any = {
name: attr.name,
type: attr.type,
value: value,
...(attr.dictType ? { dictType: attr.dictType } : {}),
};
// 只有单选和多选类型才传递 options且只传递选中的值对应的选项
if ((attr.type === 'select' || attr.type === 'multi_select') && attr.options && value) {
const selectedValues = Array.isArray(value) ? value : [value];
metaItem.options = attr.options.filter((opt: { label: string; value: string }) => selectedValues.includes(opt.value));
}
metadataArray.push(metaItem);
}
body.metadata = metadataArray;
}
return body;
};
// 提交
const onSubmit = async () => {
const form = formRef.value;
if (!form) return;
form.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true;
try {
const fullRequestBody = await buildRequestBody();
let requestBody: any;
if (isEdit.value) {
// 编辑模式:通过 _originalData 让拦截器自动处理最小化传参
const originalData = assetFormDiff.getOriginal();
requestBody = {
...fullRequestBody,
_originalData: originalData,
};
} else {
// 新增模式:传递所有字段
requestBody = fullRequestBody;
}
const request = isEdit.value ? updateAsset(requestBody) : createAsset(requestBody);
await request;
ElMessage.success(isEdit.value ? '修改成功' : '添加成功');
closeDialog();
emit('getAssetList');
} catch (error) {
ElMessage.error('提交失败');
} finally {
submitLoading.value = false;
}
}
});
};
// 暴露方法
defineExpose({
openDialog,
});
</script>
<style scoped lang="scss">
.w100 {
width: 100%;
}
.ml10 {
margin-left: 10px;
}
.mx5 {
margin: 0 5px;
}
.text-muted {
color: #909399;
font-size: 12px;
}
.avatar-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
}
.avatar {
width: 100px;
height: 100px;
display: block;
object-fit: cover;
}
.config-list-container {
width: 100%;
.config-list-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
.separator {
color: #909399;
}
.el-button.is-circle {
:deep(.el-icon) {
margin-right: 0 !important;
}
}
}
}
.unit-text {
margin-left: 8px;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.editor-wrapper {
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
:deep(.editor-toolbar) {
border-bottom: 1px solid #dcdfe6;
}
}
.attr-image-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
}
}
</style>