PsiGoodsSelector.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. <template>
  2. <view>
  3. <u-form-item :label="label" :prop="inputProp" :required="required" :labelWidth="labelWidth">
  4. <view class="selector-trigger" @tap.stop="openPicker" @click.stop="openPicker">
  5. <u-input class="selector-input" :value="currentValue" :placeholder="displayPlaceholder" :readonly="readonly" :disabled="disabled"
  6. @focus="openPicker" @click="openPicker" @tap="openPicker"></u-input>
  7. </view>
  8. </u-form-item>
  9. <view class="selector-mask" :class="{ show: visible }" @tap="closePicker"></view>
  10. <view class="selector-panel" :class="{ show: visible }">
  11. <view class="selector-header">
  12. <view class="selector-action" @tap="closePicker">取消</view>
  13. <view class="selector-title">{{ displayTitle }}</view>
  14. <view v-if="showAddGoods" class="selector-action selector-action-wide" @tap.stop="openAddGoods">新增商品</view>
  15. <view v-else class="selector-action selector-action-placeholder">取消</view>
  16. </view>
  17. <view class="selector-search">
  18. <u-search :show-action="false" v-model="keyword" :placeholder="displaySearchPlaceholder"></u-search>
  19. </view>
  20. <scroll-view :scroll-y="true" class="selector-list">
  21. <view v-if="loading" class="selector-state">加载中...</view>
  22. <view v-else-if="filteredList.length === 0" class="selector-state">{{ emptyText }}</view>
  23. <view v-else class="selector-list-inner">
  24. <view class="selector-card" v-for="item in filteredList" :key="item.id || item.tradeName"
  25. @tap="selectItem(item)">
  26. <view class="selector-name">{{ item.tradeName || '-' }}</view>
  27. <view class="selector-meta" v-if="showStock">
  28. <text>库存:{{ displayStock(item) }}</text>
  29. <text v-if="item.company">单位:{{ item.company }}</text>
  30. </view>
  31. <view class="selector-meta" v-if="item.brand || item.specification || (!showStock && item.company)">
  32. <text v-if="item.brand">品牌:{{ item.brand }}</text>
  33. <text v-if="item.specification">规格:{{ item.specification }}</text>
  34. <text v-if="!showStock && item.company">单位:{{ item.company }}</text>
  35. </view>
  36. </view>
  37. </view>
  38. </scroll-view>
  39. </view>
  40. <view class="add-mask" :class="{ show: addVisible }" @tap="closeAddGoods"></view>
  41. <view class="add-panel" :class="{ show: addVisible }">
  42. <view class="selector-header">
  43. <view class="selector-action" @tap="closeAddGoods">取消</view>
  44. <view class="selector-title">新增商品</view>
  45. <view class="selector-action" @tap.stop="submitAddGoods">{{ addLoading ? '保存中' : '保存' }}</view>
  46. </view>
  47. <scroll-view :scroll-y="true" class="add-body">
  48. <u-form-item label="所属分类" :required="true" labelWidth="76px">
  49. <view class="selector-trigger" @tap.stop="toggleCategoryList" @click.stop="toggleCategoryList">
  50. <u-input class="selector-input" :value="addForm.parentName" placeholder="请选择所属分类"
  51. readonly :disabled="isAddParentLocked"></u-input>
  52. </view>
  53. </u-form-item>
  54. <u-form-item label="名称" :required="true" labelWidth="76px">
  55. <u-input v-model="addForm.name" placeholder="请填写商品名称"></u-input>
  56. </u-form-item>
  57. <u-form-item label="品牌" labelWidth="76px">
  58. <u-input v-model="addForm.brand" placeholder="请填写品牌"></u-input>
  59. </u-form-item>
  60. <u-form-item label="规格" labelWidth="76px">
  61. <u-input v-model="addForm.specification" placeholder="请填写规格"></u-input>
  62. </u-form-item>
  63. <u-form-item label="单位" labelWidth="76px">
  64. <u-input v-model="addForm.company" placeholder="请填写单位"></u-input>
  65. </u-form-item>
  66. </scroll-view>
  67. </view>
  68. <view class="category-mask" :class="{ show: categoryListVisible }" @tap="closeCategoryList"></view>
  69. <view class="category-panel" :class="{ show: categoryListVisible }">
  70. <view class="selector-header">
  71. <view class="selector-action" @tap="closeCategoryList">取消</view>
  72. <view class="selector-title">选择分类</view>
  73. <view class="selector-action selector-action-placeholder">取消</view>
  74. </view>
  75. <view class="selector-search">
  76. <u-search :show-action="false" v-model="categoryKeyword" placeholder="搜索分类"></u-search>
  77. </view>
  78. <scroll-view :scroll-y="true" class="category-list">
  79. <view v-if="categoryOptions.length === 0" class="selector-state category-state">暂无可选分类</view>
  80. <view v-for="item in categoryOptions" :key="item.id" class="category-item"
  81. @tap="selectCategory(item)">
  82. {{ item.name || '-' }}
  83. </view>
  84. </scroll-view>
  85. </view>
  86. </view>
  87. </template>
  88. <script>
  89. import MaterialTypeService from '@/api/psi/MaterialTypeService'
  90. export default {
  91. name: 'PsiGoodsSelector',
  92. props: {
  93. rowIndex: Number,
  94. indexExperience: Number,
  95. inputForm: {
  96. type: Object,
  97. default: () => ({})
  98. },
  99. rowPath: {
  100. type: String,
  101. default: 'detailInfos'
  102. },
  103. valueFields: {
  104. type: Array,
  105. default: () => ['tradeName']
  106. },
  107. propField: {
  108. type: String,
  109. default: ''
  110. },
  111. label: {
  112. type: String,
  113. default: '物品名称'
  114. },
  115. labelWidth: {
  116. type: String,
  117. default: '68px'
  118. },
  119. placeholder: {
  120. type: String,
  121. default: ''
  122. },
  123. title: {
  124. type: String,
  125. default: ''
  126. },
  127. searchPlaceholder: {
  128. type: String,
  129. default: ''
  130. },
  131. emptyText: {
  132. type: String,
  133. default: '暂无可选物品'
  134. },
  135. parentIdField: {
  136. type: String,
  137. default: ''
  138. },
  139. parentNameField: {
  140. type: String,
  141. default: ''
  142. },
  143. parentRequiredText: {
  144. type: String,
  145. default: '请先选择分类'
  146. },
  147. requireParent: {
  148. type: Boolean,
  149. default: false
  150. },
  151. showStock: {
  152. type: Boolean,
  153. default: true
  154. },
  155. showAddGoods: {
  156. type: Boolean,
  157. default: false
  158. },
  159. required: {
  160. type: Boolean,
  161. default: true
  162. },
  163. readonly: {
  164. type: Boolean,
  165. default: true
  166. },
  167. disabled: Boolean
  168. },
  169. data() {
  170. return {
  171. visible: false,
  172. loading: false,
  173. addVisible: false,
  174. addLoading: false,
  175. categoryListVisible: false,
  176. keyword: '',
  177. categoryKeyword: '',
  178. rawMaterialList: [],
  179. goodsList: [],
  180. addForm: this.createAddForm(),
  181. materialTypeService: null
  182. }
  183. },
  184. computed: {
  185. actualIndex() {
  186. if (this.rowIndex !== undefined && this.rowIndex !== null) {
  187. return this.rowIndex
  188. }
  189. return this.indexExperience
  190. },
  191. rows() {
  192. return this.inputForm[this.rowPath] || []
  193. },
  194. currentRow() {
  195. return this.rows[this.actualIndex] || {}
  196. },
  197. inputProp() {
  198. const field = this.propField || this.valueFields[0] || ''
  199. return field ? `${this.rowPath}[${this.actualIndex}].${field}` : ''
  200. },
  201. currentValue() {
  202. for (let i = 0; i < this.valueFields.length; i++) {
  203. const value = this.currentRow[this.valueFields[i]]
  204. if (!this.isEmpty(value)) {
  205. return value
  206. }
  207. }
  208. return ''
  209. },
  210. parentId() {
  211. return this.parentIdField ? this.currentRow[this.parentIdField] : ''
  212. },
  213. currentParentName() {
  214. if (this.parentNameField && !this.isEmpty(this.currentRow[this.parentNameField])) {
  215. return this.currentRow[this.parentNameField]
  216. }
  217. return this.currentRow.procurementType || this.currentRow.typeName || this.currentRow.categoryName || ''
  218. },
  219. isAddParentLocked() {
  220. return !!this.parentIdField && !this.isEmpty(this.parentId)
  221. },
  222. displayPlaceholder() {
  223. return this.placeholder || `请选择${this.label}`
  224. },
  225. displayTitle() {
  226. return this.title || `选择${this.label}`
  227. },
  228. displaySearchPlaceholder() {
  229. return this.searchPlaceholder || `搜索${this.label}`
  230. },
  231. filteredList() {
  232. const keyword = (this.keyword || '').trim().toLowerCase()
  233. if (!keyword) {
  234. return this.goodsList
  235. }
  236. return this.goodsList.filter(item => (item.tradeName || '').toLowerCase().includes(keyword))
  237. },
  238. categoryOptions() {
  239. const keyword = (this.categoryKeyword || '').trim().toLowerCase()
  240. return this.flattenNodes(this.rawMaterialList).filter(item => {
  241. const isGoods = String(item.infoType || item.info_type || '') === '1'
  242. const name = item.name || item.tradeName || ''
  243. return !isGoods && (!keyword || name.toLowerCase().includes(keyword))
  244. }).map(item => ({
  245. ...item,
  246. name: item.name || item.tradeName || ''
  247. }))
  248. }
  249. },
  250. created() {
  251. this.materialTypeService = new MaterialTypeService()
  252. },
  253. methods: {
  254. createAddForm() {
  255. return {
  256. businessType: '',
  257. infoType: '1',
  258. name: '',
  259. brand: '',
  260. specification: '',
  261. company: '',
  262. parentId: '',
  263. parentName: ''
  264. }
  265. },
  266. isEmpty(value) {
  267. if (value === null || value === undefined) {
  268. return true
  269. }
  270. if (typeof value === 'string' && value.trim() === '') {
  271. return true
  272. }
  273. if (Array.isArray(value) && value.length === 0) {
  274. return true
  275. }
  276. return false
  277. },
  278. displayStock(item) {
  279. if (!this.isEmpty(item.currentInventory)) {
  280. return item.currentInventory
  281. }
  282. if (!this.isEmpty(item.surplusNumber)) {
  283. return item.surplusNumber
  284. }
  285. return '0'
  286. },
  287. async openPicker() {
  288. if (this.disabled) {
  289. return
  290. }
  291. if (this.visible) {
  292. return
  293. }
  294. if (this.requireParent && this.isEmpty(this.parentId)) {
  295. uni.showToast({
  296. title: this.parentRequiredText,
  297. icon: 'none'
  298. })
  299. return
  300. }
  301. this.visible = true
  302. this.keyword = ''
  303. await this.loadGoodsList()
  304. },
  305. closePicker() {
  306. this.visible = false
  307. this.closeAddGoods()
  308. },
  309. async loadGoodsList() {
  310. this.loading = true
  311. try {
  312. const data = await this.materialTypeService.cgList()
  313. this.rawMaterialList = data || []
  314. this.goodsList = this.flattenNodes(this.rawMaterialList).filter(item => {
  315. const itemParentId = item.parentId || item.parent_id || ''
  316. const isGoods = String(item.infoType || item.info_type || '') === '1'
  317. const matchParent = !this.parentIdField || this.isEmpty(this.parentId) ||
  318. String(itemParentId) === String(this.parentId)
  319. return isGoods && matchParent
  320. }).map(item => ({
  321. ...item,
  322. tradeName: item.tradeName || item.name || ''
  323. }))
  324. } catch (e) {
  325. this.goodsList = []
  326. uni.showToast({
  327. title: `${this.label}数据加载失败`,
  328. icon: 'none'
  329. })
  330. } finally {
  331. this.loading = false
  332. }
  333. },
  334. flattenNodes(nodes, result = []) {
  335. ;(nodes || []).forEach(node => {
  336. result.push(node)
  337. if (node.children && node.children.length) {
  338. this.flattenNodes(node.children, result)
  339. }
  340. })
  341. return result
  342. },
  343. selectItem(item) {
  344. this.$emit('selected', {
  345. index: this.actualIndex,
  346. item
  347. })
  348. this.closePicker()
  349. },
  350. async openAddGoods() {
  351. if (this.addLoading) {
  352. return
  353. }
  354. this.addForm = this.createAddForm()
  355. if (this.isAddParentLocked) {
  356. this.addForm.parentId = this.parentId
  357. this.addForm.parentName = this.currentParentName
  358. }
  359. this.categoryKeyword = ''
  360. this.categoryListVisible = false
  361. this.addVisible = true
  362. if (this.rawMaterialList.length === 0) {
  363. await this.loadMaterialListForAdd()
  364. }
  365. },
  366. closeAddGoods() {
  367. if (this.addLoading) {
  368. return
  369. }
  370. this.addVisible = false
  371. this.categoryListVisible = false
  372. },
  373. async loadMaterialListForAdd() {
  374. try {
  375. const data = await this.materialTypeService.cgList()
  376. this.rawMaterialList = data || []
  377. } catch (e) {
  378. this.rawMaterialList = []
  379. uni.showToast({
  380. title: '分类数据加载失败',
  381. icon: 'none'
  382. })
  383. }
  384. },
  385. toggleCategoryList() {
  386. if (this.isAddParentLocked) {
  387. return
  388. }
  389. this.categoryListVisible = !this.categoryListVisible
  390. },
  391. closeCategoryList() {
  392. this.categoryListVisible = false
  393. },
  394. selectCategory(item) {
  395. this.addForm.parentId = item.id
  396. this.addForm.parentName = item.name || item.tradeName || ''
  397. this.categoryListVisible = false
  398. },
  399. async submitAddGoods() {
  400. if (this.addLoading) {
  401. return
  402. }
  403. if (this.isEmpty(this.addForm.parentId)) {
  404. uni.showToast({
  405. title: '请选择所属分类',
  406. icon: 'none'
  407. })
  408. return
  409. }
  410. if (this.isEmpty(this.addForm.name)) {
  411. uni.showToast({
  412. title: '请填写商品名称',
  413. icon: 'none'
  414. })
  415. return
  416. }
  417. const submitData = {
  418. ...this.addForm,
  419. infoType: '1'
  420. }
  421. delete submitData.parentName
  422. this.addLoading = true
  423. try {
  424. const data = await this.materialTypeService.save(submitData)
  425. const result = this.getResponseData(data)
  426. if (typeof result === 'string' && result.indexOf('不能新增') > -1) {
  427. uni.showToast({
  428. title: result,
  429. icon: 'none'
  430. })
  431. return
  432. }
  433. uni.showToast({
  434. title: '保存成功',
  435. icon: 'success'
  436. })
  437. this.addVisible = false
  438. await this.loadGoodsList()
  439. } catch (e) {
  440. uni.showToast({
  441. title: '商品保存失败',
  442. icon: 'none'
  443. })
  444. } finally {
  445. this.addLoading = false
  446. }
  447. },
  448. getResponseData(data) {
  449. if (data && Object.prototype.hasOwnProperty.call(data, 'data')) {
  450. return data.data
  451. }
  452. return data
  453. }
  454. }
  455. }
  456. </script>
  457. <style scoped>
  458. .selector-mask {
  459. position: fixed;
  460. inset: 0;
  461. background: rgba(0, 0, 0, 0.35);
  462. opacity: 0;
  463. visibility: hidden;
  464. transition: all 0.25s ease;
  465. z-index: 1000;
  466. }
  467. .selector-trigger {
  468. width: 100%;
  469. }
  470. .selector-input {
  471. pointer-events: none;
  472. }
  473. .selector-mask.show {
  474. opacity: 1;
  475. visibility: visible;
  476. }
  477. .add-mask {
  478. position: fixed;
  479. inset: 0;
  480. background: rgba(0, 0, 0, 0.35);
  481. opacity: 0;
  482. visibility: hidden;
  483. transition: all 0.25s ease;
  484. z-index: 1002;
  485. }
  486. .add-mask.show {
  487. opacity: 1;
  488. visibility: visible;
  489. }
  490. .selector-panel {
  491. position: fixed;
  492. left: 0;
  493. right: 0;
  494. bottom: 0;
  495. height: 68vh;
  496. background: #f7f9fc;
  497. border-radius: 28rpx 28rpx 0 0;
  498. transform: translateY(100%);
  499. transition: transform 0.25s ease;
  500. z-index: 1001;
  501. display: flex;
  502. flex-direction: column;
  503. overflow: hidden;
  504. }
  505. .selector-panel.show {
  506. transform: translateY(0);
  507. }
  508. .add-panel {
  509. position: fixed;
  510. left: 0;
  511. right: 0;
  512. bottom: 0;
  513. height: 78vh;
  514. background: #fff;
  515. border-radius: 28rpx 28rpx 0 0;
  516. transform: translateY(100%);
  517. transition: transform 0.25s ease;
  518. z-index: 1003;
  519. display: flex;
  520. flex-direction: column;
  521. overflow: hidden;
  522. }
  523. .add-panel.show {
  524. transform: translateY(0);
  525. }
  526. .add-body {
  527. flex: 1;
  528. height: 0;
  529. padding: 18rpx 28rpx 40rpx;
  530. box-sizing: border-box;
  531. }
  532. .category-mask {
  533. position: fixed;
  534. inset: 0;
  535. background: rgba(0, 0, 0, 0.35);
  536. opacity: 0;
  537. visibility: hidden;
  538. transition: all 0.25s ease;
  539. z-index: 1004;
  540. }
  541. .category-mask.show {
  542. opacity: 1;
  543. visibility: visible;
  544. }
  545. .category-panel {
  546. position: fixed;
  547. left: 0;
  548. right: 0;
  549. bottom: 0;
  550. height: 62vh;
  551. background: #f7f9fc;
  552. border-radius: 28rpx 28rpx 0 0;
  553. transform: translateY(100%);
  554. transition: transform 0.25s ease;
  555. z-index: 1005;
  556. display: flex;
  557. flex-direction: column;
  558. overflow: hidden;
  559. }
  560. .category-panel.show {
  561. transform: translateY(0);
  562. }
  563. .category-list {
  564. flex: 1;
  565. height: 0;
  566. padding: 16rpx 24rpx 32rpx;
  567. box-sizing: border-box;
  568. }
  569. .category-item {
  570. margin-bottom: 14rpx;
  571. padding: 22rpx 24rpx;
  572. background: #fff;
  573. border-radius: 16rpx;
  574. font-size: 28rpx;
  575. color: #1f2937;
  576. box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
  577. }
  578. .category-state {
  579. padding-top: 120rpx;
  580. }
  581. .selector-header {
  582. display: flex;
  583. align-items: center;
  584. justify-content: space-between;
  585. padding: 28rpx 32rpx 16rpx;
  586. background: #fff;
  587. border-bottom: 1rpx solid #eef2f7;
  588. }
  589. .selector-title {
  590. font-size: 32rpx;
  591. font-weight: 600;
  592. color: #1f2937;
  593. }
  594. .selector-action {
  595. min-width: 80rpx;
  596. font-size: 28rpx;
  597. color: #2979ff;
  598. }
  599. .selector-action-wide {
  600. min-width: 128rpx;
  601. text-align: right;
  602. }
  603. .selector-action-placeholder {
  604. opacity: 0;
  605. }
  606. .selector-search {
  607. padding: 20rpx 24rpx 8rpx;
  608. background: #fff;
  609. }
  610. .selector-list {
  611. flex: 1;
  612. height: 0;
  613. padding: 16rpx 24rpx 32rpx;
  614. box-sizing: border-box;
  615. }
  616. .selector-list-inner {
  617. display: flex;
  618. flex-direction: column;
  619. gap: 16rpx;
  620. }
  621. .selector-state {
  622. padding-top: 120rpx;
  623. text-align: center;
  624. font-size: 28rpx;
  625. color: #94a3b8;
  626. }
  627. .selector-card {
  628. background: #fff;
  629. border-radius: 20rpx;
  630. padding: 24rpx;
  631. box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
  632. }
  633. .selector-name {
  634. font-size: 30rpx;
  635. font-weight: 600;
  636. color: #0f172a;
  637. }
  638. .selector-meta {
  639. display: flex;
  640. gap: 16rpx;
  641. margin-top: 10rpx;
  642. font-size: 24rpx;
  643. color: #64748b;
  644. flex-wrap: wrap;
  645. }
  646. </style>