13 Commits

Author SHA1 Message Date
YunaiV
c164904a14 chore: 合并 github/master,引入 PR #259 BPMN 流程设计器审批节点自定义配置编辑后丢失修复 2026-05-04 00:36:22 +08:00
芋道源码
a0ceb45df9 Merge pull request #259 from lb1565387341/fix_bpmn_custom_user_config
fix: [bpm][antd&ele] 修复流程设计器自定义配置编辑后丢失的问题
2026-05-04 00:26:29 +08:00
YunaiV
c641542c71 fix(bpm):修正 BPM 流程实例审批弹窗网关分支重算的并发与提交问题
- 提交时不再用节点表单值覆盖 data.variables;与预览阶段使用同一份合并变量
- onChange 加 useDebounceFn(300ms) + 请求序号去重,handleAudit 提交前 await 最新一轮重算
- 切换任务时重置请求序号与 pending 重算
- 改用 form-create 官方 formData() 取节点表单当前值
- 节点表单初始化等 fApi 就绪后再计算下一节点(until + 1s 兜底)

同步至 web-antd / web-ele 两端
2026-05-03 16:35:03 +08:00
YunaiV
a3d8e4bfc1 feat: 添加包含和不包含条件选项到常量定义 2026-05-03 11:04:58 +08:00
YunaiV
e385823d46 fix: 修复 Vben5.0 form-create 多图上传校验拒绝 png/jpeg/gif,isImage 兼容 MIME 与扩展名两种 accept 写法 2026-05-02 22:56:38 +08:00
YunaiV
897220e19a fix: 修复 Vben5.0 download 接口 token 过期不触发刷新,导出/下载文件变成「账号未登录」JSON;web-antd / web-ele / web-naive / web-tdesign 加 Blob 业务错误嗅探拦截器 2026-05-02 20:36:00 +08:00
YunaiV
b293e112c6 fix: 修复 MALL 商品保存时 SKU 价格被反复 *100 的漂移 2026-05-02 20:23:43 +08:00
YunaiV
627e31f1b0 fix: 修复 Vben5.0 CRM 合同配置 / 客户公海规则配置表单 label 错用 labelClass: 'w-100',Tailwind v4 动态间距下被解析为 400px 撑爆 w-1/4 容器,挤掉 RadioGroup 输入区,改用 labelWidth: 120 2026-05-02 19:44:21 +08:00
YunaiV
8020b4b743 fix: 修复 MALL 商品列表/选择器「价格」列展示原始的「分」(web-antd / web-ele)
商品列表 [mall/product/spu/data.ts] 与商品选择器 [mall/product/spu/components/spu-select-data.ts]
的「价格」列原先 formatter: 'formatAmount2',只做了小数格式化、漏了「分转元」,导致
19900 直接显示成 19900.00(应为 199.00 元)。同文件的 marketPrice / costPrice 已正确使用
fenToYuan,唯独 price 漏了。

顺手将 spu/data.ts 的 price / marketPrice / costPrice 三列从手写闭包统一切到已注册的
formatFenToYuanAmount formatter,单位「元」从 cell 后缀挪进列标题(如「价格(元)」),
减少 8 处闭包并复用平台统一的 null/NaN 处理。
2026-05-02 19:38:50 +08:00
YunaiV
228c5463da fix: 修复 IoT 物模型表单 Form.Item 嵌套字段 name 误用点号字符串,事件类型等校验始终失败 / resetFields 写错路径 2026-05-02 19:27:35 +08:00
YunaiV
50ee691191 fix: 修复 web-ele 下 ApiSelect / ApiTreeSelect 误用 antd 的 fieldNames 写法导致下拉无内容
element-plus 适配器走 ApiComponent,识别的是 labelField / valueField / childrenField;
而 fieldNames 是 antd 风格写法,从 web-antd 复制过来未做适配,导致内部数据无法被映射成
{ label, value, children },下拉树/列表显示为空。

涉及:
- CRM 客户 / 联系人 / 线索 新增表单的「地址」树
- CRM 商机状态「应用部门」、产品「产品类型」树
- ERP 销售出库的 客户 / 销售人员 / 结算账户 / 产品 / 创建人 下拉
2026-05-02 18:55:48 +08:00
YunaiV
eda6ffaf1e fix: 修复 web-ele 下 ApiSelect / ApiTreeSelect 误用 antd 的 fieldNames 写法导致下拉无内容
element-plus 适配器走 ApiComponent,识别的是 labelField / valueField / childrenField;
而 fieldNames 是 antd 风格写法,从 web-antd 复制过来未做适配,导致内部数据无法被映射成
{ label, value, children },下拉树/列表显示为空。

涉及:
- CRM 客户 / 联系人 / 线索 新增表单的「地址」树
- CRM 商机状态「应用部门」、产品「产品类型」树
- ERP 销售出库的 客户 / 销售人员 / 结算账户 / 产品 / 创建人 下拉
2026-05-02 18:53:11 +08:00
liubei
e136679934 fix: [bpm][antd&ele] 修复流程设计器自定义配置编辑后丢失的问题 2026-02-09 15:35:53 +08:00
39 changed files with 552 additions and 299 deletions

View File

