diff --git a/inventory-web/src/views/material/buyOdoo.vue b/inventory-web/src/views/material/buyOdoo.vue
index ebdff1f..ca3625b 100644
--- a/inventory-web/src/views/material/buyOdoo.vue
+++ b/inventory-web/src/views/material/buyOdoo.vue
@@ -201,6 +201,7 @@
+
@@ -355,6 +356,20 @@
-
+
+
+
+ 打开链接
+ 格式无效
+ -
+
+
@@ -522,6 +537,16 @@
+
+
+
+
+
+
@@ -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 = {
@@ -1015,6 +1041,7 @@ const permissionMap: Record = {
// ★ 与后端读过滤(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; }
diff --git a/inventory-web/src/views/material/list.vue b/inventory-web/src/views/material/list.vue
index 357d924..3d5bca4 100644
--- a/inventory-web/src/views/material/list.vue
+++ b/inventory-web/src/views/material/list.vue
@@ -198,6 +198,7 @@
+
@@ -343,6 +344,21 @@
-
+
+
+
+ 打开链接
+ 格式无效
+ -
+
+
@@ -529,6 +545,17 @@
+
+
+
+
+
+
@@ -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 = {
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];