0e77c98ead
加一排 tab(全部/待支付/已完成/已取消)与各状态计数,点一下只看该状态的订单。数据本来就是全量返回(/hjc/order/my 不分页、也没有状态参数),所以在前端筛,切 tab 不重新请求。选中项写进 URL(?status=pending),刷新、分享、浏览器后退都保持;认不出的值回落「全部」而不是筛成空列表。状态口径复用 utils/orderStatus.ts,页面不重写判定。不新增「已退款」tab:标书售卖没有退款(ADR 0005),payStatus=3 只是防御性展示。 Closes .scratch/web-order-status-tabs/issues/01-orders-status-tabs.md
321 lines
13 KiB
Vue
321 lines
13 KiB
Vue
<template>
|
||
<div class="container mx-auto px-4 py-10">
|
||
<div class="mb-6 flex items-center justify-between">
|
||
<h1 class="text-2xl font-bold text-gray-900">我的订单</h1>
|
||
<HjcUserBar />
|
||
</div>
|
||
|
||
<div v-if="!loggedIn" class="py-20 text-center">
|
||
<p class="text-gray-500">请先登录后查看订单</p>
|
||
<NuxtLink to="/login?redirect=/tender/orders" class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else-if="error" class="py-20 text-center text-red-500">{{ error }}</div>
|
||
|
||
<template v-else>
|
||
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
|
||
|
||
<!--
|
||
状态筛选 tab。**在前端筛**:`/api/tender/my-orders` 一次返回本企业全部订单
|
||
(后端 `/hjc/order/my` 不分页、也没有状态参数),为四个 tab 各发一次请求等于把
|
||
同一份数据拉四遍,切 tab 还要多一次白屏。判定复用共享的 `orderStatus.ts`,
|
||
页面里**不重写**"什么算待支付"——列表与详情两处各写一份必然分叉。
|
||
-->
|
||
<div
|
||
class="mb-6 flex flex-wrap gap-2 border-b border-gray-200 pb-3"
|
||
role="group"
|
||
aria-label="订单状态筛选"
|
||
>
|
||
<button
|
||
v-for="t in STATUS_TABS"
|
||
:key="t.key"
|
||
type="button"
|
||
:aria-pressed="status === t.key"
|
||
class="rounded-md px-4 py-1.5 text-sm transition-colors"
|
||
:class="status === t.key ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'"
|
||
@click="selectTab(t.key)"
|
||
>
|
||
{{ t.label }}
|
||
<span class="ml-1 text-xs" :class="status === t.key ? 'text-white/80' : 'text-gray-400'">
|
||
{{ countOf(t.key) }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="filtered.length" class="space-y-4">
|
||
<div v-for="o in filtered" :key="o.id" class="rounded-lg border border-gray-200 p-5">
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
|
||
<p class="mt-1 text-sm text-gray-500">订单号:{{ o.orderNo }} · 项目编号:{{ o.projectNo }}</p>
|
||
</div>
|
||
<span class="flex shrink-0 items-center gap-2">
|
||
<span v-if="o.invoices?.length" class="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">已开票</span>
|
||
<span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="mt-4 flex items-center justify-between text-sm">
|
||
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
|
||
<span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span>
|
||
</div>
|
||
|
||
<!--
|
||
标书附件:**只有已支付订单**才有这一块——`tenderFiles` 由后端按 payStatus=1
|
||
下发(hjc-h5 的列表与详情同一口径)。未支付订单这里什么都不渲染,
|
||
因为地址压根没下发,不是前端藏起来的。
|
||
-->
|
||
<div v-if="o.tenderFiles?.length" class="mt-4 border-t border-gray-100 pt-4">
|
||
<p class="mb-2 text-sm font-medium text-gray-700">标书附件</p>
|
||
<HjcTenderFiles :files="o.tenderFiles" />
|
||
</div>
|
||
|
||
<!-- 电子发票:有才有(甲方开票后推送过来),作废的也列出来 -->
|
||
<div v-if="o.invoices?.length" class="mt-4 border-t border-gray-100 pt-4">
|
||
<p class="mb-2 text-sm font-medium text-gray-700">电子发票</p>
|
||
<HjcInvoiceFiles :files="o.invoices" />
|
||
</div>
|
||
|
||
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
|
||
<!--
|
||
详情入口。**不**把整张卡片做成可点:卡片里已经有「取消订单」「去支付」
|
||
和被撑开的一列附件链接,嵌套点击的误触代价比省一次点击更高。
|
||
-->
|
||
<NuxtLink
|
||
:to="`/tender/orders/${o.id}`"
|
||
class="text-sm text-blue-600 hover:underline"
|
||
>
|
||
查看详情
|
||
</NuxtLink>
|
||
|
||
<!--
|
||
待支付订单的操作区。判定用 hjcCanActOn(payStatus===0 && orderStatus!==2,且支付时限未到),
|
||
**不用** statusKey==='pending'——payStatus=2(支付失败)也会落进 pending 兜底。
|
||
再排除「支付时限已到」:那种单后端会拒绝出码、渠道单也已随同一时刻失效(ADR 0019),
|
||
留一个点了必然失败的「去支付」只会让人白点一次。
|
||
-->
|
||
<div v-if="hjcCanActOn(o)" class="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||
:disabled="cancelling && cancelTarget?.id === o.id"
|
||
@click="askCancel(o)"
|
||
>
|
||
取消订单
|
||
</button>
|
||
<NuxtLink
|
||
:to="`/tender/pay?orderNo=${encodeURIComponent(o.orderNo)}`"
|
||
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
|
||
>
|
||
去支付
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="loaded" class="py-20 text-center text-gray-500">
|
||
<p>{{ emptyText }}</p>
|
||
<!--
|
||
筛空但确实有单:给一条一步回到「全部」的出路。只留一句「暂无」,
|
||
用户很难自己判断是"这个状态没有"还是"我的订单全丢了"。
|
||
-->
|
||
<button
|
||
v-if="orders.length && status !== 'all'"
|
||
type="button"
|
||
class="mt-4 rounded-md border border-gray-300 px-5 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||
@click="selectTab('all')"
|
||
>
|
||
查看全部订单({{ orders.length }})
|
||
</button>
|
||
</div>
|
||
<SiteLoading v-else />
|
||
</template>
|
||
|
||
<HjcConfirmDialog
|
||
:open="cancelDialogOpen"
|
||
title="确定取消该订单吗?"
|
||
content="取消后订单不可恢复,如需购买请重新下单。"
|
||
confirm-text="确定取消"
|
||
cancel-text="再想想"
|
||
loading-text="取消中..."
|
||
:loading="cancelling"
|
||
@confirm="confirmCancel"
|
||
@cancel="cancelDialogOpen = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
HJC_ORDER_FILTER_EMPTY_TEXT,
|
||
HJC_ORDER_FILTER_TABS,
|
||
hjcCanActOn,
|
||
hjcMatchOrderFilter,
|
||
hjcOrderFilterFromQuery,
|
||
hjcOrderStatusClass,
|
||
hjcOrderStatusKey,
|
||
hjcOrderStatusLabel,
|
||
type HjcOrderFilterKey
|
||
} from '~/utils/orderStatus'
|
||
|
||
const { isLoggedIn, handleAuthCode } = useHjcAuth()
|
||
/**
|
||
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
|
||
*
|
||
* 下面这句在 **SSR 期间** 就会执行:裸 `$fetch` 走的是 Nuxt 全局 ofetch,服务端渲染时
|
||
* **不会**把浏览器请求里的 Cookie 转发给站内 `/api` 代理(Nuxt 只在 `useRequestFetch`
|
||
* / `useFetch` 里做转发)。代理拿不到 `hjc_token` 就等于匿名调用,后端按契约返回
|
||
* `code=401`;`handleAuthCode` 据此判定「凭据失效」→ 在服务端清 Token →
|
||
* SSR 响应带上 `Set-Cookie: hjc_token=; Max-Age=0` 并跳登录页。
|
||
* 结果就是:刷新「我的订单」= 把自己登出(Cookie 被真正删掉,此后一直是未登录)。
|
||
*
|
||
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
|
||
*/
|
||
const requestFetch = useRequestFetch()
|
||
const loggedIn = ref(false)
|
||
const orders = shallowRef<any[]>([])
|
||
const loaded = ref(false)
|
||
const error = ref('')
|
||
/** 操作结果提示(取消成功/已支付/未核对上),与 error 分开:它不是错误 */
|
||
const notice = ref('')
|
||
|
||
/**
|
||
* 当前选中的状态 tab。初值取自 URL(`?status=pending`)——SSR 首屏就按它渲染,
|
||
* 于是刷新、分享链接、从订单详情页返回都还停在同一个 tab 上。
|
||
*
|
||
* 认不出的值由 `hjcOrderFilterFromQuery` 回落成「全部」,**不是**筛成空列表。
|
||
*/
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const STATUS_TABS = HJC_ORDER_FILTER_TABS
|
||
const status = ref<HjcOrderFilterKey>(hjcOrderFilterFromQuery(route.query.status))
|
||
|
||
/** 各状态的订单数。一次遍历数完,不为每个 tab 各 filter 一遍整个列表 */
|
||
const statusCounts = computed<Record<string, number>>(() => {
|
||
const acc: Record<string, number> = {}
|
||
for (const o of orders.value) {
|
||
const key = hjcOrderStatusKey(o)
|
||
acc[key] = (acc[key] || 0) + 1
|
||
}
|
||
return acc
|
||
})
|
||
|
||
/** tab 上的计数:`all` 是总数,其余查上面那张表 */
|
||
function countOf(key: HjcOrderFilterKey) {
|
||
return key === 'all' ? orders.value.length : statusCounts.value[key] || 0
|
||
}
|
||
|
||
/** 当前 tab 下要显示的订单 */
|
||
const filtered = computed(() => orders.value.filter((o) => hjcMatchOrderFilter(o, status.value)))
|
||
|
||
/** 空列表文案随 tab 变化(筛到「已完成」却只写「暂无订单」,用户分不清是没单还是丢了单) */
|
||
const emptyText = computed(() => HJC_ORDER_FILTER_EMPTY_TEXT[status.value])
|
||
|
||
/**
|
||
* 切 tab:先改本地状态(立刻重渲染),再把选中项同步进 URL。
|
||
*
|
||
* 用 `replace` 而不是 `push`——点四个 tab 就要按四次「后退」才回得到上一页。
|
||
* `all` 不进 URL:干净地址本身就表示"不筛",不必留一个 `?status=all`。
|
||
*/
|
||
function selectTab(key: HjcOrderFilterKey) {
|
||
if (status.value === key) return
|
||
status.value = key
|
||
const query = { ...route.query } as Record<string, string | undefined>
|
||
if (key === 'all') {
|
||
delete query.status
|
||
} else {
|
||
query.status = key
|
||
}
|
||
router.replace({ query })
|
||
}
|
||
|
||
/** 正在确认取消的那张订单 + 弹窗开合 */
|
||
const cancelTarget = shallowRef<any | null>(null)
|
||
const cancelDialogOpen = ref(false)
|
||
const cancelling = ref(false)
|
||
|
||
function formatMoney(n?: number | string) {
|
||
return Number(n ?? 0).toFixed(2)
|
||
}
|
||
function fmtTime(s?: string) {
|
||
return s ? s.slice(0, 19).replace('T', ' ') : '-'
|
||
}
|
||
|
||
/** 拉取(或重新拉取)订单列表。取消成功后走这里就地更新,不留整页 loading。 */
|
||
async function reload() {
|
||
try {
|
||
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
|
||
const res: any = await requestFetch('/api/tender/my-orders')
|
||
const auth = await handleAuthCode(res, '/tender/orders')
|
||
if (auth.handled) {
|
||
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
|
||
error.value = auth.message || ''
|
||
loggedIn.value = isLoggedIn()
|
||
return
|
||
}
|
||
if (res?.code === 0) {
|
||
orders.value = Array.isArray(res.data) ? res.data : []
|
||
error.value = ''
|
||
} else {
|
||
error.value = res?.message || '订单加载失败'
|
||
}
|
||
} catch (e: any) {
|
||
error.value = e?.data?.message || e?.message || '订单加载失败'
|
||
}
|
||
}
|
||
|
||
function askCancel(o: any) {
|
||
notice.value = ''
|
||
cancelTarget.value = o
|
||
cancelDialogOpen.value = true
|
||
}
|
||
|
||
async function confirmCancel() {
|
||
const o = cancelTarget.value
|
||
if (!o) return
|
||
cancelling.value = true
|
||
notice.value = ''
|
||
try {
|
||
const res: any = await $fetch('/api/tender/cancel', { method: 'POST', body: { orderNo: o.orderNo } })
|
||
const auth = await handleAuthCode(res, '/tender/orders')
|
||
if (auth.handled) {
|
||
notice.value = auth.message || ''
|
||
return
|
||
}
|
||
if (res?.code !== 0) {
|
||
notice.value = res?.message || '取消失败,请稍后重试'
|
||
return
|
||
}
|
||
// 「其实已经付了」**不是错误**:后端同样返回 code=0,结论在结构化字段里。
|
||
// 按字段判定,绝不匹配 message 字符串。
|
||
const d = res.data || {}
|
||
if (d.cancelled) {
|
||
notice.value = d.verified === false
|
||
// 微信不可达时如实告知没核对上,而不是替它下结论
|
||
? '订单已取消(未能在微信侧核对,如你已付款请稍后刷新订单)'
|
||
: '订单已取消'
|
||
} else if (d.paid) {
|
||
notice.value = '该订单已支付成功,无需取消'
|
||
} else {
|
||
notice.value = res?.message || '取消未生效,请刷新后查看订单状态'
|
||
}
|
||
// 无论哪种结论都重拉列表:状态可能已在服务端变化(含被自愈成已支付)
|
||
await reload()
|
||
} catch (e: any) {
|
||
notice.value = e?.data?.message || e?.message || '取消失败,请稍后重试'
|
||
} finally {
|
||
cancelling.value = false
|
||
cancelDialogOpen.value = false
|
||
cancelTarget.value = null
|
||
}
|
||
}
|
||
|
||
loggedIn.value = isLoggedIn()
|
||
if (loggedIn.value) {
|
||
await reload()
|
||
loaded.value = true
|
||
} else {
|
||
loaded.value = true
|
||
}
|
||
</script>
|