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
This commit is contained in:
2026-09-30 10:13:23 +08:00
parent fb5a8ce4a2
commit 0e77c98ead
2 changed files with 164 additions and 4 deletions
+101 -4
View File
@@ -15,8 +15,35 @@
<template v-else> <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> <p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
<div v-if="orders.length" class="space-y-4"> <!--
<div v-for="o in orders" :key="o.id" class="rounded-lg border border-gray-200 p-5"> 状态筛选 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 class="flex items-start justify-between">
<div> <div>
<p class="font-semibold text-gray-900">{{ o.projectName }}</p> <p class="font-semibold text-gray-900">{{ o.projectName }}</p>
@@ -86,7 +113,21 @@
</div> </div>
</div> </div>
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</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 /> <SiteLoading v-else />
</template> </template>
@@ -106,9 +147,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
HJC_ORDER_FILTER_EMPTY_TEXT,
HJC_ORDER_FILTER_TABS,
hjcCanActOn, hjcCanActOn,
hjcMatchOrderFilter,
hjcOrderFilterFromQuery,
hjcOrderStatusClass, hjcOrderStatusClass,
hjcOrderStatusLabel hjcOrderStatusKey,
hjcOrderStatusLabel,
type HjcOrderFilterKey
} from '~/utils/orderStatus' } from '~/utils/orderStatus'
const { isLoggedIn, handleAuthCode } = useHjcAuth() const { isLoggedIn, handleAuthCode } = useHjcAuth()
@@ -132,6 +179,56 @@ const error = ref('')
/** 操作结果提示(取消成功/已支付/未核对上),与 error 分开:它不是错误 */ /** 操作结果提示(取消成功/已支付/未核对上),与 error 分开:它不是错误 */
const notice = ref('') 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 cancelTarget = shallowRef<any | null>(null)
const cancelDialogOpen = ref(false) const cancelDialogOpen = ref(false)
+63
View File
@@ -154,3 +154,66 @@ export function hjcCanActOnOrder(o?: HjcOrderLike | null): boolean {
if (!o) return false if (!o) return false
return o.payStatus === 0 && o.orderStatus !== 2 return o.payStatus === 0 && o.orderStatus !== 2
} }
// ------------------------------------------------------------------ 列表筛选
/**
* 订单列表的筛选键:`all`(不筛)+ 四个状态键。
*
* `all` 刻意**不**并进 `HjcOrderStatusKey`,而是单独一层联合:一旦把 `'all'` 混进状态键,
* `hjcOrderStatusKey()` 的返回值就不再是"这张单真的处于什么状态",每个 `switch`
* 都得补一个永远走不到的 `case 'all'`,漏补时 TS 还会以为已经穷尽。
*/
export type HjcOrderFilterKey = 'all' | HjcOrderStatusKey
/**
* 列表页的筛选 tab(数组顺序即展示顺序)。
*
* **没有「已退款」**:标书售卖没有退款功能,`payStatus === 3` 只是防御性展示
* (工作区根目录 `docs/adr/0005-no-refund-for-tender-sales.md`)。挂一个永远是空的 tab,
* 只会让人追问"我的退款去哪了";真出现退款单时它在「全部」里照样找得到。
*
* 标签必须与 `hjc-h5/src/pages/order/list.vue` 的 tabs 保持一致——同本文件头注释的口径。
*/
export const HJC_ORDER_FILTER_TABS: ReadonlyArray<{ key: HjcOrderFilterKey; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待支付' },
{ key: 'done', label: '已完成' },
{ key: 'cancel', label: '已取消' }
]
/**
* 每个筛选键的空列表文案。
*
* 必须逐键给全(`Record<HjcOrderFilterKey, string>` 会盯着):筛到「已完成」却看到一句
* 「暂无订单」,用户无从判断是"这单没有"还是"我的单全丢了"。`refund` 目前没有对应 tab,
* 但类型里还在,留着文案免得将来加 tab 时漏掉。
*/
export const HJC_ORDER_FILTER_EMPTY_TEXT: Record<HjcOrderFilterKey, string> = {
all: '暂无订单',
pending: '暂无待支付订单',
done: '暂无已完成订单',
cancel: '暂无已取消订单',
refund: '暂无已退款订单'
}
/**
* URL 上的 `status` 查询参数 → 筛选键。
*
* 认不出来的一律回落 `all`,**不是**回落成"空筛选":把非法参数当成一个筛不出东西的条件,
* 用户看到的就是"一张单都没有"且没有任何提示,只会当成丢单。
* 容忍数组(`?status=a&status=b` 取第一个)与缺失。
*/
export function hjcOrderFilterFromQuery(raw: unknown): HjcOrderFilterKey {
const value = Array.isArray(raw) ? raw[0] : raw
const key = typeof value === 'string' ? value : ''
return HJC_ORDER_FILTER_TABS.some((t) => t.key === key) ? (key as HjcOrderFilterKey) : 'all'
}
/** 这张订单是否命中筛选键(`all` 恒命中,其余按状态键判定) */
export function hjcMatchOrderFilter(
o: HjcOrderLike | null | undefined,
key: HjcOrderFilterKey
): boolean {
return key === 'all' || hjcOrderStatusKey(o) === key
}