purchase-form.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131
  1. <!-- 采购流程专用表单组件 -->
  2. <template>
  3. <view class="purchase-form-component">
  4. <!-- 基本信息 -->
  5. <uni-card>
  6. <uni-section titleFontSize="1.3rem" title="基本信息" type="line"></uni-section>
  7. <uni-forms ref="baseFormRef" :modelValue="baseForm" :rules="baseFormRules" label-position="left" :label-width="100" :border="true">
  8. <uni-forms-item name="contractPurchaseNumber" label="采购单号">
  9. <uni-easyinput v-model="baseForm.contractPurchaseNumber" disabled placeholder="自动生成"></uni-easyinput>
  10. </uni-forms-item>
  11. <uni-forms-item name="contractPurchaseName" label="采购单名称" required>
  12. <uni-easyinput v-model="baseForm.contractPurchaseName"
  13. :disabled="!isInitiateOrFieldEditable('contract_purchase_name')"
  14. :placeholder="'请输入采购单名称'"
  15. :clearable="false"></uni-easyinput>
  16. </uni-forms-item>
  17. <uni-forms-item name="applyDate" label="申请日期">
  18. <uni-easyinput v-model="baseForm.applyDate" type="date" disabled />
  19. </uni-forms-item>
  20. <uni-forms-item name="department" label="申请部门">
  21. <uni-easyinput v-model="baseForm.department" disabled></uni-easyinput>
  22. </uni-forms-item>
  23. <!-- 部门意见 -->
  24. <uni-forms-item label="部门意见" name="departmentalOpinion">
  25. <view class="element_value_container">
  26. <view v-if="isApprovalFieldEditable(departmentalOpinionElem)" class="element_value">
  27. <!-- 审批签字板 -->
  28. <view v-if="departmentalOpinionElem && departmentalOpinionElem.defaultValue == ''">
  29. <uni-row>
  30. <uni-col :span="24">
  31. <button type="primary" @click="handleApprovalSignature('departmental_opinion')">手动签名</button>
  32. </uni-col>
  33. <uni-col :span="24">
  34. <button style="margin-top: 5px;" type="primary" @click="handleAutoSeal('departmental_opinion')">一键签名</button>
  35. </uni-col>
  36. </uni-row>
  37. </view>
  38. <view v-else-if="departmentalOpinionElem && departmentalOpinionElem.defaultValue" class="signature_img">
  39. <img style="width: 100%;" mode="widthFix" @click="handleApprovalSignature('departmental_opinion')"
  40. :src="config.baseUrlPre + departmentalOpinionElem.sealImgPath"
  41. :alt="departmentalOpinionElem.elementName + '签名'" />
  42. </view>
  43. </view>
  44. <view v-else-if="departmentalOpinionElem && typeof departmentalOpinionElem.sealImgPath === 'string' && departmentalOpinionElem.sealImgPath.startsWith('/shares')" class="signature_img">
  45. <img style="width: 100%;" mode="widthFix"
  46. :src="config.baseUrlPre + departmentalOpinionElem.sealImgPath" />
  47. </view>
  48. </view>
  49. </uni-forms-item>
  50. <!-- 分管副总意见 -->
  51. <uni-forms-item label="分管副总" name="dgmOpinion">
  52. <view class="element_value_container">
  53. <view v-if="isApprovalFieldEditable(deputyGeneralManagerOpinionElem)" class="element_value">
  54. <!-- 审批签字板 -->
  55. <view v-if="deputyGeneralManagerOpinionElem && deputyGeneralManagerOpinionElem.defaultValue == ''">
  56. <uni-row>
  57. <uni-col :span="24">
  58. <button type="primary" @click="handleApprovalSignature('deputy_general_manager_opinion')">手动签名</button>
  59. </uni-col>
  60. <uni-col :span="24">
  61. <button style="margin-top: 5px;" type="primary" @click="handleAutoSeal('deputy_general_manager_opinion')">一键签名</button>
  62. </uni-col>
  63. </uni-row>
  64. </view>
  65. <view v-else-if="deputyGeneralManagerOpinionElem && deputyGeneralManagerOpinionElem.defaultValue" class="signature_img">
  66. <img style="width: 100%;" mode="widthFix" @click="handleApprovalSignature('deputy_general_manager_opinion')"
  67. :src="config.baseUrlPre + deputyGeneralManagerOpinionElem.sealImgPath"
  68. :alt="deputyGeneralManagerOpinionElem.elementName + '签名'" />
  69. </view>
  70. </view>
  71. <view v-else-if="deputyGeneralManagerOpinionElem && typeof deputyGeneralManagerOpinionElem.sealImgPath === 'string' && deputyGeneralManagerOpinionElem.sealImgPath.startsWith('/shares')" class="signature_img">
  72. <img style="width: 100%;" mode="widthFix"
  73. :src="config.baseUrlPre + deputyGeneralManagerOpinionElem.sealImgPath" />
  74. </view>
  75. </view>
  76. </uni-forms-item>
  77. <!-- 审核副总意见 -->
  78. <uni-forms-item label="分管副总" name="auditDgmOpinion">
  79. <view class="element_value_container">
  80. <view v-if="isApprovalFieldEditable(auditDeputyGeneralManagerOpinionElem)" class="element_value">
  81. <!-- 审批签字板 -->
  82. <view v-if="auditDeputyGeneralManagerOpinionElem && auditDeputyGeneralManagerOpinionElem.defaultValue == ''">
  83. <uni-row>
  84. <uni-col :span="24">
  85. <button type="primary" @click="handleApprovalSignature('audit_deputy_general_manager_opinion')">手动签名</button>
  86. </uni-col>
  87. <uni-col :span="24">
  88. <button style="margin-top: 5px;" type="primary" @click="handleAutoSeal('audit_deputy_general_manager_opinion')">一键签名</button>
  89. </uni-col>
  90. </uni-row>
  91. </view>
  92. <view v-else-if="auditDeputyGeneralManagerOpinionElem && auditDeputyGeneralManagerOpinionElem.defaultValue" class="signature_img">
  93. <img style="width: 100%;" mode="widthFix" @click="handleApprovalSignature('audit_deputy_general_manager_opinion')"
  94. :src="config.baseUrlPre + auditDeputyGeneralManagerOpinionElem.sealImgPath"
  95. :alt="auditDeputyGeneralManagerOpinionElem.elementName + '签名'" />
  96. </view>
  97. </view>
  98. <view v-else-if="auditDeputyGeneralManagerOpinionElem && typeof auditDeputyGeneralManagerOpinionElem.sealImgPath === 'string' && auditDeputyGeneralManagerOpinionElem.sealImgPath.startsWith('/shares')" class="signature_img">
  99. <img style="width: 100%;" mode="widthFix"
  100. :src="config.baseUrlPre + auditDeputyGeneralManagerOpinionElem.sealImgPath" />
  101. </view>
  102. </view>
  103. </uni-forms-item>
  104. <!-- 总经理意见 -->
  105. <uni-forms-item label="总经理" name="gmOpinion">
  106. <view class="element_value_container">
  107. <view v-if="isApprovalFieldEditable(generalManagerOpinionElem)" class="element_value">
  108. <!-- 审批签字板 -->
  109. <view v-if="generalManagerOpinionElem && generalManagerOpinionElem.defaultValue == ''">
  110. <uni-row>
  111. <uni-col :span="24">
  112. <button type="primary" @click="handleApprovalSignature('general_manager_opinion')">手动签名</button>
  113. </uni-col>
  114. <uni-col :span="24">
  115. <button style="margin-top: 5px;" type="primary" @click="handleAutoSeal('general_manager_opinion')">一键签名</button>
  116. </uni-col>
  117. </uni-row>
  118. </view>
  119. <view v-else-if="generalManagerOpinionElem && generalManagerOpinionElem.defaultValue" class="signature_img">
  120. <img style="width: 100%;" mode="widthFix" @click="handleApprovalSignature('general_manager_opinion')"
  121. :src="config.baseUrlPre + generalManagerOpinionElem.sealImgPath"
  122. :alt="generalManagerOpinionElem.elementName + '签名'" />
  123. </view>
  124. </view>
  125. <view v-else-if="generalManagerOpinionElem && typeof generalManagerOpinionElem.sealImgPath === 'string' && generalManagerOpinionElem.sealImgPath.startsWith('/shares')" class="signature_img">
  126. <img style="width: 100%;" mode="widthFix"
  127. :src="config.baseUrlPre + generalManagerOpinionElem.sealImgPath" />
  128. </view>
  129. </view>
  130. </uni-forms-item>
  131. </uni-forms>
  132. </uni-card>
  133. <!-- 物料明细 -->
  134. <uni-card>
  135. <uni-section titleFontSize="1.3rem" title="物料明细" type="line"></uni-section>
  136. <!-- 添加物料按钮:发起环节或字段可编辑时显示 -->
  137. <view v-if="isSeModel" class="material-actions">
  138. <button type="primary" size="mini" @click="openMaterialSelector" plain>添加物料</button>
  139. </view>
  140. <!-- 物料列表 - 卡片式展示 -->
  141. <view v-if="materialList.length > 0" class="material-list">
  142. <view v-for="(item, index) in materialList" :key="'material-' + item.materialCode + '-' + index" class="material-card">
  143. <view class="material-header" @click="toggleExpand(index)">
  144. <view class="material-main-info">
  145. <text class="material-name">{{ item.materialName }}</text>
  146. <text class="material-code">{{ item.materialCode }}</text>
  147. </view>
  148. <view class="material-expand">
  149. <uni-icons :type="item.expanded ? 'up' : 'down'" size="16" color="#999"></uni-icons>
  150. </view>
  151. </view>
  152. <!-- 展开的详细信息 -->
  153. <view v-show="item.expanded" class="material-detail">
  154. <view class="detail-row">
  155. <text class="detail-label">规格型号:</text>
  156. <text class="detail-value">{{ item.materialModel }}</text>
  157. <text class="detail-label" style="margin-left: 15px;">单位:</text>
  158. <text class="detail-value">{{ item.measureName }}</text>
  159. </view>
  160. <view class="detail-row delete-row">
  161. <text class="detail-label">数量:</text>
  162. <uni-easyinput
  163. v-model="item.qty"
  164. type="digit"
  165. v-if="isSeModel"
  166. placeholder="请输入数量"
  167. style="width: 100px; display: inline-block; margin-right: 15px;"
  168. />
  169. <text v-if="!isSeModel" class="detail-value">{{ item.qty }}</text>
  170. <button v-if="isSeModel" type="warn" size="mini" @click="removeMaterial(index)">删除</button>
  171. </view>
  172. </view>
  173. </view>
  174. </view>
  175. <view v-else-if="materialList.length === 0" class="empty-materials">
  176. <text>暂无物料</text>
  177. </view>
  178. </uni-card>
  179. <!-- 选择器弹出层 -->
  180. <uni-popup ref="selectorPopup" type="center">
  181. <view class="selector-popup">
  182. <view class="popup-header">
  183. <text class="popup-title">选择物料</text>
  184. <uni-icons type="closeempty" size="20" @click="closePopup"></uni-icons>
  185. </view>
  186. <!-- 搜索框 -->
  187. <view class="search-bar">
  188. <uni-easyinput
  189. v-model="searchKeyword"
  190. placeholder="输入物料名称搜索"
  191. clearable
  192. @confirm="handleSearch"
  193. />
  194. <button type="primary" size="mini" @click="handleSearch">搜索</button>
  195. </view>
  196. <scroll-view
  197. scroll-y
  198. class="popup-content"
  199. refresher-enabled
  200. :refresher-triggered="isRefreshing"
  201. @refresh="onRefresh"
  202. @scrolltolower="loadMore"
  203. >
  204. <view v-for="(item, index) in selectorList" :key="index"
  205. class="selector-item"
  206. @click="selectItem(item)">
  207. <view class="selector-item-content">
  208. <view class="item-info">
  209. <text class="item-name">{{ item.materialName || item.itemName }}</text>
  210. <text class="item-code">{{ item.materialCode || item.itemCode }}</text>
  211. <text class="item-spec">{{ item.materialModel || item.specification || '' }}</text>
  212. <text class="item-unit">{{ item.measureName || item.unit || '' }}</text>
  213. </view>
  214. <text v-if="isSelected(item)" class="selected-tag">已选择</text>
  215. </view>
  216. </view>
  217. <!-- 加载状态 -->
  218. <view v-if="isLoading && selectorList.length > 0" class="loading-text">
  219. <uni-load-more status="loading" />
  220. </view>
  221. <!-- 没有更多数据 -->
  222. <view v-else-if="!hasMore && selectorList.length > 0" class="no-more-text">
  223. <text>没有更多了</text>
  224. </view>
  225. <!-- 空数据提示 -->
  226. <view v-if="selectorList.length === 0 && !isLoading" class="empty-data">
  227. <text>{{ searchKeyword ? '暂无相关数据' : '暂无数据' }}</text>
  228. </view>
  229. </scroll-view>
  230. </view>
  231. </uni-popup>
  232. <!-- 签名板弹出层 -->
  233. <uni-popup ref="signaturePopup" @maskClick="closeSignature">
  234. <view class="signature_container" :class="{ 'signature_container_landscape': isLandscape }">
  235. <view class="signature_content">
  236. <l-signature ref="signatureRef" v-if="signaturePopupShow" :landscape="isLandscape" :penSize="8"
  237. :minLineWidth="4" :maxLineWidth="12" :openSmooth="true" :preferToDataURL="true"
  238. backgroundColor="#ffffff" penColor="black"></l-signature>
  239. </view>
  240. <view class="signature_button_container">
  241. <uni-row :gutter="10">
  242. <uni-col :span="6">
  243. <button type="warn" @click="onclickSignatureButton('undo')">撤销</button>
  244. </uni-col>
  245. <uni-col :span="6">
  246. <button type="warn" @click="onclickSignatureButton('clear')">清空</button>
  247. </uni-col>
  248. <uni-col :span="6">
  249. <button type="primary" @click="onclickSignatureButton('save')">保存</button>
  250. </uni-col>
  251. <uni-col :span="6">
  252. <button @click="onclickSignatureButton('landscape')">全屏</button>
  253. </uni-col>
  254. </uni-row>
  255. </view>
  256. </view>
  257. </uni-popup>
  258. </view>
  259. </template>
  260. <script setup lang="ts">
  261. import { ref, watch, computed, nextTick } from 'vue'
  262. import { getMaterialList } from '@/api/purchase.js'
  263. import { useUserStore } from '@/store/user.js'
  264. import $modal from '@/plugins/modal.js'
  265. import config from '@/config.js'
  266. import { uploadSignatureBoardImg, getSeal } from '@/api/process.js'
  267. const userStore = useUserStore()
  268. const props = defineProps({
  269. formData: {
  270. type: Object,
  271. default: () => ({})
  272. },
  273. formElements: {
  274. type: Array,
  275. default: () => []
  276. },
  277. repeatingForm: {
  278. type: Object,
  279. default: () => ({ elementItem: [], elements: [] })
  280. },
  281. // 是否为发起环节(seModel == '1')
  282. isInitiate: {
  283. type: Boolean,
  284. default: false
  285. },
  286. // 当前环节可编辑的字段列表
  287. editableFields: {
  288. type: Array,
  289. default: () => []
  290. },
  291. // 当前环节的审批意见配置
  292. currentTacheOpinion: {
  293. type: Object,
  294. default: null
  295. }
  296. })
  297. const emits = defineEmits(['update', 'signature-change'])
  298. // 表单数据
  299. const baseForm = ref<any>({
  300. contractPurchaseNumber: '',
  301. contractPurchaseName: '',
  302. applyDate: '',
  303. department: ''
  304. })
  305. const baseFormRules = ref({
  306. contractPurchaseName: [
  307. {
  308. required: true,
  309. errorMessage: '请输入采购单名称'
  310. }
  311. ]
  312. })
  313. const materialList = ref<any[]>([])
  314. const selectorList = ref<any[]>([])
  315. const searchKeyword = ref('')
  316. const isLoading = ref(false)
  317. const hasMore = ref(true)
  318. const currentPage = ref(1)
  319. const pageSize = ref(20)
  320. const isRefreshing = ref(false)
  321. const selectorPopup = ref(null)
  322. const signaturePopup = ref(null)
  323. const signatureRef = ref(null)
  324. const signatureImg = ref('')
  325. const currentApprovalText = ref('')
  326. const approvals = ref<any[]>([]) // 历史审批意见列表
  327. const signaturePopupShow = ref(false)
  328. const isLandscape = ref(false)
  329. // 获取指定的审批意见元素
  330. const getApprovalElement = (fieldName: string) => {
  331. if (!props.formElements || !Array.isArray(props.formElements)) {
  332. return null
  333. }
  334. // 从 formElements 中找到对应的字段
  335. const elem = props.formElements.find(e => e.tableField === fieldName || e.elementName.includes(fieldName.replace(/_/g, '')))
  336. return elem || null
  337. }
  338. // 获取部门意见元素(计算属性,避免重复调用)
  339. const departmentalOpinionElem = computed(() => getApprovalElement('departmental_opinion'))
  340. // 获取副总经理意见元素
  341. const deputyGeneralManagerOpinionElem = computed(() => getApprovalElement('deputy_general_manager_opinion'))
  342. // 获取审计副总经理意见元素
  343. const auditDeputyGeneralManagerOpinionElem = computed(() => getApprovalElement('audit_deputy_general_manager_opinion'))
  344. // 获取总经理意见元素
  345. const generalManagerOpinionElem = computed(() => getApprovalElement('general_manager_opinion'))
  346. // 是否发起环节
  347. const isSeModel = computed(() => props.isInitiate)
  348. // 计算各部门意见是否可编辑
  349. const isApprovalFieldEditable = (elem: any) => {
  350. if (!elem) return false
  351. return props.editableFields.includes(elem.tableField)
  352. }
  353. // 处理审批意见签名
  354. let lastFormInsId = ''
  355. // 计算字段是否可编辑 (只需要该字段在 table_fields 中即可)
  356. const getFieldEditable = (fieldName: string) => {
  357. // 如果没有配置 editableFields,则都不可编辑
  358. if (!props.editableFields || props.editableFields.length === 0) {
  359. return false
  360. }
  361. // 检查该字段是否在可编辑字段列表中 (table_fields 包含的字段才可以编辑)
  362. return props.editableFields.includes(fieldName)
  363. }
  364. // 计算是否为发起环节或字段可编辑 (满足任一条件即可)
  365. const isInitiateOrFieldEditable = (fieldName: string) => {
  366. return props.isInitiate || getFieldEditable(fieldName)
  367. }
  368. // 监听表单数据变化
  369. watch(() => props.formData, (newVal) => {
  370. if (!newVal || Object.keys(newVal).length === 0) {
  371. return
  372. }
  373. // 使用 formInsId 判断是否是新的数据(formInsId 肯定存在)
  374. const currentFormInsId = newVal.lFormInsId || newVal.universalid || ''
  375. // 只有当 formInsId 变化时,才认为是新的数据需要加载
  376. if (currentFormInsId && currentFormInsId !== lastFormInsId) {
  377. lastFormInsId = currentFormInsId
  378. // 填充基本信息
  379. baseForm.value = {
  380. contractPurchaseNumber: newVal.contractPurchaseNumber || '',
  381. contractPurchaseName: newVal.contractPurchaseName || '',
  382. applyDate: newVal.applyDate || '',
  383. department: newVal.department || ''
  384. }
  385. // 从 details 或 detailList 中获取物料列表 (后端返回的是 details)
  386. const rawList = newVal.details || newVal.detailList || []
  387. // 加载物料数据 (即使为空也加载,因为可能是新增场景)
  388. if (Array.isArray(rawList)) {
  389. materialList.value = rawList.map((item: any) => ({
  390. materialCode: item.materialCode || item.itemCode,
  391. materialName: item.materialName || item.itemName,
  392. materialModel: item.materialModel || item.specification,
  393. measureName: item.measureName || item.unit,
  394. itemTypeName: item.itemTypeName || '',
  395. qty: item.qty || 0,
  396. expanded: true // 默认展开
  397. }))
  398. } else {
  399. materialList.value = []
  400. }
  401. }
  402. }, { immediate: true, deep: true })
  403. // 切换展开/收起状态
  404. function toggleExpand(index: number) {
  405. if (materialList.value[index]) {
  406. materialList.value[index].expanded = !materialList.value[index].expanded
  407. }
  408. }
  409. // 打开物料选择器
  410. async function openMaterialSelector() {
  411. currentPage.value = 1
  412. hasMore.value = true
  413. selectorList.value = []
  414. searchKeyword.value = ''
  415. await loadMaterialList(1)
  416. selectorPopup.value.open()
  417. }
  418. // 关闭弹出层
  419. function closePopup() {
  420. selectorPopup.value.close()
  421. }
  422. // 加载物料列表
  423. function loadMaterialList(page: number = 1) {
  424. isLoading.value = true
  425. getMaterialList(userStore.user.useId, page, pageSize.value, searchKeyword.value)
  426. .then(({ returnParams }) => {
  427. const list = returnParams.list || []
  428. const total = returnParams.total || 0
  429. if (page === 1) {
  430. selectorList.value = list
  431. } else {
  432. selectorList.value = [...selectorList.value, ...list]
  433. }
  434. hasMore.value = selectorList.value.length < total
  435. currentPage.value = page
  436. })
  437. .catch(err => {
  438. console.error('加载物料失败:', err)
  439. $modal.msgError('加载物料失败')
  440. })
  441. .finally(() => {
  442. isLoading.value = false
  443. isRefreshing.value = false
  444. })
  445. }
  446. // 搜索
  447. function handleSearch() {
  448. loadMaterialList(1)
  449. }
  450. // 刷新
  451. function onRefresh() {
  452. isRefreshing.value = true
  453. loadMaterialList(1)
  454. }
  455. // 加载更多
  456. function loadMore() {
  457. if (!isLoading.value && hasMore.value) {
  458. loadMaterialList(currentPage.value + 1)
  459. }
  460. }
  461. // 选择物料
  462. async function selectItem(item: any) {
  463. // 检查是否已选择
  464. const exists = materialList.value.find(m =>
  465. m.materialCode === item.materialCode ||
  466. m.materialCode === item.itemCode
  467. )
  468. if (!exists) {
  469. // 使用完整的字段映射(参考 start.vue)
  470. const newItem = {
  471. materialCode: item.materialCode || item.itemCode,
  472. materialName: item.materialName || item.itemName,
  473. materialModel: item.materialModel || item.specification,
  474. measureName: item.measureName || item.unit,
  475. itemTypeName: item.itemTypeName || '',
  476. qty: 0,
  477. expanded: true
  478. }
  479. materialList.value = [...materialList.value, newItem]
  480. await nextTick()
  481. emitUpdate()
  482. }
  483. closePopup()
  484. }
  485. // 判断是否已选择
  486. function isSelected(item: any) {
  487. return materialList.value.some(m => m.materialCode === item.materialCode)
  488. }
  489. // 删除物料
  490. async function removeMaterial(index: number) {
  491. uni.showModal({
  492. title: '',
  493. content: '确认删除该物料?',
  494. success: async (res) => {
  495. if (res.confirm) {
  496. // 使用新数组替换旧数组,确保触发响应式更新
  497. materialList.value = materialList.value.filter((_, i) => i !== index)
  498. await nextTick()
  499. emitUpdate()
  500. }
  501. }
  502. })
  503. }
  504. // 发送更新事件
  505. function emitUpdate() {
  506. emits('update', {
  507. ...baseForm.value,
  508. detailList: materialList.value
  509. })
  510. }
  511. // 处理审批意见签名
  512. const currentApprovalFieldName = ref('')
  513. // 存储每个签名字段的印章信息
  514. const approvalSealInfo = ref<Record<string, any>>({})
  515. function handleApprovalSignature(fieldName: string) {
  516. currentApprovalFieldName.value = fieldName
  517. signaturePopupShow.value = false
  518. nextTick(() => {
  519. signaturePopupShow.value = true
  520. })
  521. signaturePopup.value.open()
  522. }
  523. // 处理一键签名
  524. function handleAutoSeal(fieldName: string) {
  525. const elem = getApprovalElement(fieldName)
  526. if (elem) {
  527. getSeal(userStore.user.useId).then(({ returnParams }) => {
  528. const elem = getApprovalElement(fieldName)
  529. elem.defaultValue = returnParams.sealFileId.universalid
  530. elem.sealImgPath = returnParams.sealFileId.path
  531. // 保存印章信息(用于后端处理)
  532. // 一键签名时,sealFileId.universalid 就是印章模板 ID
  533. approvalSealInfo.value[fieldName] = {
  534. sealInsId: returnParams.sealFileId.universalid, // 签名实例 ID(这里与印章模板 ID 相同)
  535. sealFileId: returnParams.sealFileId.universalid, // 印章模板 ID(用于 imgval)
  536. left: 0, // 默认左边距为 0
  537. top: 0 // 默认上边距为 0
  538. }
  539. }).catch(err => {
  540. $modal.msgError('获取签名失败:' + err)
  541. })
  542. }
  543. }
  544. // 初始化签字板
  545. function initSignature() {
  546. signaturePopupShow.value = false
  547. setTimeout(() => {
  548. signaturePopupShow.value = true
  549. }, 100)
  550. }
  551. // 点击签字板按钮
  552. function onclickSignatureButton(event: string) {
  553. switch (event) {
  554. case 'undo':
  555. signatureRef.value.undo()
  556. break
  557. case 'clear':
  558. signatureRef.value.clear()
  559. break
  560. case 'save':
  561. signatureRef.value.canvasToTempFilePath({
  562. success: (res: any) => {
  563. if (res.isEmpty) {
  564. $modal.msgError('签名不能为空!')
  565. return
  566. }
  567. // 判断上传文件是否是 base64
  568. if (res.tempFilePath.substring(0, 'data:image/png;base64,'.length) == 'data:image/png;base64,') {
  569. const _fileData = res.tempFilePath
  570. uploadSignatureBoardImg(userStore.user.useId, _fileData, getApprovalElement(currentApprovalFieldName.value).tableField)
  571. .then(({ returnParams }) => {
  572. const elem = getApprovalElement(currentApprovalFieldName.value)
  573. elem.defaultValue = returnParams.sealInsID
  574. elem.sealImgPath = returnParams.path
  575. // 保存印章信息(用于后端处理)
  576. // sealInsID 是手写签名的实例 ID
  577. approvalSealInfo.value[currentApprovalFieldName.value] = {
  578. sealInsId: returnParams.sealInsID, // 签名实例 ID
  579. sealFileId: returnParams.sealInsID, // 手写签名图片 ID
  580. left: 0, // 默认左边距为 0
  581. top: 0 // 默认上边距为 0
  582. }
  583. currentApprovalFieldName.value = ''
  584. signaturePopupShow.value = false
  585. signaturePopup.value.close()
  586. })
  587. } else {
  588. // 转 base64
  589. uni.getFileSystemManager().readFile({
  590. filePath: res.tempFilePath,
  591. encoding: 'base64',
  592. success: (fileData: any) => {
  593. const _fileData = 'data:image/png;base64,' + fileData.data
  594. uploadSignatureBoardImg(userStore.user.useId, _fileData, getApprovalElement(currentApprovalFieldName.value).tableField)
  595. .then(({ returnParams }) => {
  596. const elem = getApprovalElement(currentApprovalFieldName.value)
  597. elem.defaultValue = returnParams.sealInsID
  598. elem.sealImgPath = returnParams.path
  599. // 保存印章信息(用于后端处理)
  600. // sealInsID 是手写签名的实例 ID
  601. approvalSealInfo.value[currentApprovalFieldName.value] = {
  602. sealInsId: returnParams.sealInsID, // 签名实例 ID
  603. sealFileId: returnParams.sealInsID, // 手写签名图片 ID
  604. left: 0, // 默认左边距为 0
  605. top: 0 // 默认上边距为 0
  606. }
  607. currentApprovalFieldName.value = ''
  608. signaturePopupShow.value = false
  609. signaturePopup.value.close()
  610. })
  611. }
  612. })
  613. }
  614. }
  615. })
  616. break
  617. case 'landscape':
  618. isLandscape.value = !isLandscape.value
  619. initSignature()
  620. break
  621. }
  622. }
  623. // 清空签名
  624. function clearSignature() {
  625. onclickSignatureButton('clear')
  626. }
  627. // 保存签名
  628. function saveSignature() {
  629. onclickSignatureButton('save')
  630. }
  631. // 关闭签名
  632. function closeSignature() {
  633. signaturePopupShow.value = false
  634. signaturePopup.value.close()
  635. }
  636. // 暴露验证方法给父组件
  637. defineExpose({
  638. validate: async () => {
  639. // 采购表单验证
  640. if (!baseForm.value.contractPurchaseName) {
  641. return Promise.reject(new Error('请输入采购单名称'))
  642. }
  643. // 验证采购单名称长度
  644. if (baseForm.value.contractPurchaseName && baseForm.value.contractPurchaseName.length > 100) {
  645. return Promise.reject(new Error('采购单名称长度不能超过 100 个字符'))
  646. }
  647. if (!materialList.value || materialList.value.length === 0) {
  648. return Promise.reject(new Error('请至少添加一个物料'))
  649. }
  650. // 验证物料数量(发起环节或字段可编辑时)
  651. if (isSeModel.value && materialList.value.length > 0) {
  652. for (let i = 0; i < materialList.value.length; i++) {
  653. const item = materialList.value[i]
  654. // 验证数量不能为空且必须大于 0
  655. if (!item.qty || Number(item.qty) <= 0) {
  656. return Promise.reject(new Error(`请填写第${i + 1}个物料的数量,且必须大于 0`))
  657. }
  658. }
  659. }
  660. // 验证审批意见字段(如果在 table_fields 中)
  661. const approvalFields = [
  662. { fieldName: 'departmental_opinion', msg: '部门意见' },
  663. { fieldName: 'deputy_general_manager_opinion', msg: '副总经理意见' },
  664. { fieldName: 'audit_deputy_general_manager_opinion', msg: '审核副总意见' },
  665. { fieldName: 'general_manager_opinion', msg: '总经理意见' }
  666. ]
  667. for (const field of approvalFields) {
  668. const elem = getApprovalElement(field.fieldName)
  669. if (elem) {
  670. // 检查该字段是否在当前环节的可编辑字段列表中
  671. // editableFields 包含了当前环节可编辑的所有字段(即 table_fields)
  672. if (props.editableFields && props.editableFields.includes(field.fieldName)) {
  673. if (!elem.defaultValue || elem.defaultValue === '') {
  674. return Promise.reject(new Error(field.msg + '不能为空!'))
  675. }
  676. }
  677. }
  678. }
  679. return Promise.resolve()
  680. },
  681. // 获取表单数据(返回 formElements 格式)
  682. getFormElements: () => {
  683. const formElements: any[] = []
  684. // 添加基本信息字段
  685. Object.keys(baseForm.value).forEach(key => {
  686. const value = baseForm.value[key]
  687. if (value !== undefined && value !== null) {
  688. formElements.push({
  689. name: key,
  690. value: String(value),
  691. type: '0' // 普通文本类型
  692. })
  693. }
  694. })
  695. // 添加审批意见字段
  696. const approvalFields = ['departmental_opinion', 'deputy_general_manager_opinion', 'audit_deputy_general_manager_opinion', 'general_manager_opinion']
  697. approvalFields.forEach(fieldName => {
  698. const elem = getApprovalElement(fieldName)
  699. if (elem) {
  700. formElements.push({
  701. name: fieldName,
  702. value: elem.defaultValue || '',
  703. type: elem.type || '0'
  704. })
  705. // 添加签名图片的 imgval 值(用于后端处理)
  706. // 格式:sealFileId_left_top(必须使用印章模板 ID,而不是签名实例 ID)
  707. if (approvalSealInfo.value[fieldName]) {
  708. const sealInfo = approvalSealInfo.value[fieldName]
  709. formElements.push({
  710. name: fieldName + '_imgval',
  711. value: `${sealInfo.sealFileId}_${sealInfo.left}_${sealInfo.top}`,
  712. type: '0'
  713. })
  714. } else if (elem.sealImgPath && elem.defaultValue) {
  715. // 兼容旧数据:如果有 sealImgPath 和 defaultValue,提取 sealId 构建 imgval
  716. // sealImgPath 格式:/shares/document/seal/593268258724500.png
  717. const sealIdMatch = elem.sealImgPath.match(/\/seal\/(\d+)\.png$/)
  718. if (sealIdMatch) {
  719. const sealId = sealIdMatch[1]
  720. formElements.push({
  721. name: fieldName + '_imgval',
  722. value: `${sealId}_0_0`,
  723. type: '0'
  724. })
  725. }
  726. }
  727. }
  728. })
  729. // 添加物料明细(JSON 字符串格式)
  730. if (materialList.value && materialList.value.length > 0) {
  731. formElements.push({
  732. name: 'detailList',
  733. value: JSON.stringify(materialList.value),
  734. type: '0'
  735. })
  736. }
  737. return formElements
  738. },
  739. // 获取表单数据(兼容旧版本,返回键值对格式)
  740. getFormData: () => {
  741. const formData: any = {
  742. ...baseForm.value,
  743. detailList: materialList.value || []
  744. }
  745. // 添加审批意见字段数据
  746. const approvalFields = ['departmental_opinion', 'deputy_general_manager_opinion', 'audit_deputy_general_manager_opinion', 'general_manager_opinion']
  747. approvalFields.forEach(fieldName => {
  748. const elem = getApprovalElement(fieldName)
  749. if (elem) {
  750. formData[fieldName] = elem.defaultValue || ''
  751. // 添加签名图片的 imgval 值(用于后端处理)
  752. // 格式:sealFileId_left_top(必须使用印章模板 ID,而不是签名实例 ID)
  753. if (approvalSealInfo.value[fieldName]) {
  754. const sealInfo = approvalSealInfo.value[fieldName]
  755. formData[fieldName + '_imgval'] = `${sealInfo.sealFileId}_${sealInfo.left}_${sealInfo.top}`
  756. } else if (elem.sealImgPath && elem.defaultValue) {
  757. // 兼容旧数据:如果有 sealImgPath 和 defaultValue,提取 sealId 构建 imgval
  758. // sealImgPath 格式:/shares/document/seal/593268258724500.png
  759. const sealIdMatch = elem.sealImgPath.match(/\/seal\/(\d+)\.png$/)
  760. if (sealIdMatch) {
  761. const sealId = sealIdMatch[1]
  762. formData[fieldName + '_imgval'] = `${sealId}_0_0`
  763. }
  764. }
  765. }
  766. })
  767. return formData
  768. }
  769. })
  770. </script>
  771. <style lang="scss" scoped>
  772. /*.purchase-form-component {*/
  773. // 基本信息中的禁用字段样式优化
  774. ::v-deep .uni-forms {
  775. .uni-forms-item__content {
  776. .uni-easyinput__content-input {
  777. font-size: calc(14px + 1.2*(1rem - 16px)) !important;
  778. font-weight: 500;
  779. color: #333;
  780. }
  781. .uni-date {
  782. .uni-icons {
  783. font-size: calc(22px + 1.2*(1rem - 16px)) !important;
  784. font-weight: 500;
  785. }
  786. .uni-date__x-input {
  787. height: auto;
  788. font-size: calc(14px + 1.2*(1rem - 16px)) !important;
  789. font-weight: 500;
  790. color: #333;
  791. }
  792. }
  793. }
  794. }
  795. // 审批意见字段样式(与 index.vue 保持一致)
  796. .element_value_container {
  797. .signature_img {
  798. width: 180px;
  799. }
  800. }
  801. // 签名弹窗样式(与 index.vue 保持一致)
  802. .signature_container {
  803. background-color: #f5f5f5;
  804. height: 40vh;
  805. width: 90vw;
  806. .signature_content {
  807. height: 80%;
  808. width: 100%;
  809. }
  810. .signature_button_container {
  811. height: 20%;
  812. width: 100%;
  813. button {
  814. height: 100%;
  815. }
  816. }
  817. }
  818. .signature_container_landscape {
  819. height: 100vh;
  820. width: 100vw;
  821. .signature_content {
  822. height: 85%;
  823. width: 100%;
  824. }
  825. .signature_button_container {
  826. margin-top: 10%;
  827. height: 15%;
  828. width: 100%;
  829. button {
  830. height: 100%;
  831. width: 100%;
  832. transform: rotate(90deg);
  833. }
  834. }
  835. }
  836. .material-actions {
  837. display: flex;
  838. gap: 10px;
  839. margin-bottom: 15px;
  840. button {
  841. flex: 1;
  842. }
  843. }
  844. // 物料列表 - 卡片式展示
  845. .material-list {
  846. display: flex;
  847. flex-direction: column;
  848. gap: 10px;
  849. }
  850. .material-card {
  851. border: 1px solid #e5e5e5;
  852. border-radius: 6px;
  853. overflow: hidden;
  854. background-color: #fff;
  855. margin-bottom: 8px;
  856. .material-header {
  857. display: flex;
  858. justify-content: space-between;
  859. align-items: center;
  860. padding: 10px 12px;
  861. background-color: #f8f9fa;
  862. cursor: pointer;
  863. .material-main-info {
  864. display: flex;
  865. align-items: center;
  866. gap: 8px;
  867. flex: 1;
  868. .material-name {
  869. font-size: 14px;
  870. font-weight: bold;
  871. color: #333;
  872. }
  873. .material-code {
  874. font-size: 12px;
  875. color: #999;
  876. white-space: nowrap;
  877. }
  878. }
  879. .material-expand {
  880. display: flex;
  881. align-items: center;
  882. margin-left: 10px;
  883. }
  884. }
  885. .material-detail {
  886. padding: 10px 12px;
  887. border-top: 1px solid #e5e5e5;
  888. .detail-row {
  889. display: flex;
  890. align-items: center;
  891. flex-wrap: wrap;
  892. gap: 8px;
  893. .detail-label {
  894. font-size: 13px;
  895. color: #666;
  896. flex-shrink: 0;
  897. }
  898. .detail-value {
  899. flex: 0 0 auto;
  900. font-size: 13px;
  901. color: #333;
  902. }
  903. }
  904. .delete-row {
  905. margin-top: 10px;
  906. text-align: center;
  907. padding-top: 8px;
  908. border-top: 1px dashed #e5e5e5;
  909. }
  910. }
  911. }
  912. .empty-materials {
  913. text-align: center;
  914. padding: 30px;
  915. color: #909399;
  916. font-size: 14px;
  917. }
  918. // 弹窗样式
  919. .selector-popup {
  920. width: 90%;
  921. max-width: 500px;
  922. max-height: 70vh;
  923. background-color: #fff;
  924. border-radius: 12px;
  925. overflow: hidden;
  926. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  927. /* 确保弹窗居中 */
  928. position: relative;
  929. margin: 0 auto;
  930. .popup-header {
  931. display: flex;
  932. justify-content: space-between;
  933. align-items: center;
  934. padding: 15px;
  935. border-bottom: 1px solid #e5e5e5;
  936. .popup-title {
  937. font-size: 16px;
  938. font-weight: bold;
  939. }
  940. }
  941. .search-bar {
  942. display: flex;
  943. gap: 10px;
  944. padding: 10px 15px;
  945. align-items: center;
  946. }
  947. .popup-content {
  948. max-height: 50vh;
  949. .selector-item {
  950. padding: 10px 12px;
  951. border-bottom: 1px solid #f0f0f0;
  952. &:active {
  953. background-color: #f5f5f5;
  954. }
  955. .selector-item-content {
  956. display: flex;
  957. justify-content: space-between;
  958. align-items: center;
  959. gap: 8px;
  960. .item-info {
  961. flex: 1;
  962. display: flex;
  963. flex-wrap: wrap;
  964. align-items: center;
  965. gap: 4px;
  966. .item-name {
  967. font-size: 15px;
  968. font-weight: bold;
  969. color: #333;
  970. flex-basis: 100%;
  971. margin-bottom: 4px;
  972. }
  973. .item-code,
  974. .item-spec,
  975. .item-unit {
  976. font-size: 13px;
  977. color: #666;
  978. white-space: nowrap;
  979. font-weight: 500;
  980. }
  981. .item-code::after,
  982. .item-spec::after,
  983. .item-unit::after {
  984. content: ' | ';
  985. margin: 0 4px;
  986. color: #ddd;
  987. }
  988. .item-unit:last-child::after {
  989. content: '';
  990. }
  991. }
  992. .selected-tag {
  993. color: #409eff;
  994. font-size: 12px;
  995. flex-shrink: 0;
  996. white-space: nowrap;
  997. }
  998. }
  999. }
  1000. .loading-text {
  1001. text-align: center;
  1002. padding: 12px;
  1003. color: #909399;
  1004. font-size: 13px;
  1005. }
  1006. .no-more-text {
  1007. text-align: center;
  1008. padding: 12px;
  1009. color: #909399;
  1010. font-size: 13px;
  1011. }
  1012. .empty-data {
  1013. text-align: center;
  1014. padding: 30px;
  1015. color: #909399;
  1016. }
  1017. }
  1018. }
  1019. /*}*/
  1020. </style>