diff --git a/api/cashier.ts b/api/cashier.ts index dac605f..51af782 100644 --- a/api/cashier.ts +++ b/api/cashier.ts @@ -12,10 +12,76 @@ export interface CashierOrderPayload { house_id?: string; } -export const merchantConfig = (data: MerchantConfigQuery = {}) => get('v1/cashier/merchant', data); +export interface CashierOrderResult { + order_id: string; +} -export const createCashierOrder = (data: CashierOrderPayload) => post('v1/cashier/order/create', data); +export interface CashierPayResult { + timeStamp: string; + nonceStr: string; + package: string; + signType: string; + paySign: string; +} -export const cashierPay = (data: { order_id: string }) => post('v1/cashier/pay', data); +export interface BoundMerchantInfo { + merchant_id?: string; + merchant_name?: string; + config?: { + score_name?: string; + }; + merchant?: { + merchant_id?: string; + merchant_name?: string; + config?: { + score_name?: string; + }; + }; + supplier?: { + give_rate?: string | number; + }; + give_rate?: string | number; +} + +export interface SupplierInfo { + supplier_id?: string | number; + supplier_name?: string; + logo?: string; +} + +export interface ApiResult { + code: number | string; + msg?: string; + data?: T; +} + +export interface TradeQueryResult { + order_id?: number | string; + trade_no?: string; + pay_status?: number; + pay_type?: number; + total_money?: string; + reduce_money?: string; + real_money?: string; +} + +export const merchantConfig = (data: MerchantConfigQuery = {}) => get('v1/cashier/merchant', data); + +export const createCashierOrder = (data: CashierOrderPayload) => + post>('v1/cashier/order/create', data); + +export const cashierPay = (data: { order_id: string }) => post>('v1/cashier/pay', data); export const cashierRecordList = (data: any = {}) => get('v1/cashier/records', data); + +export const bindMerchantByUrl = (url: string) => + get>('user/v1/bing.merchant', { url }); + +export const supplierByUrl = (url: string) => + get>('user/v1/supplier', { url }); + +export const queryTrade = (tradeNo: string) => + get>('user/v1/trade/query', { trade_no: tradeNo }); + +export const cancelTrade = (tradeNo: string) => + get('user/v1/trade/cancel', { trade_no: tradeNo }); diff --git a/config/index.ts b/config/index.ts index b125520..bc2c809 100644 --- a/config/index.ts +++ b/config/index.ts @@ -10,6 +10,6 @@ export const APP_CONFIG = { export const DEFAULT_MERCHANT = { id: '', name: '上海大观园', - background: '/static/cashier/merchant-bg.png', - accumulationRate: 0.04 + background: 'https://dev.api.leapy.cn/upload/202606/26/e3ce560c144a106fa6e41af2118bc208.jpg', + accumulationRate: 0 } as const; diff --git a/mock/pay-checkout.ts b/mock/pay-checkout.ts new file mode 100644 index 0000000..0964f55 --- /dev/null +++ b/mock/pay-checkout.ts @@ -0,0 +1,47 @@ +export interface PayCheckoutOrder { + orderId: string; + tradeNo: string; + amount: string; + merchantName: string; + orderDesc: string; + remark: string; + expireAt: number; + payChannel: 'wxpay'; +} + +// Static mock for embedded checkout page; replace with API later. +export const MOCK_PAY_CHECKOUT_ORDER: PayCheckoutOrder = { + orderId: 'mock-order-20260702001', + tradeNo: 'GSP202607021430001234', + amount: '1.25', + merchantName: '江南米粉', + orderDesc: '消费者付款', + remark: '', + expireAt: Date.now() + 5 * 60 * 1000, + payChannel: 'wxpay' +}; + +export const buildMockOrderFromOptions = (options: Record = {}): PayCheckoutOrder => { + const amount = options.amount || MOCK_PAY_CHECKOUT_ORDER.amount; + const expireSeconds = Number(options.expire_seconds || 300); + + return { + orderId: options.order_id || options.orderId || MOCK_PAY_CHECKOUT_ORDER.orderId, + tradeNo: options.trade_no || options.tradeNo || MOCK_PAY_CHECKOUT_ORDER.tradeNo, + amount, + merchantName: decodeQuery(options.merchant_name || options.merchantName) || MOCK_PAY_CHECKOUT_ORDER.merchantName, + orderDesc: decodeQuery(options.order_desc || options.orderDesc) || MOCK_PAY_CHECKOUT_ORDER.orderDesc, + remark: decodeQuery(options.remark) || '', + expireAt: Date.now() + (Number.isFinite(expireSeconds) ? expireSeconds : 300) * 1000, + payChannel: 'wxpay' + }; +}; + +const decodeQuery = (value?: string) => { + if (!value) return ''; + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } +}; diff --git a/pages.json b/pages.json index b7a2d58..d3c12da 100644 --- a/pages.json +++ b/pages.json @@ -8,6 +8,24 @@ "navigationBarTitleText": "卓享汇收银台" } }, + { + "path": "pages/cashier/result", + "style": { + "enablePullDownRefresh": false, + "navigationBarTextStyle": "black", + "navigationBarTitleText": "支付结果" + } + }, + { + "path": "pages/pay/checkout", + "style": { + "enablePullDownRefresh": false, + "navigationBarTextStyle": "black", + "navigationBarTitleText": "确认支付", + "navigationBarBackgroundColor": "#F3F4F6", + "backgroundColor": "#F3F4F6" + } + }, { "path": "pages/records/index", "style": { diff --git a/pages/cashier/index.vue b/pages/cashier/index.vue index 3249b60..2d670fd 100644 --- a/pages/cashier/index.vue +++ b/pages/cashier/index.vue @@ -1,60 +1,72 @@ diff --git a/pages/cashier/result.vue b/pages/cashier/result.vue new file mode 100644 index 0000000..c86bd4d --- /dev/null +++ b/pages/cashier/result.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/pages/pay/checkout.vue b/pages/pay/checkout.vue new file mode 100644 index 0000000..f0fc43c --- /dev/null +++ b/pages/pay/checkout.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/static/cashier/bg-image.png b/static/cashier/bg-image.png new file mode 100644 index 0000000..7f8e638 Binary files /dev/null and b/static/cashier/bg-image.png differ diff --git a/unocss/a-hua-unocss/index.scss b/unocss/a-hua-unocss/index.scss index fd40910..9e5f5f8 100644 --- a/unocss/a-hua-unocss/index.scss +++ b/unocss/a-hua-unocss/index.scss @@ -2,3 +2,6 @@ + + + diff --git a/utils/merchant-context.ts b/utils/merchant-context.ts new file mode 100644 index 0000000..67e5bcf --- /dev/null +++ b/utils/merchant-context.ts @@ -0,0 +1,74 @@ +import CryptoJS from 'crypto-js'; +import { encryptedStorage } from '@/utils/storage'; + +export const ACTIVE_QR_URL_CACHE_KEY = 'cashier:property:active-url'; +export const ACTIVE_QR_HASH_CACHE_KEY = 'cashier:property:active-url-hash'; + +const decodeOption = (value?: string) => { + if (!value) return ''; + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } +}; + +export const resolveMerchantUrlFromOptions = (options: Record) => { + return decodeOption(options.url || options.qr_url || ''); +}; + +const readQueryParam = (raw: string, key: string) => { + const match = raw.match(new RegExp(`[?&]${key}=([^&]+)`)); + return match ? decodeOption(match[1]) : ''; +}; + +export const resolveMerchantUrlFromScanResult = (raw: string) => { + const text = (raw || '').trim(); + if (!text) return ''; + + if (/^https?:\/\//i.test(text)) { + return text; + } + + const fromUrl = readQueryParam(text, 'url') || readQueryParam(text, 'qr_url'); + if (fromUrl) return fromUrl; + + const fromQ = readQueryParam(text, 'q'); + if (fromQ) return resolveMerchantUrlFromScanResult(fromQ); + + return ''; +}; + +export const getMerchantUrlHash = (merchantUrl: string) => { + return CryptoJS.SHA256(merchantUrl).toString(); +}; + +export const getPropertyBindingCacheKeyByHash = (hash: string) => { + // Hash key keeps cache key short/stable and avoids raw URL special characters. + return `cashier:property:binding:${hash}`; +}; + +export const getSupplierCacheKeyByHash = (hash: string) => { + return `cashier:supplier:detail:${hash}`; +}; + +export const getPropertyBindingCacheKey = (merchantUrl: string) => { + return getPropertyBindingCacheKeyByHash(getMerchantUrlHash(merchantUrl)); +}; + +export const getSupplierCacheKey = (merchantUrl: string) => { + return getSupplierCacheKeyByHash(getMerchantUrlHash(merchantUrl)); +}; + +export const syncActiveMerchantContext = (merchantUrl: string) => { + const currentHash = getMerchantUrlHash(merchantUrl); + const previousHash = encryptedStorage.get(ACTIVE_QR_HASH_CACHE_KEY) || ''; + encryptedStorage.set(ACTIVE_QR_URL_CACHE_KEY, merchantUrl); + encryptedStorage.set(ACTIVE_QR_HASH_CACHE_KEY, currentHash); + + return { + currentHash, + previousHash, + switched: Boolean(previousHash && previousHash !== currentHash) + }; +}; diff --git a/utils/request.ts b/utils/request.ts index beca749..cac94c4 100644 --- a/utils/request.ts +++ b/utils/request.ts @@ -36,7 +36,11 @@ const buildUrl = (endpoint: string) => { const isAuthExpired = (statusCode: number, body: any) => { const businessCode = Number(body?.code); - return statusCode === 401 || statusCode === 402 || businessCode === 401 || businessCode === 402; + const message = String(body?.msg || '').replace(/\s+/g, ''); + const hasAuthWord = /登录|token|auth|鉴权/i.test(message); + const hasExpiredWord = /过期|失效|无效|未登录/.test(message); + const isLegacyExpired500 = businessCode === 500 && (hasAuthWord && hasExpiredWord); + return statusCode === 401 || statusCode === 402 || businessCode === 401 || businessCode === 402 || isLegacyExpired500; }; const requestOnce = async (url: string, options: RequestOptions, retried: boolean): Promise => { @@ -57,6 +61,10 @@ const requestOnce = async (url: string, options: RequestOptions, retried: boo header: headers }); + const body: any = response.data; + const businessCode = Number(body?.code); + const businessMsg = String(body?.msg || ''); + if (isAuthExpired(response.statusCode, response.data)) { if (!options.skipAutoLogin && !options.skipAuth && !retried) { authStorage.clearToken(); @@ -71,8 +79,8 @@ const requestOnce = async (url: string, options: RequestOptions, retried: boo throw new RequestError(`HTTP 请求失败:${response.statusCode}`, response.statusCode, response.data); } - if (Number((response.data as any)?.code) === 500) { - throw new RequestError((response.data as any)?.msg || '服务器处理失败', response.statusCode, response.data); + if (businessCode === 500) { + throw new RequestError(businessMsg || '服务器处理失败', response.statusCode, response.data); } return response.data; diff --git a/utils/storage.ts b/utils/storage.ts index 1292b2a..ac13fc0 100644 --- a/utils/storage.ts +++ b/utils/storage.ts @@ -6,15 +6,32 @@ interface StorageEnvelope { expiresAt: number; } +const AES_KEY = CryptoJS.enc.Hex.parse(CryptoJS.SHA256(APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString()); +const AES_IV = CryptoJS.enc.Hex.parse(CryptoJS.MD5(APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString()); + const encrypt = (value: unknown) => { - return CryptoJS.AES.encrypt(JSON.stringify(value), APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString(); + const payload = CryptoJS.enc.Utf8.parse(JSON.stringify(value)); + return CryptoJS.AES.encrypt(payload, AES_KEY, { + iv: AES_IV, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7 + }).toString(); }; const decrypt = (value: string): T | null => { try { - const decrypted = CryptoJS.AES.decrypt(value, APP_CONFIG.STORAGE_ENCRYPTION_KEY) - .toString(CryptoJS.enc.Utf8); - return decrypted ? JSON.parse(decrypted) as T : null; + const decrypted = CryptoJS.AES.decrypt(value, AES_KEY, { + iv: AES_IV, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7 + }).toString(CryptoJS.enc.Utf8); + if (decrypted) { + return JSON.parse(decrypted) as T; + } + + // Backward compatibility for old passphrase-based ciphertexts. + const legacy = CryptoJS.AES.decrypt(value, APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString(CryptoJS.enc.Utf8); + return legacy ? JSON.parse(legacy) as T : null; } catch (error) { return null; } @@ -52,16 +69,26 @@ export const encryptedStorage = { } }; +let cachedTokenLogged = false; + export const authStorage = { getToken() { - return encryptedStorage.get(APP_CONFIG.STORAGE_TOKEN_KEY) || ''; + const token = encryptedStorage.get(APP_CONFIG.STORAGE_TOKEN_KEY) || ''; + if (token && !cachedTokenLogged) { + cachedTokenLogged = true; + console.log('[cashier] token:', token); + } + return token; }, setToken(token: string, expiresInSeconds = 0) { encryptedStorage.set(APP_CONFIG.STORAGE_TOKEN_KEY, token, expiresInSeconds); + cachedTokenLogged = true; + console.log('[cashier] token:', token); }, clearToken() { encryptedStorage.remove(APP_CONFIG.STORAGE_TOKEN_KEY); + cachedTokenLogged = false; } }; diff --git a/utils/wxpay.ts b/utils/wxpay.ts new file mode 100644 index 0000000..fc2d733 --- /dev/null +++ b/utils/wxpay.ts @@ -0,0 +1,27 @@ +export interface WxPayParams { + timeStamp: string; + nonceStr: string; + package: string; + signType: string; + paySign: string; +} + +export const requestWxPayment = (params: WxPayParams) => { + return new Promise((resolve, reject) => { + uni.requestPayment({ + provider: 'wxpay', + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign, + success: () => resolve(), + fail: (error) => reject(error) + }); + }); +}; + +export const isPaymentCancelled = (error: unknown) => { + const message = String((error as any)?.errMsg || ''); + return message.includes('cancel'); +};