feat(purchase): 采购申请弹窗支持批量建单与商家链接一键跳转

批量建单:
  openCreateDialog 新增 batchItems 分支 —— 待采购清单勾选多个物料时,
  明细表一次性铺成 N 行,实现一单多品的合并采购。单条与批量共用
  fillRowFromPrefill 做字段映射,不各写一份。

商家链接一键跳转:
  输入框加后置按钮,点击在新标签页打开,方便采购员比对价格。
  ★ 链接先过 safeHref 白名单(只放行 http/https)—— 该字段由用户自由填写,
    直接交给 window.open 会让 javascript: 之类的伪协议被当成代码执行。
    非法时按钮置灰,不做静默失败。

交接方式:
  单行与批量统一走 localStorage(query 里只传一次性 key,取完即删),
  不经过 URL。原因:material_base.purchase_link 是 text,实测已有 516 字符的
  真实外链,走 URL 会撞上长度上限并被静默截断。曾经单行走 query 时被迫引入
  一个「链接多长就不带」的硬编码阈值,导致同一物料单行跳转带链接、批量跳转
  不带 —— 现在两条路径统一,无长度上限。
This commit is contained in:
yueli
2026-09-18 14:31:14 +08:00
parent 690ee2c81d
commit 70dd257392

View File

@ -252,9 +252,19 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="商家链接" min-width="160">
<el-table-column label="商家链接" min-width="210">
<template #default="{ row }">
<el-input v-model="row.supplier_link" placeholder="https://" clearable />
<!-- 后置跳转按钮:链接非法(非 http/https时置灰不做静默失败 -->
<el-input v-model="row.supplier_link" placeholder="https://" clearable>
<template #append>
<el-button
:icon="Link"
:disabled="!safeHref(row.supplier_link)"
title="在新标签页打开链接"
@click="openLink(row.supplier_link)"
/>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="备注" min-width="150">
@ -458,7 +468,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Plus, Delete, CopyDocument, Picture } from '@element-plus/icons-vue'
import { Refresh, Plus, Delete, CopyDocument, Picture, Link } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialPurchase } from '@/api/purchase'
@ -916,29 +926,98 @@ const handleStatusChange = () => {
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
/**
* 链接白名单:只放行 http/https。
*
* ★ 商家链接由用户自由填写,直接交给 window.open 会让 `javascript:` 之类的
* 伪协议被当成可执行代码。与物料列表 / 待采购清单的 safeHref 同一约定。
*/
const safeHref = (url: any): string | null => {
const s = String(url || '').trim()
return /^https?:\/\//i.test(s) ? s : null
}
/** 明细行「商家链接」的跳转按钮:新标签页打开,方便采购员比对价格 */
const openLink = (url: any) => {
const href = safeHref(url)
if (!href) {
ElMessage.warning('链接格式无效,仅支持 http / https 地址')
return
}
window.open(href, '_blank', 'noopener,noreferrer')
}
// --- 新建 ---
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
/** 把一条预填数据灌进明细行(单条与批量共用同一套字段映射) */
const fillRowFromPrefill = (
row: FormItemRow,
src: { materialId: number; name?: string; spec?: string; quantity?: number; supplierLink?: string }
) => {
row.materialBaseId = src.materialId
row.name = src.name || ''
row.spec_model = src.spec || ''
// 待采购清单带来的默认数量与供应商链接(都是 FormItemRow 已有字段,
// 不需要动接口/模板/工厂函数。unit 仍不带入 —— 明细行没有该字段。
if (src.quantity && src.quantity > 0) {
row.quantity = src.quantity
}
if (src.supplierLink) {
row.supplier_link = src.supplierLink
}
return row
}
const openCreateDialog = (prefill?: {
materialId?: number
name?: string
spec?: string
unit?: string
quantity?: number
supplierLink?: string
/** 批量:待采购清单勾选多个物料时,一次性铺成多行 */
batchItems?: Array<{
materialId: number
name?: string
spec?: string
quantity?: number
supplierLink?: string
}>
}) => {
dialogTitle.value = '新建采购申请'
form.value = {
approver_id: undefined,
remark: ''
}
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
const firstRow = createEmptyFormItem()
if (prefill?.materialId) {
firstRow.materialBaseId = prefill.materialId
firstRow.name = prefill.name || ''
firstRow.spec_model = prefill.spec || ''
if (prefill?.batchItems && prefill.batchItems.length > 0) {
// ★ 批量建单:勾选 N 个物料 → 明细表直接铺 N 行,一单多品合并采购。
// materialOptions 必须把每一行的物料都塞进去,否则 el-select 找到不到
// 选项,已填的物料会显示成空白(单条预填时也有这个约束)。
formItems.value = prefill.batchItems.map(it =>
fillRowFromPrefill(createEmptyFormItem(), it)
)
materialOptions.value = prefill.batchItems.map(it => ({
id: it.materialId,
name: it.name || '',
spec_model: it.spec || ''
}))
} else if (prefill?.materialId) {
formItems.value = [fillRowFromPrefill(createEmptyFormItem(), {
materialId: prefill.materialId,
name: prefill.name,
spec: prefill.spec,
quantity: prefill.quantity,
supplierLink: prefill.supplierLink
})]
materialOptions.value = [{
id: prefill.materialId,
name: prefill.name || '',
spec_model: prefill.spec || ''
}]
} else {
formItems.value = [createEmptyFormItem()]
materialOptions.value = []
}
formItems.value = [firstRow]
formDialogVisible.value = true
@ -1393,14 +1472,52 @@ onMounted(() => {
fetchData()
fetchApprovers()
// 从基础信息跳转过来的参数
// 跨页跳转过来的预填参数。生产端有两处:
// - 基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
// - 待采购清单的「生成采购申请」按钮(额外带 quantity / supplier_link
//
// ★ 本段依赖**组件重建**:两个生产端都用 window.open 开新标签页,
// 每次都是全新实例onMounted 必然触发。
// 若将来有人改成「同 path 的 router.push」如 /purchase?material_id=2 → material_id=3
// 本段不会重跑,预填会静默失效 —— 届时必须改造为
// watch(() => route.query.material_id, ..., { immediate: true })。
const query = route.query
// 批次 A待采购清单「批量生成采购申请」——物料较多含可长达数百字符的
// 商家链接),塞进 URL 有长度风险,故走 localStorage 中转query 里只传一个键。
if (query.batch_key) {
const key = String(query.batch_key)
try {
const raw = localStorage.getItem(key)
// ★ 读完立刻删除:这是一次性交接数据,留着重进页面会重复弹出;
// 多个标签页各自用独立的 key互不干扰。
localStorage.removeItem(key)
const items = raw ? JSON.parse(raw) : []
if (Array.isArray(items) && items.length > 0) {
openCreateDialog({ batchItems: items })
} else {
ElMessage.warning('批量数据已失效,请回到待采购清单重新勾选')
}
} catch (e) {
localStorage.removeItem(key)
ElMessage.error('批量数据解析失败,请重新勾选')
}
return
}
// 批次 B单条预填走 URL query。
// 生产端只有基础信息 / 基础信息(Odoo) 的「发起采购申请」按钮
// (它们只带 id/名称/规格,不带链接,所以没有 URL 长度风险)。
// 待采购清单的单行与批量按钮统一走上面的 batch_key两条路径共用一套回填。
if (query.material_id) {
const rawQty = Number(query.quantity)
openCreateDialog({
materialId: Number(query.material_id),
name: (query.name as string) || '',
spec: (query.spec as string) || '',
unit: (query.unit as string) || ''
unit: (query.unit as string) || '',
quantity: rawQty > 0 ? rawQty : undefined,
supplierLink: (query.supplier_link as string) || ''
})
}
})