feat: wire ticket client experience

This commit is contained in:
2026-07-30 16:49:43 +08:00
parent 7a2133dc99
commit b6e90f67b1
2 changed files with 306 additions and 8 deletions

View File

@@ -0,0 +1,292 @@
<template>
<view class="h-screen overflow-hidden bg-[#f5f7fa] text-[#172033]">
<TicketHome
v-if="currentView === TICKET_VIEWS.HOME"
:scenic-spot="SCENIC_SPOT"
:groups="visibleGroups"
:products="products"
@back="goBack"
@open-travelers="openTravelerManager(false)"
@open-orders="navigate(TICKET_VIEWS.ORDERS)"
@open-detail="openProductDetail"
@buy="startBooking"
/>
<ProductDetail
v-else-if="currentView === TICKET_VIEWS.DETAIL"
:product="currentProduct"
@back="goBack"
@buy="startBooking"
/>
<BookingFlow
v-else-if="currentView === TICKET_VIEWS.BOOKING"
:product="currentProduct"
:draft="bookingDraft"
:travelers="travelers"
:available-dates="AVAILABLE_DATES"
:insurance="INSURANCE"
@back="goBack"
@update-draft="updateBookingDraft"
@open-travelers="openTravelerManager(true)"
@submit="submitBooking"
/>
<TravelerManager
v-else-if="currentView === TICKET_VIEWS.TRAVELERS"
:travelers="travelers"
:selected-ids="bookingDraft.travelerIds"
:selection-mode="travelerSelectionMode"
@back="goBack"
@toggle="toggleTraveler"
@save="saveTraveler"
/>
<PaymentResult
v-else-if="currentView === TICKET_VIEWS.PAID"
:order="currentOrder"
@home="returnHome"
@orders="navigate(TICKET_VIEWS.ORDERS)"
@detail="showCurrentOrderDetail"
@credentials="navigate(TICKET_VIEWS.CREDENTIALS)"
/>
<OrderList
v-else-if="currentView === TICKET_VIEWS.ORDERS"
:orders="orders"
@back="goBack"
@open="openOrder"
/>
<OrderDetail
v-else-if="currentView === TICKET_VIEWS.ORDER_DETAIL"
:order="currentOrder"
@back="goBack"
@credentials="navigate(TICKET_VIEWS.CREDENTIALS)"
@refund="openRefund"
/>
<RefundFlow
v-else-if="[TICKET_VIEWS.REFUND, TICKET_VIEWS.REFUND_RESULT].includes(currentView)"
:order="currentOrder"
:reasons="REFUND_REASONS"
:submitted-refund="currentView === TICKET_VIEWS.REFUND_RESULT ? submittedRefund : null"
@back="goBack"
@submit="submitRefund"
@detail="replaceWithOrderDetail"
@home="returnHome"
/>
<CredentialsView
v-else-if="currentView === TICKET_VIEWS.CREDENTIALS"
:order="currentOrder"
@back="goBack"
@detail="replaceWithOrderDetail"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import TicketHome from "./components/TicketHome.vue";
import ProductDetail from "./components/ProductDetail.vue";
import BookingFlow from "./components/BookingFlow.vue";
import TravelerManager from "./components/TravelerManager.vue";
import PaymentResult from "./components/PaymentResult.vue";
import OrderList from "./components/OrderList.vue";
import OrderDetail from "./components/OrderDetail.vue";
import RefundFlow from "./components/RefundFlow.vue";
import CredentialsView from "./components/CredentialsView.vue";
import {
AVAILABLE_DATES,
GROUPS,
INITIAL_ORDERS,
INITIAL_TRAVELERS,
INSURANCE,
PRODUCTS,
REFUND_REASONS,
SCENIC_SPOT,
} from "./data.js";
import {
TICKET_VIEWS,
applyRefund,
createBookingDraft,
createPaidOrder,
popViewHistory,
validateBooking,
} from "./model.mjs";
const clone = (value) => JSON.parse(JSON.stringify(value));
const currentView = ref(TICKET_VIEWS.HOME);
const viewHistory = ref([]);
const products = ref(clone(PRODUCTS));
const travelers = ref(clone(INITIAL_TRAVELERS));
const orders = ref(clone(INITIAL_ORDERS));
const refunds = ref([]);
const currentProductId = ref(PRODUCTS[0]?.id || "");
const currentOrderId = ref(INITIAL_ORDERS[0]?.id || "");
const bookingDraft = ref(createBookingDraft(PRODUCTS[0]));
const travelerSelectionMode = ref(false);
const submittedRefund = ref(null);
const visibleGroups = computed(() =>
[...GROUPS]
.filter((group) => group.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder)
);
const currentProduct = computed(() =>
products.value.find((product) => product.id === currentProductId.value) ||
products.value[0]
);
const currentOrder = computed(() =>
orders.value.find((order) => order.id === currentOrderId.value) ||
orders.value[0]
);
const navigate = (view) => {
viewHistory.value = [...viewHistory.value, currentView.value];
currentView.value = view;
};
const replaceView = (view) => {
currentView.value = view;
};
const goBack = () => {
if (!viewHistory.value.length) {
uni.navigateBack();
return;
}
const previous = popViewHistory(viewHistory.value);
viewHistory.value = previous.history;
currentView.value = previous.view;
};
const returnHome = () => {
currentView.value = TICKET_VIEWS.HOME;
viewHistory.value = [];
submittedRefund.value = null;
};
const openProductDetail = (productId) => {
currentProductId.value = productId;
navigate(TICKET_VIEWS.DETAIL);
};
const startBooking = (productId) => {
currentProductId.value = productId;
bookingDraft.value = createBookingDraft(currentProduct.value);
navigate(TICKET_VIEWS.BOOKING);
};
const updateBookingDraft = (draft) => {
bookingDraft.value = draft;
};
const openTravelerManager = (selectionMode) => {
travelerSelectionMode.value = selectionMode;
navigate(TICKET_VIEWS.TRAVELERS);
};
const toggleTraveler = (travelerId) => {
const selectedIds = bookingDraft.value.travelerIds || [];
bookingDraft.value = {
...bookingDraft.value,
travelerIds: selectedIds.includes(travelerId)
? selectedIds.filter((id) => id !== travelerId)
: [...selectedIds, travelerId],
};
};
const saveTraveler = (traveler) => {
travelers.value = [...travelers.value, traveler];
if (travelerSelectionMode.value) {
bookingDraft.value = {
...bookingDraft.value,
travelerIds: [...new Set([...bookingDraft.value.travelerIds, traveler.id])],
};
}
uni.showToast({ title: "出行人已保存", icon: "success" });
};
const submitBooking = () => {
const product = currentProduct.value;
const draft = bookingDraft.value;
const error = validateBooking({
product,
draft,
travelers: travelers.value,
});
if (error) {
uni.showToast({ title: error, icon: "none" });
return;
}
if (product.stock < draft.quantity) {
uni.showToast({ title: "当前库存不足,请减少购买数量", icon: "none" });
return;
}
const order = createPaidOrder({
product,
draft,
travelers: travelers.value,
});
orders.value = [order, ...orders.value];
products.value = products.value.map((item) =>
item.id === product.id
? {
...item,
stock: Math.max(0, item.stock - draft.quantity),
sold: item.sold + draft.quantity,
}
: item
);
currentOrderId.value = order.id;
navigate(TICKET_VIEWS.PAID);
};
const openOrder = (orderId) => {
currentOrderId.value = orderId;
navigate(TICKET_VIEWS.ORDER_DETAIL);
};
const showCurrentOrderDetail = () => {
navigate(TICKET_VIEWS.ORDER_DETAIL);
};
const replaceWithOrderDetail = () => {
replaceView(TICKET_VIEWS.ORDER_DETAIL);
};
const openRefund = () => {
submittedRefund.value = null;
navigate(TICKET_VIEWS.REFUND);
};
const submitRefund = ({ entitlementIds, reason }) => {
const result = applyRefund(
currentOrder.value,
entitlementIds,
reason
);
if (!result.refund) {
uni.showToast({ title: "所选权益暂不可退款", icon: "none" });
return;
}
orders.value = orders.value.map((order) =>
order.id === result.order.id ? result.order : order
);
refunds.value = [result.refund, ...refunds.value];
submittedRefund.value = result.refund;
navigate(TICKET_VIEWS.REFUND_RESULT);
};
</script>

View File

@@ -137,14 +137,20 @@
{
"root": "pages-service",
"pages": [
{
"path": "order/list",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"path": "order/list",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "tickets/index",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages-quick",
"pages": [