feat(M08-A): 接入顾客端选店下单入口

This commit is contained in:
Codex
2026-06-24 21:26:32 +08:00
parent c90f6e34a1
commit 4ac2c960e4
20 changed files with 734 additions and 32 deletions
-9
View File
@@ -1,17 +1,8 @@
App({
onLaunch() {
// 记录本地启动时间,便于开发调试。
const logs = wx.getStorageSync('logs') || []
logs.unshift(Date.now())
wx.setStorageSync('logs', logs)
// 获取临时登录凭证;正式换取会话在 M08 接入。
wx.login({
success: res => {
// 后续将 res.code 发送到 /app-api/auth/wechat-login。
void res
}
})
},
globalData: {
userInfo: null
+6
View File
@@ -59,6 +59,12 @@ Page({
}
},
openStore(event) {
const storeId = event.currentTarget.dataset.storeId
if (!storeId) return
wx.navigateTo({ url: `/pages/store/detail?storeId=${encodeURIComponent(storeId)}` })
},
async resolveScene(code, sourceType) {
this.setData({ loading: true, errorMessage: '' })
try {
+2 -1
View File
@@ -8,13 +8,14 @@
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{!loading && stores.length === 0}}" class="empty">暂无门店</view>
<view wx:for="{{stores}}" wx:key="id" class="store-card">
<view wx:for="{{stores}}" wx:key="id" class="store-card" bindtap="openStore" data-store-id="{{item.id}}">
<view class="store-name">{{item.name}}</view>
<view>{{item.city}}{{item.district}} {{item.address}}</view>
<view wx:if="{{item.distanceMeters !== null}}">距离 {{item.distanceMeters}} 米</view>
<view class="{{item.openNow ? 'open' : 'closed'}}">
{{item.openNow ? '营业中' : '休息中'}}
</view>
<view class="card-action">查看房间</view>
</view>
</view>
</scroll-view>
+10
View File
@@ -36,6 +36,10 @@
line-height: 1.7;
}
.store-card:active {
background: #eef4ff;
}
.store-name {
font-size: 34rpx;
font-weight: 600;
@@ -55,3 +59,9 @@
color: #888888;
text-align: center;
}
.card-action {
margin-top: 16rpx;
color: #1f6feb;
font-weight: 600;
}
+125
View File
@@ -1,7 +1,32 @@
const {
request,
ensureLogin,
cents,
clientRequestId,
} = require('../../utils/api.js')
function defaultStartAt() {
const date = new Date(Date.now() + 30 * 60 * 1000)
date.setSeconds(0, 0)
return date
}
function isoMinutesAfter(date, minutes) {
return new Date(date.getTime() + minutes * 60 * 1000).toISOString()
}
Page({
data: {
storeId: '',
roomId: '',
loading: false,
errorMessage: '',
successMessage: '',
durationMinutes: 120,
pricingMode: 'HOURLY',
quote: null,
order: null,
payment: null,
},
onLoad(options) {
this.setData({
@@ -9,4 +34,104 @@ Page({
roomId: options.roomId || '',
})
},
setDuration(event) {
this.setData({
durationMinutes: Number(event.currentTarget.dataset.minutes),
quote: null,
order: null,
payment: null,
errorMessage: '',
successMessage: '',
})
},
async quote() {
await this.withRequest(async () => {
const payload = this.pricingPayload()
const response = await request('/pricing/quote', { method: 'POST', data: payload })
this.setData({
quote: {
...response.data,
totalText: cents(response.data.totalCents),
depositText: cents(response.data.depositCents),
},
successMessage: '报价已生成',
})
})
},
async reserve() {
await this.withRequest(async () => {
const payload = this.pricingPayload()
const response = await request('/orders/reserve', { method: 'POST', data: payload })
this.setData({
order: response.data,
successMessage: '时段已预占,请继续支付',
})
})
},
async createTestPayment() {
if (!this.data.order || !this.data.order.orderId) {
this.setData({ errorMessage: '请先预占订单' })
return
}
await this.withRequest(async () => {
const response = await request('/payments', {
method: 'POST',
data: {
orderId: this.data.order.orderId,
provider: 'TEST',
clientRequestId: clientRequestId('miniapp-pay'),
},
})
this.setData({
payment: {
...response.data,
amountText: cents(response.data.amountCents),
},
successMessage: '测试支付单已创建',
})
})
},
async completeTestPayment() {
if (!this.data.payment || !this.data.payment.paymentId) {
this.setData({ errorMessage: '请先创建测试支付单' })
return
}
await this.withRequest(async () => {
await request(`/payments/${encodeURIComponent(this.data.payment.paymentId)}/test-complete`, {
method: 'POST',
data: {
callbackId: clientRequestId('miniapp-callback'),
amountCents: this.data.payment.amountCents,
},
})
this.setData({ successMessage: '测试支付已完成,生产环境将切换微信支付' })
})
},
pricingPayload() {
const start = defaultStartAt()
return {
roomId: this.data.roomId,
startAt: start.toISOString(),
endAt: isoMinutesAfter(start, this.data.durationMinutes),
pricingMode: this.data.pricingMode,
}
},
async withRequest(work) {
this.setData({ loading: true, errorMessage: '', successMessage: '' })
try {
await ensureLogin()
await work()
} catch (error) {
this.setData({ errorMessage: error.message || '操作失败' })
} finally {
this.setData({ loading: false })
}
},
})
+37 -1
View File
@@ -2,5 +2,41 @@
<view class="title">房间详情</view>
<view>门店编号:{{storeId}}</view>
<view>房间编号:{{roomId}}</view>
<view class="notice">扫码或 NFC 只打开此页面,开门权限仍由有效订单单独校验。</view>
<view class="section-title">选择时长</view>
<view class="duration-row">
<button size="mini" class="{{durationMinutes === 60 ? 'selected' : ''}}" data-minutes="60" bindtap="setDuration">1小时</button>
<button size="mini" class="{{durationMinutes === 120 ? 'selected' : ''}}" data-minutes="120" bindtap="setDuration">2小时</button>
<button size="mini" class="{{durationMinutes === 180 ? 'selected' : ''}}" data-minutes="180" bindtap="setDuration">3小时</button>
</view>
<view class="action-grid">
<button loading="{{loading}}" bindtap="quote">报价</button>
<button loading="{{loading}}" bindtap="reserve">预占</button>
<button loading="{{loading}}" bindtap="createTestPayment">测试支付</button>
<button loading="{{loading}}" bindtap="completeTestPayment">完成测试支付</button>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{successMessage}}" class="success">{{successMessage}}</view>
<view wx:if="{{quote}}" class="result-card">
<view class="result-title">报价</view>
<view>总价:{{quote.totalText}}</view>
<view>押金:{{quote.depositText}}</view>
</view>
<view wx:if="{{order}}" class="result-card">
<view class="result-title">订单</view>
<view>订单编号:{{order.orderId}}</view>
<view wx:if="{{order.reservationId}}">预占编号:{{order.reservationId}}</view>
</view>
<view wx:if="{{payment}}" class="result-card">
<view class="result-title">支付</view>
<view>支付编号:{{payment.paymentId}}</view>
<view>金额:{{payment.amountText}}</view>
<view>状态:{{payment.status}}</view>
</view>
<view class="notice">扫码或 NFC 只打开此页面,开门权限仍由有效订单单独校验;生产支付以微信预支付为准。</view>
</view>
+48
View File
@@ -1,5 +1,7 @@
.page {
padding: 32rpx;
min-height: 100vh;
background: #f5f6f8;
}
.title {
@@ -12,3 +14,49 @@
margin-top: 32rpx;
color: #777777;
}
.section-title {
margin: 32rpx 0 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.duration-row,
.action-grid {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
margin-bottom: 24rpx;
}
.action-grid button {
flex: 1 1 280rpx;
}
.selected {
color: #ffffff;
background: #1f6feb;
}
.result-card {
margin-top: 20rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
line-height: 1.7;
}
.result-title {
font-size: 32rpx;
font-weight: 600;
}
.success {
margin: 16rpx 0;
color: #17823b;
}
.error {
margin: 16rpx 0;
color: #c73535;
}
+52
View File
@@ -1,8 +1,60 @@
const { request, ensureLogin, cents } = require('../../utils/api.js')
Page({
data: {
storeId: '',
loading: false,
errorMessage: '',
store: null,
rooms: [],
wifi: null,
},
onLoad(options) {
this.setData({ storeId: options.storeId || '' })
if (options.storeId) this.loadStore(options.storeId)
},
async loadStore(storeId) {
this.setData({ loading: true, errorMessage: '' })
try {
const [storeResponse, roomsResponse] = await Promise.all([
request(`/stores/${encodeURIComponent(storeId)}`),
request(`/stores/${encodeURIComponent(storeId)}/rooms`),
])
this.setData({
store: storeResponse.data,
rooms: (roomsResponse.data || []).map((room) => ({
...room,
basePriceText: cents(room.basePriceCents),
depositText: cents(room.depositCents),
})),
})
} catch (error) {
this.setData({ errorMessage: error.message || '门店加载失败' })
} finally {
this.setData({ loading: false })
}
},
openRoom(event) {
const roomId = event.currentTarget.dataset.roomId
if (!roomId || !this.data.storeId) return
wx.navigateTo({
url: `/pages/room/detail?storeId=${encodeURIComponent(this.data.storeId)}&roomId=${encodeURIComponent(roomId)}`,
})
},
async loadWifi() {
if (!this.data.storeId) return
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const response = await request(`/stores/${encodeURIComponent(this.data.storeId)}/wifi`)
this.setData({ wifi: response.data })
} catch (error) {
this.setData({ errorMessage: error.message || '请登录并确认到店权限后查看 Wi-Fi' })
} finally {
this.setData({ loading: false })
}
},
})
+28 -2
View File
@@ -1,5 +1,31 @@
<view class="page">
<view class="title">门店详情</view>
<view>门店编号:{{storeId}}</view>
<view wx:if="{{store}}" class="store-head">
<view class="title">{{store.name}}</view>
<view class="muted">{{store.city}}{{store.district}} {{store.address}}</view>
<view class="{{store.openNow ? 'open' : 'closed'}}">{{store.openNow ? '营业中' : '休息中'}}</view>
<view wx:if="{{store.contactPhone}}" class="muted">电话:{{store.contactPhone}}</view>
</view>
<view wx:else class="title">门店详情</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view class="section-title">房间</view>
<view wx:if="{{!loading && rooms.length === 0}}" class="empty">暂无可预订房间</view>
<view wx:for="{{rooms}}" wx:key="id" class="room-card" bindtap="openRoom" data-room-id="{{item.id}}">
<view class="room-name">{{item.name}}</view>
<view class="muted">{{item.categoryName}} · {{item.capacity}}人 · {{item.roomNo}}</view>
<view class="price">{{item.basePriceText}}/小时</view>
<view class="muted">押金 {{item.depositText}} · 最少 {{item.minimumMinutes}} 分钟</view>
<view class="{{item.operationalStatus === 'AVAILABLE' ? 'open' : 'closed'}}">
{{item.operationalStatus === 'AVAILABLE' ? '可预订' : '暂不可订'}}
</view>
</view>
<button loading="{{loading}}" bindtap="loadWifi">查看到店 Wi-Fi</button>
<view wx:if="{{wifi}}" class="wifi-box">
<view>SSID{{wifi.ssid}}</view>
<view>密码:{{wifi.password}}</view>
</view>
<view class="notice">场景码仅用于页面导航,不授予开门或设备控制权限。</view>
</view>
+55
View File
@@ -1,5 +1,7 @@
.page {
padding: 32rpx;
min-height: 100vh;
background: #f5f6f8;
}
.title {
@@ -12,3 +14,56 @@
margin-top: 32rpx;
color: #777777;
}
.store-head,
.room-card,
.wifi-box {
margin-bottom: 24rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
}
.room-card:active {
background: #eef4ff;
}
.section-title {
margin: 32rpx 0 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.room-name {
font-size: 34rpx;
font-weight: 600;
}
.muted {
margin-top: 8rpx;
color: #667085;
}
.price {
margin-top: 12rpx;
color: #b42318;
font-size: 34rpx;
font-weight: 700;
}
.open {
margin-top: 8rpx;
color: #17823b;
}
.closed,
.error {
margin-top: 8rpx;
color: #c73535;
}
.empty {
padding: 64rpx 0;
color: #888888;
text-align: center;
}
+50 -1
View File
@@ -1,13 +1,17 @@
const { APP_API_BASE_URL, WECHAT_APP_ID } = require('../config/env.js')
const TOKEN_KEY = 'qipai_access_token'
function request(path, options = {}) {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync(TOKEN_KEY)
wx.request({
url: `${APP_API_BASE_URL}${path}`,
method: options.method || 'GET',
data: options.data,
header: {
'x-wechat-appid': WECHAT_APP_ID,
...(token ? { authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
success(response) {
@@ -22,4 +26,49 @@ function request(path, options = {}) {
})
}
module.exports = { request }
function login() {
return new Promise((resolve, reject) => {
wx.login({
success: async ({ code }) => {
try {
const response = await request('/auth/wechat-login', {
method: 'POST',
data: { code },
})
wx.setStorageSync(TOKEN_KEY, response.data.accessToken)
resolve(response.data)
} catch (error) {
reject(error)
}
},
fail: reject,
})
})
}
function ensureLogin() {
const token = wx.getStorageSync(TOKEN_KEY)
if (token) return Promise.resolve({ accessToken: token })
return login()
}
function clearSession() {
wx.removeStorageSync(TOKEN_KEY)
}
function cents(value) {
return `${((Number(value || 0)) / 100).toFixed(2)}`
}
function clientRequestId(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
module.exports = {
request,
login,
ensureLogin,
clearSession,
cents,
clientRequestId,
}