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:
yueli
2026-09-17 11:26:42 +08:00
parent 6cb30a21fd
commit 938051fe29
2 changed files with 95 additions and 6 deletions

View File

@ -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; }

View File

@ -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];