From 23587418c123443cc58f547a59c7c76c9bcc0304 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 12:37:01 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E9=97=A8=E5=BA=97=E7=AB=AF=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=9B=B8=E6=9C=BA=E5=8A=9F=E8=83=BD=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-shop/src/pages/HomePage.tsx | 16 +++-- packages/weixin-sdk/src/index.ts | 2 +- packages/weixin-sdk/src/scan.ts | 92 ++++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index a09ca35..66a2fab 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -27,7 +27,7 @@ function formatScanError(e: unknown): string { return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; } if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) { - return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`; + return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; } return msg; } @@ -73,7 +73,7 @@ export default function HomePage() { }; }, [loadDashboard]); - async function runScan() { + async function runScan(opts?: { postAuthWarmup?: boolean }) { if (!isWechatEnv()) { setScanMsg('请在微信内打开门店端进行扫码核销'); return; @@ -81,8 +81,14 @@ export default function HomePage() { setScanning(true); setScanMsg(''); try { + // 授权回跳后强制重签,避免沿用带 code 的旧签名态 + if (opts?.postAuthWarmup) { + weixinSdk.reset(); + } await weixinSdk.init(); - const raw = await weixinSdk.scanQrCode(); + const raw = await weixinSdk.scanQrCode( + opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, + ); if (!raw) { void loadDashboard(); return; @@ -111,12 +117,14 @@ export default function HomePage() { } setAuthModalOpen(false); setAuthError(''); + // 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致 stripOAuthParamsFromLocation(); setSearchParams({}, { replace: true }); const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; sessionStorage.removeItem(PENDING_SCAN_KEY); if (shouldScan) { - window.setTimeout(() => void runScan(), 0); + // OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次 + window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400); } }) .catch((e) => { diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 0dcbd2d..eeffa6a 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -70,7 +70,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) { getPhoneNumber: () => getWechatPhoneNumber(config), getLocation: () => getWechatLocation(config), getLocationDetailed: () => getWechatLocationDetailed(config), - scanQrCode: () => scanQrCode(config), + scanQrCode: (options?: Parameters[1]) => scanQrCode(config, options), chooseImages: (options?: Parameters[1]) => chooseWechatImages(config, options), pay: (prepay: Parameters[0]) => diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts index e14c6c3..026c0ad 100644 --- a/packages/weixin-sdk/src/scan.ts +++ b/packages/weixin-sdk/src/scan.ts @@ -1,5 +1,10 @@ import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env'; -import { ensureJssdkReady } from './jssdk'; +import { + ensureJssdkReady, + getJssdkSignUrl, + initWechatJssdk, + resetJssdkConfig, +} from './jssdk'; import type { WeixinSdkConfig } from './types'; function delay(ms: number): Promise { @@ -11,7 +16,7 @@ export function formatScanFailMessage(errMsg: string): string { const msg = errMsg.trim() || '扫码失败'; if (/cancel/i.test(msg)) return ''; - // iOS 常见:JSSDK 权限离线校验尚未完成,并非系统相机权限 + // iOS / 授权回调后常见:JSSDK 权限离线校验尚未完成,并非系统相机权限 if (/offline verifying|permission value is offline/i.test(msg)) { return '微信权限验证中,请稍候再试或刷新页面后重新扫码'; } @@ -33,6 +38,47 @@ export function formatScanFailMessage(errMsg: string): string { return msg; } +function isScanPermissionWarmupError(msg: string): boolean { + return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test( + msg, + ); +} + +function checkJsApi(apiName: string): Promise { + return new Promise((resolve) => { + if (!window.wx?.checkJsApi) { + resolve(false); + return; + } + window.wx.checkJsApi({ + jsApiList: [apiName], + success: (res) => resolve(!!res.checkResult?.[apiName]), + fail: () => resolve(false), + }); + }); +} + +async function ensureScanJssdk(config: WeixinSdkConfig): Promise { + const jssdkOptions = { + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + jsApiList: ['scanQRCode', 'checkJsApi'], + }; + + await ensureJssdkReady(jssdkOptions); + + let ready = await checkJsApi('scanQRCode'); + if (!ready) { + resetJssdkConfig(); + await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() }); + ready = await checkJsApi('scanQRCode'); + } + if (!ready) { + throw new Error('微信扫码接口未授权,请刷新页面后重试'); + } +} + function invokeScanQrCode(): Promise { return new Promise((resolve, reject) => { window.wx!.scanQRCode!({ @@ -52,8 +98,16 @@ function invokeScanQrCode(): Promise { }); } -/** 调起扫码(返回二维码/条码内容,失败返回 null) */ -export async function scanQrCode(config: WeixinSdkConfig): Promise { +export type ScanQrCodeOptions = { + /** OAuth / 首次绑定后权限离线校验更慢,加长预热 */ + postAuthWarmup?: boolean; +}; + +/** 调起扫码(返回二维码/条码内容,取消返回 null) */ +export async function scanQrCode( + config: WeixinSdkConfig, + options: ScanQrCodeOptions = {}, +): Promise { const platform = getRuntimePlatform(); if (platform === 'mini' && window.wx?.scanCode) { @@ -68,32 +122,42 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise 0) { - await delay(800); + if (lastError && isScanPermissionWarmupError(lastError.message)) { + resetJssdkConfig(); + await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() }); + await checkJsApi('scanQRCode'); + } + await delay(700 + attempt * 500); } try { return await invokeScanQrCode(); } catch (e) { lastError = e instanceof Error ? e : new Error('扫码失败'); - const raw = lastError.message; - const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw); + const retryable = isScanPermissionWarmupError(lastError.message); if (attempt < maxAttempts - 1 && retryable) continue; throw lastError; } From 9f672214570379d42dae8dc36a2f75ef9d0dedd7 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 12:53:39 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=95=86=E5=93=81=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/pages/home/index.tsx | 22 ++++++----- .../src/pages/product-detail/index.tsx | 37 ++++++++++++++++--- .../common/guards/optional-jwt-auth.guard.ts | 11 +++++- .../src/modules/catalog/catalog.service.ts | 8 +--- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index dd9c7e5..a4ef76d 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -110,16 +110,6 @@ export default function HomePage() { }); }, []); - useDidShow(() => { - syncTabBarSelected(0); - void capturePromoSceneAndTouchScan(); - void loadMiniHome(); - void resolveUserCity().then((resolved) => { - setDisplayCity(resolved.displayCity); - setCityCode(getCityCodeForCatalog(resolved)); - }); - }); - const loadProducts = useCallback(() => { setLoading(true); return request(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) @@ -130,6 +120,18 @@ export default function HomePage() { .finally(() => setLoading(false)); }, [cityCode]); + useDidShow(() => { + syncTabBarSelected(0); + void capturePromoSceneAndTouchScan(); + void loadMiniHome(); + // 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表 + void loadProducts(); + void resolveUserCity().then((resolved) => { + setDisplayCity(resolved.displayCity); + setCityCode(getCityCodeForCatalog(resolved)); + }); + }); + useEffect(() => { void loadProducts(); }, [loadProducts]); diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 0d58126..a480ee7 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -1,6 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; -import Taro, { usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; +import Taro, { + useDidShow, + usePageScroll, + useRouter, + useShareAppMessage, + useShareTimeline, +} from '@tarojs/taro'; import type { ProductDetailContentDto } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import PageNavBar from '../../components/PageNavBar'; @@ -51,13 +57,32 @@ export default function ProductDetailPage() { setHeaderSolid(scrollTop > 100); }); - useEffect(() => { + const loadProduct = useCallback(() => { if (!productId) return; - request(`/catalog/products/${productId}`) - .then((p) => setProduct(normalizeFulfillmentFlags(p))) - .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); + request(`/catalog/products/${productId}`) + .then((p) => { + if (!p) { + setProduct(null); + toast('商品不存在或暂未开放'); + return; + } + setProduct(normalizeFulfillmentFlags(p)); + }) + .catch((e) => { + setProduct(null); + toast(e instanceof Error ? e.message : '加载失败'); + }); }, [productId]); + useEffect(() => { + loadProduct(); + }, [loadProduct]); + + // 登录后返回详情须带 token 重拉,否则白名单商品会一直空白 + useDidShow(() => { + loadProduct(); + }); + const sharePayload = useMemo( () => ({ title: product?.name || DEFAULT_SHARE_TITLE, diff --git a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts index 1eb5103..2beb65a 100644 --- a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts +++ b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts @@ -7,6 +7,15 @@ import { JwtService } from '@nestjs/jwt'; import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types'; import type { AuthUser } from './jwt-auth.guard'; +/** C 端 H5 / 小程序同属 USER,目录白名单只认 actorId→手机号,允许端间 token 兼容 */ +const USER_CLIENT_APPS = new Set([ClientApp.USER_MINI, ClientApp.USER_H5]); + +function clientAppCompatible(headerApp: ClientApp | undefined, payloadApp: unknown): boolean { + if (!headerApp || payloadApp == null) return false; + if (headerApp === payloadApp) return true; + return USER_CLIENT_APPS.has(headerApp) && USER_CLIENT_APPS.has(payloadApp as ClientApp); +} + @Injectable() export class OptionalJwtAuthGuard implements CanActivate { constructor(private readonly jwtService: JwtService) {} @@ -20,7 +29,7 @@ export class OptionalJwtAuthGuard implements CanActivate { try { const payload = this.jwtService.verify(auth.slice(7)); const clientApp = req.headers['x-client-app'] as ClientApp; - if (!clientApp || payload.clientApp !== clientApp) return true; + if (!clientAppCompatible(clientApp, payload.clientApp)) return true; const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp]; if (payload.actorType !== expectedActor) return true; req.user = { diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index 2430397..c64b9c7 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -81,11 +81,9 @@ export class CatalogService { return serializeBigInt( visible.map((p) => { const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []); - const { visibilityPhones, ...rest } = p; + const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p; return { ...rest, - visibilityWhitelistEnabled: p.visibilityWhitelistEnabled, - visibilityPhones: visibilityPhones.map((row) => row.phone), benefitAmount: p.benefitAmount ?? p.price, price: Number(p.price), benefitDisplay: Number(p.benefitAmount ?? p.price), @@ -119,11 +117,9 @@ export class CatalogService { }); const media = mapProductMedia(product, resources); - const { visibilityPhones, ...rest } = product; + const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product; return serializeBigInt({ ...rest, - visibilityWhitelistEnabled: product.visibilityWhitelistEnabled, - visibilityPhones: visibilityPhones.map((row) => row.phone), benefitAmount: product.benefitAmount ?? product.price, price: Number(product.price), ...media, From 87c4af7364847644ff6b8a2f331b8661236ced97 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 13:09:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E7=94=B5=E8=AF=9D=20?= =?UTF-8?q?=E2=86=92=2013203801799=EF=BC=88CUSTOMER=5FSERVICE=5FPHONE?= =?UTF-8?q?=EF=BC=8C=E5=8D=8F=E8=AE=AE=E6=96=87=E6=A1=88=E8=B5=B0=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=EF=BC=89=20?= =?UTF-8?q?=E6=9D=83=E7=9B=8A=E9=A1=B5=E5=8E=BB=E6=8E=89=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=8C=E8=8F=9C=E5=8D=95=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20=E6=94=AF=E4=BB=98=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E6=94=B9=20reLaunch=20=E6=B8=85=E6=A0=88=EF=BC=9B=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=20=E5=87=8F=E6=95=B0=E9=87=8F=E4=BD=8E=E4=BA=8E=E8=B5=B7?= =?UTF-8?q?=E8=B4=AD=E6=97=B6=20toast=E3=80=8C=E5=90=8C=E5=9F=8E/=E8=B7=A8?= =?UTF-8?q?=E5=9F=8E/=E7=8E=B0=E5=9C=BA=E6=8F=90=E8=B4=A7=E8=87=B3?= =?UTF-8?q?=E5=B0=91=E8=B4=AD=E4=B9=B0=20N=20=E7=93=B6=E3=80=8D=20?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E6=88=90=E5=8A=9F=E5=8F=AF=E8=AF=84=E6=98=9F?= =?UTF-8?q?=E5=B9=B6=20POST=20/redeem/ratings=EF=BC=9BHQ=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E9=97=A8=E5=BA=97=E8=AF=84=E4=BB=B7=E3=80=8D?= =?UTF-8?q?=E9=A1=B5=20/store-ratings=20=E6=88=90=E5=8A=9F=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E5=9B=9E=E5=88=B0=E9=A6=96=E9=A1=B5=E3=80=8D=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20.redeem-cancel-btn=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=92=91=E7=A0=B4=E5=B8=83=E5=B1=80=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=94=B9=E4=B8=BA=E6=A0=B8=E9=94=80=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=EF=BC=9AGET=20/redeem/records=EF=BC=9B=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=BA=93=E5=B7=B2=E6=9C=89=202=20=E7=AC=94=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E6=88=B7=2013073729990=EF=BC=89=E5=8F=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E3=80=82=E6=9D=83=E7=9B=8A=E6=98=8E=E7=BB=86=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=98=A0=E5=B0=84=E4=B9=9F=E4=B8=80=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E4=BA=86=20=E6=88=91=E7=9A=84=E9=A1=B5=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E6=8F=90=E9=AB=98=E7=82=B9=E5=87=BB=E5=B1=82=E7=BA=A7=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A2=AB=E5=8D=A1=E7=89=87=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 2 + apps/admin-web/src/pages/StoreRatingsPage.tsx | 105 ++++++++++++++++++ apps/admin-web/src/pages/StoresPage.tsx | 7 +- apps/mini-user/src/pages/benefit/index.tsx | 86 +++++++++----- apps/mini-user/src/pages/mine/index.tsx | 5 +- .../src/pages/order-confirm-pickup/index.tsx | 10 +- .../src/pages/order-confirm/index.tsx | 19 +++- .../src/pages/order-detail/index.tsx | 8 ++ apps/mini-user/src/pages/orders/index.tsx | 5 +- apps/mini-user/src/pages/pay/index.tsx | 6 +- .../src/pages/redeem-success/index.tsx | 89 +++++++++++++-- apps/mini-user/src/styles/mine.css | 8 +- apps/mini-user/src/styles/redeem.css | 43 +++++++ packages/shared-types/src/config.ts | 2 +- .../src/modules/benefit/benefit.service.ts | 24 +++- .../ops/admin-store-ratings.controller.ts | 15 +++ .../ops/admin-store-ratings.service.ts | 58 ++++++++++ .../src/modules/ops/dto/admin-query.dto.ts | 10 ++ .../dukang-api/src/modules/ops/ops.module.ts | 4 + .../src/modules/redeem/redeem.controller.ts | 13 +++ .../src/modules/redeem/redeem.service.ts | 40 +++++++ 22 files changed, 506 insertions(+), 55 deletions(-) create mode 100644 apps/admin-web/src/pages/StoreRatingsPage.tsx create mode 100644 server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-store-ratings.service.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 6d653a4..1ab9e14 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -6,6 +6,7 @@ import DashboardPage from './pages/DashboardPage'; import UsersPage from './pages/UsersPage'; import OrdersPage from './pages/OrdersPage'; import StoresPage from './pages/StoresPage'; +import StoreRatingsPage from './pages/StoreRatingsPage'; import StoreAccountsPage from './pages/StoreAccountsPage'; import BenefitCouponsPage from './pages/BenefitCouponsPage'; import BenefitLedgersPage from './pages/BenefitLedgersPage'; @@ -78,6 +79,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 2fd6e51..8406a2a 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -51,6 +51,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '门店', children: [ { key: '/stores', label: '门店列表' }, + { key: '/store-ratings', label: '门店评价' }, { key: '/store-categories', label: '门店分类' }, { key: '/store-accounts', label: '门店账户' }, { key: '/store-media', label: '门店资源' }, @@ -145,6 +146,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/knowledge-bases': 'knowledge_bases', 'stores-group': 'stores', '/stores': 'stores', + '/store-ratings': 'stores', '/store-categories': 'stores', '/store-accounts': 'stores', '/store-media': 'stores', diff --git a/apps/admin-web/src/pages/StoreRatingsPage.tsx b/apps/admin-web/src/pages/StoreRatingsPage.tsx new file mode 100644 index 0000000..ab7a6a3 --- /dev/null +++ b/apps/admin-web/src/pages/StoreRatingsPage.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { useState } from 'react'; +import { Button, Form, Input, Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useSearchParams } from 'react-router-dom'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + serviceScore: number; + envScore: number; + createdAt: string; + redeemNo: string; + redeemAmount: number; + store?: { id: string; name: string; cityName?: string }; + user?: { userNo: string; phone: string | null; nickname: string | null }; +}; + +export default function StoreRatingsPage() { + const [searchParams] = useSearchParams(); + const initialStoreId = searchParams.get('storeId') || ''; + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ + ...(initialStoreId ? { storeId: initialStoreId } : {}), + }); + + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/admin/store-ratings', + () => { + const qs = new URLSearchParams(); + if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); + return qs; + }, + [filters], + ); + + const columns: ColumnsType = [ + { title: '核销号', dataIndex: 'redeemNo', width: 180 }, + { + title: '门店', + dataIndex: ['store', 'name'], + render: (v, row) => (row.store?.cityName ? `${v}(${row.store.cityName})` : v), + }, + { + title: '用户', + width: 140, + render: (_, row) => row.user?.phone || row.user?.userNo || '-', + }, + { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, + { title: '服务分', dataIndex: 'serviceScore', width: 80 }, + { title: '环境分', dataIndex: 'envScore', width: 80 }, + { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, + ]; + + return ( +
+ + 门店评价 + +
{ + setFilters({ + storeId: v.storeId || '', + redeemNo: v.redeemNo || '', + }); + setPage(1); + }} + > + + + + + + + + + +
+ { + setPage(p); + setPageSize(ps); + }, + }} + /> + + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index e1782e9..36f385a 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -770,9 +770,12 @@ export default function StoresPage() { { title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 80, + title: '操作', width: 140, render: (_, row) => ( - + + + + ), }, ]; diff --git a/apps/mini-user/src/pages/benefit/index.tsx b/apps/mini-user/src/pages/benefit/index.tsx index df102b1..85e034c 100644 --- a/apps/mini-user/src/pages/benefit/index.tsx +++ b/apps/mini-user/src/pages/benefit/index.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; -import ShareNavButton from '../../components/ShareNavButton'; import WechatShareReady from '../../components/WechatShareReady'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import { goLogin } from '../../lib/auth-nav'; @@ -31,6 +30,14 @@ type CouponItem = { sourceProduct: string; }; +type RedeemHistoryItem = { + id: string; + redeemNo: string; + amount: number; + storeName: string; + createdAt: string; +}; + function formatMoney(amount: number) { return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } @@ -46,11 +53,13 @@ export default function BenefitPage() { const [loggedIn, setLoggedIn] = useState(() => isLoggedIn()); const [summary, setSummary] = useState(null); const [coupons, setCoupons] = useState([]); + const [redeemHistory, setRedeemHistory] = useState([]); const [tab, setTab] = useState<'available' | 'history'>('available'); const resetGuestState = useCallback(() => { setSummary(null); setCoupons([]); + setRedeemHistory([]); }, []); const loadBenefit = useCallback(() => { @@ -58,10 +67,17 @@ export default function BenefitPage() { return Promise.all([ request('/benefit/summary'), request('/benefit/coupons'), + request<{ list?: RedeemHistoryItem[] } | RedeemHistoryItem[]>('/redeem/records?page=1&pageSize=50'), ]) - .then(([s, list]) => { + .then(([s, list, records]) => { setSummary(s); setCoupons(Array.isArray(list) ? list : []); + const hist = Array.isArray(records) + ? records + : Array.isArray(records?.list) + ? records.list + : []; + setRedeemHistory(hist); }) .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); }, []); @@ -89,8 +105,6 @@ export default function BenefitPage() { }); const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0); - const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID'); - const visible = tab === 'available' ? available : history; const sharePayload = useMemo( () => ({ @@ -123,9 +137,6 @@ export default function BenefitPage() { 郑州市 - - - @@ -180,26 +191,26 @@ export default function BenefitPage() { - {visible.length === 0 ? ( - {tab === 'available' ? '暂无可用权益' : '暂无历史记录'} - ) : ( - visible.map((c) => ( - - - - - {c.sourceProduct || '好客权益'} - ¥{formatMoney(c.balance)} - - NO. {c.couponNo} - - - - - - 已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)} - - {tab === 'available' ? ( + {tab === 'available' ? ( + available.length === 0 ? ( + 暂无可用权益 + ) : ( + available.map((c) => ( + + + + + {c.sourceProduct || '好客权益'} + ¥{formatMoney(c.balance)} + + NO. {c.couponNo} + + + + + + 已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)} + @@ -210,7 +221,26 @@ export default function BenefitPage() { > 立即核销 - ) : null} + + + )) + ) + ) : redeemHistory.length === 0 ? ( + 暂无核销记录 + ) : ( + redeemHistory.map((r) => ( + + + + + {r.storeName || '门店核销'} + -¥{formatMoney(Number(r.amount))} + + NO. {r.redeemNo} + + + {r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'} + )) diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index f359681..397e29d 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -221,7 +221,10 @@ export default function MinePage() { /** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */ async function handleAvatarTap() { - if (bindingWx || savingProfile) return; + if (bindingWx || savingProfile) { + toast(savingProfile ? '资料保存中…' : '请稍候…'); + return; + } if (!isLoggedIn()) { goLogin('/pages/mine/index'); return; diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index 3704d0c..9d187e8 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -7,7 +7,7 @@ import { goLogin } from '../../lib/auth-nav'; import { buildPayUrl } from '../../lib/checkout-nav'; import { ensurePayReady } from '../../lib/pay-ready'; import { fetchUserProfile } from '../../lib/pay-wechat'; -import { request } from '../../lib/api'; +import { request, toast } from '../../lib/api'; import { getProductMainImage } from '../../lib/product-images'; type PreviewProduct = { @@ -75,7 +75,13 @@ export default function OrderConfirmPickupPage() { const canSubmit = !!preview && quantityOk && !loading && !previewLoading; function updateQuantity(next: number) { - if (next < minQty) return; + if (next < 1) return; + if (next < minQty) { + const tip = `现场提货至少购买 ${minQty} 瓶`; + toast(tip); + setMsg(tip); + return; + } setQuantity(next); } diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 2e665d0..1195316 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -168,6 +168,15 @@ export default function OrderConfirmPage() { function updateQuantity(next: number) { if (next < 1) return; + if (next < minQty) { + const tip = isCross + ? `跨城配送至少购买 ${minQty} 瓶(1箱)` + : `同城配送至少购买 ${minQty} 瓶`; + toast(tip); + setMsg(tip); + setQuantity(Math.max(1, next)); + return; + } setQuantity(next); } @@ -211,11 +220,11 @@ export default function OrderConfirmPage() { return; } if (!quantityOk) { - setMsg( - isCross - ? `跨城配送至少购买 ${minQty} 瓶(1箱)` - : `同城配送至少购买 ${minQty} 瓶`, - ); + const tip = isCross + ? `跨城配送至少购买 ${minQty} 瓶(1箱)` + : `同城配送至少购买 ${minQty} 瓶`; + setMsg(tip); + toast(tip); } return; } diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 5744d6e..83cb554 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -180,6 +180,14 @@ export default function OrderDetailPage() { { + const pages = Taro.getCurrentPages(); + if (pages.length > 1) { + Taro.navigateBack(); + return; + } + Taro.switchTab({ url: '/pages/home/index' }); + }} right={} /> diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx index b1d8928..0a87cf5 100644 --- a/apps/mini-user/src/pages/orders/index.tsx +++ b/apps/mini-user/src/pages/orders/index.tsx @@ -93,7 +93,10 @@ export default function OrdersPage() { return ( - + Taro.switchTab({ url: '/pages/home/index' })} + /> {TABS.map((t) => ( void; +}) { + return ( + + {label} + + {[1, 2, 3, 4, 5].map((score) => ( + onChange(score)} + > + ★ + + ))} + + + ); +} + export default function RedeemSuccessPage() { const router = useRouter(); + const [serviceScore, setServiceScore] = useState(5); + const [envScore, setEnvScore] = useState(5); + const [loading, setLoading] = useState(false); const record = useMemo(() => { try { @@ -38,14 +69,49 @@ export default function RedeemSuccessPage() { ? new Date(record.createdAt).toLocaleString('zh-CN') : new Date().toLocaleString('zh-CN'); + function clearCache() { + try { + Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY); + } catch { + /* ignore */ + } + } + function goBenefit() { - Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY); + clearCache(); Taro.switchTab({ url: '/pages/benefit/index' }); } + function goHome() { + clearCache(); + Taro.switchTab({ url: '/pages/home/index' }); + } + + async function submitRatingAndFinish() { + setLoading(true); + try { + if (record?.id) { + await request('/redeem/ratings', { + method: 'POST', + data: { + redeemRecordId: record.id, + serviceScore, + envScore, + }, + }); + toast('评价已提交', 'success'); + } + } catch { + /* 评价失败不阻塞返回 */ + } finally { + setLoading(false); + goBenefit(); + } + } + return ( - + @@ -69,14 +135,21 @@ export default function RedeemSuccessPage() { - - 返回权益 + + 为门店服务评分 + + + Taro.switchTab({ url: '/pages/home/index' })} + className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`} + onClick={() => { + if (!loading) void submitRatingAndFinish(); + }} > + {loading ? '提交中…' : '提交评价并返回'} + + 回到首页 diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css index 06ae44d..8d12e83 100644 --- a/apps/mini-user/src/styles/mine.css +++ b/apps/mini-user/src/styles/mine.css @@ -10,9 +10,10 @@ .mine-header { position: relative; + z-index: 2; padding: 16px var(--space-page) 44px; background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%); - overflow: hidden; + overflow: visible; } .mine-header-texture { @@ -33,11 +34,12 @@ position: relative; display: flex; align-items: center; - z-index: 1; + z-index: 3; } .mine-avatar-wrap { position: relative; + z-index: 4; flex-shrink: 0; margin-right: 12px; } @@ -160,7 +162,7 @@ .mine-main { margin-top: -28px; position: relative; - z-index: 2; + z-index: 1; padding: 0 var(--space-page) 0; } diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css index 3995378..a6d1891 100644 --- a/apps/mini-user/src/styles/redeem.css +++ b/apps/mini-user/src/styles/redeem.css @@ -378,6 +378,49 @@ word-break: break-all; } +.redeem-success-rating { + margin: 16px var(--space-page) 0; + padding: 16px; + background: var(--color-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); +} + +.redeem-success-rating-title { + display: block; + font-size: 15px; + font-weight: 700; + color: var(--color-on-surface); + margin-bottom: 12px; +} + +.redeem-rating-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.redeem-rating-label { + font-size: 14px; + color: var(--color-on-surface); +} + +.redeem-star-row { + display: flex; + gap: 6px; +} + +.redeem-star-btn { + font-size: 22px; + color: #d0d0d0; + line-height: 1; +} + +.redeem-star-btn--active { + color: #f5a623; +} + .ledger-item { display: flex; justify-content: space-between; diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index f94de98..1b8b658 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -55,7 +55,7 @@ export const MINI_USER_STATIC_OSS_BASE = export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`; /** 总部客服电话(C 端联系客服) */ -export const CUSTOMER_SERVICE_PHONE = '400-888-1234'; +export const CUSTOMER_SERVICE_PHONE = '13203801799'; /** * 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话) diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts index 50b5a79..46e7752 100644 --- a/server/dukang-api/src/modules/benefit/benefit.service.ts +++ b/server/dukang-api/src/modules/benefit/benefit.service.ts @@ -119,7 +119,29 @@ export class BenefitService { where: benefitLedgerWhere(userId, couponId), orderBy: { createdAt: 'desc' }, }); - return serializeBigInt(list); + return serializeBigInt( + list.map((e) => { + const type = e.param1 || ''; + const amount = Number(e.amount1 ?? 0); + const title = + e.remark || + (type === 'REDEEM' + ? '门店核销' + : type === 'GRANT' + ? '购酒入账' + : type === 'REFUND_VOID' + ? '退款作废' + : '权益变动'); + return { + id: e.id, + type, + amount, + title, + createdAt: e.createdAt, + balanceAfter: e.amount2 != null ? Number(e.amount2) : null, + }; + }), + ); } async getCoupon(userId: bigint, couponId: bigint) { diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts new file mode 100644 index 0000000..4e8208f --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { AdminStoreRatingsService } from './admin-store-ratings.service'; +import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; + +@Controller('admin/store-ratings') +@UseGuards(HqAuthGuard) +export class AdminStoreRatingsController { + constructor(private readonly service: AdminStoreRatingsService) {} + + @Get() + list(@Query() query: AdminStoreRatingsQueryDto) { + return this.service.list(query); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts new file mode 100644 index 0000000..2def44d --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; + +@Injectable() +export class AdminStoreRatingsService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: AdminStoreRatingsQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.StoreRatingWhereInput = {}; + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.redeemNo) { + where.redeemRecord = { redeemNo: { contains: query.redeemNo } }; + } + + const [items, total] = await Promise.all([ + this.prisma.storeRating.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + store: { select: { id: true, name: true, cityName: true } }, + redeemRecord: { + select: { + id: true, + redeemNo: true, + amount: true, + user: { select: { id: true, userNo: true, phone: true, nickname: true } }, + }, + }, + }, + }), + this.prisma.storeRating.count({ where }), + ]); + + return serializeBigInt({ + items: items.map((r) => ({ + id: r.id, + serviceScore: r.serviceScore, + envScore: r.envScore, + createdAt: r.createdAt, + store: r.store, + redeemRecordId: r.redeemRecordId, + redeemNo: r.redeemRecord.redeemNo, + redeemAmount: Number(r.redeemRecord.amount), + user: r.redeemRecord.user, + })), + total, + page, + pageSize, + }); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index c2da8e2..2e1c213 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -244,6 +244,16 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto { userId?: string; } +export class AdminStoreRatingsQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + storeId?: string; + + @IsOptional() + @IsString() + redeemNo?: string; +} + export class AdminDeliveriesQueryDto extends PaginationQueryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 2b78686..26bf70f 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -23,6 +23,8 @@ import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './ import { AdminBenefitService } from './admin-benefit.service'; import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin-redeem.controller'; import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service'; +import { AdminStoreRatingsController } from './admin-store-ratings.controller'; +import { AdminStoreRatingsService } from './admin-store-ratings.service'; import { AdminHqAccountsController } from './admin-hq-accounts.controller'; import { AdminHqAccountsService } from './admin-hq-accounts.service'; import { AdminProductsController } from './admin-products.controller'; @@ -90,6 +92,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide AdminBenefitCouponsController, AdminBenefitLedgersController, AdminRedeemRecordsController, + AdminStoreRatingsController, AdminDeliveriesController, AdminHqAccountsController, AdminProductsController, @@ -122,6 +125,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide AdminCitiesService, AdminBenefitService, AdminRedeemService, + AdminStoreRatingsService, AdminDeliveriesService, AdminHqAccountsService, AdminProductsService, diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index 4db8803..d85cfb8 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -36,6 +36,19 @@ export class UserRedeemController { rating(@CurrentUser() user: AuthUser, @Body() body: Record) { return this.redeemService.submitRating(user.actorId, body as never); } + + @Get('records') + records( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + ) { + return this.redeemService.listUserRecords( + user.actorId, + Number(page), + Number(pageSize), + ); + } } @Controller('shop/redeem') diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 6944d82..f485792 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -1095,11 +1095,51 @@ export class RedeemService { }); } + async listUserRecords(userId: bigint, page = 1, pageSize = 20) { + const take = Math.min(Math.max(pageSize, 1), 50); + const skip = (Math.max(page, 1) - 1) * take; + const [list, total] = await Promise.all([ + this.prisma.redeemRecord.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { + store: { select: { id: true, name: true } }, + rating: { select: { serviceScore: true, envScore: true } }, + }, + }), + this.prisma.redeemRecord.count({ where: { userId } }), + ]); + return { + list: serializeBigInt( + list.map((r) => ({ + id: r.id, + redeemNo: r.redeemNo, + amount: Number(r.amount), + storeId: r.storeId, + storeName: r.store?.name ?? '门店', + createdAt: r.createdAt, + rating: r.rating + ? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore } + : null, + })), + ), + total, + page: Math.max(page, 1), + pageSize: take, + }; + } + async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) { const record = await this.prisma.redeemRecord.findFirst({ where: { id: BigInt(body.redeemRecordId), userId }, }); if (!record) throw new NotFoundException('核销记录不存在'); + const existing = await this.prisma.storeRating.findUnique({ + where: { redeemRecordId: record.id }, + }); + if (existing) return serializeBigInt(existing); const rating = await this.prisma.storeRating.create({ data: { redeemRecordId: record.id,