quotation.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <script setup>
  2. import { computed, onMounted, ref } from 'vue';
  3. import { onShow } from '@dcloudio/uni-app';
  4. import { useUserStore } from '@/stores/user';
  5. import { request, parseResponseJson } from '@/utils/request';
  6. import { IMG_EMPTY, staticAssetUrl } from '@/utils/staticAsset';
  7. import ClientSkeleton from '@/components/client/client-skeleton.vue';
  8. const userStore = useUserStore();
  9. const ICON_COPY = staticAssetUrl('copy.svg');
  10. const PAGE_SIZE = 10;
  11. const list = ref([]);
  12. const page = ref(0);
  13. const total = ref(0);
  14. const totalPages = ref(0);
  15. const loading = ref(false);
  16. const initialLoaded = ref(false);
  17. const keyword = ref('');
  18. /** loading 期间收到重置请求时,当前请求结束后立即重拉 */
  19. const pendingResetFetch = ref(false);
  20. /** 请求序号:仅允许最新请求回填列表 */
  21. let fetchSeq = 0;
  22. const statusText = {
  23. submitted: '已提交',
  24. selected: '已采纳'
  25. };
  26. async function enrichRows(rawList) {
  27. return Promise.all(rawList.map(async (row) => {
  28. try {
  29. const detailRes = await request({
  30. url: `/mini/supplier/quotations/${row.id}`,
  31. method: 'GET'
  32. });
  33. const detailBody = parseResponseJson(detailRes);
  34. const items = detailBody.code === 200 ? detailBody.data?.items || [] : [];
  35. const quotedCount = items.filter((item) => Number(item.price || 0) > 0).length;
  36. return {
  37. ...row,
  38. quotedCount,
  39. unquotedCount: Math.max(items.length - quotedCount, 0)
  40. };
  41. }
  42. catch {
  43. return { ...row, quotedCount: 0, unquotedCount: 0 };
  44. }
  45. }));
  46. }
  47. async function fetchPage(reset) {
  48. if (loading.value) {
  49. if (reset)
  50. pendingResetFetch.value = true;
  51. return;
  52. }
  53. if (!reset) {
  54. if (!initialLoaded.value)
  55. return;
  56. if (total.value === 0)
  57. return;
  58. const tp = totalPages.value || (total.value > 0 ? Math.ceil(total.value / PAGE_SIZE) : 0);
  59. if (tp > 0 && page.value >= tp)
  60. return;
  61. if (list.value.length >= total.value)
  62. return;
  63. }
  64. if (reset) {
  65. list.value = [];
  66. page.value = 0;
  67. total.value = 0;
  68. totalPages.value = 0;
  69. initialLoaded.value = false;
  70. }
  71. loading.value = true;
  72. pendingResetFetch.value = false;
  73. const nextPage = reset ? 1 : page.value + 1;
  74. const currentFetchSeq = ++fetchSeq;
  75. try {
  76. const res = await request({
  77. url: '/mini/supplier/quotations',
  78. method: 'GET',
  79. data: {
  80. page: nextPage,
  81. pageSize: PAGE_SIZE
  82. }
  83. });
  84. const body = parseResponseJson(res);
  85. if (body.code !== 200) {
  86. if (reset)
  87. list.value = [];
  88. return;
  89. }
  90. if (currentFetchSeq !== fetchSeq)
  91. return;
  92. const rawList = body.data?.list || [];
  93. const enriched = await enrichRows(rawList);
  94. if (currentFetchSeq !== fetchSeq)
  95. return;
  96. if (reset) {
  97. list.value = enriched;
  98. }
  99. else {
  100. const ids = new Set(list.value.map((r) => r.id));
  101. for (const row of enriched) {
  102. if (row.id && !ids.has(row.id)) {
  103. list.value.push(row);
  104. ids.add(row.id);
  105. }
  106. }
  107. }
  108. page.value = nextPage;
  109. total.value = Number(body.data?.total ?? 0);
  110. totalPages.value = Number(body.data?.totalPages ?? 0);
  111. initialLoaded.value = true;
  112. }
  113. finally {
  114. loading.value = false;
  115. if (pendingResetFetch.value) {
  116. pendingResetFetch.value = false;
  117. void fetchPage(true);
  118. }
  119. }
  120. }
  121. function onScrollToLower() {
  122. if (!initialLoaded.value || loading.value)
  123. return;
  124. if (total.value === 0)
  125. return;
  126. const tp = totalPages.value || (total.value > 0 ? Math.ceil(total.value / PAGE_SIZE) : 0);
  127. if (tp > 0 && page.value >= tp)
  128. return;
  129. if (list.value.length >= total.value)
  130. return;
  131. void fetchPage(false);
  132. }
  133. const filteredList = computed(() => {
  134. const kw = keyword.value.trim().toLowerCase();
  135. if (!kw)
  136. return list.value;
  137. return list.value.filter((row) => {
  138. return (row.id.toLowerCase().includes(kw) ||
  139. (row.inquiryTitle || '').toLowerCase().includes(kw) ||
  140. (row.submitTime || '').toLowerCase().includes(kw));
  141. });
  142. });
  143. const emptyHint = computed(() => {
  144. if (keyword.value.trim() && list.value.length > 0 && filteredList.value.length === 0) {
  145. return '未找到匹配记录';
  146. }
  147. return '暂无报价记录';
  148. });
  149. const canLoadMore = computed(() => {
  150. if (!initialLoaded.value || loading.value || total.value === 0)
  151. return false;
  152. const tp = totalPages.value || Math.ceil(total.value / PAGE_SIZE);
  153. if (tp > 0 && page.value >= tp)
  154. return false;
  155. return list.value.length < total.value;
  156. });
  157. function onSearchClick() {
  158. uni.hideKeyboard && uni.hideKeyboard();
  159. }
  160. function copyText(text, label = '内容') {
  161. if (!text) {
  162. uni.showToast({ title: `暂无${label}`, icon: 'none' });
  163. return;
  164. }
  165. uni.setClipboardData({
  166. data: String(text),
  167. success: () => uni.showToast({ title: `${label}已复制`, icon: 'none' })
  168. });
  169. }
  170. function goDetail(id) {
  171. if (!id)
  172. return;
  173. uni.navigateTo({
  174. url: `/pages/supplier/quotation/detail/detail?id=${id}`
  175. });
  176. }
  177. onMounted(() => {
  178. if (!userStore.isLoggedIn) {
  179. uni.reLaunch({ url: '/pages/login/login' });
  180. return;
  181. }
  182. if (!userStore.isSupplier) {
  183. uni.showToast({ title: '仅供应商可访问', icon: 'none' });
  184. setTimeout(() => userStore.navigateToHome(), 1200);
  185. return;
  186. }
  187. void fetchPage(true);
  188. });
  189. onShow(() => {
  190. if (!userStore.isLoggedIn || !userStore.isSupplier)
  191. return;
  192. void fetchPage(true);
  193. });
  194. </script>
  195. <template>
  196. <view class="quotation-root">
  197. <view class="quotation-page">
  198. <view class="quotation-top">
  199. <view class="search-wrap">
  200. <view class="search-bar">
  201. <input
  202. v-model="keyword"
  203. class="search-input"
  204. type="text"
  205. confirm-type="search"
  206. placeholder="请输入报价单号/日期/询价单名称"
  207. @confirm="onSearchClick"
  208. />
  209. <text class="search-action" @click="onSearchClick">搜索</text>
  210. </view>
  211. </view>
  212. </view>
  213. <scroll-view
  214. scroll-y
  215. class="list"
  216. :enable-flex="true"
  217. :lower-threshold="100"
  218. :show-scrollbar="true"
  219. @scrolltolower="onScrollToLower"
  220. >
  221. <view class="list-inner">
  222. <view v-for="(row, idx) in filteredList" :key="row.id" class="card" @click="goDetail(row.id)">
  223. <view class="row top-row">
  224. <view class="top-left">
  225. <text class="seq">{{ idx + 1 }}.</text>
  226. <text class="time">报价日期:{{ row.submitTime || '-' }}</text>
  227. </view>
  228. <text class="st" :class="row.status === 'selected' ? 'selected' : 'submitted'">
  229. {{ statusText[row.status || ''] || row.status || '-' }}
  230. </text>
  231. </view>
  232. <view class="id-row">
  233. <text class="id-text">报价单号:{{ row.id }}</text>
  234. <view class="copy-btn" @click.stop="copyText(row.id, '报价单号')">
  235. <image class="copy-icon" :src="ICON_COPY" mode="aspectFit" />
  236. <text class="copy-text">复制</text>
  237. </view>
  238. </view>
  239. <view class="count-row">
  240. <view class="count-pill warning">未报价 {{ row.unquotedCount }}</view>
  241. <view class="count-pill success">已报价 {{ row.quotedCount }}</view>
  242. </view>
  243. </view>
  244. <view v-if="filteredList.length === 0 && !loading && initialLoaded" class="empty-state">
  245. <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
  246. <text class="empty-text">{{ emptyHint }}</text>
  247. </view>
  248. <view v-if="loading && list.length === 0" class="loading-state">
  249. <ClientSkeleton variant="list" :rows="4" />
  250. </view>
  251. <view v-if="loading && list.length > 0" class="loading-more">
  252. <text>加载中...</text>
  253. </view>
  254. <view v-else-if="initialLoaded && total > 0 && !canLoadMore" class="nomore">
  255. <text>没有更多了</text>
  256. </view>
  257. </view>
  258. </scroll-view>
  259. </view>
  260. </view>
  261. </template>
  262. <style lang="scss">
  263. /* 与 disableScroll 配合:整页不纵向滚动,仅列表 scroll-view 滚动 */
  264. page {
  265. height: 100%;
  266. overflow: hidden;
  267. }
  268. </style>
  269. <style lang="scss" scoped>
  270. .quotation-root {
  271. position: relative;
  272. width: 100%;
  273. min-height: 100%;
  274. }
  275. /* #ifdef H5 */
  276. /* 默认导航栏:内容区从 window-top 起,避免 H5 整页再套一层滚动条 */
  277. .quotation-page {
  278. position: fixed;
  279. left: 0;
  280. right: 0;
  281. top: var(--window-top, 0px);
  282. bottom: var(--window-bottom, 0px);
  283. width: 100%;
  284. z-index: 1;
  285. display: flex;
  286. flex-direction: column;
  287. background: #f3f7f5;
  288. overflow: hidden;
  289. box-sizing: border-box;
  290. padding: 20rpx;
  291. }
  292. /* #endif */
  293. /* #ifndef H5 */
  294. .quotation-page {
  295. height: 100%;
  296. max-height: 100%;
  297. min-height: 0;
  298. display: flex;
  299. flex-direction: column;
  300. background: #f3f7f5;
  301. overflow: hidden;
  302. box-sizing: border-box;
  303. padding: 20rpx;
  304. }
  305. /* #endif */
  306. .quotation-top {
  307. flex-shrink: 0;
  308. }
  309. .search-wrap {
  310. margin-bottom: 16rpx;
  311. }
  312. .search-bar {
  313. display: flex;
  314. align-items: center;
  315. height: 72rpx;
  316. background: #ffffff;
  317. border: 1rpx solid #d9e9e1;
  318. border-radius: 36rpx;
  319. padding: 0 8rpx 0 24rpx;
  320. box-sizing: border-box;
  321. }
  322. .search-input {
  323. flex: 1;
  324. min-width: 0;
  325. height: 72rpx;
  326. background: transparent;
  327. border: none;
  328. padding: 0;
  329. font-size: 28rpx;
  330. color: #303133;
  331. }
  332. .search-action {
  333. flex-shrink: 0;
  334. padding: 0 16rpx 0 8rpx;
  335. font-size: 24rpx;
  336. color: #0a9f6d;
  337. font-weight: 600;
  338. }
  339. .list {
  340. flex: 1;
  341. flex-basis: 0;
  342. min-height: 0;
  343. height: 0;
  344. width: 100%;
  345. box-sizing: border-box;
  346. overflow: hidden;
  347. }
  348. .list-inner {
  349. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  350. }
  351. .card {
  352. background: linear-gradient(160deg, #ffffff 0%, #f9fdfb 100%);
  353. border-radius: 24rpx;
  354. border: 1rpx solid #d9ebe3;
  355. padding: 24rpx;
  356. margin-bottom: 20rpx;
  357. box-shadow: 0 10rpx 24rpx rgba(10, 159, 109, 0.08);
  358. position: relative;
  359. .top-row {
  360. align-items: center;
  361. margin-bottom: 12rpx;
  362. }
  363. .top-left {
  364. display: inline-flex;
  365. align-items: center;
  366. gap: 10rpx;
  367. min-width: 0;
  368. }
  369. .seq {
  370. flex-shrink: 0;
  371. font-size: 24rpx;
  372. color: #303133;
  373. font-weight: 700;
  374. }
  375. .row {
  376. display: flex;
  377. justify-content: space-between;
  378. }
  379. .id-row {
  380. display: flex;
  381. align-items: center;
  382. justify-content: space-between;
  383. gap: 10rpx;
  384. margin-bottom: 14rpx;
  385. }
  386. .id-text {
  387. flex: 1;
  388. min-width: 0;
  389. font-size: 24rpx;
  390. color: #6f7e77;
  391. overflow: hidden;
  392. text-overflow: ellipsis;
  393. white-space: nowrap;
  394. }
  395. .count-row {
  396. display: flex;
  397. align-items: center;
  398. justify-content: flex-start;
  399. gap: 20rpx;
  400. }
  401. /* 兼容部分端不支持 flex gap 的情况 */
  402. .count-row .count-pill + .count-pill {
  403. margin-left: 20rpx;
  404. }
  405. .count-pill {
  406. display: inline-flex;
  407. align-items: center;
  408. padding: 6rpx 14rpx;
  409. border-radius: 999rpx;
  410. font-size: 22rpx;
  411. font-weight: 600;
  412. }
  413. .count-pill.warning {
  414. background: #ffedd5;
  415. color: #b45309;
  416. }
  417. .count-pill.success {
  418. background: #dcfce7;
  419. color: #15803d;
  420. }
  421. .st {
  422. font-size: 22rpx;
  423. color: #6b7280;
  424. padding: 6rpx 16rpx;
  425. border-radius: 999rpx;
  426. background: #f3f4f6;
  427. font-weight: 600;
  428. }
  429. .submitted {
  430. color: #111827;
  431. background: transparent;
  432. }
  433. .selected {
  434. color: #087f57;
  435. background: #dff1e8;
  436. }
  437. .time {
  438. font-size: 26rpx;
  439. color: #303133;
  440. font-weight: 500;
  441. }
  442. }
  443. .copy-btn {
  444. flex-shrink: 0;
  445. min-height: 36rpx;
  446. padding: 0 10rpx;
  447. border-radius: 999rpx;
  448. background: #eef8f3;
  449. display: inline-flex;
  450. align-items: center;
  451. gap: 6rpx;
  452. }
  453. .copy-icon {
  454. width: 20rpx;
  455. height: 20rpx;
  456. }
  457. .copy-text {
  458. font-size: 18rpx;
  459. color: #189b65;
  460. }
  461. .empty-state {
  462. display: flex;
  463. flex-direction: column;
  464. align-items: center;
  465. padding: 96rpx 0;
  466. .empty-image {
  467. width: 160rpx;
  468. height: 160rpx;
  469. margin-bottom: 12rpx;
  470. }
  471. .empty-text {
  472. font-size: 28rpx;
  473. color: #909399;
  474. text-align: center;
  475. }
  476. }
  477. .loading-state,
  478. .loading-more,
  479. .nomore {
  480. display: flex;
  481. align-items: center;
  482. justify-content: center;
  483. padding: 32rpx 0;
  484. color: #909399;
  485. font-size: 26rpx;
  486. }
  487. </style>