feat: 我的订单页面等调整

This commit is contained in:
2026-07-31 10:38:39 +08:00
parent a36183d4d8
commit ec1ff6ded0
7 changed files with 388 additions and 97 deletions

View File

@@ -1,54 +1,170 @@
<template>
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
<view class="box-border w-full min-w-0 overflow-hidden rounded-[16px] bg-white p-[16px]">
<text class="block break-words text-[16px] font-medium text-[#171717]">{{ order.productName || '景区产品' }}</text>
<view class="mt-[10px] flex min-w-0 text-[13px] text-[#667085]"><text class="shrink-0">订单号</text><text class="ml-[8px] min-w-0 flex-1 break-all text-right">{{ order.orderNo || order.id || '-' }}</text></view>
<text class="mt-[8px] block break-words text-[14px] text-[#475467]">{{ order.visitDate || '-' }} {{ order.timeSlot || '全天' }}</text>
<text class="mt-[6px] block break-words text-[14px] text-[#475467]">出行人 {{ firstTraveler }}</text>
<view class="box-border flex w-full min-w-0 flex-col gap-4 p-4 pb-6">
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4 shadow-sm">
<view class="flex min-w-0 items-start justify-between gap-3">
<view class="min-w-0 flex-1">
<text class="block truncate font-bold text-slate-900">{{ order.productName || '景区产品' }}</text>
<text class="mt-1 block text-xs text-slate-400">{{ order.visitDate || '—' }} · {{ order.timeSlot || '全天' }}</text>
</view>
<text class="shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold" :class="orderStatus.className">{{ orderStatus.label }}</text>
</view>
<view class="mt-3 flex min-w-0 justify-between gap-3 border-t border-slate-100 pt-3 text-sm">
<text class="shrink-0 text-slate-500">游客</text>
<text class="min-w-0 break-words text-right font-semibold text-slate-800">{{ firstTraveler }} · {{ maskPhone(order.phone) }}</text>
</view>
</view>
<view v-if="order.gateCredential" class="mt-[12px] box-border w-full min-w-0 overflow-hidden rounded-[18px] bg-[#5b21b6] p-[18px]">
<text class="text-[18px] font-semibold text-white">第三方票务凭证</text>
<text class="mt-[6px] block text-[13px] text-[#ddd6fe]">请向检票人员出示此二维码</text>
<view class="mt-[16px] box-border flex w-full min-w-0 flex-col items-center rounded-[12px] bg-white p-[14px]">
<view class="grid h-[176px] w-[176px] max-w-full grid-cols-9 grid-rows-9 border border-[#5b21b6] p-[4px]"><view v-for="(cell, index) in gateGrid" :key="index" class="h-full w-full" :class="cell ? 'bg-[#5b21b6]' : 'bg-white'" /></view>
<view class="mt-[12px] flex min-w-0 items-center justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="ml-[8px] text-[16px] font-medium text-[#15803d]">{{ order.gateCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view>
<view v-if="order.gateCredential" class="box-border w-full min-w-0 rounded-2xl bg-white p-5 shadow-sm">
<view class="flex min-w-0 items-start justify-between gap-3">
<view class="flex min-w-0 flex-1 items-center gap-3">
<view class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-50">
<uni-icons type="scan" size="21" color="#2563eb" />
</view>
<view class="mt-[16px] min-w-0 border-t border-[#7c3aed] pt-[12px]"><text v-for="right in gateRights" :key="right.id" class="mb-[6px] block break-words text-[13px] text-[#ede9fe]">{{ right.name }} × {{ right.quantity || 0 }}</text><text class="mt-[6px] block text-[12px] leading-[18px] text-[#ddd6fe]">请在预约日期和时段内完成核验截图可能无法正常使用</text></view>
<view class="min-w-0 flex-1">
<text class="block text-xs text-blue-500">第三方票务系统出票</text>
<text class="mt-1 block font-bold text-slate-900">第三方票务凭证</text>
</view>
</view>
<text class="shrink-0 rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">{{ gateStatusText }}</text>
</view>
<view v-if="order.selfCredential" class="mt-[12px] box-border w-full min-w-0 overflow-hidden rounded-[18px] border border-[#bfdbfe] bg-[#eff6ff] p-[18px]">
<text class="text-[18px] font-semibold text-[#1e3a8a]">自营园内权益</text>
<text class="mt-[6px] block text-[13px] text-[#3b82f6]">请向服务人员出示此二维码</text>
<view class="mt-[16px] box-border flex w-full min-w-0 flex-col items-center rounded-[12px] bg-white p-[14px]">
<view class="grid h-[176px] w-[176px] max-w-full grid-cols-9 grid-rows-9 border border-[#1d4ed8] p-[4px]"><view v-for="(cell, index) in selfGrid" :key="index" class="h-full w-full" :class="cell ? 'bg-[#1d4ed8]' : 'bg-white'" /></view>
<view class="mt-[12px] flex min-w-0 items-center justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="ml-[8px] text-[16px] font-medium text-[#15803d]">{{ order.selfCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view>
</view>
<view class="mt-[16px] min-w-0 border-t border-[#bfdbfe] pt-[12px]"><text v-for="right in selfRights" :key="right.id" class="mb-[6px] block break-words text-[13px] text-[#1e3a8a]">{{ right.name }} × {{ right.quantity || 0 }}</text><text class="mt-[6px] block text-[12px] leading-[18px] text-[#3b82f6]">请在服务点出示二维码权益核销后不可再次使用</text></view>
<view class="mt-4 box-border flex w-full min-w-0 justify-center rounded-2xl bg-slate-50 p-4">
<Qrcode
cid="ticket-gate-credential"
:size="164"
unit="px"
:val="gateQrValue"
:load-make="true"
:onval="true"
:show-loading="false"
foreground="#0f172a"
pdground="#0f172a"
/>
</view>
<view v-if="!order.gateCredential && !order.selfCredential" class="flex min-h-[260px] min-w-0 flex-col items-center justify-center"><uni-icons type="info" size="34" color="#98a2b3" /><text class="mt-[12px] text-[14px] text-[#98a2b3]">暂无可用凭证</text></view>
<view class="h-[24px]" />
<view class="mt-4 flex flex-col gap-2">
<view v-for="right in gateRights" :key="right.id" class="flex min-w-0 items-center justify-between gap-3 rounded-xl bg-slate-50 px-3 py-2.5">
<text class="min-w-0 flex-1 break-words text-sm font-medium text-slate-700">{{ right.name }} × {{ right.quantity || 0 }}</text>
<text class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold" :class="entitlementMeta(right.status).className">{{ entitlementMeta(right.status).label }}</text>
</view>
</view>
<text class="mt-3 block text-center text-xs leading-5 text-slate-400">门票可凭此码或身份证入园观光车按第三方票务规则使用</text>
</view>
<view v-if="order.selfCredential" class="box-border w-full min-w-0 rounded-2xl bg-white p-5 shadow-sm">
<view class="flex min-w-0 items-start justify-between gap-3">
<view class="flex min-w-0 flex-1 items-center gap-3">
<view class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-50">
<uni-icons type="checkbox-filled" size="21" color="#059669" />
</view>
<view class="min-w-0 flex-1">
<text class="block text-xs text-emerald-600">园内工作人员扫码</text>
<text class="mt-1 block font-bold text-slate-900">园内权益凭证</text>
</view>
</view>
<text class="shrink-0 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">自营凭证</text>
</view>
<view class="mt-4 box-border flex w-full min-w-0 justify-center rounded-2xl bg-slate-50 p-4">
<Qrcode
cid="ticket-self-credential"
:size="164"
unit="px"
:val="selfQrValue"
:load-make="true"
:onval="true"
:show-loading="false"
foreground="#0f172a"
pdground="#0f172a"
/>
</view>
<view class="mt-4 flex flex-col gap-2">
<view v-for="right in selfRights" :key="right.id" class="flex min-w-0 items-center justify-between gap-3 rounded-xl bg-slate-50 px-3 py-2.5">
<text class="min-w-0 flex-1 break-words text-sm font-medium text-slate-700">{{ right.name }} × {{ right.quantity || 0 }}{{ right.unit || '份' }}</text>
<text class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold" :class="entitlementMeta(right.status).className">{{ entitlementMeta(right.status).label }}</text>
</view>
</view>
<text class="mt-3 block text-center text-xs leading-5 text-slate-400">多个自营二销项目共用此码员工扫码后选择项目核销</text>
</view>
<view v-if="!order.gateCredential && !order.selfCredential" class="box-border flex min-h-[300px] w-full min-w-0 flex-col items-center justify-center rounded-2xl bg-white p-6 shadow-sm">
<view class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
<uni-icons type="info" size="30" color="#94a3b8" />
</view>
<text class="mt-4 text-sm font-semibold text-slate-500">暂无可用凭证</text>
<text class="mt-2 text-center text-xs leading-5 text-slate-400">出票完成后二维码会展示在这里</text>
</view>
</view>
</scroll-view>
<view class="box-border w-full min-w-0 bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button></view>
<view class="box-border w-full min-w-0 border-t border-slate-200 bg-white p-4 pb-[calc(env(safe-area-inset-bottom)+16px)]">
<button class="detail-action m-0 box-border h-12 w-full min-w-0 overflow-hidden rounded-full border border-slate-200 bg-white p-0 text-sm font-semibold leading-[48px] text-slate-600" @tap="emit('detail')">查看订单详情</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { computed } from "vue";
import Qrcode from "@/components/Qrcode/index.vue";
import { ENTITLEMENT_STATUS_META, ORDER_STATUS_META } from "../data.js";
const props = defineProps({ order: { type: Object, required: true } })
const emit = defineEmits(['detail'])
const firstTraveler = computed(() => props.order?.travelers?.[0]?.name || '未填写出行人')
const gateRights = computed(() => (props.order?.entitlements || []).filter((right) => right.fulfillment === 'third_party'))
const selfRights = computed(() => (props.order?.entitlements || []).filter((right) => right.fulfillment === 'self'))
const makeGrid = (token) => {
const source = String(token || 'credential')
return Array.from({ length: 81 }, (_, index) => ((source.charCodeAt(index % source.length) + index * 17 + Math.floor(index / 9) * 7) % 5) < 3)
}
const gateGrid = computed(() => makeGrid(props.order?.gateCredential?.qrToken))
const selfGrid = computed(() => makeGrid(props.order?.selfCredential?.qrToken))
const props = defineProps({ order: { type: Object, required: true } });
const emit = defineEmits(["detail"]);
const fallbackOrderStatus = {
label: "待使用",
className: "border-blue-200 bg-blue-50 text-blue-700",
};
const fallbackEntitlementStatus = {
label: "待使用",
className: "bg-blue-50 text-blue-700",
};
const orderStatus = computed(
() => ORDER_STATUS_META[props.order?.status] || fallbackOrderStatus
);
const firstTraveler = computed(
() =>
props.order?.customerName ||
props.order?.travelers?.[0]?.name ||
"未填写出行人"
);
const gateRights = computed(() =>
(props.order?.entitlements || []).filter(
(right) => right.fulfillment === "third_party"
)
);
const selfRights = computed(() =>
(props.order?.entitlements || []).filter(
(right) => right.fulfillment === "self"
)
);
const resolveGateQrValue = (order) =>
String(order?.gateCredential?.qrToken || order?.gateCredential?.ticketNo || order?.id || "ticket-gate-demo");
const resolveSelfQrValue = (order) =>
String(order?.selfCredential?.qrToken || order?.selfCredential?.credentialNo || order?.id || "ticket-self-demo");
const gateQrValue = computed(() => resolveGateQrValue(props.order));
const selfQrValue = computed(() => resolveSelfQrValue(props.order));
const gateStatusText = computed(
() =>
({
issued: "可使用",
used: "已使用",
issuing: "出票中",
failed: "出票失败",
})[props.order?.gateCredential?.status] || "可使用"
);
const entitlementMeta = (status) =>
ENTITLEMENT_STATUS_META[status] || fallbackEntitlementStatus;
const maskPhone = (value) =>
String(value || "—").replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
</script>
<style scoped>
.detail-action::after {
border: 0;
border-radius: 9999px;
}
</style>

View File

@@ -1,77 +1,159 @@
<template>
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
<view class="relative flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
<view class="box-border w-full min-w-0 overflow-hidden rounded-[16px] bg-white p-[16px]">
<view class="flex min-w-0 items-start justify-between gap-[10px]">
<text class="min-w-0 flex-1 break-words text-[16px] font-medium leading-[22px] text-[#171717]">{{ order.productName || '景区产品' }}</text>
<text class="shrink-0 rounded-[12px] bg-[#ecfdf3] px-[8px] py-[3px] text-[12px] text-[#15803d]">{{ statusText }}</text>
<view class="box-border flex w-full min-w-0 flex-col gap-3 p-4 pb-28">
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4">
<view class="flex min-w-0 items-start justify-between gap-3">
<text class="min-w-0 flex-1 break-words text-base font-bold text-slate-900">{{ order.productName }}</text>
<text class="inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-xs font-semibold" :class="statusMeta.className">{{ statusMeta.label }}</text>
</view>
<view class="mt-[12px] flex min-w-0 items-start text-[13px] text-[#667085]">
<text class="shrink-0">订单号</text>
<text class="ml-[8px] min-w-0 flex-1 break-all text-right">{{ order.orderNo || order.id || '-' }}</text>
<view class="mt-4 flex min-w-0 items-center justify-between gap-3 text-sm">
<text class="shrink-0 text-slate-400">订单号</text>
<text class="min-w-0 break-all text-right text-slate-500">{{ order.orderNo || order.id || '' }}</text>
</view>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">使用信息</text>
<view class="mt-[14px] flex min-w-0 justify-between gap-[12px] text-[14px]"><text class="shrink-0 text-[#667085]">使用日期</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.visitDate || '-' }}</text></view>
<view class="mt-[12px] flex min-w-0 justify-between gap-[12px] text-[14px]"><text class="shrink-0 text-[#667085]">使用时段</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.timeSlot || '全天' }}</text></view>
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4">
<text class="block text-base font-bold text-slate-900">使用信息</text>
<view class="mt-4 flex flex-col gap-3 text-sm">
<view class="flex min-w-0 justify-between gap-3">
<text class="shrink-0 text-slate-400">使用日期</text>
<text class="min-w-0 break-words text-right font-semibold text-slate-900">{{ order.visitDate }}</text>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">出行人</text>
<view v-for="traveler in order.travelers || []" :key="traveler.id || traveler.name" class="mt-[14px] flex min-w-0 justify-between gap-[12px] text-[14px]">
<text class="min-w-0 flex-1 break-words text-[#344054]">{{ traveler.name || '-' }}</text>
<text class="shrink-0 text-[#667085]">{{ traveler.idType || '游客' }}</text>
<view class="flex min-w-0 justify-between gap-3">
<text class="shrink-0 text-slate-400">使用时段</text>
<text class="min-w-0 break-words text-right font-semibold text-slate-900">{{ order.timeSlot }}</text>
</view>
<view v-if="!(order.travelers || []).length" class="mt-[14px] text-[14px] text-[#98a2b3]">暂无出行人信息</view>
<view class="mt-[14px] flex min-w-0 border-t border-[#f2f4f7] pt-[14px] text-[14px]">
<text class="shrink-0 text-[#667085]">联系人手机</text>
<text class="ml-[12px] min-w-0 flex-1 break-all text-right text-[#344054]">{{ order.phone || '-' }}</text>
</view>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">权益与使用状态</text>
<view v-for="right in order.entitlements || []" :key="right.id" class="mt-[14px] flex min-w-0 items-center justify-between gap-[12px] border-b border-[#f2f4f7] pb-[12px] last:border-b-0 last:pb-0">
<view class="min-w-0 flex-1"><text class="block break-words text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[4px] block text-[12px] text-[#98a2b3]">{{ fulfillmentText(right.fulfillment) }}</text></view>
<text class="shrink-0 text-right text-[13px] text-[#667085]">{{ right.quantity || 0 }} · {{ rightStatus(right.status) }}</text>
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4">
<text class="block text-base font-bold text-slate-900">出行人</text>
<view class="mt-4 flex min-w-0 justify-between gap-3 text-sm">
<text class="min-w-0 flex-1 break-words text-slate-500">{{ order.customerName || order.contactName || order.travelers?.[0]?.name || '—' }}</text>
<text class="shrink-0 text-slate-400">{{ travelerIdType }}</text>
</view>
<view class="mt-4 flex min-w-0 justify-between gap-3 border-t border-slate-100 pt-4 text-sm">
<text class="shrink-0 text-slate-400">联系手机</text>
<text class="min-w-0 break-all text-right font-semibold text-slate-700">{{ order.phone || '—' }}</text>
</view>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">费用信息</text>
<view class="mt-[14px] flex justify-between gap-[12px] text-[14px]"><text class="text-[#667085]">产品金额</text><text class="text-right text-[#344054]">¥{{ money(order.productAmount) }}</text></view>
<view class="mt-[12px] flex justify-between gap-[12px] text-[14px]"><text class="text-[#667085]">保险金额</text><text class="text-right text-[#344054]">¥{{ money(order.insuranceAmount) }}</text></view>
<view class="mt-[12px] flex justify-between gap-[12px] border-t border-[#f2f4f7] pt-[12px] text-[14px]"><text class="text-[#667085]">实付金额</text><text class="font-semibold text-[#ef4444]">¥{{ money(order.paidAmount) }}</text></view>
<view class="mt-[12px] flex min-w-0 justify-between gap-[12px] text-[13px]"><text class="shrink-0 text-[#667085]">支付时间</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.paidAt || '-' }}</text></view>
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4">
<text class="font-bold text-slate-900">权益与使用状态</text>
<view class="mt-3 divide-y divide-slate-100">
<view v-for="right in order.entitlements || []" :key="right.id" class="flex min-w-0 items-center justify-between gap-3 py-3">
<view class="min-w-0 flex-1">
<text class="block break-words text-sm font-semibold text-slate-900">{{ right.name }} × {{ right.quantity }}</text>
<text class="mt-1 block break-words text-xs text-slate-400">{{ right.fulfillment === 'third_party' ? '第三方出票' : '自营权益码' }} · 退款金额 {{ money(right.refundAllocation) }}</text>
</view>
<text class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold" :class="entitlementMeta(right.status).className">{{ entitlementMeta(right.status).label }}</text>
</view>
<view v-if="!order.entitlements?.length" class="py-6 text-center text-sm text-slate-400">支付完成后生成履约权益</view>
</view>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">退款规则</text>
<text class="mt-[10px] block text-[13px] leading-[20px] text-[#667085]">未使用的权益可按产品退款规则申请退款已核销权益不支持退款实际退款以审核结果为准</text>
<view class="box-border w-full min-w-0 rounded-2xl bg-white p-4">
<text class="font-bold text-slate-900">费用信息</text>
<view class="mt-3 flex flex-col gap-3 text-sm">
<view class="flex min-w-0 justify-between gap-3">
<text class="shrink-0 text-slate-400">商品金额</text>
<text class="min-w-0 break-words text-right font-semibold text-slate-900">{{ money(productAmount) }}</text>
</view>
<view class="flex min-w-0 justify-between gap-3">
<text class="shrink-0 text-slate-400">出行保险</text>
<text class="min-w-0 break-words text-right font-semibold text-slate-900">{{ insuranceText }}</text>
</view>
<view class="flex min-w-0 justify-between gap-3 border-t border-slate-100 pt-3">
<text class="shrink-0 text-slate-400">订单实付</text>
<text class="min-w-0 break-words text-right text-lg font-bold text-orange-600">{{ money(order.amount ?? order.paidAmount) }}</text>
</view>
</view>
<view class="mt-3 flex min-w-0 justify-between gap-3 text-xs text-slate-400">
<text class="shrink-0">支付时间</text>
<text class="min-w-0 break-words text-right">{{ order.paidAt }}</text>
</view>
</view>
<view class="box-border w-full min-w-0 rounded-2xl p-4 text-xs leading-5" :class="refundRuleClass">
<view class="flex min-w-0 items-start gap-2">
<uni-icons class="mt-0.5 shrink-0" type="info" size="16" :color="refundRuleIconColor" />
<view class="min-w-0 flex-1">
<text class="font-bold">退款规则</text>
<text class="mt-1 block">{{ refundRuleText }}</text>
</view>
</view>
</view>
<view class="h-[24px]" />
</view>
</scroll-view>
<view class="box-border flex w-full min-w-0 gap-[12px] bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]">
<button v-if="hasCredential" class="m-0 min-w-0 flex-1 rounded-[24px] border border-[#7c3aed] bg-white p-0 text-[15px] leading-[48px] text-[#6d28d9]" @click="emit('credentials')">查看凭证</button>
<button v-if="canRefund" class="m-0 min-w-0 flex-1 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('refund')">申请退款</button>
<view class="absolute bottom-0 left-0 right-0 box-border flex w-full min-w-0 gap-3 border-t border-slate-200 bg-white p-4 pb-[calc(env(safe-area-inset-bottom)+16px)]">
<button v-if="hasCredential" class="m-0 h-12 min-w-0 flex-1 rounded-full border border-blue-200 bg-blue-50 p-0 text-sm font-bold leading-[48px] text-blue-600" @tap="emit('credentials')">查看凭证</button>
<button class="refund-action m-0 box-border h-12 min-w-0 flex-1 overflow-hidden rounded-full border border-transparent bg-orange-500 p-0 text-sm font-bold leading-[48px] text-white disabled:border-slate-200 disabled:bg-slate-200 disabled:text-slate-400" :disabled="!canRefund" @tap="canRefund && emit('refund')">{{ refundButtonText }}</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { ENTITLEMENT_STATUS_META, ORDER_STATUS_META } from '../data.js'
const props = defineProps({ order: { type: Object, required: true } })
const emit = defineEmits(['credentials', 'refund'])
const canRefund = computed(() => (props.order?.entitlements || []).some((right) => right.status === 'pending'))
const hasCredential = computed(() => Boolean(props.order?.gateCredential || props.order?.selfCredential))
const money = (value) => Number(value || 0).toFixed(2)
const fulfillmentText = (value) => value === 'third_party' ? '第三方票务核验' : '景区自营权益'
const rightStatus = (value) => ({ pending: '待使用', refunding: '退款中', used: '已使用', refunded: '已退款' }[value] || '待使用')
const statusText = computed(() => ({ paid: '待使用', refunding: '退款中', completed: '已完成', refunded: '已退款' }[props.order?.status] || '待使用'))
const fallbackStatus = { label: '未知状态', className: 'border-slate-200 bg-slate-50 text-slate-600' }
const fallbackEntitlement = { label: '待使用', className: 'bg-blue-50 text-blue-700' }
const statusMeta = computed(() => ORDER_STATUS_META[props.order?.status] || fallbackStatus)
const entitlementMeta = (status) => ENTITLEMENT_STATUS_META[status] || fallbackEntitlement
const isPackage = computed(() => props.order?.refundMode === 'all_or_nothing')
const hasUsedEntitlement = computed(() => (props.order?.entitlements || []).some((right) => right.usedQuantity > 0 || right.status === 'used'))
const packageHasUsed = computed(() => isPackage.value && hasUsedEntitlement.value)
const isRefunding = computed(() => props.order?.status === 'refunding')
const isRefunded = computed(() => props.order?.status === 'refunded')
const travelerIdType = computed(() => props.order?.travelers?.[0]?.idType || (props.order?.idCard ? '身份证' : '—'))
const refundableRights = computed(() => (props.order?.entitlements || []).filter((right) => right.status === 'pending' && Number(right.usedQuantity || 0) === 0))
const canRefund = computed(() => (
!['pending_payment', 'closed'].includes(props.order?.status)
&& !isRefunding.value
&& !isRefunded.value
&& !packageHasUsed.value
&& refundableRights.value.length > 0
))
const hasCredential = computed(() => Boolean(props.order?.gateCredential?.qrToken || props.order?.selfCredential))
const productAmount = computed(() => (
props.order?.productAmount
?? Number(props.order?.amount || props.order?.paidAmount || 0) - Number(props.order?.insurance?.amount || props.order?.insuranceAmount || 0)
))
const insuranceTotal = computed(() => (
props.order?.insurance?.amount
?? props.order?.insuranceAmount
?? Number(props.order?.insurance?.price || 0) * Number(props.order?.insurance?.quantity || 0)
))
const insuranceUnitPrice = computed(() => props.order?.insurance?.unitPrice ?? props.order?.insurance?.price ?? 0)
const insuranceText = computed(() => props.order?.insurance
? `${money(insuranceTotal.value)}${money(insuranceUnitPrice.value)}/份 × ${props.order.insurance.quantity}`
: '未购买')
const refundRuleClass = computed(() => {
if (packageHasUsed.value) return 'bg-rose-50 text-rose-700'
if (isPackage.value) return 'bg-amber-50 text-amber-700'
return 'bg-blue-50 text-blue-700'
})
const refundRuleIconColor = computed(() => packageHasUsed.value ? '#be123c' : isPackage.value ? '#b45309' : '#1d4ed8')
const refundRuleText = computed(() => {
if (packageHasUsed.value) return '套餐中已有门票、观光车或园内项目被核销,按照套餐规则整单不可退款。'
if (isPackage.value) return '套餐内全部权益未使用,可申请整单全额退款;任一权益使用后整单不可退。'
return '未使用权益可申请退款,已核销或已退款权益不可再次申请。'
})
const refundButtonText = computed(() => {
if (isRefunding.value) return '退款处理中'
if (isRefunded.value) return '已退款'
return canRefund.value ? '申请退款' : '不可退款'
})
const money = (value) => `¥${Number(value || 0).toLocaleString('zh-CN')}`
</script>
<style scoped>
.refund-action::after {
border: 0;
border-radius: 9999px;
}
</style>