@@ -128,6 +128,49 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
},
});
// add by 芋艿:对应 https://t.zsxq.com/SHqWw 反馈
// 处理 Blob 响应中的业务错误(如 401后端把「账号未登录」包成 HTTP 200 + body {code: 401, msg: ...}
// download 强制 responseType: 'blob' 后被 axios 包成 application/json 的 BlobdefaultResponseInterceptor 走
// responseReturn === 'body' 分支直接返回,绕过了 authenticateResponseInterceptor 的 401 token 刷新;
// 这里把这种 Blob 解析回 JSON再以 axios 风格抛出,让后续拦截器接管
client.addResponseInterceptor({
fulfilled: async (response) => {
const blob = response.data;
if (!(blob instanceof Blob)) {
return response;
}
// Blob.type 在部分环境可能为空或大小写不一,叠加 response header 一起判断更稳
const blobType = (blob.type || '').toLowerCase();
const headerType = String(
response.headers?.['content-type'] ??
response.headers?.['Content-Type'] ??
'',
).toLowerCase();
if (
!blobType.includes('application/json') &&
!headerType.includes('application/json')
) {
return response;
}
let parsed: any;
try {
parsed = JSON.parse(await blob.text());
} catch {
return response;
}
if (parsed && parsed.code !== undefined && parsed.code !== 0) {
response.data = parsed;
throw Object.assign(new Error(parsed.msg ?? 'Request failed'), {
config: response.config,
response,
data: parsed,
isAxiosError: true,
});
}
return response;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({

View File

@@ -6,18 +6,36 @@ export function useImagesUpload() {
return defineComponent({
name: 'ImagesUpload',
props: {
multiple: {
accept: {
type: Array,
default: () => ['image/jpeg', 'image/png', 'image/gif'],
},
disabled: {
type: Boolean,
default: true,
default: false,
},
maxNumber: {
type: Number,
default: 5,
},
maxSize: {
type: Number,
default: 5,
},
multiple: {
type: Boolean,
default: true,
},
},
setup(props) {
return () => (
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
<ImageUpload
accept={props.accept as string[]}
disabled={props.disabled}
maxNumber={props.maxNumber}
maxSize={props.maxSize}
multiple={props.multiple}
/>
);
},
});

View File

@@ -26,7 +26,7 @@ export function useUploadFileRule() {
makeRequiredRule(),
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '文件类型',
value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
options: [
@@ -40,12 +40,6 @@ export function useUploadFileRule() {
mode: 'multiple',
},
},
{
type: 'switch',
field: 'autoUpload',
title: '是否在选取文件后立即进行上传',
value: true,
},
{
type: 'switch',
field: 'drag',
@@ -54,23 +48,23 @@ export function useUploadFileRule() {
},
{
type: 'switch',
field: 'isShowTip',
field: 'showDescription',
title: '是否显示提示',
value: true,
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'inputNumber',
field: 'limit',
field: 'maxNumber',
title: '数量限制',
value: 5,
props: { min: 0 },
props: { min: 1 },
},
{
type: 'switch',

View File

@@ -24,15 +24,9 @@ export function useUploadImageRule() {
props(_: any, { t }: any) {
return localeProps(t, `${name}.props`, [
makeRequiredRule(),
{
type: 'switch',
field: 'drag',
title: '拖拽上传',
value: false,
},
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '图片类型限制',
value: ['image/jpeg', 'image/png', 'image/gif'],
options: [
@@ -52,40 +46,16 @@ export function useUploadImageRule() {
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'input',
field: 'height',
title: '组件高度',
value: '150px',
},
{
type: 'input',
field: 'width',
title: '组件宽度',
value: '150px',
},
{
type: 'input',
field: 'borderradius',
title: '组件边框圆角',
value: '8px',
},
{
type: 'switch',
field: 'disabled',
title: '是否显示删除按钮',
value: true,
},
{
type: 'switch',
field: 'showBtnText',
title: '是否显示按钮文字',
value: true,
title: '是否禁用',
value: false,
},
]);
},

View File

@@ -24,15 +24,9 @@ export function useUploadImagesRule() {
props(_: any, { t }: any) {
return localeProps(t, `${name}.props`, [
makeRequiredRule(),
{
type: 'switch',
field: 'drag',
title: '拖拽上传',
value: false,
},
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '图片类型限制',
value: ['image/jpeg', 'image/png', 'image/gif'],
options: [
@@ -48,40 +42,27 @@ export function useUploadImagesRule() {
],
props: {
mode: 'multiple',
maxNumber: 5,
},
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'inputNumber',
field: 'limit',
field: 'maxNumber',
title: '数量限制',
value: 5,
props: { min: 0 },
props: { min: 1 },
},
{
type: 'input',
field: 'height',
title: '组件高度',
value: '150px',
},
{
type: 'input',
field: 'width',
title: '组件宽度',
value: '150px',
},
{
type: 'input',
field: 'borderradius',
title: '组件边框圆角',
value: '8px',
type: 'switch',
field: 'disabled',
title: '是否禁用',
value: false,
},
]);
},

View File

@@ -175,7 +175,7 @@ const resetCustomConfigList = () => {
approveType.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:ApproveType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:ApproveType`, {
value: ApproveType.USER,
});
@@ -184,7 +184,7 @@ const resetCustomConfigList = () => {
assignStartUserHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignStartUserHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignStartUserHandlerType`, {
value: 1,
});
@@ -194,13 +194,13 @@ const resetCustomConfigList = () => {
rejectHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:RejectHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:RejectHandlerType`, { value: 1 });
rejectHandlerType.value = rejectHandlerTypeEl.value.value;
returnNodeIdEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:RejectReturnTaskId`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:RejectReturnTaskId`, {
value: '',
});
@@ -210,7 +210,7 @@ const resetCustomConfigList = () => {
assignEmptyHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignEmptyHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignEmptyHandlerType`, {
value: 1,
});
@@ -218,7 +218,7 @@ const resetCustomConfigList = () => {
assignEmptyUserIdsEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignEmptyUserIds`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignEmptyUserIds`, {
value: '',
});

View File

@@ -767,6 +767,14 @@ export const COMPARISON_OPERATORS: DictDataType[] = [
value: '<=',
label: '小于等于',
},
{
value: 'contain',
label: '包含',
},
{
value: '!contain',
label: '不包含',
},
];
// 审批操作按钮名称
export const OPERATION_BUTTON_NAME = new Map<number, string>();

View File

@@ -24,6 +24,7 @@ import { useUserStore } from '@vben/stores';
import { isEmpty } from '@vben/utils';
import FormCreate from '@form-create/ant-design-vue';
import { until, useDebounceFn } from '@vueuse/core';
import {
Alert,
Button,
@@ -113,6 +114,8 @@ const nextAssigneesActivityNode = ref<BpmProcessInstanceApi.ApprovalNodeInfo[]>(
[],
); // 下一个审批节点信息
const nextAssigneesTimelineRef = ref(); // 下一个节点审批人时间线组件的引用
let nextApprovalRequestId = 0; // 请求序号onChange 高频触发时,丢弃过期请求结果
let pendingNextNodesTask: null | Promise<unknown> = null; // 跟踪 onChange 触发的最新一轮重算,提交前需 await 等其完成
const approveReasonForm: any = reactive({
reason: '',
signPicUrl: '',
@@ -256,7 +259,6 @@ async function openPopover(type: string) {
message.warning('表单校验不通过,请先完善表单!!');
return;
}
await initNextAssigneesFormField();
}
if (type === 'return') {
// 获取退回节点
@@ -269,6 +271,20 @@ async function openPopover(type: string) {
Object.keys(popOverVisible.value).forEach((item) => {
if (popOverVisible.value[item]) popOverVisible.value[item] = item === type;
});
if (type === 'approve') {
// 当前任务有节点表单时,等 form-create 的 fApi 就绪后再计算下一个节点;
// 没有节点表单时approveFormFApi 永远不会被赋值,跳过等待
if (runningTask.value?.formId > 0) {
// 1s 兜底超时;超时 until 会抛错,这里静默吞掉,让首次计算照常进行
await until(
() => typeof approveFormFApi.value?.validate === 'function',
)
.toBeTruthy({ timeout: 1000 })
.catch(() => {});
}
// 初始化下一个审批人表单字段
await initNextAssigneesFormField();
}
}
/** 关闭气泡卡 */
@@ -286,6 +302,8 @@ function closePopover(type: string, formRef: any | FormInstance) {
/** 流程通过时,根据表单变量查询新的流程节点,判断下一个节点类型是否为自选审批人 */
async function initNextAssigneesFormField() {
// 记录当前请求序号;如果在等待响应期间又有新请求发出,本次结果作废
const requestId = ++nextApprovalRequestId;
// 获取修改的流程变量, 暂时只支持流程表单
const variables = getUpdatedProcessInstanceVariables();
const data = await getNextApprovalNodes({
@@ -293,6 +311,12 @@ async function initNextAssigneesFormField() {
taskId: runningTask.value.id,
processVariablesStr: JSON.stringify(variables),
});
// 已有更新的请求发出,丢弃本次过期结果,避免把旧分支节点回写到当前列表
if (requestId !== nextApprovalRequestId) {
return;
}
// 在最新结果到达时再清空,避免请求期间出现节点信息抖动
nextAssigneesActivityNode.value = [];
if (data && data.length > 0) {
const customApproveUsersData: Record<string, any[]> = {}; // 用于收集需要设置到 Timeline 组件的自定义审批人数据
data.forEach((node: BpmProcessInstanceApi.ApprovalNodeInfo) => {
@@ -327,6 +351,12 @@ async function initNextAssigneesFormField() {
}
}
/** onChange 高频触发时合并 300ms 内的连续按键,减少网关查询请求 */
const debouncedInitNextAssigneesFormField = useDebounceFn(
initNextAssigneesFormField,
300,
);
/** 选择下一个节点的审批人 */
function selectNextAssigneesConfirm(id: string, userList: any[]) {
approveReasonForm.nextAssignees[id] = userList?.map((item: any) => item.id);
@@ -362,6 +392,10 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
}
if (pass) {
// 等待 onChange 触发的最新一轮重算落地,避免拿旧分支节点 + 旧审批人选择 + 新表单变量的错配组合提交
if (pendingNextNodesTask) {
await pendingNextNodesTask;
}
const nextAssigneesValid = validateNextAssignees();
if (!nextAssigneesValid) return;
const variables = getUpdatedProcessInstanceVariables();
@@ -376,12 +410,10 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
if (runningTask.value.signEnable) {
data.signPicUrl = approveReasonForm.signPicUrl;
}
// 多表单处理,并且有额外的 approveForm 表单需要校验 + 拼接到 data 表单里提交
// TODO 芋艿 任务有多表单这里要如何处理,会和可编辑的字段冲突
// 多表单处理:节点表单需要校验;变量已经在 getUpdatedProcessInstanceVariables 中合并到 data.variables无需再覆盖
const formCreateApi = approveFormFApi.value;
if (Object.keys(formCreateApi)?.length > 0) {
await formCreateApi.validate();
data.variables = approveForm.value.value;
}
await approveTask(data);
popOverVisible.value.approve = false;
@@ -648,18 +680,32 @@ function loadTodoTask(task: any) {
approveForm.value = {};
runningTask.value = task;
approveFormFApi.value = {};
// 切换任务时重置请求序号与 pending 重算,避免旧任务飞行中的请求/Promise 串到新任务
nextApprovalRequestId += 1;
pendingNextNodesTask = null;
reasonRequire.value = task?.reasonRequire ?? false;
nodeTypeName.value =
task?.nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE ? '办理' : '审批';
// 处理 approve 表单
if (task && task.formId && task.formConf) {
const tempApproveForm = {};
const tempApproveForm: { option?: any; rule?: any; value?: any } = {};
setConfAndFields2(
tempApproveForm,
task.formConf,
task.formFields,
task.formVariables,
);
// 为表单添加 onChange 事件,当表单值变化时,重新计算下一个节点的信息;网关分支可能依赖表单字段
tempApproveForm.option.onChange = () => {
// 弹窗打开时,才重新计算下一个节点的信息
if (!popOverVisible.value.approve) {
return;
}
// useDebounceFn 会把前一次返回的 Promise reject 掉,需 catch 吞掉 'cancelled'
pendingNextNodesTask = debouncedInitNextAssigneesFormField().catch(
() => {},
);
};
approveForm.value = tempApproveForm;
} else {
approveForm.value = {}; // 占位,避免为空
@@ -684,9 +730,17 @@ async function validateNormalForm() {
/** 从可以编辑的流程表单字段,获取需要修改的流程实例的变量 */
function getUpdatedProcessInstanceVariables() {
const variables: any = {};
props.writableFields.forEach((field: string) => {
variables[field] = props.normalFormApi.getValue(field);
});
// 从流程表单(流程定义级别)中获取变量
if (props.writableFields?.length && props.normalFormApi) {
props.writableFields.forEach((field: string) => {
variables[field] = props.normalFormApi.getValue(field);
});
}
// 从节点表单(节点级别)中获取变量;通过 form-create 官方的 formData() 拿当前值
const nodeFormData = approveFormFApi.value?.formData?.();
if (nodeFormData) {
Object.assign(variables, nodeFormData);
}
return variables;
}

View File

@@ -18,7 +18,7 @@ import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
labelWidth: 120,
},
layout: 'horizontal',
schema,

View File

@@ -18,7 +18,7 @@ import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
labelWidth: 120,
},
layout: 'horizontal',
schema,

View File

@@ -29,7 +29,10 @@ function handleChange(val: any) {
</script>
<template>
<Form.Item label="元素类型" name="property.dataSpecs.childDataType">
<Form.Item
:name="['property', 'dataSpecs', 'childDataType']"
label="元素类型"
>
<Radio.Group v-model:value="dataSpecs.childDataType" @change="handleChange">
<template v-for="item in getDataTypeOptions()" :key="item.value">
<Radio
@@ -50,7 +53,7 @@ function handleChange(val: any) {
</template>
</Radio.Group>
</Form.Item>
<Form.Item label="元素个数" name="property.dataSpecs.size">
<Form.Item :name="['property', 'dataSpecs', 'size']" label="元素个数">
<Input
v-model:value="dataSpecs.size"
placeholder="请输入数组中的元素个数"

View File

@@ -35,9 +35,9 @@ watch(
<template>
<Form.Item
:name="['event', 'type']"
:rules="[{ required: true, message: '请选择事件类型', trigger: 'change' }]"
label="事件类型"
name="event.type"
>
<Radio.Group v-model:value="thingModelEvent.type">
<Radio

View File

@@ -154,8 +154,8 @@ watch(
<!-- 文本型配置 -->
<Form.Item
v-if="property.dataType === IoTDataSpecsDataTypeEnum.TEXT"
:name="['property', 'dataSpecs', 'length']"
label="数据长度"
name="property.dataSpecs.length"
>
<Input
v-model:value="property.dataSpecs.length"
@@ -189,8 +189,8 @@ watch(
/>
<Form.Item
v-if="!isStructDataSpecs && !isParams"
:name="['property', 'accessMode']"
label="读写类型"
name="property.accessMode"
>
<Radio.Group v-model:value="property.accessMode">
<Radio

View File

@@ -35,9 +35,9 @@ watch(
<template>
<Form.Item
:name="['service', 'callType']"
:rules="[{ required: true, message: '请选择调用方式', trigger: 'change' }]"
label="调用方式"
name="service.callType"
>
<Radio.Group v-model:value="service.callType">
<Radio

View File

@@ -89,10 +89,10 @@ export function useGridColumns(
},
{
field: 'price',
title: '商品售价',
title: '商品售价(元)',
minWidth: 90,
align: 'center',
formatter: 'formatAmount2',
formatter: 'formatFenToYuanAmount',
},
{
field: 'salesCount',

View File

@@ -2,7 +2,7 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { fenToYuan, handleTree, treeToString } from '@vben/utils';
import { handleTree, treeToString } from '@vben/utils';
import { getCategoryList } from '#/api/mall/product/category';
import { getRangePickerDefaultProps } from '#/utils';
@@ -101,25 +101,21 @@ export function useGridColumns(
},
{
field: 'price',
title: '价格',
title: '价格(元)',
minWidth: 100,
formatter: 'formatAmount2',
formatter: 'formatFenToYuanAmount',
},
{
field: 'marketPrice',
title: '市场价',
title: '市场价(元)',
minWidth: 100,
formatter: ({ row }) => {
return `${fenToYuan(row.marketPrice)}`;
},
formatter: 'formatFenToYuanAmount',
},
{
field: 'costPrice',
title: '成本价',
title: '成本价(元)',
minWidth: 100,
formatter: ({ row }) => {
return `${fenToYuan(row.costPrice)}`;
},
formatter: 'formatFenToYuanAmount',
},
{
field: 'salesCount',

View File

@@ -181,31 +181,28 @@ async function handleSubmit() {
.merge(descriptionFormApi)
.merge(otherFormApi)
.submitAllForm(true);
values.skus = formData.value.skus;
// 校验商品名称不能为空(用于 SKU name
if (!values.name || values.name.trim() === '') {
message.error('商品名称不能为空');
return;
}
if (values.skus) {
try {
// 校验 sku
skuListRef.value.validateSku();
} catch {
message.error('【库存价格】不完善,请填写相关信息');
return;
}
values.skus.forEach((item) => {
// 给 sku name 赋值(使用商品名称作为 SKU 名称)
item.name = values.name;
// 金额转换:元转分
item.price = convertToInteger(item.price);
item.marketPrice = convertToInteger(item.marketPrice);
item.costPrice = convertToInteger(item.costPrice);
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
});
try {
// 校验 sku
skuListRef.value.validateSku();
} catch {
message.error('【库存价格】不完善,请填写相关信息');
return;
}
// 金额转换:元转分
values.skus = formData.value.skus!.map((item) => ({
...item,
name: values.name,
price: convertToInteger(item.price),
marketPrice: convertToInteger(item.marketPrice),
costPrice: convertToInteger(item.costPrice),
firstBrokeragePrice: convertToInteger(item.firstBrokeragePrice),
secondBrokeragePrice: convertToInteger(item.secondBrokeragePrice),
}));
// 处理轮播图列表:上传组件可能返回对象或字符串,统一处理成字符串数组
const newSliderPicUrls: any[] = [];
values.sliderPicUrls!.forEach((item: any) => {
@@ -235,14 +232,15 @@ async function getDetail() {
formLoading.value = true;
try {
const res = await getSpu(spuId.value!);
// 金额转换:元转
res.skus?.forEach((item) => {
item.price = formatToFraction(item.price);
item.marketPrice = formatToFraction(item.marketPrice);
item.costPrice = formatToFraction(item.costPrice);
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
});
// 金额转换:分转元
res.skus = res.skus?.map((item) => ({
...item,
price: formatToFraction(item.price),
marketPrice: formatToFraction(item.marketPrice),
costPrice: formatToFraction(item.costPrice),
firstBrokeragePrice: formatToFraction(item.firstBrokeragePrice),
secondBrokeragePrice: formatToFraction(item.secondBrokeragePrice),
}));
formData.value = res;
// 初始化各表单值
infoFormApi.setValues(res).then();

View File

@@ -128,6 +128,49 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
},
});
// add by 芋艿:对应 https://t.zsxq.com/SHqWw 反馈
// 处理 Blob 响应中的业务错误(如 401后端把「账号未登录」包成 HTTP 200 + body {code: 401, msg: ...}
// download 强制 responseType: 'blob' 后被 axios 包成 application/json 的 BlobdefaultResponseInterceptor 走
// responseReturn === 'body' 分支直接返回,绕过了 authenticateResponseInterceptor 的 401 token 刷新;
// 这里把这种 Blob 解析回 JSON再以 axios 风格抛出,让后续拦截器接管
client.addResponseInterceptor({
fulfilled: async (response) => {
const blob = response.data;
if (!(blob instanceof Blob)) {
return response;
}
// Blob.type 在部分环境可能为空或大小写不一,叠加 response header 一起判断更稳
const blobType = (blob.type || '').toLowerCase();
const headerType = String(
response.headers?.['content-type'] ??
response.headers?.['Content-Type'] ??
'',
).toLowerCase();
if (
!blobType.includes('application/json') &&
!headerType.includes('application/json')
) {
return response;
}
let parsed: any;
try {
parsed = JSON.parse(await blob.text());
} catch {
return response;
}
if (parsed && parsed.code !== undefined && parsed.code !== 0) {
response.data = parsed;
throw Object.assign(new Error(parsed.msg ?? 'Request failed'), {
config: response.config,
response,
data: parsed,
isAxiosError: true,
});
}
return response;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({

View File

@@ -6,18 +6,36 @@ export function useImagesUpload() {
return defineComponent({
name: 'ImagesUpload',
props: {
multiple: {
accept: {
type: Array,
default: () => ['image/jpeg', 'image/png', 'image/gif'],
},
disabled: {
type: Boolean,
default: true,
default: false,
},
maxNumber: {
type: Number,
default: 5,
},
maxSize: {
type: Number,
default: 5,
},
multiple: {
type: Boolean,
default: true,
},
},
setup(props) {
return () => (
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
<ImageUpload
accept={props.accept as string[]}
disabled={props.disabled}
maxNumber={props.maxNumber}
maxSize={props.maxSize}
multiple={props.multiple}
/>
);
},
});

View File

@@ -26,7 +26,7 @@ export function useUploadFileRule() {
makeRequiredRule(),
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '文件类型',
value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
options: [
@@ -40,12 +40,6 @@ export function useUploadFileRule() {
multiple: true,
},
},
{
type: 'switch',
field: 'autoUpload',
title: '是否在选取文件后立即进行上传',
value: true,
},
{
type: 'switch',
field: 'drag',
@@ -54,23 +48,23 @@ export function useUploadFileRule() {
},
{
type: 'switch',
field: 'isShowTip',
field: 'showDescription',
title: '是否显示提示',
value: true,
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'inputNumber',
field: 'limit',
field: 'maxNumber',
title: '数量限制',
value: 5,
props: { min: 0 },
props: { min: 1 },
},
{
type: 'switch',

View File

@@ -24,15 +24,9 @@ export function useUploadImageRule() {
props(_: any, { t }: any) {
return localeProps(t, `${name}.props`, [
makeRequiredRule(),
{
type: 'switch',
field: 'drag',
title: '拖拽上传',
value: false,
},
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '图片类型限制',
value: ['image/jpeg', 'image/png', 'image/gif'],
options: [
@@ -52,40 +46,16 @@ export function useUploadImageRule() {
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'input',
field: 'height',
title: '组件高度',
value: '150px',
},
{
type: 'input',
field: 'width',
title: '组件宽度',
value: '150px',
},
{
type: 'input',
field: 'borderradius',
title: '组件边框圆角',
value: '8px',
},
{
type: 'switch',
field: 'disabled',
title: '是否显示删除按钮',
value: true,
},
{
type: 'switch',
field: 'showBtnText',
title: '是否显示按钮文字',
value: true,
title: '是否禁用',
value: false,
},
]);
},

View File

@@ -24,15 +24,9 @@ export function useUploadImagesRule() {
props(_: any, { t }: any) {
return localeProps(t, `${name}.props`, [
makeRequiredRule(),
{
type: 'switch',
field: 'drag',
title: '拖拽上传',
value: false,
},
{
type: 'select',
field: 'fileType',
field: 'accept',
title: '图片类型限制',
value: ['image/jpeg', 'image/png', 'image/gif'],
options: [
@@ -48,40 +42,27 @@ export function useUploadImagesRule() {
],
props: {
multiple: true,
maxNumber: 5,
},
},
{
type: 'inputNumber',
field: 'fileSize',
field: 'maxSize',
title: '大小限制(MB)',
value: 5,
props: { min: 0 },
},
{
type: 'inputNumber',
field: 'limit',
field: 'maxNumber',
title: '数量限制',
value: 5,
props: { min: 0 },
props: { min: 1 },
},
{
type: 'input',
field: 'height',
title: '组件高度',
value: '150px',
},
{
type: 'input',
field: 'width',
title: '组件宽度',
value: '150px',
},
{
type: 'input',
field: 'borderradius',
title: '组件边框圆角',
value: '8px',
type: 'switch',
field: 'disabled',
title: '是否禁用',
value: false,
},
]);
},

View File

@@ -164,7 +164,7 @@ const resetCustomConfigList = () => {
approveType.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:ApproveType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:ApproveType`, {
value: ApproveType.USER,
});
@@ -173,7 +173,7 @@ const resetCustomConfigList = () => {
assignStartUserHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignStartUserHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignStartUserHandlerType`, {
value: 1,
});
@@ -183,13 +183,13 @@ const resetCustomConfigList = () => {
rejectHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:RejectHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:RejectHandlerType`, { value: 1 });
rejectHandlerType.value = rejectHandlerTypeEl.value.value;
returnNodeIdEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:RejectReturnTaskId`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:RejectReturnTaskId`, {
value: '',
});
@@ -199,7 +199,7 @@ const resetCustomConfigList = () => {
assignEmptyHandlerTypeEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignEmptyHandlerType`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignEmptyHandlerType`, {
value: 1,
});
@@ -207,7 +207,7 @@ const resetCustomConfigList = () => {
assignEmptyUserIdsEl.value =
elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:AssignEmptyUserIds`,
)?.[0] ||
) ||
bpmnInstances().moddle.create(`${prefix}:AssignEmptyUserIds`, {
value: '',
});

View File

@@ -767,6 +767,14 @@ export const COMPARISON_OPERATORS: DictDataType[] = [
value: '<=',
label: '小于等于',
},
{
value: 'contain',
label: '包含',
},
{
value: '!contain',
label: '不包含',
},
];
// 审批操作按钮名称
export const OPERATION_BUTTON_NAME = new Map<number, string>();

View File

@@ -23,6 +23,7 @@ import { useUserStore } from '@vben/stores';
import { isEmpty } from '@vben/utils';
import FormCreate from '@form-create/element-ui';
import { until, useDebounceFn } from '@vueuse/core';
import {
ElAlert,
ElButton,
@@ -111,6 +112,8 @@ const nextAssigneesActivityNode = ref<BpmProcessInstanceApi.ApprovalNodeInfo[]>(
[],
); // 下一个审批节点信息
const nextAssigneesTimelineRef = ref(); // 下一个节点审批人时间线组件的引用
let nextApprovalRequestId = 0; // 请求序号onChange 高频触发时,丢弃过期请求结果
let pendingNextNodesTask: null | Promise<unknown> = null; // 跟踪 onChange 触发的最新一轮重算,提交前需 await 等其完成
const approveReasonForm: any = reactive({
reason: '',
signPicUrl: '',
@@ -264,7 +267,6 @@ async function openPopover(type: string) {
ElMessage.warning('表单校验不通过,请先完善表单!!');
return;
}
await initNextAssigneesFormField();
}
if (type === 'return') {
// 获取退回节点
@@ -277,6 +279,20 @@ async function openPopover(type: string) {
Object.keys(popOverVisible.value).forEach((item) => {
if (popOverVisible.value[item]) popOverVisible.value[item] = item === type;
});
if (type === 'approve') {
// 当前任务有节点表单时,等 form-create 的 fApi 就绪后再计算下一个节点;
// 没有节点表单时approveFormFApi 永远不会被赋值,跳过等待
if (runningTask.value?.formId > 0) {
// 1s 兜底超时;超时 until 会抛错,这里静默吞掉,让首次计算照常进行
await until(
() => typeof approveFormFApi.value?.validate === 'function',
)
.toBeTruthy({ timeout: 1000 })
.catch(() => {});
}
// 初始化下一个审批人表单字段
await initNextAssigneesFormField();
}
}
/** 关闭气泡卡 */
@@ -294,6 +310,8 @@ function closePopover(type: string, formRef: any | FormInstance) {
/** 流程通过时,根据表单变量查询新的流程节点,判断下一个节点类型是否为自选审批人 */
async function initNextAssigneesFormField() {
// 记录当前请求序号;如果在等待响应期间又有新请求发出,本次结果作废
const requestId = ++nextApprovalRequestId;
// 获取修改的流程变量, 暂时只支持流程表单
const variables = getUpdatedProcessInstanceVariables();
const data = await getNextApprovalNodes({
@@ -301,6 +319,12 @@ async function initNextAssigneesFormField() {
taskId: runningTask.value.id,
processVariablesStr: JSON.stringify(variables),
});
// 已有更新的请求发出,丢弃本次过期结果,避免把旧分支节点回写到当前列表
if (requestId !== nextApprovalRequestId) {
return;
}
// 在最新结果到达时再清空,避免请求期间出现节点信息抖动
nextAssigneesActivityNode.value = [];
if (data && data.length > 0) {
const customApproveUsersData: Record<string, any[]> = {}; // 用于收集需要设置到 Timeline 组件的自定义审批人数据
data.forEach((node: BpmProcessInstanceApi.ApprovalNodeInfo) => {
@@ -335,6 +359,12 @@ async function initNextAssigneesFormField() {
}
}
/** onChange 高频触发时合并 300ms 内的连续按键,减少网关查询请求 */
const debouncedInitNextAssigneesFormField = useDebounceFn(
initNextAssigneesFormField,
300,
);
/** 选择下一个节点的审批人 */
function selectNextAssigneesConfirm(id: string, userList: any[]) {
approveReasonForm.nextAssignees[id] = userList?.map((item: any) => item.id);
@@ -370,6 +400,10 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
}
if (pass) {
// 等待 onChange 触发的最新一轮重算落地,避免拿旧分支节点 + 旧审批人选择 + 新表单变量的错配组合提交
if (pendingNextNodesTask) {
await pendingNextNodesTask;
}
const nextAssigneesValid = validateNextAssignees();
if (!nextAssigneesValid) return;
const variables = getUpdatedProcessInstanceVariables();
@@ -384,12 +418,10 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
if (runningTask.value.signEnable) {
data.signPicUrl = approveReasonForm.signPicUrl;
}
// 多表单处理,并且有额外的 approveForm 表单需要校验 + 拼接到 data 表单里提交
// TODO 芋艿 任务有多表单这里要如何处理,会和可编辑的字段冲突
// 多表单处理:节点表单需要校验;变量已经在 getUpdatedProcessInstanceVariables 中合并到 data.variables无需再覆盖
const formCreateApi = approveFormFApi.value;
if (Object.keys(formCreateApi)?.length > 0) {
await formCreateApi.validate();
data.variables = approveForm.value.value;
}
await approveTask(data);
popOverVisible.value.approve = false;
@@ -656,18 +688,32 @@ function loadTodoTask(task: any) {
approveForm.value = {};
runningTask.value = task;
approveFormFApi.value = {};
// 切换任务时重置请求序号与 pending 重算,避免旧任务飞行中的请求/Promise 串到新任务
nextApprovalRequestId += 1;
pendingNextNodesTask = null;
reasonRequire.value = task?.reasonRequire ?? false;
nodeTypeName.value =
task?.nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE ? '办理' : '审批';
// 处理 approve 表单
if (task && task.formId && task.formConf) {
const tempApproveForm = {};
const tempApproveForm: { option?: any; rule?: any; value?: any } = {};
setConfAndFields2(
tempApproveForm,
task.formConf,
task.formFields,
task.formVariables,
);
// 为表单添加 onChange 事件,当表单值变化时,重新计算下一个节点的信息;网关分支可能依赖表单字段
tempApproveForm.option.onChange = () => {
// 弹窗打开时,才重新计算下一个节点的信息
if (!popOverVisible.value.approve) {
return;
}
// useDebounceFn 会把前一次返回的 Promise reject 掉,需 catch 吞掉 'cancelled'
pendingNextNodesTask = debouncedInitNextAssigneesFormField().catch(
() => {},
);
};
approveForm.value = tempApproveForm;
} else {
approveForm.value = {}; // 占位,避免为空
@@ -692,9 +738,17 @@ async function validateNormalForm() {
/** 从可以编辑的流程表单字段,获取需要修改的流程实例的变量 */
function getUpdatedProcessInstanceVariables() {
const variables: any = {};
props.writableFields.forEach((field: string) => {
variables[field] = props.normalFormApi.getValue(field);
});
// 从流程表单(流程定义级别)中获取变量
if (props.writableFields?.length && props.normalFormApi) {
props.writableFields.forEach((field: string) => {
variables[field] = props.normalFormApi.getValue(field);
});
}
// 从节点表单(节点级别)中获取变量;通过 form-create 官方的 formData() 拿当前值
const nodeFormData = approveFormFApi.value?.formData?.();
if (nodeFormData) {
Object.assign(variables, nodeFormData);
}
return variables;
}

View File

@@ -35,7 +35,9 @@ export function useFormSchema(): VbenFormSchema[] {
return handleTree(data);
},
multiple: true,
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择应用部门',
defaultExpandAll: true,
},

View File

@@ -122,7 +122,9 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择地址',
},
},

View File

@@ -144,7 +144,9 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择地址',
},
},

View File

@@ -18,7 +18,7 @@ import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
labelWidth: 120,
},
layout: 'horizontal',
schema,

View File

@@ -131,7 +131,9 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择地址',
allowClear: true,
},

View File

@@ -18,7 +18,7 @@ import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
labelWidth: 120,
},
layout: 'horizontal',
schema,

View File

@@ -70,7 +70,9 @@ export function useFormSchema(): VbenFormSchema[] {
const data = await getProductCategoryList();
return handleTree(data);
},
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择产品类型',
clearable: true,
},

View File

@@ -67,10 +67,8 @@ export function useFormSchema(formType: string): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getCustomerSimpleList,
fieldNames: {
label: 'name',
value: 'id',
},
labelField: 'name',
valueField: 'id',
},
rules: 'required',
},
@@ -83,10 +81,8 @@ export function useFormSchema(formType: string): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getSimpleUserList,
fieldNames: {
label: 'nickname',
value: 'id',
},
labelField: 'nickname',
valueField: 'id',
},
},
{
@@ -201,10 +197,8 @@ export function useFormSchema(formType: string): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getAccountSimpleList,
fieldNames: {
label: 'name',
value: 'id',
},
labelField: 'name',
valueField: 'id',
},
},
{
@@ -353,10 +347,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getProductSimpleList,
fieldNames: {
label: 'name',
value: 'id',
},
labelField: 'name',
valueField: 'id',
},
},
{
@@ -377,10 +369,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getCustomerSimpleList,
fieldNames: {
label: 'name',
value: 'id',
},
labelField: 'name',
valueField: 'id',
},
},
{
@@ -405,10 +395,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
allowClear: true,
showSearch: true,
api: getSimpleUserList,
fieldNames: {
label: 'nickname',
value: 'id',
},
labelField: 'nickname',
valueField: 'id',
},
},
{

View File

@@ -88,10 +88,10 @@ export function useGridColumns(
},
{
field: 'price',
title: '商品售价',
title: '商品售价(元)',
minWidth: 90,
align: 'center',
formatter: 'formatAmount2',
formatter: 'formatFenToYuanAmount',
},
{
field: 'salesCount',

View File

@@ -2,7 +2,7 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { fenToYuan, handleTree, treeToString } from '@vben/utils';
import { handleTree, treeToString } from '@vben/utils';
import { getCategoryList } from '#/api/mall/product/category';
import { getRangePickerDefaultProps } from '#/utils';
@@ -105,25 +105,21 @@ export function useGridColumns(
},
{
field: 'price',
title: '价格',
title: '价格(元)',
minWidth: 100,
formatter: 'formatAmount2',
formatter: 'formatFenToYuanAmount',
},
{
field: 'marketPrice',
title: '市场价',
title: '市场价(元)',
minWidth: 100,
formatter: ({ row }) => {
return `${fenToYuan(row.marketPrice)}`;
},
formatter: 'formatFenToYuanAmount',
},
{
field: 'costPrice',
title: '成本价',
title: '成本价(元)',
minWidth: 100,
formatter: ({ row }) => {
return `${fenToYuan(row.costPrice)}`;
},
formatter: 'formatFenToYuanAmount',
},
{
field: 'salesCount',

View File

@@ -181,31 +181,28 @@ async function handleSubmit() {
.merge(descriptionFormApi)
.merge(otherFormApi)
.submitAllForm(true);
values.skus = formData.value.skus;
// 校验商品名称不能为空(用于 SKU name
if (!values.name || values.name.trim() === '') {
ElMessage.error('商品名称不能为空');
return;
}
if (values.skus) {
try {
// 校验 sku
skuListRef.value.validateSku();
} catch {
ElMessage.error('【库存价格】不完善,请填写相关信息');
return;
}
values.skus.forEach((item) => {
// 给 sku name 赋值(使用商品名称作为 SKU 名称)
item.name = values.name;
// 金额转换:元转分
item.price = convertToInteger(item.price);
item.marketPrice = convertToInteger(item.marketPrice);
item.costPrice = convertToInteger(item.costPrice);
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
});
try {
// 校验 sku
skuListRef.value.validateSku();
} catch {
ElMessage.error('【库存价格】不完善,请填写相关信息');
return;
}
// 金额转换:元转分
values.skus = formData.value.skus!.map((item) => ({
...item,
name: values.name,
price: convertToInteger(item.price),
marketPrice: convertToInteger(item.marketPrice),
costPrice: convertToInteger(item.costPrice),
firstBrokeragePrice: convertToInteger(item.firstBrokeragePrice),
secondBrokeragePrice: convertToInteger(item.secondBrokeragePrice),
}));
// 处理轮播图列表:上传组件可能返回对象或字符串,统一处理成字符串数组
const newSliderPicUrls: any[] = [];
values.sliderPicUrls!.forEach((item: any) => {
@@ -235,14 +232,15 @@ async function getDetail() {
formLoading.value = true;
try {
const res = await getSpu(spuId.value!);
// 金额转换:元转
res.skus?.forEach((item) => {
item.price = formatToFraction(item.price);
item.marketPrice = formatToFraction(item.marketPrice);
item.costPrice = formatToFraction(item.costPrice);
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
});
// 金额转换:分转元
res.skus = res.skus?.map((item) => ({
...item,
price: formatToFraction(item.price),
marketPrice: formatToFraction(item.marketPrice),
costPrice: formatToFraction(item.costPrice),
firstBrokeragePrice: formatToFraction(item.firstBrokeragePrice),
secondBrokeragePrice: formatToFraction(item.secondBrokeragePrice),
}));
formData.value = res;
// 初始化各表单值
infoFormApi.setValues(res).then();

View File

@@ -127,6 +127,49 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
},
});
// add by 芋艿:对应 https://t.zsxq.com/SHqWw 反馈
// 处理 Blob 响应中的业务错误(如 401后端把「账号未登录」包成 HTTP 200 + body {code: 401, msg: ...}
// download 强制 responseType: 'blob' 后被 axios 包成 application/json 的 BlobdefaultResponseInterceptor 走
// responseReturn === 'body' 分支直接返回,绕过了 authenticateResponseInterceptor 的 401 token 刷新;
// 这里把这种 Blob 解析回 JSON再以 axios 风格抛出,让后续拦截器接管
client.addResponseInterceptor({
fulfilled: async (response) => {
const blob = response.data;
if (!(blob instanceof Blob)) {
return response;
}
// Blob.type 在部分环境可能为空或大小写不一,叠加 response header 一起判断更稳
const blobType = (blob.type || '').toLowerCase();
const headerType = String(
response.headers?.['content-type'] ??
response.headers?.['Content-Type'] ??
'',
).toLowerCase();
if (
!blobType.includes('application/json') &&
!headerType.includes('application/json')
) {
return response;
}
let parsed: any;
try {
parsed = JSON.parse(await blob.text());
} catch {
return response;
}
if (parsed && parsed.code !== undefined && parsed.code !== 0) {
response.data = parsed;
throw Object.assign(new Error(parsed.msg ?? 'Request failed'), {
config: response.config,
response,
data: parsed,
isAxiosError: true,
});
}
return response;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({

View File

@@ -127,6 +127,49 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
},
});
// add by 芋艿:对应 https://t.zsxq.com/SHqWw 反馈
// 处理 Blob 响应中的业务错误(如 401后端把「账号未登录」包成 HTTP 200 + body {code: 401, msg: ...}
// download 强制 responseType: 'blob' 后被 axios 包成 application/json 的 BlobdefaultResponseInterceptor 走
// responseReturn === 'body' 分支直接返回,绕过了 authenticateResponseInterceptor 的 401 token 刷新;
// 这里把这种 Blob 解析回 JSON再以 axios 风格抛出,让后续拦截器接管
client.addResponseInterceptor({
fulfilled: async (response) => {
const blob = response.data;
if (!(blob instanceof Blob)) {
return response;
}
// Blob.type 在部分环境可能为空或大小写不一,叠加 response header 一起判断更稳
const blobType = (blob.type || '').toLowerCase();
const headerType = String(
response.headers?.['content-type'] ??
response.headers?.['Content-Type'] ??
'',
).toLowerCase();
if (
!blobType.includes('application/json') &&
!headerType.includes('application/json')
) {
return response;
}
let parsed: any;
try {
parsed = JSON.parse(await blob.text());
} catch {
return response;
}
if (parsed && parsed.code !== undefined && parsed.code !== 0) {
response.data = parsed;
throw Object.assign(new Error(parsed.msg ?? 'Request failed'), {
config: response.config,
response,
data: parsed,
isAxiosError: true,
});
}
return response;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({

View File

@@ -103,11 +103,37 @@ export const defaultImageAccepts = [
'webp',
];
/**
* 图片类 MIME 子类型到扩展名的别名映射;未列出的子类型按字面量与扩展名比较
*/
const IMAGE_MIME_SUBTYPE_ALIASES: Record<string, string[]> = {
apng: ['apng', 'png'],
jpeg: ['jpeg', 'jpg'],
pjpeg: ['jpeg', 'jpg'],
'svg+xml': ['svg'],
tiff: ['tif', 'tiff'],
'x-icon': ['ico'],
};
/**
* 判断 MIME 子类型是否与文件扩展名匹配image/* 限定为已知图片扩展名集合
*/
function matchMimeSubtype(subtype: string, ext: string): boolean {
if (subtype === '*') {
return defaultImageAccepts.includes(ext);
}
const aliases = IMAGE_MIME_SUBTYPE_ALIASES[subtype];
if (aliases) {
return aliases.includes(ext);
}
return subtype === ext;
}
/**
* 判断文件是否为图片
*
* @param filename 文件名
* @param accepts 支持的文件类型
* @param accepts 支持的文件类型,兼容 MIME如 image/png、.ext如 .png与纯后缀如 png
* @returns 是否为图片
*/
export function isImage(
@@ -118,7 +144,23 @@ export function isImage(
return false;
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
return accepts.includes(ext);
if (!ext) {
return false;
}
return accepts.some((accept) => {
const lower = accept.toLowerCase();
// MIME 类型,例如 image/png image/* 仅放行已知图片扩展
if (lower.includes('/')) {
const subtype = lower.split('/').pop() || '';
return matchMimeSubtype(subtype, ext);
}
// 以点号开头的扩展名,例如 .png
if (lower.startsWith('.')) {
return lower.slice(1) === ext;
}
// 纯后缀
return lower === ext;
});
}
/**