| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- <script setup>
- import { computed, onMounted, ref } from 'vue';
- import { onShow } from '@dcloudio/uni-app';
- import { useUserStore } from '@/stores/user';
- import { request, parseResponseJson } from '@/utils/request';
- import { IMG_EMPTY, staticAssetUrl } from '@/utils/staticAsset';
- import ClientSkeleton from '@/components/client/client-skeleton.vue';
- const userStore = useUserStore();
- const ICON_COPY = staticAssetUrl('copy.svg');
- const PAGE_SIZE = 10;
- const list = ref([]);
- const page = ref(0);
- const total = ref(0);
- const totalPages = ref(0);
- const loading = ref(false);
- const initialLoaded = ref(false);
- const keyword = ref('');
- /** loading 期间收到重置请求时,当前请求结束后立即重拉 */
- const pendingResetFetch = ref(false);
- /** 请求序号:仅允许最新请求回填列表 */
- let fetchSeq = 0;
- const statusText = {
- submitted: '已提交',
- selected: '已采纳'
- };
- async function enrichRows(rawList) {
- return Promise.all(rawList.map(async (row) => {
- try {
- const detailRes = await request({
- url: `/mini/supplier/quotations/${row.id}`,
- method: 'GET'
- });
- const detailBody = parseResponseJson(detailRes);
- const items = detailBody.code === 200 ? detailBody.data?.items || [] : [];
- const quotedCount = items.filter((item) => Number(item.price || 0) > 0).length;
- return {
- ...row,
- quotedCount,
- unquotedCount: Math.max(items.length - quotedCount, 0)
- };
- }
- catch {
- return { ...row, quotedCount: 0, unquotedCount: 0 };
- }
- }));
- }
- async function fetchPage(reset) {
- if (loading.value) {
- if (reset)
- pendingResetFetch.value = true;
- return;
- }
- if (!reset) {
- if (!initialLoaded.value)
- return;
- if (total.value === 0)
- return;
- const tp = totalPages.value || (total.value > 0 ? Math.ceil(total.value / PAGE_SIZE) : 0);
- if (tp > 0 && page.value >= tp)
- return;
- if (list.value.length >= total.value)
- return;
- }
- if (reset) {
- list.value = [];
- page.value = 0;
- total.value = 0;
- totalPages.value = 0;
- initialLoaded.value = false;
- }
- loading.value = true;
- pendingResetFetch.value = false;
- const nextPage = reset ? 1 : page.value + 1;
- const currentFetchSeq = ++fetchSeq;
- try {
- const res = await request({
- url: '/mini/supplier/quotations',
- method: 'GET',
- data: {
- page: nextPage,
- pageSize: PAGE_SIZE
- }
- });
- const body = parseResponseJson(res);
- if (body.code !== 200) {
- if (reset)
- list.value = [];
- return;
- }
- if (currentFetchSeq !== fetchSeq)
- return;
- const rawList = body.data?.list || [];
- const enriched = await enrichRows(rawList);
- if (currentFetchSeq !== fetchSeq)
- return;
- if (reset) {
- list.value = enriched;
- }
- else {
- const ids = new Set(list.value.map((r) => r.id));
- for (const row of enriched) {
- if (row.id && !ids.has(row.id)) {
- list.value.push(row);
- ids.add(row.id);
- }
- }
- }
- page.value = nextPage;
- total.value = Number(body.data?.total ?? 0);
- totalPages.value = Number(body.data?.totalPages ?? 0);
- initialLoaded.value = true;
- }
- finally {
- loading.value = false;
- if (pendingResetFetch.value) {
- pendingResetFetch.value = false;
- void fetchPage(true);
- }
- }
- }
- function onScrollToLower() {
- if (!initialLoaded.value || loading.value)
- return;
- if (total.value === 0)
- return;
- const tp = totalPages.value || (total.value > 0 ? Math.ceil(total.value / PAGE_SIZE) : 0);
- if (tp > 0 && page.value >= tp)
- return;
- if (list.value.length >= total.value)
- return;
- void fetchPage(false);
- }
- const filteredList = computed(() => {
- const kw = keyword.value.trim().toLowerCase();
- if (!kw)
- return list.value;
- return list.value.filter((row) => {
- return (row.id.toLowerCase().includes(kw) ||
- (row.inquiryTitle || '').toLowerCase().includes(kw) ||
- (row.submitTime || '').toLowerCase().includes(kw));
- });
- });
- const emptyHint = computed(() => {
- if (keyword.value.trim() && list.value.length > 0 && filteredList.value.length === 0) {
- return '未找到匹配记录';
- }
- return '暂无报价记录';
- });
- const canLoadMore = computed(() => {
- if (!initialLoaded.value || loading.value || total.value === 0)
- return false;
- const tp = totalPages.value || Math.ceil(total.value / PAGE_SIZE);
- if (tp > 0 && page.value >= tp)
- return false;
- return list.value.length < total.value;
- });
- function onSearchClick() {
- uni.hideKeyboard && uni.hideKeyboard();
- }
- function copyText(text, label = '内容') {
- if (!text) {
- uni.showToast({ title: `暂无${label}`, icon: 'none' });
- return;
- }
- uni.setClipboardData({
- data: String(text),
- success: () => uni.showToast({ title: `${label}已复制`, icon: 'none' })
- });
- }
- function goDetail(id) {
- if (!id)
- return;
- uni.navigateTo({
- url: `/pages/supplier/quotation/detail/detail?id=${id}`
- });
- }
- onMounted(() => {
- if (!userStore.isLoggedIn) {
- uni.reLaunch({ url: '/pages/login/login' });
- return;
- }
- if (!userStore.isSupplier) {
- uni.showToast({ title: '仅供应商可访问', icon: 'none' });
- setTimeout(() => userStore.navigateToHome(), 1200);
- return;
- }
- void fetchPage(true);
- });
- onShow(() => {
- if (!userStore.isLoggedIn || !userStore.isSupplier)
- return;
- void fetchPage(true);
- });
- </script>
- <template>
- <view class="quotation-root">
- <view class="quotation-page">
- <view class="quotation-top">
- <view class="search-wrap">
- <view class="search-bar">
- <input
- v-model="keyword"
- class="search-input"
- type="text"
- confirm-type="search"
- placeholder="请输入报价单号/日期/询价单名称"
- @confirm="onSearchClick"
- />
- <text class="search-action" @click="onSearchClick">搜索</text>
- </view>
- </view>
- </view>
- <scroll-view
- scroll-y
- class="list"
- :enable-flex="true"
- :lower-threshold="100"
- :show-scrollbar="true"
- @scrolltolower="onScrollToLower"
- >
- <view class="list-inner">
- <view v-for="(row, idx) in filteredList" :key="row.id" class="card" @click="goDetail(row.id)">
- <view class="row top-row">
- <view class="top-left">
- <text class="seq">{{ idx + 1 }}.</text>
- <text class="time">报价日期:{{ row.submitTime || '-' }}</text>
- </view>
- <text class="st" :class="row.status === 'selected' ? 'selected' : 'submitted'">
- {{ statusText[row.status || ''] || row.status || '-' }}
- </text>
- </view>
- <view class="id-row">
- <text class="id-text">报价单号:{{ row.id }}</text>
- <view class="copy-btn" @click.stop="copyText(row.id, '报价单号')">
- <image class="copy-icon" :src="ICON_COPY" mode="aspectFit" />
- <text class="copy-text">复制</text>
- </view>
- </view>
- <view class="count-row">
- <view class="count-pill warning">未报价 {{ row.unquotedCount }}</view>
- <view class="count-pill success">已报价 {{ row.quotedCount }}</view>
- </view>
- </view>
- <view v-if="filteredList.length === 0 && !loading && initialLoaded" class="empty-state">
- <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
- <text class="empty-text">{{ emptyHint }}</text>
- </view>
- <view v-if="loading && list.length === 0" class="loading-state">
- <ClientSkeleton variant="list" :rows="4" />
- </view>
- <view v-if="loading && list.length > 0" class="loading-more">
- <text>加载中...</text>
- </view>
- <view v-else-if="initialLoaded && total > 0 && !canLoadMore" class="nomore">
- <text>没有更多了</text>
- </view>
- </view>
- </scroll-view>
- </view>
- </view>
- </template>
- <style lang="scss">
- /* 与 disableScroll 配合:整页不纵向滚动,仅列表 scroll-view 滚动 */
- page {
- height: 100%;
- overflow: hidden;
- }
- </style>
- <style lang="scss" scoped>
- .quotation-root {
- position: relative;
- width: 100%;
- min-height: 100%;
- }
- /* #ifdef H5 */
- /* 默认导航栏:内容区从 window-top 起,避免 H5 整页再套一层滚动条 */
- .quotation-page {
- position: fixed;
- left: 0;
- right: 0;
- top: var(--window-top, 0px);
- bottom: var(--window-bottom, 0px);
- width: 100%;
- z-index: 1;
- display: flex;
- flex-direction: column;
- background: #f3f7f5;
- overflow: hidden;
- box-sizing: border-box;
- padding: 20rpx;
- }
- /* #endif */
- /* #ifndef H5 */
- .quotation-page {
- height: 100%;
- max-height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: #f3f7f5;
- overflow: hidden;
- box-sizing: border-box;
- padding: 20rpx;
- }
- /* #endif */
- .quotation-top {
- flex-shrink: 0;
- }
- .search-wrap {
- margin-bottom: 16rpx;
- }
- .search-bar {
- display: flex;
- align-items: center;
- height: 72rpx;
- background: #ffffff;
- border: 1rpx solid #d9e9e1;
- border-radius: 36rpx;
- padding: 0 8rpx 0 24rpx;
- box-sizing: border-box;
- }
- .search-input {
- flex: 1;
- min-width: 0;
- height: 72rpx;
- background: transparent;
- border: none;
- padding: 0;
- font-size: 28rpx;
- color: #303133;
- }
- .search-action {
- flex-shrink: 0;
- padding: 0 16rpx 0 8rpx;
- font-size: 24rpx;
- color: #0a9f6d;
- font-weight: 600;
- }
- .list {
- flex: 1;
- flex-basis: 0;
- min-height: 0;
- height: 0;
- width: 100%;
- box-sizing: border-box;
- overflow: hidden;
- }
- .list-inner {
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- }
- .card {
- background: linear-gradient(160deg, #ffffff 0%, #f9fdfb 100%);
- border-radius: 24rpx;
- border: 1rpx solid #d9ebe3;
- padding: 24rpx;
- margin-bottom: 20rpx;
- box-shadow: 0 10rpx 24rpx rgba(10, 159, 109, 0.08);
- position: relative;
- .top-row {
- align-items: center;
- margin-bottom: 12rpx;
- }
- .top-left {
- display: inline-flex;
- align-items: center;
- gap: 10rpx;
- min-width: 0;
- }
- .seq {
- flex-shrink: 0;
- font-size: 24rpx;
- color: #303133;
- font-weight: 700;
- }
- .row {
- display: flex;
- justify-content: space-between;
- }
- .id-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10rpx;
- margin-bottom: 14rpx;
- }
- .id-text {
- flex: 1;
- min-width: 0;
- font-size: 24rpx;
- color: #6f7e77;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .count-row {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 20rpx;
- }
- /* 兼容部分端不支持 flex gap 的情况 */
- .count-row .count-pill + .count-pill {
- margin-left: 20rpx;
- }
- .count-pill {
- display: inline-flex;
- align-items: center;
- padding: 6rpx 14rpx;
- border-radius: 999rpx;
- font-size: 22rpx;
- font-weight: 600;
- }
- .count-pill.warning {
- background: #ffedd5;
- color: #b45309;
- }
- .count-pill.success {
- background: #dcfce7;
- color: #15803d;
- }
- .st {
- font-size: 22rpx;
- color: #6b7280;
- padding: 6rpx 16rpx;
- border-radius: 999rpx;
- background: #f3f4f6;
- font-weight: 600;
- }
- .submitted {
- color: #111827;
- background: transparent;
- }
- .selected {
- color: #087f57;
- background: #dff1e8;
- }
- .time {
- font-size: 26rpx;
- color: #303133;
- font-weight: 500;
- }
- }
- .copy-btn {
- flex-shrink: 0;
- min-height: 36rpx;
- padding: 0 10rpx;
- border-radius: 999rpx;
- background: #eef8f3;
- display: inline-flex;
- align-items: center;
- gap: 6rpx;
- }
- .copy-icon {
- width: 20rpx;
- height: 20rpx;
- }
- .copy-text {
- font-size: 18rpx;
- color: #189b65;
- }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 96rpx 0;
- .empty-image {
- width: 160rpx;
- height: 160rpx;
- margin-bottom: 12rpx;
- }
- .empty-text {
- font-size: 28rpx;
- color: #909399;
- text-align: center;
- }
- }
- .loading-state,
- .loading-more,
- .nomore {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 32rpx 0;
- color: #909399;
- font-size: 26rpx;
- }
- </style>
|