View File

@@ -7,17 +7,22 @@
<view class="flex min-w-0 justify-between gap-[12px]"><text class="shrink-0 text-[#667085]">订单号</text><text class="min-w-0 flex-1 break-all text-right text-[#344054]">{{ order.orderNo || order.id || '-' }}</text></view>
<view class="mt-[12px] flex justify-between"><text class="text-[#667085]">退款状态</text><text class="text-[#d97706]">待审核</text></view>
</view>
<button class="mt-[28px] box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button>
<button class="mt-[28px] box-border w-full min-w-0 rounded-[24px] bg-orange-500 p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button>
<button class="mt-[12px] box-border w-full min-w-0 rounded-[24px] border border-[#d0d5dd] bg-white p-0 text-[15px] leading-[48px] text-[#344054]" @click="emit('home')">返回景区首页</button>
</view>
<view v-else class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
<view class="mb-[12px] box-border w-full min-w-0 rounded-[16px] bg-orange-50 p-[16px] text-orange-700">
<text class="block text-[14px] font-semibold">{{ isPackage ? '套餐整单退款' : '按未使用权益退款' }}</text>
<text class="mt-[4px] block text-[12px] leading-[20px]">{{ isPackage ? '套餐不支持拆项退款,本次将退回全部未使用权益对应的整单实付金额。' : '请选择需要退款的未使用权益,退款金额按购买时实际分摊金额计算。' }}</text>
</view>
<view class="box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">选择退款权益</text>
<view v-for="right in pendingRights" :key="right.id" class="mt-[12px] box-border flex w-full min-w-0 items-center rounded-[12px] border p-[12px]" :class="selectedIds.includes(right.id) ? 'border-[#7c3aed] bg-[#faf5ff]' : 'border-[#eaecf0] bg-white'" @click="toggleRight(right.id)">
<view class="flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full border" :class="selectedIds.includes(right.id) ? 'border-[#7c3aed] bg-[#7c3aed]' : 'border-[#d0d5dd] bg-white'"><uni-icons v-if="selectedIds.includes(right.id)" type="checkmarkempty" size="14" color="#ffffff" /></view>
<view v-for="right in pendingRights" :key="right.id" class="mt-[12px] box-border flex w-full min-w-0 items-center rounded-[12px] border p-[12px]" :class="selectedIds.includes(right.id) ? 'border-orange-400 bg-orange-50' : 'border-[#eaecf0] bg-white'" @click="toggleRight(right.id)">
<view class="flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full border" :class="selectedIds.includes(right.id) ? 'border-orange-500 bg-orange-500' : 'border-[#d0d5dd] bg-white'"><uni-icons v-if="selectedIds.includes(right.id)" type="checkmarkempty" size="14" color="#ffffff" /></view>
<view class="ml-[10px] min-w-0 flex-1"><text class="block break-words text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[3px] block break-words text-[12px] text-[#98a2b3]">{{ right.quantity || 0 }}可退 ¥{{ money((right.refundAllocation || 0) * (right.quantity || 0)) }}</text></view>
</view>
<text v-if="!pendingRights.length" class="mt-[14px] block text-[14px] text-[#98a2b3]">当前没有可申请退款的权益</text>
@@ -26,19 +31,19 @@
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">退款原因</text>
<view v-for="reason in reasons" :key="reason" class="mt-[12px] flex min-w-0 items-start" @click="selectedReason = reason">
<view class="mt-[1px] flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full border" :class="selectedReason === reason ? 'border-[#7c3aed] bg-[#7c3aed]' : 'border-[#d0d5dd] bg-white'"><view v-if="selectedReason === reason" class="h-[7px] w-[7px] rounded-full bg-white" /></view>
<view class="mt-[1px] flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded-full border" :class="selectedReason === reason ? 'border-orange-500 bg-orange-500' : 'border-[#d0d5dd] bg-white'"><view v-if="selectedReason === reason" class="h-[7px] w-[7px] rounded-full bg-white" /></view>
<text class="ml-[10px] min-w-0 flex-1 break-words text-[14px] leading-[21px] text-[#344054]">{{ reason }}</text>
</view>
</view>
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
<view class="flex min-w-0 items-center justify-between gap-[12px]"><text class="text-[15px] text-[#667085]">预计退款金额</text><text class="shrink-0 text-[22px] font-semibold text-[#ef4444]">¥{{ money(refundAmount) }}</text></view>
<view class="flex min-w-0 items-center justify-between gap-[12px]"><text class="text-[15px] text-[#667085]">预计退款金额</text><text class="shrink-0 text-[22px] font-semibold text-orange-600">¥{{ money(refundAmount) }}</text></view>
<text class="mt-[8px] block text-[12px] text-[#98a2b3]">实际到账金额以审核结果为准</text>
</view>
<view class="h-[24px]" />
</view>
</scroll-view>
<view class="box-border w-full min-w-0 bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="submitRefund">提交退款申请</button></view>
<view class="box-border w-full min-w-0 bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="box-border w-full min-w-0 rounded-[24px] bg-orange-500 p-0 text-[15px] leading-[48px] text-white" @click="submitRefund">提交退款申请</button></view>
</view>
</template>
@@ -54,6 +59,7 @@ const props = defineProps({
const emit = defineEmits(['submit', 'detail', 'home'])
const selectedIds = ref([])
const selectedReason = ref('')
const isPackage = computed(() => props.order?.refundMode === 'all_or_nothing')
const pendingRights = computed(() => (props.order?.entitlements || []).filter((right) => right.status === 'pending'))
const refundAmount = computed(() => calculateRefundAmount(props.order, selectedIds.value))
const money = (value) => Number(value || 0).toFixed(2)

View File

@@ -2,7 +2,7 @@
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
<TopNavBar
title="查看凭证"
subtitle="入园及园内权益核验"
:subtitle="ticketStore.currentOrder?.orderNo || ''"
title-align="left"
background="#ffffff"
/>

View File

@@ -13,6 +13,13 @@ export const ORDER_STATUS_META = {
exception: { label: '异常', className: 'border-rose-200 bg-rose-50 text-rose-700' },
}
export const ENTITLEMENT_STATUS_META = {
pending: { label: '待使用', className: 'bg-blue-50 text-blue-700' },
used: { label: '已使用', className: 'bg-emerald-50 text-emerald-700' },
refunding: { label: '退款中', className: 'bg-orange-50 text-orange-700' },
refunded: { label: '已退款', className: 'bg-slate-100 text-slate-600' },
}
export const SCENIC_SPOT = {
id: 'libo-xiaoqikong',
name: '荔波小七孔景区',

View File

@@ -16,7 +16,7 @@ import {
test('provides coherent default visitor ticket data', async () => {
const data = await import('./data.js')
const { AVAILABLE_DATES, GROUPS, INITIAL_TRAVELERS, INSURANCE, ORDER_STATUS_META, PLACEHOLDER_IMAGE, PRODUCTS, REFUND_REASONS, SCENIC_SPOT } = data
const { AVAILABLE_DATES, ENTITLEMENT_STATUS_META, GROUPS, INITIAL_TRAVELERS, INSURANCE, ORDER_STATUS_META, PLACEHOLDER_IMAGE, PRODUCTS, REFUND_REASONS, SCENIC_SPOT } = data
const { INITIAL_ORDERS } = data
assert.equal(PLACEHOLDER_IMAGE, 'https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/oneFeel/2082673896938569729.jpg')
@@ -86,6 +86,10 @@ test('provides coherent default visitor ticket data', async () => {
exception: '异常',
},
)
assert.deepEqual(
Object.fromEntries(Object.entries(ENTITLEMENT_STATUS_META).map(([status, meta]) => [status, meta.label])),
{ pending: '待使用', used: '已使用', refunding: '退款中', refunded: '已退款' },
)
assert.equal(INITIAL_ORDERS.length, 11)
assert.deepEqual(
INITIAL_ORDERS.map(({ orderNo, productName, status, amount }) => [orderNo, productName, status, amount]),

View File

@@ -94,6 +94,82 @@ test("order list matches the visitor prototype card layout and fields", async ()
assert.match(component, /type="right"/);
});
test("order detail matches the visitor prototype sections and actions", async () => {
const component = await readTicketFile("components/OrderDetail.vue");
const page = await readTicketFile("order-detail.vue");
assert.match(component, /box-border flex w-full min-w-0 flex-col gap-3 p-4 pb-28/);
assert.match(component, /rounded-2xl bg-white p-4/);
assert.match(component, /ORDER_STATUS_META/);
assert.match(component, /rounded-full border px-2\.5 py-1 text-xs font-semibold/);
assert.match(component, /:class="statusMeta\.className"/);
assert.match(component, /order\.orderNo/);
assert.match(component, /使用信息/);
assert.match(component, /使用日期/);
assert.match(component, /使用时段/);
assert.match(component, /出行人/);
assert.match(component, /travelerIdType/);
assert.match(component, /联系手机/);
assert.match(component, /order\.customerName/);
assert.match(component, /order\.phone/);
assert.doesNotMatch(component, /当前状态|statusDescription|<uni-icons type="list"/);
assert.ok(component.indexOf("order.productName") < component.indexOf("order.orderNo"));
assert.match(component, /第三方出票/);
assert.match(component, /自营权益码/);
assert.match(component, /退款金额/);
assert.match(component, /ENTITLEMENT_STATUS_META/);
assert.match(component, /商品金额/);
assert.match(component, /出行保险/);
assert.match(component, /订单实付/);
assert.match(component, /套餐内全部权益未使用,可申请整单全额退款/);
assert.match(component, /套餐中已有门票、观光车或园内项目被核销/);
assert.match(component, /:disabled="!canRefund"/);
assert.match(component, /退款处理中/);
assert.match(component, /不可退款/);
assert.match(component, /refund-action/);
assert.match(component, /\.refund-action::after/);
assert.match(component, /border:\s*(?:0|none)/);
assert.match(page, /subtitle="查看使用信息与订单权益"/);
assert.doesNotMatch(page, /:subtitle="ticketStore\.currentOrder\?\.orderNo \|\| ''"/);
});
test("credentials match the visitor prototype and render real QR codes", async () => {
const component = await readTicketFile("components/CredentialsView.vue");
const page = await readTicketFile("credentials.vue");
assert.match(component, /import Qrcode from "@\/components\/Qrcode\/index\.vue"/);
assert.equal((component.match(/<Qrcode/g) || []).length, 2);
assert.match(component, /cid="ticket-gate-credential"/);
assert.match(component, /cid="ticket-self-credential"/);
assert.match(component, /:val="gateQrValue"/);
assert.match(component, /:val="selfQrValue"/);
assert.match(component, /:load-make="true"/);
assert.match(component, /order\?\.gateCredential\?\.qrToken \|\| order\?\.gateCredential\?\.ticketNo \|\| order\?\.id/);
assert.match(component, /order\?\.selfCredential\?\.qrToken \|\| order\?\.selfCredential\?\.credentialNo \|\| order\?\.id/);
assert.match(component, /第三方票务凭证/);
assert.match(component, /园内权益凭证/);
assert.match(component, /多个自营二销项目共用此码,员工扫码后选择项目核销/);
assert.match(component, /ENTITLEMENT_STATUS_META/);
assert.match(component, /rounded-2xl bg-white p-4 shadow-sm/);
assert.doesNotMatch(component, /gateGrid|selfGrid|makeGrid/);
assert.doesNotMatch(component, /bg-\[#5b21b6\]|from-violet-700|to-indigo-700/);
assert.match(page, /:subtitle="ticketStore\.currentOrder\?\.orderNo \|\| ''"/);
});
test("refund flow shows the package notice and uses the orange visitor theme", async () => {
const component = await readTicketFile("components/RefundFlow.vue");
assert.match(component, /套餐整单退款/);
assert.match(component, /套餐不支持拆项退款,本次将退回全部未使用权益对应的整单实付金额。/);
assert.match(component, /refundMode === 'all_or_nothing'/);
assert.match(component, /bg-orange-50/);
assert.match(component, /border-orange-400/);
assert.match(component, /border-orange-500 bg-orange-500/);
assert.match(component, /text-orange-600/);
assert.match(component, /bg-orange-500/);
assert.doesNotMatch(component, /#7c3aed|#faf5ff/);
});
test("traveler manager matches the approved prototype details", async () => {
const component = await readTicketFile("components/TravelerManager.vue");
const page = await readTicketFile("travelers.vue");