Files
weicw-dshsa 0e77c98ead feat(order): 官网「我的订单」列表加状态筛选 tab
加一排 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
2026-09-30 10:13:23 +08:00

321 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>