feat(material): 采购链接前端(表单 + 列表列),并修复 Odoo 页保存后丢搜索
一、采购链接 UI(list.vue 基础信息 / buyOdoo.vue 基础信息(Odoo))
各补齐 6 处:columns、permissionMap、列设置复选框、表格列、表单字段、compareFields。
· 表格列带双重守卫(columns.X.visible && hasColPermission),无权限者整列不渲染
· 表单字段套 hasFieldPermission('purchaseLink'),无权限者整项不显示
· ★ compareFields 必须加:编辑走的是「差异比对后只提交变更字段」,
漏了这一项则改了链接也不会被提交(静默失败,排查成本极高)
二、采购链接的 href 白名单
新增 safeHref():只放行 http/https。
★ 该字段由用户自由填写,直接绑到 href 上时 `javascript:` 伪协议会被浏览器
当成可执行链接 —— 点一下就执行脚本。非法值的行显示「格式无效」而非链接。
三、修复 Odoo 页「保存后列表变空、必须重新搜索才恢复」
根因:fetchOdooSummary() **无条件清空 groupCache**,却只在关键词变化时重置
activeCategories。保存编辑后关键词未变 → 分组仍显示为「已展开」、缓存却空了,
而 loadGroupItems 只由展开事件触发、不会重跑 —— 面板保持展开却无数据。
修法:关键词未变时,清缓存后把**仍展开的分组重新拉一遍**(与文件内批量操作
处既有的 delete + loadGroupItems 模式一致);关键词变了才折叠全部分组。
基础信息页(list.vue)不受影响:它的 getList 会完整带上 queryParams。
This commit is contained in:
@ -201,6 +201,7 @@
|
||||
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
|
||||
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
|
||||
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
|
||||
<el-checkbox v-if="hasColPermission('purchaseLink')" v-model="columns.purchaseLink.visible" label="采购链接" />
|
||||
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -355,6 +356,20 @@
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 采购链接:href 必须先过 safeHref 白名单,防止 javascript: 伪协议 -->
|
||||
<el-table-column v-if="columns.purchaseLink.visible && hasColPermission('purchaseLink')" label="采购链接" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="safeHref(row.purchaseLink)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchaseLink)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>打开链接</el-link>
|
||||
<span v-else-if="row.purchaseLink" style="color: #909399;" :title="row.purchaseLink">格式无效</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
@ -522,6 +537,16 @@
|
||||
<el-input-number v-model="form.referencePrice" :precision="2" :min="0" controls-position="right" style="width: 100%" placeholder="请输入参考价格" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- 采购链接:读写共用 material_list:purchaseLink 一个码 -->
|
||||
<el-form-item label="采购链接" prop="purchaseLink" v-if="hasFieldPermission('purchaseLink')">
|
||||
<el-input
|
||||
v-model="form.purchaseLink"
|
||||
placeholder="补货用的购买地址,如淘宝/1688 商品链接"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
|
||||
@ -1004,7 +1029,8 @@ const columns = reactive({
|
||||
commonName: { visible: true }, category: { visible: true }, type: { visible: true },
|
||||
spec: { visible: true }, unit: { visible: true }, inventory: { visible: true },
|
||||
available: { visible: true }, files: { visible: true }, isEnabled: { visible: true },
|
||||
isInspectionRequired: { visible: true }, referencePrice: { visible: true }, warningStatus: { visible: true }
|
||||
isInspectionRequired: { visible: true }, referencePrice: { visible: true }, warningStatus: { visible: true },
|
||||
purchaseLink: { visible: true }
|
||||
});
|
||||
|
||||
const permissionMap: Record<string, string> = {
|
||||
@ -1015,6 +1041,7 @@ const permissionMap: Record<string, string> = {
|
||||
// ★ 与后端读过滤(field_permissions.py)对齐;原先写 material_list:operation
|
||||
// 会与后端口径不一致,渲染出一列全是「-」的空表头
|
||||
isInspectionRequired: 'material_list:isInspectionRequired', referencePrice: 'material_list:referencePrice',
|
||||
purchaseLink: 'material_list:purchaseLink',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
|
||||
@ -1066,6 +1093,16 @@ const initColumnPermissions = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 采购链接的可点击地址白名单(与 list.vue 同名同实现)。
|
||||
* ★ 该字段由用户自由填写,直接绑 href 时 `javascript:` 伪协议会被当成
|
||||
* 可执行链接 —— 只放行 http/https。
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim();
|
||||
return /^https?:\/\//i.test(s) ? s : null;
|
||||
};
|
||||
|
||||
const hasFieldPermission = (field: string) => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
|
||||
const code = permissionMap[field];
|
||||
@ -1107,7 +1144,7 @@ const initForm = {
|
||||
id: undefined, companyName: '', name: '', commonName: '', category: '', type: '', spec: '', unit: '',
|
||||
visibilityLevel: 0, generalManual: [] as string[], generalImage: [] as string[], isEnabled: true,
|
||||
referencePrice: undefined as number | undefined,
|
||||
productImageRemark: '', manualLinkRemark: ''
|
||||
productImageRemark: '', manualLinkRemark: '', purchaseLink: ''
|
||||
};
|
||||
const form = ref({...initForm});
|
||||
|
||||
@ -1183,13 +1220,22 @@ const fetchOdooSummary = async () => {
|
||||
const res: any = await getOdooSummary(params);
|
||||
if (res?.code === 200) {
|
||||
groupSummary.value = res.data ?? [];
|
||||
// 搜索条件变更 → 清除旧缓存,折叠所有分组
|
||||
const keywordChanged = queryParams.keyword !== lastKeyword.value;
|
||||
// 缓存一律作废:分组摘要变了,明细可能已过期
|
||||
groupCache.value = new Map();
|
||||
groupLoadingMap.value = new Map();
|
||||
if (queryParams.keyword !== lastKeyword.value) {
|
||||
if (keywordChanged) {
|
||||
// 搜索条件变了 → 折叠全部分组,等用户自己展开
|
||||
activeCategories.value = [];
|
||||
lastKeyword.value = queryParams.keyword;
|
||||
return;
|
||||
}
|
||||
// ★ 搜索条件没变(典型场景:保存编辑后刷新):
|
||||
// 必须把**仍处于展开状态**的分组重新拉一遍。
|
||||
// 原先只清缓存不重拉,而 loadGroupItems 只由展开事件触发、不会重跑,
|
||||
// 于是面板保持「展开」却没有任何数据 —— 看起来像被清空了,
|
||||
// 用户只能重新输入搜索条件才恢复。
|
||||
activeCategories.value.forEach(cat => loadGroupItems(cat));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取 Odoo 摘要失败', err);
|
||||
@ -1350,7 +1396,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key]; const originalVal = original[key];
|
||||
if (Array.isArray(currentVal) && Array.isArray(originalVal)) { if (!isArraysEqual(currentVal, originalVal)) payload[key] = currentVal; }
|
||||
|
||||
@ -198,6 +198,7 @@
|
||||
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
|
||||
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
|
||||
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
|
||||
<el-checkbox v-if="hasColPermission('purchaseLink')" v-model="columns.purchaseLink.visible" label="采购链接" />
|
||||
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -343,6 +344,21 @@
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 采购链接:地址由用户自由填写,href 必须先过 safeHref 白名单,
|
||||
否则 javascript: 之类的伪协议会被当成可点击链接执行 -->
|
||||
<el-table-column v-if="columns.purchaseLink.visible && hasColPermission('purchaseLink')" label="采购链接" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="safeHref(row.purchaseLink)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchaseLink)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>打开链接</el-link>
|
||||
<span v-else-if="row.purchaseLink" style="color: #909399;" :title="row.purchaseLink">格式无效</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
@ -529,6 +545,17 @@
|
||||
<el-input-number v-model="form.referencePrice" :precision="2" :min="0" controls-position="right" style="width: 100%" placeholder="请输入参考价格" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- ★ 采购链接:读写共用 material_list:purchaseLink 一个码
|
||||
(与参考价格同模式),无权限者整项不显示 -->
|
||||
<el-form-item label="采购链接" prop="purchaseLink" v-if="hasFieldPermission('purchaseLink')">
|
||||
<el-input
|
||||
v-model="form.purchaseLink"
|
||||
placeholder="补货用的购买地址,如淘宝/1688 商品链接"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
|
||||
@ -976,6 +1003,7 @@ const columns = reactive({
|
||||
isInspectionRequired: { visible: true },
|
||||
isApprovalRequired: { visible: true },
|
||||
referencePrice: { visible: true },
|
||||
purchaseLink: { visible: true },
|
||||
warningStatus: { visible: true }
|
||||
});
|
||||
|
||||
@ -1000,6 +1028,7 @@ const permissionMap: Record<string, string> = {
|
||||
isInspectionRequired: 'material_list:isInspectionRequired',
|
||||
isApprovalRequired: 'material_list:isApprovalRequired',
|
||||
referencePrice: 'material_list:referencePrice',
|
||||
purchaseLink: 'material_list:purchaseLink',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
|
||||
@ -1088,6 +1117,19 @@ const hasFieldPermission = (field: string) => {
|
||||
return userStore.hasPermission(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 采购链接的可点击地址白名单。
|
||||
*
|
||||
* ★ 为什么必须过滤:该字段由用户自由填写,直接绑到 href 上时,
|
||||
* `javascript:alert(1)` 这类伪协议会被浏览器当成可执行链接 ——
|
||||
* 点一下就执行脚本。只放行 http/https,其余一律不渲染成链接。
|
||||
* (列表与表单两处共用,保证判定口径一致。)
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim();
|
||||
return /^https?:\/\//i.test(s) ? s : null;
|
||||
};
|
||||
|
||||
// ★ 附件备注(产品图备注 / 说明书备注)的**写**权限,与读权限分离。
|
||||
// 两端必须与后端同口径:
|
||||
// 读 → material_list:files (本区块整体可见,6 个角色)
|
||||
@ -1189,6 +1231,7 @@ const initForm = {
|
||||
referencePrice: undefined as number | undefined,
|
||||
productImageRemark: '',
|
||||
manualLinkRemark: '',
|
||||
purchaseLink: '',
|
||||
};
|
||||
|
||||
const form = ref({...initForm});
|
||||
@ -1509,7 +1552,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key];
|
||||
|
||||
Reference in New Issue
Block a user