From 233ed0af3b2babe5520ef95248a572b13fc3f5d0 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 19 Aug 2026 15:54:51 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.1=20=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/mini-user-weapp-nav-title.mdc | 34 + AGENTS.md | 1 + apps/admin-web/src/App.tsx | 9 + apps/admin-web/src/admin.css | 602 ++++++++++++++++ apps/admin-web/src/layouts/AdminLayout.tsx | 35 +- apps/admin-web/src/lib/admin-events.ts | 72 ++ apps/admin-web/src/pages/BigScreenPage.tsx | 466 ++++++++++++ apps/admin-web/src/pages/OrdersPage.tsx | 14 + apps/admin-web/src/pages/StoreBillsPage.tsx | 6 +- .../src/pages/StorePackageAuditsPage.tsx | 369 +++++++++- apps/admin-web/src/pages/StoresPage.tsx | 29 +- apps/h5-partner/src/lib/api.ts | 49 +- apps/h5-partner/src/pages/LoginPage.tsx | 14 +- apps/h5-partner/src/pages/StoreDetailPage.tsx | 57 +- apps/h5-shop/src/lib/api.ts | 27 +- apps/h5-shop/src/lib/useRedeemSuccessBus.ts | 42 ++ apps/h5-shop/src/pages/LoginPage.tsx | 10 + apps/h5-shop/src/pages/MinePage.tsx | 6 + apps/h5-shop/src/pages/RedeemSuccessPage.tsx | 11 +- apps/h5-shop/src/pages/WithdrawPage.tsx | 6 + apps/mini-user/config/index.ts | 7 +- apps/mini-user/package.json | 2 - apps/mini-user/src/app.config.ts | 54 +- apps/mini-user/src/app.css | 6 - apps/mini-user/src/app.tsx | 1 - apps/mini-user/src/app.weapp.tsx | 73 ++ .../src/assets/icons/store-open-badge.png | Bin 16205 -> 828 bytes apps/mini-user/src/assets/icons/关于我们.png | Bin 8493 -> 2097 bytes apps/mini-user/src/assets/icons/删除.png | Bin 0 -> 198 bytes apps/mini-user/src/assets/icons/可用门店.png | Bin 4392 -> 1077 bytes apps/mini-user/src/assets/icons/地址管理.png | Bin 7996 -> 2066 bytes apps/mini-user/src/assets/icons/已付款.png | Bin 4186 -> 1079 bytes apps/mini-user/src/assets/icons/已完成.png | Bin 3685 -> 979 bytes apps/mini-user/src/assets/icons/待付款.png | Bin 7058 -> 1805 bytes apps/mini-user/src/assets/icons/编辑.png | Bin 0 -> 751 bytes apps/mini-user/src/assets/icons/联系客服.png | Bin 5538 -> 1424 bytes apps/mini-user/src/assets/icons/资质公示.png | Bin 6164 -> 1656 bytes apps/mini-user/src/assets/icons/默认.png | Bin 0 -> 2487 bytes .../src/components/WechatShareReady.weapp.tsx | 21 + .../src/lib/client-location.weapp.ts | 25 + apps/mini-user/src/lib/pay-ready.weapp.ts | 25 + apps/mini-user/src/lib/pay-wechat.weapp.ts | 89 +++ apps/mini-user/src/lib/region-data.ts | 16 +- apps/mini-user/src/lib/region-tree.json | 1 + apps/mini-user/src/lib/user-location.weapp.ts | 232 ++++++ apps/mini-user/src/lib/wechat-auth.weapp.ts | 80 +++ apps/mini-user/src/lib/wechat-share.weapp.ts | 165 +++++ apps/mini-user/src/lib/weixin.weapp.ts | 4 + .../src/pages/address-edit/index.tsx | 1 + apps/mini-user/src/pages/addresses/index.tsx | 1 + .../src/pages/benefit-detail/index.tsx | 1 + .../src/pages/customer-service/index.tsx | 1 + .../src/pages/invoice-apply/index.config.ts | 3 + .../src/pages/invoice-apply/index.tsx | 296 ++++++++ .../src/pages/invoice-titles/index.config.ts | 5 + .../src/pages/invoice-titles/index.tsx | 360 ++++++++++ apps/mini-user/src/pages/login/index.tsx | 1 + apps/mini-user/src/pages/mine/index.tsx | 1 + .../src/pages/order-confirm-pickup/index.tsx | 1 + .../src/pages/order-confirm/index.tsx | 1 + .../src/pages/order-detail/index.tsx | 22 + .../src/pages/order-logistics/index.tsx | 1 + apps/mini-user/src/pages/orders/index.tsx | 29 +- apps/mini-user/src/pages/pay/index.tsx | 1 + .../src/pages/pickup-receive/index.tsx | 1 + .../src/pages/privacy-policy/index.tsx | 1 + .../src/pages/product-detail/index.tsx | 1 + .../mini-user/src/pages/redeem-code/index.tsx | 1 + .../src/pages/redeem-success/index.tsx | 1 + apps/mini-user/src/pages/redeem/index.tsx | 1 + .../src/pages/store-detail/index.tsx | 1 + .../src/pages/store-package-detail/index.tsx | 2 + .../src/pages/user-agreement/index.tsx | 1 + apps/mini-user/src/styles/invoice.css | 415 +++++++++++ apps/mini-user/src/styles/order.css | 58 ++ packages/shared-types/src/index.ts | 1 + packages/shared-types/src/invoice.ts | 38 + .../shared-types/src/store-info-change.ts | 64 ++ packages/shared-types/src/trade.ts | 3 + pnpm-lock.yaml | 26 +- server/dukang-api/package.json | 2 + server/dukang-api/prisma/schema.prisma | 61 ++ .../src/common/compat/v31-compat.ts | 17 + .../common/filters/http-exception.filter.ts | 4 + .../src/common/guards/hq-auth.guard.ts | 4 +- .../src/common/guards/jwt-auth.guard.ts | 88 ++- .../common/guards/partner-primary.guard.ts | 2 +- server/dukang-api/src/jobs/jobs.module.ts | 3 +- .../src/jobs/order-expiry.scheduler.ts | 36 + .../modules/ops/admin-orders.controller.ts | 6 + .../src/modules/ops/admin-orders.service.ts | 29 + .../src/modules/ops/admin-stores.service.ts | 19 +- .../store/store-info-change.controller.ts | 99 +++ .../store/store-info-change.service.ts | 341 +++++++++ .../src/modules/store/store.module.ts | 11 +- .../src/modules/trade/dto/after-sale.dto.ts | 21 +- .../modules/trade/invoice-title.controller.ts | 67 ++ .../modules/trade/invoice-title.service.ts | 123 ++++ .../src/modules/trade/trade.module.ts | 5 +- .../src/modules/trade/trade.service.ts | 174 ++++- server/dukang-api/tsconfig.json | 3 +- 杜康好客-v3.5.1-工单迭代开发文档.md | 663 ++++++++++++++++++ 杜康好客-v3.5.1-开发文档.md | 171 +++++ 103 files changed, 5764 insertions(+), 185 deletions(-) create mode 100644 .cursor/rules/mini-user-weapp-nav-title.mdc create mode 100644 apps/admin-web/src/pages/BigScreenPage.tsx create mode 100644 apps/h5-shop/src/lib/useRedeemSuccessBus.ts create mode 100644 apps/mini-user/src/app.weapp.tsx create mode 100644 apps/mini-user/src/assets/icons/删除.png create mode 100644 apps/mini-user/src/assets/icons/编辑.png create mode 100644 apps/mini-user/src/assets/icons/默认.png create mode 100644 apps/mini-user/src/components/WechatShareReady.weapp.tsx create mode 100644 apps/mini-user/src/lib/client-location.weapp.ts create mode 100644 apps/mini-user/src/lib/pay-ready.weapp.ts create mode 100644 apps/mini-user/src/lib/pay-wechat.weapp.ts create mode 100644 apps/mini-user/src/lib/region-tree.json create mode 100644 apps/mini-user/src/lib/user-location.weapp.ts create mode 100644 apps/mini-user/src/lib/wechat-auth.weapp.ts create mode 100644 apps/mini-user/src/lib/wechat-share.weapp.ts create mode 100644 apps/mini-user/src/lib/weixin.weapp.ts create mode 100644 apps/mini-user/src/pages/invoice-apply/index.config.ts create mode 100644 apps/mini-user/src/pages/invoice-apply/index.tsx create mode 100644 apps/mini-user/src/pages/invoice-titles/index.config.ts create mode 100644 apps/mini-user/src/pages/invoice-titles/index.tsx create mode 100644 apps/mini-user/src/styles/invoice.css create mode 100644 packages/shared-types/src/store-info-change.ts create mode 100644 server/dukang-api/src/jobs/order-expiry.scheduler.ts create mode 100644 server/dukang-api/src/modules/store/store-info-change.controller.ts create mode 100644 server/dukang-api/src/modules/store/store-info-change.service.ts create mode 100644 server/dukang-api/src/modules/trade/invoice-title.controller.ts create mode 100644 server/dukang-api/src/modules/trade/invoice-title.service.ts create mode 100644 杜康好客-v3.5.1-工单迭代开发文档.md create mode 100644 杜康好客-v3.5.1-开发文档.md diff --git a/.cursor/rules/mini-user-weapp-nav-title.mdc b/.cursor/rules/mini-user-weapp-nav-title.mdc new file mode 100644 index 0000000..2632d42 --- /dev/null +++ b/.cursor/rules/mini-user-weapp-nav-title.mdc @@ -0,0 +1,34 @@ +--- +description: 小程序子页不要画与原生导航栏重复的二级 title,标题只用 navigationBarTitleText +globs: apps/mini-user/**/*.{tsx,ts} +alwaysApply: false +--- + +# mini-user · 小程序导航标题 + +微信小程序已有原生导航栏。页面内再画一层 `SubPageHeader` / 自定义 title,会与原生标题叠成**二级 title**。 + +## weapp + +- 页面标题只走 `index.config.ts` 的 `navigationBarTitleText` +- **不要**设 `navigationStyle: 'custom'`(除非该页需要完全自定义导航,如滚动透明顶栏、商品详情) +- **不要**在页面内再渲染与原生标题重复的 `SubPageHeader` +- H5 没有原生导航栏:可保留 `SubPageHeader`(仅返回键;H5 实现已隐藏 title 文案) + +```tsx +// ✅ weapp 用原生标题;H5 保留返回栏 +export default definePageConfig({ + navigationBarTitleText: '申请发票', +}); + +{process.env.TARO_ENV === 'h5' ? ( + +) : null} +``` + +```tsx +// ❌ 未配页标题 + 再画一层 SubPageHeader(weapp 会双标题) + +``` + +弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。 diff --git a/AGENTS.md b/AGENTS.md index c5547de..317b84c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,7 @@ C 端门店仅 status=OPEN **iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。 **微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。 +**微信小程序页面标题**:weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`。 ## 环境与发版 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index cbc81b8..31b8415 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import UsersPage from './pages/UsersPage'; import OrdersPage from './pages/OrdersPage'; +import BigScreenPage from './pages/BigScreenPage'; import StorePackageAuditsPage from './pages/StorePackageAuditsPage'; import StoresPage from './pages/StoresPage'; import StoreRatingsPage from './pages/StoreRatingsPage'; @@ -65,6 +66,14 @@ export default function App() { return ( } /> + + + + } + /> diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index 81f5524..2143e93 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -104,3 +104,605 @@ body, overflow: visible; max-width: none; } + +/* v3.5.1 #1 订单大屏:发布会现场 */ +html:has(.big-screen-page), +body:has(.big-screen-page), +#root:has(.big-screen-page) { + overflow: hidden; + height: 100%; +} + +.big-screen-page { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%); + color: #d6e8ff; + padding: 20px 48px 24px; + box-sizing: border-box; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.big-screen-stars { + pointer-events: none; + position: absolute; + inset: 0; + background-image: + radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent), + radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent), + radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent), + radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent), + radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent), + radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent); + opacity: 0.7; +} + +.big-screen-frame { + pointer-events: none; + position: absolute; + inset: 14px 18px; +} + +.big-screen-corner { + position: absolute; + width: 28px; + height: 28px; + border: 1px solid rgba(105, 192, 255, 0.7); +} + +.big-screen-corner--tl { + top: 0; + left: 0; + border-right: none; + border-bottom: none; +} + +.big-screen-corner--tr { + top: 0; + right: 0; + border-left: none; + border-bottom: none; +} + +.big-screen-corner--bl { + bottom: 0; + left: 0; + border-right: none; + border-top: none; +} + +.big-screen-corner--br { + bottom: 0; + right: 0; + border-left: none; + border-top: none; +} + +.big-screen-header { + position: relative; + z-index: 1; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 10px; + border-bottom: 1px solid rgba(105, 192, 255, 0.18); +} + +.big-screen-brand { + display: flex; + align-items: baseline; + gap: 10px; +} + +.big-screen-title { + margin: 0; + font-size: 28px; + line-height: 1; + font-weight: 700; + letter-spacing: 4px; + color: #fff; +} + +.big-screen-subtitle { + font-size: 16px; + color: rgba(200, 220, 245, 0.55); + letter-spacing: 2px; +} + +.big-screen-live-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +.big-screen-live-label { + font-size: 18px; + color: rgba(230, 244, 255, 0.88); + letter-spacing: 2px; +} + +.big-screen-live { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px 4px 10px; + border: 1px solid rgba(64, 169, 255, 0.7); + border-radius: 6px; + font-size: 14px; + font-weight: 700; + color: #fff; + letter-spacing: 2px; + background: rgba(8, 28, 56, 0.55); +} + +.big-screen-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ff4d4f; + box-shadow: 0 0 8px #ff4d4f; + animation: big-screen-pulse 1.2s ease-in-out infinite; +} + +@keyframes big-screen-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.35; + transform: scale(0.75); + } +} + +.big-screen-hero { + position: relative; + z-index: 1; + flex: 0 0 auto; + text-align: center; + padding: 16px 0 12px; +} + +.big-screen-clock { + font-size: 84px; + line-height: 1; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: 10px; + color: #9fd3ff; + text-shadow: 0 0 28px rgba(105, 192, 255, 0.45); +} + +.big-screen-date { + margin-top: 8px; + font-size: 16px; + letter-spacing: 4px; + color: rgba(180, 210, 240, 0.65); +} + +.big-screen-list { + position: relative; + z-index: 1; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0 8px; + overflow: hidden; +} + +.big-screen-list-head, +.big-screen-row { + display: grid; + grid-template-columns: 180px 1fr 160px 200px; + align-items: center; + column-gap: 16px; +} + +.big-screen-list-head { + flex: 0 0 auto; + padding: 0 28px 10px 40px; + font-size: 15px; + color: rgba(170, 200, 230, 0.55); + letter-spacing: 2px; +} + +.big-screen-list-body { + flex: 1 1 0%; + min-height: 0; + height: 0; + overflow: hidden; + position: relative; +} + +.big-screen-track { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + flex-direction: column; +} + +.big-screen-track.is-rolling { + animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite; +} + +.big-screen-track.is-paused { + animation-play-state: paused; +} + +@keyframes big-screen-marquee-up { + from { + transform: translateY(0); + } + to { + transform: translateY(-50%); + } +} + +.big-screen-row { + position: relative; + flex: 0 0 auto; + height: 68px; + margin-bottom: 12px; + padding: 0 28px 0 40px; + background: rgba(18, 48, 88, 0.45); + border: 1px solid transparent; + border-radius: 4px; + font-size: 22px; + color: #e8f4ff; + box-sizing: border-box; +} + +.big-screen-row.is-latest { + border-color: rgba(64, 169, 255, 0.85); + box-shadow: 0 0 16px rgba(24, 144, 255, 0.28); +} + +.big-screen-row--t1 { + background: rgba(22, 54, 98, 0.48); + border-color: rgba(105, 192, 255, 0.16); +} + +.big-screen-row--t1 .big-screen-amount { + color: #e6f4ff; +} + +.big-screen-row--t2.is-latest { + border-color: rgba(255, 229, 143, 0.95); + box-shadow: 0 0 18px rgba(255, 229, 143, 0.42); +} + +.big-screen-row--t3.is-latest { + border-color: #ffd666; + box-shadow: 0 0 22px rgba(250, 173, 20, 0.55); +} + +.big-screen-row--t2 { + background: rgba(64, 48, 8, 0.42); + border-color: rgba(255, 229, 143, 0.45); + color: #fff7d6; +} + +.big-screen-row--t2 .big-screen-amount { + color: #ffe58f; + text-shadow: 0 0 10px rgba(255, 229, 143, 0.45); +} + +.big-screen-row--t3 { + background: rgba(72, 48, 0, 0.5); + border-color: rgba(250, 173, 20, 0.75); + color: #ffe7a3; + box-shadow: 0 0 18px rgba(250, 173, 20, 0.28); +} + +.big-screen-row--t3 .big-screen-amount { + color: #ffd666; + text-shadow: 0 0 14px rgba(255, 214, 102, 0.7); +} + +.big-screen-row-mark { + position: absolute; + left: 12px; + top: 50%; + width: 10px; + height: 22px; + margin-top: -11px; + border-radius: 6px; + background: #40a9ff; + box-shadow: 0 0 10px #40a9ff; +} + +.big-screen-row--t2 .big-screen-row-mark { + background: #ffe58f; + box-shadow: 0 0 10px #ffe58f; +} + +.big-screen-row--t3 .big-screen-row-mark { + background: #ffd666; + box-shadow: 0 0 12px #ffd666; +} + +.big-screen-amount { + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.big-screen-items { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.big-screen-time, +.big-screen-phone { + font-variant-numeric: tabular-nums; + letter-spacing: 1px; + white-space: nowrap; +} + +.big-screen-empty { + text-align: center; + color: rgba(255, 255, 255, 0.45); + padding: 48px 20px; + font-size: 18px; +} + +.big-screen-fx { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + overflow: hidden; + background: rgba(4, 12, 28, 0.38); +} + +.big-screen-fx-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.big-screen-fx-shock { + position: absolute; + width: 40px; + height: 40px; + border-radius: 50%; + border: 3px solid rgba(255, 214, 102, 0.85); + animation: big-screen-shock 1.1s ease-out forwards; +} + +@keyframes big-screen-shock { + 0% { + transform: scale(0.2); + opacity: 1; + } + 100% { + transform: scale(28); + opacity: 0; + } +} + +.big-screen-fx-sweep { + position: absolute; + inset: 0; + background: linear-gradient( + 115deg, + transparent 38%, + rgba(255, 245, 200, 0.22) 50%, + transparent 62% + ); + background-size: 220% 100%; + animation: big-screen-sweep 1.4s ease-out 0.15s both; +} + +.big-screen-fx-sweep--alt { + animation-delay: 0.55s; + background: linear-gradient( + 65deg, + transparent 38%, + rgba(255, 214, 102, 0.2) 50%, + transparent 62% + ); +} + +.big-screen-fx-shock--late { + animation-delay: 0.35s; + border-color: rgba(255, 236, 179, 0.55); +} + +@keyframes big-screen-sweep { + from { + background-position: 120% 0; + opacity: 0; + } + 30% { + opacity: 1; + } + to { + background-position: -40% 0; + opacity: 0; + } +} + +.big-screen-fx-card { + position: relative; + z-index: 2; + min-width: 420px; + max-width: 72vw; + padding: 28px 40px 32px; + border-radius: 12px; + text-align: center; + background: rgba(8, 22, 48, 0.82); + backdrop-filter: blur(8px); +} + +.big-screen-fx-card--t1 { + border: 2px solid rgba(105, 192, 255, 0.85); + box-shadow: 0 0 32px rgba(24, 144, 255, 0.45); + animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both; +} + +.big-screen-fx-card--t2 { + border: 2px solid rgba(255, 229, 143, 0.95); + box-shadow: 0 0 40px rgba(255, 214, 102, 0.5); + animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both; +} + +.big-screen-fx-card--t3 { + min-width: 520px; + padding: 36px 48px 40px; + border: 3px solid #ffd666; + box-shadow: + 0 0 28px rgba(255, 214, 102, 0.85), + 0 0 80px rgba(250, 173, 20, 0.45); + animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both; +} + +@keyframes big-screen-card-in-t1 { + from { + opacity: 0; + transform: translateY(80px) scale(0.92); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes big-screen-card-in-t2 { + 0% { + opacity: 0; + transform: scale(0.6); + } + 70% { + transform: scale(1.06); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes big-screen-card-in-t3 { + 0% { + opacity: 0; + transform: scale(0.4) rotate(-4deg); + } + 55% { + transform: scale(1.12) rotate(1deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0); + } +} + +.big-screen-fx-kicker { + font-size: 16px; + letter-spacing: 8px; + color: rgba(230, 244, 255, 0.7); + margin-bottom: 8px; +} + +.big-screen-fx-card--t2 .big-screen-fx-kicker, +.big-screen-fx-card--t3 .big-screen-fx-kicker { + color: #ffe58f; +} + +.big-screen-fx-amount { + font-size: 64px; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: 2px; + line-height: 1.1; + color: #e6f7ff; +} + +.big-screen-fx-card--t1 .big-screen-fx-amount { + color: #91d5ff; + text-shadow: 0 0 18px rgba(105, 192, 255, 0.6); +} + +.big-screen-fx-card--t2 .big-screen-fx-amount { + color: #ffe58f; + text-shadow: 0 0 20px rgba(255, 229, 143, 0.7); + animation: big-screen-amount-pop 0.8s ease-out 0.15s both; +} + +.big-screen-fx-card--t3 .big-screen-fx-amount { + font-size: 84px; + color: #ffd666; + text-shadow: + 0 0 12px #ffd666, + 0 0 36px rgba(250, 173, 20, 0.8); + animation: big-screen-amount-flash 0.9s ease-in-out infinite; +} + +@keyframes big-screen-amount-pop { + 0% { + transform: scale(0.7); + } + 70% { + transform: scale(1.12); + } + 100% { + transform: scale(1); + } +} + +@keyframes big-screen-amount-flash { + 0%, + 100% { + filter: brightness(1); + } + 50% { + filter: brightness(1.35); + } +} + +.big-screen-fx-items { + margin-top: 12px; + font-size: 22px; + color: #fff; +} + +.big-screen-fx-meta { + margin-top: 10px; + display: flex; + justify-content: center; + gap: 28px; + font-size: 16px; + color: rgba(210, 228, 250, 0.75); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 1200px) { + .big-screen-clock { + font-size: 56px; + } + .big-screen-list-head, + .big-screen-row { + grid-template-columns: 140px 1fr 120px 160px; + font-size: 16px; + } + .big-screen-fx-amount { + font-size: 44px; + } + .big-screen-fx-card--t3 .big-screen-fx-amount { + font-size: 56px; + } +} + diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 4f3a1e4..17942e8 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -23,7 +23,7 @@ import { import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; -import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; +import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; const { Header, Sider, Content } = Layout; @@ -61,7 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '门店', children: [ { key: '/stores', label: '门店列表' }, - { key: '/store-package-audits', label: '套餐审核' }, + { key: '/store-package-audits', label: '审核通知' }, { key: '/store-ratings', label: '门店评价' }, { key: '/store-categories', label: '门店分类' }, { key: '/store-accounts', label: '门店账户' }, @@ -244,14 +244,14 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M .filter(Boolean) as MenuProps['items']; } -function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] { +function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] { if (!items) return items; return items.map((item) => { if (!item || typeof item !== 'object' || !('key' in item)) return item; if ('children' in item && Array.isArray(item.children)) { return { ...item, - children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount), + children: attachAuditBadge(item.children as MenuProps['items'], pendingCount), } as MenuItem; } if (String(item.key) === '/store-package-audits') { @@ -259,7 +259,7 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number ...item, label: ( - 套餐审核 + 审核通知 {pendingCount > 0 && } ), @@ -276,11 +276,14 @@ export default function AdminLayout() { const location = useLocation(); const contentRef = useRef(null); const [profile, setProfile] = useState(null); - const [packagePendingCount, setPackagePendingCount] = useState(0); + const [auditPendingCount, setAuditPendingCount] = useState(0); - function refreshPackagePendingCount() { - request<{ pendingCount: number }>('/admin/store-package-audits/summary') - .then((data) => setPackagePendingCount(data.pendingCount ?? 0)) + function refreshAuditPendingCount() { + Promise.all([ + request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })), + request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })), + ]) + .then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0))) .catch(() => {}); } @@ -289,13 +292,17 @@ export default function AdminLayout() { }, []); useEffect(() => { - refreshPackagePendingCount(); + refreshAuditPendingCount(); }, [location.pathname]); useEffect(() => { - const onChanged = () => refreshPackagePendingCount(); + const onChanged = () => refreshAuditPendingCount(); window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); - return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); + window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged); + return () => { + window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); + window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged); + }; }, []); useEffect(() => { @@ -320,8 +327,8 @@ export default function AdminLayout() { !profile || profile.adminRole === 'SUPER_ADMIN' ? MENU_ITEMS : filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []); - return attachPackageAuditBadge(base, packagePendingCount); - }, [profile, packagePendingCount]); + return attachAuditBadge(base, auditPendingCount); + }, [profile, auditPendingCount]); return ( diff --git a/apps/admin-web/src/lib/admin-events.ts b/apps/admin-web/src/lib/admin-events.ts index 649986f..818a9ce 100644 --- a/apps/admin-web/src/lib/admin-events.ts +++ b/apps/admin-web/src/lib/admin-events.ts @@ -1,5 +1,77 @@ export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed'; +export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed'; +export const BIG_SCREEN_DEMO_CHANNEL = 'dukang-big-screen'; +export const BIG_SCREEN_DEMO_STORAGE_KEY = 'dukang:big-screen-demo'; + +export type BigScreenDemoOrder = { + payAmount: number; + items: string; + userPhoneMasked: string; +}; + +export type BigScreenDemoPayload = { + type: 'demo'; + id: string; + at: number; + orders: BigScreenDemoOrder[]; +}; + +export function buildBigScreenDemoPayload(): BigScreenDemoPayload { + return { + type: 'demo', + id: `demo-${Date.now()}`, + at: Date.now(), + orders: [ + { payAmount: 1288, items: '杜康君酿 · 浓香型 × 6瓶', userPhoneMasked: '138****8888' }, + { payAmount: 688, items: '杜康君酿 · 浓香型 × 2瓶', userPhoneMasked: '139****6666' }, + { payAmount: 188, items: '杜康君酿 · 浓香型 × 1瓶', userPhoneMasked: '137****1888' }, + ], + }; +} + +export function triggerBigScreenDemo() { + const payload = buildBigScreenDemoPayload(); + try { + localStorage.setItem(BIG_SCREEN_DEMO_STORAGE_KEY, JSON.stringify(payload)); + } catch { + /* ignore quota */ + } + try { + const ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL); + ch.postMessage(payload); + ch.close(); + } catch { + /* BroadcastChannel 不可用时靠 localStorage 冷启动 */ + } + return payload; +} + +export function readPendingBigScreenDemo(maxAgeMs = 8000): BigScreenDemoPayload | null { + try { + const raw = localStorage.getItem(BIG_SCREEN_DEMO_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as BigScreenDemoPayload; + if (parsed?.type !== 'demo' || !parsed.id || !Array.isArray(parsed.orders)) return null; + if (Date.now() - Number(parsed.at || 0) > maxAgeMs) return null; + return parsed; + } catch { + return null; + } +} + +export function clearPendingBigScreenDemo() { + try { + localStorage.removeItem(BIG_SCREEN_DEMO_STORAGE_KEY); + } catch { + /* ignore */ + } +} export function notifyPackageAuditChanged() { window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT)); + window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT)); +} + +export function notifyAuditNoticeChanged() { + notifyPackageAuditChanged(); } diff --git a/apps/admin-web/src/pages/BigScreenPage.tsx b/apps/admin-web/src/pages/BigScreenPage.tsx new file mode 100644 index 0000000..0ba45f7 --- /dev/null +++ b/apps/admin-web/src/pages/BigScreenPage.tsx @@ -0,0 +1,466 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { request } from '../lib/api'; +import { + BIG_SCREEN_DEMO_CHANNEL, + clearPendingBigScreenDemo, + readPendingBigScreenDemo, + type BigScreenDemoPayload, +} from '../lib/admin-events'; + +export type BigScreenOrder = { + id: string; + orderNo: string; + payAmount: number; + items: string; + createdAt: string; + userPhoneMasked: string | null; +}; + +type AmountTier = 1 | 2 | 3; + +const POLL_MS = 3000; +const ROW_MS = 2500; +const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; +const TIER_DURATION: Record = { 1: 3000, 2: 4500, 3: 6000 }; + +function pad(n: number) { + return String(n).padStart(2, '0'); +} + +function formatClock(d: Date) { + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function formatDateLine(d: Date) { + return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`; +} + +function formatHm(iso: string): string { + try { + return formatClock(new Date(iso)); + } catch { + return iso; + } +} + +function formatAmount(n: number): string { + return Number(n || 0).toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); +} + +export function amountTier(payAmount: number): AmountTier { + if (payAmount >= 1000) return 3; + if (payAmount >= 500) return 2; + return 1; +} + +function LiveClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + return ( +
+
{formatClock(now)}
+
{formatDateLine(now)}
+
+ ); +} + +function OrderRow({ + order, + latest, +}: { + order: BigScreenOrder; + latest?: boolean; +}) { + const tier = amountTier(order.payAmount); + return ( +
+ {latest ? : null} + ¥ {formatAmount(order.payAmount)} + {order.items || '—'} + {formatHm(order.createdAt)} + {order.userPhoneMasked || '—'} +
+ ); +} + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + rot: number; + vr: number; + w: number; + h: number; + color: string; + life: number; + kind: 'rect' | 'ribbon' | 'spark'; + rain?: boolean; +}; + +const TIER_COLORS: Record = { + 1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'], + 2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'], + 3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'], +}; + +function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] { + const colors = TIER_COLORS[tier]; + const count = tier === 3 ? 180 : tier === 2 ? 110 : 70; + const out: Particle[] = []; + const cx = w / 2; + const cy = h * 0.42; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random()); + const kind: Particle['kind'] = + tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect'; + out.push({ + x: cx + (Math.random() - 0.5) * 80, + y: cy + (Math.random() - 0.5) * 40, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3), + rot: Math.random() * 360, + vr: (Math.random() - 0.5) * 18, + w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8, + h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6, + color: colors[Math.floor(Math.random() * colors.length)], + life: 1, + kind, + }); + } + if (tier >= 2) { + for (let i = 0; i < (tier === 3 ? 80 : 40); i++) { + out.push({ + x: Math.random() * w, + y: -20 - Math.random() * 80, + vx: (Math.random() - 0.5) * 1.4, + vy: 3 + Math.random() * 5, + rot: Math.random() * 360, + vr: (Math.random() - 0.5) * 10, + w: 5 + Math.random() * 8, + h: 10 + Math.random() * 14, + color: colors[Math.floor(Math.random() * colors.length)], + life: 1, + kind: 'rect', + rain: true, + }); + } + } + return out; +} + +function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) { + const canvasRef = useRef(null); + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; + const tier = amountTier(order.payAmount); + const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount); + + useEffect(() => { + const duration = TIER_DURATION[tier]; + const timer = window.setTimeout(() => onDoneRef.current(), duration); + return () => window.clearTimeout(timer); + }, [order.id, tier]); + + useEffect(() => { + if (tier !== 3) return; + const start = performance.now(); + const dur = 900; + let raf = 0; + const tick = (now: number) => { + const p = Math.min(1, (now - start) / dur); + const eased = 1 - (1 - p) ** 3; + setDisplayAmount(Math.round(order.payAmount * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [order.payAmount, order.id, tier]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const resize = () => { + canvas.width = canvas.clientWidth * devicePixelRatio; + canvas.height = canvas.clientHeight * devicePixelRatio; + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + }; + resize(); + const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight); + let raf = 0; + let last = performance.now(); + const started = performance.now(); + const duration = TIER_DURATION[tier]; + const gravity = tier === 3 ? 0.18 : 0.14; + const loop = (now: number) => { + const dt = Math.min(32, now - last) / 16.6; + last = now; + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + for (const p of particles) { + p.vy += gravity * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.rot += p.vr * dt; + p.life -= (tier === 3 ? 0.0032 : 0.005) * dt; + if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) { + p.x = Math.random() * canvas.clientWidth; + p.y = -16 - Math.random() * 60; + p.vx = (Math.random() - 0.5) * 1.4; + p.vy = 3 + Math.random() * 5; + p.life = 1; + } + if (p.life <= 0) continue; + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate((p.rot * Math.PI) / 180); + ctx.globalAlpha = Math.max(0, p.life); + ctx.fillStyle = p.color; + if (p.kind === 'spark') { + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + } else { + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + } + ctx.restore(); + } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + const onResize = () => resize(); + window.addEventListener('resize', onResize); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', onResize); + }; + }, [order.id, tier]); + + return ( +
+ + {tier === 3 ?
: null} + {tier === 3 ?
: null} + {tier >= 2 ?
: null} + {tier >= 2 ?
: null} +
+
{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}
+
¥ {formatAmount(displayAmount)}
+
{order.items || '—'}
+
+ {order.userPhoneMasked || '—'} + {formatHm(order.createdAt)} +
+
+
+ ); +} + +export default function BigScreenPage() { + const [items, setItems] = useState([]); + const [loadError, setLoadError] = useState(''); + const [paused, setPaused] = useState(false); + const [celebrate, setCelebrate] = useState(null); + const seenIdsRef = useRef | null>(null); + const queueRef = useRef([]); + const celebratingRef = useRef(false); + const viewportRef = useRef(null); + const demoSeenRef = useRef>(new Set()); + const [viewportH, setViewportH] = useState(0); + + const playNext = useCallback(() => { + const next = queueRef.current.shift() ?? null; + celebratingRef.current = !!next; + setCelebrate(next); + setPaused(!!next); + }, []); + + const enqueueNew = useCallback( + (fresh: BigScreenOrder[]) => { + if (!fresh.length) return; + const ranked = [...fresh].sort((a, b) => { + const td = amountTier(b.payAmount) - amountTier(a.payAmount); + if (td !== 0) return td; + return +new Date(b.createdAt) - +new Date(a.createdAt); + }); + queueRef.current.push(...ranked); + if (!celebratingRef.current) playNext(); + }, + [playNext], + ); + + const playDemo = useCallback( + (payload: BigScreenDemoPayload) => { + if (!payload?.orders?.length || demoSeenRef.current.has(payload.id)) return; + demoSeenRef.current.add(payload.id); + const now = new Date().toISOString(); + const fake: BigScreenOrder[] = payload.orders.map((o, i) => ({ + id: `${payload.id}-${i}`, + orderNo: `${payload.id}-${i}`, + payAmount: o.payAmount, + items: o.items, + createdAt: now, + userPhoneMasked: o.userPhoneMasked, + })); + setItems((prev) => [...fake, ...prev.filter((row) => !row.id.startsWith('demo-'))]); + enqueueNew(fake); + clearPendingBigScreenDemo(); + }, + [enqueueNew], + ); + + const fetchData = useCallback(() => { + return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000') + .then((d) => { + const list = d.items ?? []; + setItems((prev) => { + const demos = prev.filter((o) => o.id.startsWith('demo-')); + return demos.length ? [...demos, ...list] : list; + }); + setLoadError(''); + const seen = seenIdsRef.current; + if (!seen) { + seenIdsRef.current = new Set(list.map((o) => o.id)); + return; + } + const fresh = list.filter((o) => !seen.has(o.id)); + for (const o of fresh) seen.add(o.id); + enqueueNew(fresh); + }) + .catch((e) => { + setLoadError(e instanceof Error ? e.message : '加载失败'); + }); + }, [enqueueNew]); + + useEffect(() => { + void fetchData(); + const id = setInterval(() => void fetchData(), POLL_MS); + return () => clearInterval(id); + }, [fetchData]); + + useEffect(() => { + const pending = readPendingBigScreenDemo(); + if (pending) playDemo(pending); + + let ch: BroadcastChannel | null = null; + try { + ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL); + ch.onmessage = (ev: MessageEvent) => { + if (ev.data?.type === 'demo' && ev.data.orders?.length) playDemo(ev.data); + }; + } catch { + ch = null; + } + return () => { + ch?.close(); + }; + }, [playDemo]); + + useEffect(() => { + const html = document.documentElement; + const prevHtml = html.style.overflow; + const prevBody = document.body.style.overflow; + html.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + return () => { + html.style.overflow = prevHtml; + document.body.style.overflow = prevBody; + }; + }, []); + + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + const measure = () => setViewportH(el.clientHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const unitItems = useMemo(() => { + if (!items.length) return []; + const rowH = 80; + const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH)); + const minCount = visible + 1; + const unit: BigScreenOrder[] = []; + while (unit.length < minCount) unit.push(...items); + return unit; + }, [items, viewportH]); + + const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]); + const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS; + const rolling = items.length > 0 && unitItems.length > 0; + const latestId = items[0]?.id; + + return ( +
+
+
+ + + + +
+ +
+
+

杜康好客

+ · 发布会现场 +
+
+ 实时成交 + + + LIVE + +
+
+ + + +
+
+ 下单金额 + 下单商品 + 下单时间 + 下单人 +
+
+ {items.length === 0 ? ( +
{loadError || '暂无订单'}
+ ) : ( +
+ {trackItems.map((o, idx) => ( + + ))} +
+ )} +
+
+ + {celebrate ? ( + + ) : null} +
+ ); +} diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index e6e2182..7dfbf82 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -21,6 +21,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; +import { triggerBigScreenDemo } from '../lib/admin-events'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, @@ -191,6 +192,7 @@ export default function OrdersPage() { const [trackOpen, setTrackOpen] = useState(false); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); + const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -542,6 +544,18 @@ export default function OrdersPage() { 订单监控 + + {isSuperAdmin ? ( + + ) : null} {canProxyOrder ? ( + {row.status === 'PENDING' ? ( + <> + + + + ) : ( + row.rejectReason || null + )} + + ), + }, + ]; + + return ( +
+ + {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( + + ))} + + void reload(p, status), + }} + /> + + setDetailOpen(false)} + extra={ + detail?.status === 'PENDING' ? ( + + + + + ) : null + } + > + {detailLoading ? ( + 加载中… + ) : detail ? ( + <> + + {STORE_INFO_CHANGE_STATUS_LABELS[detail.status]} + + 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)} + + + {detail.rejectReason ? ( + 驳回原因:{detail.rejectReason} + ) : null} + {detail.diffs && detail.diffs.length ? ( + + {detail.diffs.map((d) => ( + + + + {fmtFieldValue(d.field, d.live)} + + + + {fmtFieldValue(d.field, d.proposed)} + + + + ))} + + ) : ( + 无变更字段明细 + )} + + ) : null} + + + setRejectOpen(false)} + onOk={() => { + if (!activeId) return; + if (!rejectReason.trim()) { + message.warning('请填写驳回原因'); + return; + } + void audit(activeId, 'REJECT', rejectReason.trim()); + setRejectOpen(false); + }} + > + setRejectReason(e.target.value)} + /> + + + ); +} + export default function StorePackageAuditsPage() { const [loading, setLoading] = useState(false); const [items, setItems] = useState([]); @@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() { // 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉 const [searchParams] = useSearchParams(); + const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package'; + const [activeTab, setActiveTab] = useState(initialTab); + const infoRequestId = searchParams.get('infoRequestId'); useEffect(() => { const rid = searchParams.get('requestId'); if (rid) void openDetail(rid); @@ -438,33 +732,52 @@ export default function StorePackageAuditsPage() { return (
- 套餐变更审核 - - {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( - - ))} - -
void reload(p, status), - }} + 审核通知 + + + {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( + + ))} + +
void reload(p, status), + }} + /> + + ), + }, + { + key: 'info', + label: '信息变更', + children: , + }, + ]} /> ( @@ -791,6 +793,31 @@ export default function StoresPage() { ) : null} + {row.pendingInfoChangeId ? ( + <> + + + + ) : null} + ), }, diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 312f021..1373263 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -156,10 +156,19 @@ async function rawRequest( if (authToken) headers.Authorization = `Bearer ${authToken}`; const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); + const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })) as { + code: number; + message?: string; + data?: T; + reason?: string; + }; if (json.code !== 0) { - const err = new Error(json.message || '请求失败') as Error & { status?: number }; + const err = new Error(json.message || '请求失败') as Error & { + status?: number; + reason?: string; + }; err.status = json.code === 401 ? 401 : json.code; + err.reason = json.reason; if (json.code === 400) { reportApiError( { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, @@ -224,8 +233,19 @@ export async function request( try { return await requestWithAuthRetry(path, fetchOptions); } catch (e) { - const err = e as Error & { status?: number }; + const err = e as Error & { status?: number; reason?: string }; const message = err.message || '请求失败'; + // 账号停用 / 合伙人绑定失效:强制退出登录 + if (err.reason === 'ACCOUNT_DISABLED') { + if (localStorage.getItem(ACCESS_TOKEN)) { + clearAuth({ keepProfile: true }); + if (!silent) showPartnerToast(message, 'error'); + if (typeof window !== 'undefined' && !isOnAppPath('/login')) { + window.location.href = toAppPath('/login?disabled=1'); + } + } + throw e; + } if (err.status === 401) { if (localStorage.getItem(ACCESS_TOKEN)) { clearAuth({ keepProfile: true }); @@ -260,7 +280,14 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner touchPartnerSession(); return { authenticated: true, partner }; } catch (e) { - const err = e as Error & { status?: number }; + const err = e as Error & { status?: number; reason?: string }; + if (err.reason === 'ACCOUNT_DISABLED') { + clearAuth({ keepProfile: true }); + if (typeof window !== 'undefined' && !isOnAppPath('/login')) { + window.location.href = toAppPath('/login?disabled=1'); + } + return { authenticated: false, partner: getPartnerProfile() }; + } if (err.status === 401) { const refreshed = await refreshSession(); if (refreshed?.partner) { @@ -277,3 +304,17 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner /** @deprecated 使用 PartnerSessionPayload */ export type PartnerAuthPayload = PartnerSessionPayload; + +export function submitStoreInfoChangeRequest(storeId: string, fields: Record) { + return request('PARTNER_H5', `/partner/stores/${storeId}/info-change-request`, { + method: 'POST', + body: JSON.stringify(fields), + }); +} + +export function listStoreInfoChangeRequests(storeId: string) { + return request>( + 'PARTNER_H5', + `/partner/stores/${storeId}/info-change-requests`, + ); +} diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index 640338e..84c0c5f 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -99,7 +99,7 @@ function formatWechatError(e: unknown): string { export default function LoginPage() { const navigate = useNavigate(); const { applySession, refresh, account } = usePartnerSession(); - const [params] = useSearchParams(); + const [params, setSearchParams] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getPartnerProfile(); const remembered = loadRememberedPhone(); @@ -120,6 +120,18 @@ export default function LoginPage() { .catch(() => setWxAuthorize(false)); }, []); + useEffect(() => { + if (params.get('disabled') === '1') { + const tip = '账号已停用或解绑,请重新登录'; + setMsg(tip); + toastError(tip); + const next = new URLSearchParams(params); + next.delete('disabled'); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const quickName = savedProfile?.name ?? '城市合伙人'; const quickCompany = savedProfile?.companyName ?? ''; const quickPhone = savedProfile?.phone || phone; diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx index eee3398..d691521 100644 --- a/apps/h5-partner/src/pages/StoreDetailPage.tsx +++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; -import { request } from '../lib/api'; +import { request, submitStoreInfoChangeRequest, listStoreInfoChangeRequests } from '../lib/api'; import { toastError, toastSuccess } from '../lib/toast'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { canManagePartnerStore } from '../lib/partnerAccess'; @@ -60,6 +60,7 @@ export default function StoreDetailPage() { const [mapPickerOpen, setMapPickerOpen] = useState(false); const [actionError, setActionError] = useState(''); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); + const [pendingInfoChange, setPendingInfoChange] = useState(false); function applyStore(data: Record) { setStore(data); @@ -97,6 +98,12 @@ export default function StoreDetailPage() { setStore(null); setLoadError(e instanceof Error ? e.message : '加载失败'); }); + // 拉取该门店的信息变更审核记录,若有 PENDING 则展示「审核中」横幅 + listStoreInfoChangeRequests(id) + .then((reqs) => + setPendingInfoChange(Array.isArray(reqs) && reqs.some((r) => r.status === 'PENDING')), + ) + .catch(() => {}); }, [id]); async function changeStatus(next: StoreStatusValue) { @@ -162,26 +169,24 @@ export default function StoreDetailPage() { setSaving(true); setActionError(''); try { - const data = await request>('PARTNER_H5', `/partner/stores/${id}/basic`, { - method: 'PUT', - body: JSON.stringify({ - name: form.name.trim(), - contactPhone: form.contactPhone.trim(), - address: form.address.trim(), - intro: form.intro.trim(), - benefitUsageRule: form.benefitUsageRule.trim() || null, - ...(form.latitude.trim() && form.longitude.trim() - ? { - latitude: Number(form.latitude), - longitude: Number(form.longitude), - } - : {}), - }), + // v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店 + await submitStoreInfoChangeRequest(id, { + name: form.name.trim(), + contactPhone: form.contactPhone.trim(), + address: form.address.trim(), + intro: form.intro.trim(), + benefitUsageRule: form.benefitUsageRule.trim() || null, + ...(form.latitude.trim() && form.longitude.trim() + ? { + latitude: Number(form.latitude), + longitude: Number(form.longitude), + } + : {}), }); - applyStore(data); - toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存'); + setPendingInfoChange(true); + toastSuccess('变更已提交,等待总部审核'); } catch (e) { - setActionError(e instanceof Error ? e.message : '保存失败'); + setActionError(e instanceof Error ? e.message : '提交失败'); } finally { setSaving(false); } @@ -279,6 +284,16 @@ export default function StoreDetailPage() { {auditStatus === 'APPROVED' && (

总部审核已通过,可将门店设为营业中。

)} + {pendingInfoChange && ( +
+

+ 基础信息变更审核中 +

+

+ 已提交的资料变更正在等待总部审核,审核通过后才会覆盖门店线上信息。审核期间可继续修改并重新提交。 +

+
+ )} {canMutate && !auditPending && status !== 'CLOSED' && ( @@ -481,8 +496,8 @@ export default function StoreDetailPage() { {canMutate && ( )} diff --git a/apps/h5-shop/src/lib/api.ts b/apps/h5-shop/src/lib/api.ts index b36e390..fadec35 100644 --- a/apps/h5-shop/src/lib/api.ts +++ b/apps/h5-shop/src/lib/api.ts @@ -202,7 +202,7 @@ async function rawRequest( if (authToken) headers.Authorization = `Bearer ${authToken}`; const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - let json: { code: number; message?: string; data?: T }; + let json: { code: number; message?: string; data?: T; reason?: string }; try { json = await res.json(); } catch { @@ -213,8 +213,12 @@ async function rawRequest( throw err; } if (json.code !== 0) { - const err = new Error(json.message || '请求失败') as Error & { status?: number }; + const err = new Error(json.message || '请求失败') as Error & { + status?: number; + reason?: string; + }; err.status = res.status >= 500 ? res.status : json.code; + err.reason = json.reason; if (json.code === 400) { reportApiError( { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, @@ -254,7 +258,15 @@ async function requestWithAuthRetry( try { return await rawRequest(path, options); } catch (e) { - const err = e as Error & { status?: number }; + const err = e as Error & { status?: number; reason?: string }; + // 账号停用 / 门店关闭 / 合伙人绑定失效:强制退出登录 + if (err.reason === 'ACCOUNT_DISABLED') { + clearAuth(); + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.replace('/login?disabled=1'); + } + throw e; + } const canRecover = err.status === 401 && !retried && @@ -303,7 +315,14 @@ export async function ensureSession(): Promise<{ needsSelectStore: needsStoreSelection({ store, stores: me.stores }), }; } catch (e) { - const err = e as Error & { status?: number }; + const err = e as Error & { status?: number; reason?: string }; + if (err.reason === 'ACCOUNT_DISABLED') { + clearAuth(); + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.replace('/login?disabled=1'); + } + return { authenticated: false, store: null, needsSelectStore: false }; + } if (err.status === 401) { const refreshed = await refreshSession(); if (refreshed) { diff --git a/apps/h5-shop/src/lib/useRedeemSuccessBus.ts b/apps/h5-shop/src/lib/useRedeemSuccessBus.ts new file mode 100644 index 0000000..41c3bd0 --- /dev/null +++ b/apps/h5-shop/src/lib/useRedeemSuccessBus.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; + +/** + * v3.5.1 #2:门店端核销即时刷新。 + * 核销成功后,通过浏览器自定义事件通知「门店信息页 / 提现页」即时刷新余额与提现按钮状态, + * 避免用户手动下拉刷新。 + */ + +const REDEEM_SUCCESS_EVENT = 'shop:redeem-success'; + +export type RedeemSuccessPayload = { + redeemNo?: string; + amount?: number; + storeId?: string; +}; + +/** 核销成功页 mount 时调用,广播核销成功事件 */ +export function notifyRedeemSuccess(payload: RedeemSuccessPayload = {}) { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(REDEEM_SUCCESS_EVENT, { detail: payload })); +} + +/** 订阅核销成功事件,回调在事件触发时执行(通常用于刷新余额/提现状态) */ +export function useRedeemSuccessListener( + callback: (payload: RedeemSuccessPayload) => void, + deps: React.DependencyList = [], +) { + const savedCallback = useRef(callback); + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function handler(e: Event) { + const payload = (e as CustomEvent).detail ?? {}; + savedCallback.current(payload); + } + window.addEventListener(REDEEM_SUCCESS_EVENT, handler); + return () => window.removeEventListener(REDEEM_SUCCESS_EVENT, handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index b2ee058..bbadc01 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -86,6 +86,16 @@ export default function LoginPage() { if (hint) setMsg(hint); }, []); + useEffect(() => { + if (params.get('disabled') === '1') { + setMsg('账号已被停用或门店已关闭,请重新登录'); + const next = new URLSearchParams(params); + next.delete('disabled'); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; void handleShopWechatCallbackOnce() diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index 1fe75f0..471cf12 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -14,6 +14,7 @@ import { } from '../lib/wechat-auth'; import { isWechatEnv } from '../lib/weixin'; import { useStorePageView } from '../lib/usePageView'; +import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus'; export default function MinePage() { useStorePageView('store_mine_view'); @@ -43,6 +44,11 @@ export default function MinePage() { void loadMine(); }, [loadMine]); + // v3.5.1 #2:核销成功后即时刷新门店信息(余额等) + useRedeemSuccessListener(() => { + void loadMine(); + }, [loadMine]); + useEffect(() => { if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; void handleShopWechatCallbackOnce() diff --git a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx index a2a0ce4..01e0584 100644 --- a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx +++ b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx @@ -1,5 +1,6 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus'; function formatAmount(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -29,6 +30,14 @@ export default function RedeemSuccessPage() { ? new Date(String(result.createdAt)).toLocaleString('zh-CN') : new Date().toLocaleString('zh-CN'); + // v3.5.1 #2:核销成功后广播事件,通知门店信息页 / 提现页即时刷新 + useEffect(() => { + notifyRedeemSuccess({ + redeemNo: redeemNo !== '—' ? redeemNo : undefined, + amount, + }); + }, [redeemNo, amount]); + return (
diff --git a/apps/h5-shop/src/pages/WithdrawPage.tsx b/apps/h5-shop/src/pages/WithdrawPage.tsx index 3732626..424cbce 100644 --- a/apps/h5-shop/src/pages/WithdrawPage.tsx +++ b/apps/h5-shop/src/pages/WithdrawPage.tsx @@ -9,6 +9,7 @@ import { import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import { request } from '../lib/api'; import { useStorePageView } from '../lib/usePageView'; +import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus'; type StatusFilter = 'all' | StoreWithdrawStatus; @@ -42,6 +43,11 @@ export default function WithdrawPage() { void load(); }, [load]); + // v3.5.1 #2:核销成功后即时刷新可提余额与提现按钮状态 + useRedeemSuccessListener(() => { + void load(); + }, [load]); + const filtered = useMemo(() => { if (statusFilter === 'all') return items; return items.filter((r) => r.status === statusFilter); diff --git a/apps/mini-user/config/index.ts b/apps/mini-user/config/index.ts index ced7c2f..a928fbf 100644 --- a/apps/mini-user/config/index.ts +++ b/apps/mini-user/config/index.ts @@ -11,7 +11,7 @@ const isDevMode = /** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */ const API_ORIGIN = process.env.VITE_API_TARGET ?? - (isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com'); + (isDevMode ? 'http://192.168.1.10:3010' : 'https://api.dukanghaoke.com'); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); @@ -42,7 +42,7 @@ export default defineConfig(async () => ({ }, sourceRoot: 'src', outputRoot: 'dist', - plugins: ['@tarojs/plugin-framework-react', '@tarojs/plugin-html'], + plugins: ['@tarojs/plugin-framework-react'], alias: { // 指向包目录(不是 index.js),否则 react-dom/client 会变成 index.js/client react: REACT_DIR, @@ -98,6 +98,9 @@ export default defineConfig(async () => ({ mini: { /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */ debugReact: isDevMode, + optimizeMainPackage: { + enable: true, + }, postcss: { pxtransform: { enable: true, config: {} }, cssModules: { enable: false }, diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index f547e24..5fbac76 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -17,11 +17,9 @@ "@dukang/shared-types": "workspace:*", "@dukang/shared-ui": "workspace:*", "@dukang/weixin-sdk": "workspace:*", - "element-china-area-data": "^6.1.0", "@tarojs/components": "4.2.0", "@tarojs/helper": "4.2.0", "@tarojs/plugin-framework-react": "4.2.0", - "@tarojs/plugin-html": "4.2.0", "@tarojs/plugin-platform-h5": "4.2.0", "@tarojs/plugin-platform-weapp": "4.2.0", "@tarojs/react": "4.2.0", diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 9414796..b45ee4c 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -4,27 +4,41 @@ export default defineAppConfig({ 'pages/stores/index', 'pages/benefit/index', 'pages/mine/index', - 'pages/product-detail/index', - 'pages/store-detail/index', - 'pages/store-package-detail/index', - 'pages/order-confirm/index', - 'pages/order-confirm-pickup/index', - 'pages/pay/index', - 'pages/orders/index', - 'pages/order-detail/index', - 'pages/order-logistics/index', - 'pages/pickup-receive/index', - 'pages/addresses/index', - 'pages/address-edit/index', - 'pages/customer-service/index', - 'pages/benefit-detail/index', - 'pages/redeem/index', - 'pages/redeem-code/index', - 'pages/redeem-success/index', - 'pages/login/index', - 'pages/user-agreement/index', - 'pages/privacy-policy/index', ], + subPackages: [ + { root: 'pages/product-detail', pages: ['index'] }, + { root: 'pages/store-detail', pages: ['index'] }, + { root: 'pages/store-package-detail', pages: ['index'] }, + { root: 'pages/order-confirm', pages: ['index'] }, + { root: 'pages/order-confirm-pickup', pages: ['index'] }, + { root: 'pages/pay', pages: ['index'] }, + { root: 'pages/orders', pages: ['index'] }, + { root: 'pages/order-detail', pages: ['index'] }, + { root: 'pages/order-logistics', pages: ['index'] }, + { root: 'pages/pickup-receive', pages: ['index'] }, + { root: 'pages/addresses', pages: ['index'] }, + { root: 'pages/address-edit', pages: ['index'] }, + { root: 'pages/customer-service', pages: ['index'] }, + { root: 'pages/benefit-detail', pages: ['index'] }, + { root: 'pages/redeem', pages: ['index'] }, + { root: 'pages/redeem-code', pages: ['index'] }, + { root: 'pages/redeem-success', pages: ['index'] }, + { root: 'pages/login', pages: ['index'] }, + { root: 'pages/user-agreement', pages: ['index'] }, + { root: 'pages/privacy-policy', pages: ['index'] }, + { root: 'pages/invoice-titles', pages: ['index'] }, + { root: 'pages/invoice-apply', pages: ['index'] }, + ], + preloadRule: { + 'pages/home/index': { + network: 'all', + packages: ['pages/product-detail', 'pages/login'], + }, + 'pages/mine/index': { + network: 'all', + packages: ['pages/orders', 'pages/login'], + }, + }, window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#FAF9F7', diff --git a/apps/mini-user/src/app.css b/apps/mini-user/src/app.css index bd7b0b3..97fda38 100644 --- a/apps/mini-user/src/app.css +++ b/apps/mini-user/src/app.css @@ -5,13 +5,7 @@ @import './styles/stores.css'; @import './styles/benefit.css'; @import './styles/mine.css'; -@import './styles/product-detail.css'; -@import './styles/store-detail.css'; -@import './styles/login.css'; -@import './styles/legal.css'; -@import './styles/order.css'; @import './styles/address.css'; -@import './styles/redeem.css'; page, body { diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 30c1ea7..74ecf6e 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -1,5 +1,4 @@ import './lib/intl-polyfill'; -import './lib/text-encoding-polyfill'; import { PropsWithChildren, useRef } from 'react'; import Taro, { useDidShow } from '@tarojs/taro'; import WechatShareBootstrap from './components/WechatShareBootstrap'; diff --git a/apps/mini-user/src/app.weapp.tsx b/apps/mini-user/src/app.weapp.tsx new file mode 100644 index 0000000..830fc66 --- /dev/null +++ b/apps/mini-user/src/app.weapp.tsx @@ -0,0 +1,73 @@ +import './lib/intl-polyfill'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; +import { installClientErrorReporting } from './lib/client-error'; +import { prefetchShareBrandAssets } from './lib/wechat-share'; +import { capturePromoSceneAndTouchScan } from './lib/promo'; +import { initClientVersionChecks } from './lib/client-version'; +import './app.css'; + +installClientErrorReporting(); +prefetchShareBrandAssets(); + +function App({ children }: PropsWithChildren) { + const handlingRef = useRef(false); + + useEffect(() => { + void capturePromoSceneAndTouchScan(); + initClientVersionChecks(); + }, []); + + useDidShow((options?: { + referrerInfo?: { + appId?: string; + extraData?: { status?: string; errormsg?: string; req_extradata?: Record }; + }; + }) => { + if (handlingRef.current) return; + + const referrerInfo = + options?.referrerInfo || + (typeof Taro.getEnterOptionsSync === 'function' + ? ( + Taro.getEnterOptionsSync() as { + referrerInfo?: { + appId?: string; + extraData?: { + status?: string; + errormsg?: string; + req_extradata?: Record; + }; + }; + } + ).referrerInfo + : undefined); + if (!referrerInfo?.appId) return; + + handlingRef.current = true; + void handleWechatOrderConfirmShow({ referrerInfo }) + .then((result) => { + if (result.redirectUrl) { + Taro.redirectTo({ url: result.redirectUrl }).catch(() => { + Taro.reLaunch({ url: result.redirectUrl! }); + }); + return; + } + if (!result.handled || !result.orderId) return; + const pages = Taro.getCurrentPages(); + const cur = pages[pages.length - 1] as { route?: string } | undefined; + const route = cur?.route || ''; + if (route.includes('pickup-receive')) { + Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {}); + } + }) + .finally(() => { + handlingRef.current = false; + }); + }); + + return <>{children}; +} + +export default App; diff --git a/apps/mini-user/src/assets/icons/store-open-badge.png b/apps/mini-user/src/assets/icons/store-open-badge.png index 79eabd6404edf3c80d3c81da47418bea3461fded..13d0e668b7720c37e43ad24e5e905fdcaa3a3abe 100644 GIT binary patch delta 816 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 at$zWuE}!@wYJFz_0000er`MayD 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 literal 0 HcmV?d00001 diff --git a/apps/mini-user/src/assets/icons/可用门店.png b/apps/mini-user/src/assets/icons/可用门店.png index 85cab48f6c6785c1c9f6e6d400d83377e6c4b755..8ea8057b1e1a4a5ed01d671bda84a178c82af651 100644 GIT binary patch delta 1068 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 diff --git a/apps/mini-user/src/assets/icons/已完成.png b/apps/mini-user/src/assets/icons/已完成.png index d0d2b304c3551107c2177f34b18f6fe0e5b43368..3c5e5f97242c9bbfefdeadcea3470e9f3c3279e7 100644 GIT binary patch delta 969 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 diff --git a/apps/mini-user/src/assets/icons/待付款.png b/apps/mini-user/src/assets/icons/待付款.png index ae6ff549be40cbcc5f069316973a0eb83f808f65..6082e77608cfba90d56269d320d675283813a26d 100644 GIT binary patch delta 1802 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 diff --git a/apps/mini-user/src/assets/icons/编辑.png b/apps/mini-user/src/assets/icons/编辑.png new file mode 100644 index 0000000000000000000000000000000000000000..32d85d0dbeeae646a6ad3d366bbd21b813b30672 GIT binary patch literal 751 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58CaNs)Vi3hp+Jfy-O<;Pfnj4m_n$;okZf^~ zyA#8@b22Z19O(d`5Lb{|3zM}L#%nE1*IO8@w=h{}VGJfs(ie5C21;|31o;Is?4O^Y z;Bdd5DNAz$0|Qf>r;B4q#jUq@4E=9AFtj~vp0#NjXa9;-SNbMKNHlu+9`BJ&bVzks zrIp&NdGhZYrqIJT?M{cCuAR^F{x|!(%g#wc-h~`4P!O`@=fPh^X*U0D?7kc<`}gnh z&%)ZrQUU)Z1HM5j) z!;A&zK$1aCEDtU?PGDHGYH6baV}k7!eIBb~0p}9|-kqSSqQogR$%`L;hKYhevom ze33F?Z1GQPc*`Ju;G5M8rWe?Jzf1=);T3K0RSmR8aV&} literal 0 HcmV?d00001 diff --git a/apps/mini-user/src/assets/icons/联系客服.png b/apps/mini-user/src/assets/icons/联系客服.png index 2802a568776932441f772638a7e1c40ad96f0bbc..aee653536827fe4042edcbd4863633ac84e0bcc0 100644 GIT binary patch delta 1418 zcmV;51$FwOE07D27=Hu<0001AcC)Pj000DMK}|sb0I`n?{9y$E0004VQb$4nuFf3k z0000RP)t-s00024I5e*~G_5!_t~fKVI54j`G_5!@t~fNtL8iU{000PdQchC30V?8 zdmeE58!BN(TTUT@b(LmO`@$VYPw6cB<7yfk%3y6b7)@ogBL^-kca_DWE&mvD6a+(x zf?>!|g1Q22bbrhSX)4&*NYLWI*pJdEfFmcBYb5Tl2jb0y~^A8kF7)EMDIf0wk}@rXeVt8 zO9T(ZBtN08a|1ZI;z2^JI|4XVitLHZO{Im>N8YHHQGYR)>%|co^;KcTNJ?_Kh9Yq! zD>Wui-hZlcKu(Z^RATElp<=ykmL;^$Q{Mufsq#Q7K$7grycI8;B0DAH0@Fe(DMhKb z;{Iw`DPdRTfi+U-|N@D+IY#zgd z1Y}@lo?rB*E-I0kG6^NINLe$*yNHrlQCl^MYkw>Q2~9EoDvLWANYS(=4IPBy9P=ZS zxTIZPgS=+qlD6Fq*{kG8Trt0|>C@w=@o1k}u@YQ9y=&0$%HsJ89b)k$r)BSOQ4f=FmM2Pb?=aF8isDYOkxdrTJbdB;J3LRg_7TEE)mr4?5q$aeV$drR*H#JX#( zycdw_n*b^1AUQ}5l7r+RIYW)>$iJUk0mDSkJkW#;;9OB= z^R8_Z!B0Ys&NH}EcE;=xY5i42p9K5pO_+$!=#8SZ(~}D{a^Z8$9)H{* z4Fp%Ro3LVf{6nq9BdsYYc-jT_o-L)!#$~7|r&jAaIi-e{$*EV2vV&VG=rPB&SCFQITPa}D_e)7J3rd;}E((F#QdwwgoIxb= zFCS7?;foAtzt4hpuigqX@K#x6;D4xlMGn*nShr> zm;Q;)@~PjZw~6%DJA2Q+CUB)ZP2~R#yQh!XPI#Xx*&!}xbyAN}G(n!4HA$i$q7P&A zS*oeI7~(M4&jputP`eUz#>lbFy*fwO^(5`U9fr17=~7v`%SkTtiLM_SIe#QX=Pjog zy2h#7m1>v)MtU>bbrs5OxXQ&<$N)M&z`RN6aFP4gPu>0*?N-!Qe2ML*X zqlF2w00000NkvXXt^-0~f=(QozW@LL literal 5538 zcmV;T6^)>b<^_nwM7mxtasnxFNcd2Kv;=gY5X;FGNbr#e5X&7K7vHC0a!pN6ppV3< zNaI_BKFe2vG_dZfm@dti@DBT)fyi(trMM0=HYI76ATTFP%jQUdh#MCgZM9K?oI!52 zBSke`%1|V+PG&%0Gsd(mLqhCx^|HW}OvwUEo;f`d zVtfLm_S@kSWe@~eVX1bR?_KCrsx~K%W~_*Ey=F|0X6f0@-V+2~W6r@h?3UQx1vHNL zoLc$@l3e$GdAPR(;SsL%8d$E4x2L76*=~85u}-%gr#0^-LDXxUHoEMmOqBk5lU@;o zN3tcBCb2`@+H1&mo}ox%l2FFjuMqF`snxw92)qX7#TPH6`x(^T!9?koH|YgIKn?`r zyh7Do*U*|P1ehe0u}!KwDPNN&2)pz219AIGbSkCUy)02Lrd-Jq1S3>ArJ^ZCeb`q^ z_Q4QkWxB!jX0~}UMS8xRZBC^pV`zI>B)YL=Zi{3ei0F}`KG>UlTQ3rRt}97`FyNMA zoKo3+GL$5EzWlj)zWuRzy8E$t;`E4XqmKP!^Y?3!o*sX0{(30V^X+W&1hN(6u{m*U zgrqTJLvM2oX!EGK{DpGK5roNM$lia^o(^u?SCe~(ZSD#7$k2!cZ51zw^Y`m#(pV4% zGK1)Vdd{3C|FTw!BuR=O46uoU95yEfMsQ4%o-g4M?zp3#=_SEFW?h&t&@cBe7G$xQ z<^@yWi;64;61L$NA4hpR8G<) z8CIAuh3ag?t=H+A=PJnZe9LV#=&;QtxRqd%EHEo@lj6U0)C5_A9Drbo6zt^A!kuP@ z6(a6L!7A>kB5orFfIDt8w>_j91_R$Ba*{+#kTRGHMh=T0mOBY|7Vb15J4y_7ahtjA zjDaD_D9~X*lG4_JYHU_blmr1_*aW0NMzVQ~!9gr{5*qly7*s@|e23pZAQps#&FB7W zYF6l9>XWPkA<7e#hNMUZghu({XZ zkuoxSM5PXsD4VH(YnCFEGQ|-7ZxefY&z30Brw4|^6(GbMah3Cgd)N8nQBKEk2H?gm-vc4|QB>Cojp!~Z2kDo+A zkaDSS`cXB>clLFrv4#ks>`e?}6T}Q2HyO>i>=Pn7y9nu=ARKoXEcLlG;!N2`CdZjP z`f&pUm?R_mjlC1f$kV}nViCb(@|_Z74Q)aJ;v1&ItOs8v^{)Fk|f|<*U*LLA&qR^sBmy`<)R4Q>!^t@Dg_z zq8m0G%$Fi)-wN-x z^RrrE{<-KOK|G*s=uLFq2y$d_-=d?Ehi0qD5&cPc3K??6-#s@XG zzo9{Sd+a(Z$w51&MuJZOhw&ob2_nsOqf@KSI=O4b`~)xwe$QRHZyjr&SAs};4|dZ! zwK`_TH16?K%ZN_3{Cg3v1Tkd)BWSw(m(2y`si+PR-~mo@s%3=Z+s}}ABS_&rXi9sB z%S%JjDZ;w#qovgh~ub?78gQbiw#68_2Uoxoe{DAw%MxdzBhOd>}YlB+FDNc|wI4`6-{>+95_+_yd zVVg^6YKf*Kc!&}NdI>2v`Y&Wfa;h335EzLC>m<4-GLmY%p5(9GPSJYpqM3!$Ah3_OFf*J|LQv$mwlD6 z*K&`~)|M__mXoVIG3ca6GzhE;Ld=(w>ot;PTNO5=FYSuY^Uvh^k{wF`;_N{d#g9=- z=+Z)l0SIPniLxcgvY1@mFFMtyMH6~dgt2EEl!VC8v?)?Yr%W8;OpuORf23M7HdskB zWbc`DpDNm z*i|uSf>4Ab2Yoo$o7i74=={TNebX9&1>!tQv~DdRp@ISgNujk&63Ub$?zUnK4dOg^ zNg*U~pzgLE;6xA}NkTMJst$~LN)iV!nH-FvgNP1_UN}v&oCwlEYn`iP1E)r8=SB+% z067qI5XT#o*x|I2TX+FR&WZsR{{HUSgE6o$c5KW+#GB z^h>;S@-Uccoy?#gQu-K~E(wx)4^l63%7TUWaO+^F>uG=nX|6Dg=}uQJF)Pi46fINNg5S zeWF4@2@;h#RGHWiP=dr}5!ELu1o}&ms9;uQQbj-slB(rY?;r>$L4v@nm86P*5+qg2 zsop^lP=W-3Su05u0VPNa%W09}AfN;pj;*z&Ef7$Gv;a+u3jUm-339~voc0Yypj1|jCv)ZQ;(Hw?LkUbKK2Eh5&hGfwz%75 z`=`5%I3!@f;lV|d)&y~>G{&+e;bT-SxK0HYeOdM%{nrIRCxRHFJD191DeF4Q#L^B~ zJxuF)yR05=(p+Z-P6Q$9OB~`h%D9;@&eE1l^3yCH_zG{>9%Cnhl$9qxcmKKh%W0n4 z9{zYsx{kb3EpwCOKJYmagq1gmPqUzZ#_iDb5dj|jEZL+74hfPw6XeI{i9HCXj|eC8 zu0Ja_(vXkncd{`ilbcEIiitBpSOJW}NdhSaB4g0U#2t(=O0jJ}H_x{cdW#T*y-0dM z+E*zMB@7X(jKYi9q*k;rVBbjLDMAodCN&@p8Ici&+urBIUc}IEu?`mit4AKNYt&OK zix@#t0@4s=k|5SXI^j%(Z7HJ!4ntefflrJej2#1M;~$xMgh?`HVvM^=7*H6qs25`} z@ZE49l*CAsAgmbgDL~O!N1Mml<|(D=0z(>2;?X%)+d?mpBVSP!#Y>zZtR0Uwv3U}U zHGL%qTc>?0M(6FH=!{u=De@(uj~8;Vrd|lbf(uWwz2w(jCjgk~ij|BNm?S&^GNv*T z-K0PdZ{%Rj+7X0H)<_H{-0~$F(b0%wUr{#$$n)nK8f%ui2oxmY0nm1~1Z*)YfF54s zBCiBtu$d%i#2pibf_;zOGBL8*=J^tJdj*i&IMl?pHCO`1Ae421L9w;qw`T7IsnoB) zw8biyr%r44iJ@Mj0^j46?TSGt9{>xY*iaq?tC8)$N?R9_U<6@>7)*{$W>`rww5_II zj%2YpDPWwNS>Z917z}pc#I`Mn^81EuVKM#jse%pkIDSxq>;qN-I@^)u293Tgfo0Mh zO#nYD6AZDvoRy@2aX9~e75cgZ1WLO;@}fvF2yQP6yP7X-Yg>679%w@}uQP(|H*N>& zUk?oI45axgfe3eH3gTk-_0-qR5Gd^W_bYeiB0b-JwkwfoOptOAr4UCH1PRelts_1J zR+H-~wBm$I{WC;xUTLEQsgTfXa0K89?weMMG7s#TnwD#uUkTC{IW6ae0KCVX$c(As z5z>3GYDy4BK~qo!R+H~zOO#Q=!(wGDr1xO;hC~o%zD~~OGav(65{~9edIFuj_z|ySE5CAz^2j!^gv8%uwb-$lV zkasBbg(LzcIS_ZWliKyvs?u5sQX!#NF$4t2(fQP>0z(N>Arb2}F}+;Lf#yarwSpe` zsRYSk&_5CgxRb-a^7lm~bdi#w1d&2=;Br8Yxg}~Hb1;0S!wo^M|5#Fj979F#H9}x9 zxw;2AvPOkIWi~|o^GwlwZCVNP$x!;XJ_7vM`Vu?jhRm<}r4DnNNXfyPDnS^9fhLz` zV@icRj*Az_XtfE5a`_2zM6+U*EA;7)666yg`u02mAcu3jQPvXGidCu8hl>*A104G1 z6av+h$~E37>qz34_LU%|we)ftfx>IJPpQgUk{}1`ssv#adX#vS=g!QD1L)=C>SdR% z*9)vT;j`Lt1Lx)PLSBQBPKF$;tP+G#NIb13-^b|4^Cec4EM2^CXXb=vfe2hUsCw#VEf;fxGA78FMtst|I$9o$FGN{RMG_NK55ZgxJQAPJ|Dna(ZNaZDWUJzo6 z#H6qlDWE?Hfxop*Gd5)J7w{97ORV7u zGFSBtJjxQ@1Z0~oK$bnSxr%--Z-|op2S!SecQeNKf;%megO{k=Cd0o|wKgD2-4gZk zS^njH-XZVwTY>d-J)TPmavYb=?yfNR=aGClxyICqM7%^djj^w@;+OZMdwiq`9!iiV zXnD*FPcdC$s>4$p`H|eC;zvZ=JQ#oW^#3W3ROs=R62v38bwz_5bDKX2YeZIy>elNc zpGi30IEIH34D2sSw%l{KH*?42WHPve+NQFcPS0)FlogyWDvezi$ z4gdc$Ff>p~kkE*i^)0ZZikGRAgLlPE48J}CEWMmc5HGNv7K)f7-_bsuE9OsmIz`!R z^K=ulmW_2gQa}lE1Qg%5ED`^hn(t~n$hU|ym>AE!i#5IiO9}E7GM?@)iszq_n_BJS zjHg}&;pfb(1UZM0_~&o`78mJJU_p$hUdI`y;8TK}LdU~r=x|ARk7BcLxWuSTuLP-( z2zE6|YzB7m@6Ljmpg~1a1Zhy^aI^C2RVuCT;@_Rch>a2?MyqtJ6Hl)?Rw0_|l^_94 z@s@wHFJOf&s~VAcZCSK;#YVkrQN#@zkxioG^?7H!Y4=cqG)2rQ|2BGpSTeUxP3&3W z1PIa}#LK`p<}VOE{@nca&>zIzTcQMU$FPwE1i+Dc5zCK}3~HCWj^UIbbx;z_Ga~b+ zpg18>Opf3D-SL>cn^0Sui5WG^uDM+iKmg30kdvz@q4L#t$NTqP4heviAQC7xlwqUr zB9^CA*=*B~?~XS#LGA3rCP;0VWHXk5a7SKbM+|r~jXBPkRta(jAYUIF+G07?1_;>m zIvx%2_c)#mdkiH;eXA-#>SHCElb3uA{k|bDn};@^K7kZBOo-X$i34!;7A-8O1Ze@9 zRFR4#=*XG7%!(A-%NgPd_7X@PP2=2K6N>~TNXM37lAt5s*1A#agK5ClqW@KA|}ulw2q76&*p z$z9$3^+%Kxyi zdTeff&y|4~h=CZ0ff$H^{Qn?dq1!1M;^Jpm4+df&24W!pI^=T%NHb^|h=CZ0ff$H^ z7)WQxmw&csf9XQn%(M)oN66;btU)1SGY|v$uR*@}qkl#Fo%OzVr$`94UB7&L+_La7 z8VusTsMld%O^1Q}#4h*#G6W>x1@XFn$cq-+Jai#n%f$VuU℘GW@4LAzr)9Bzlkl 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 nV { + void Taro.showShareMenu({ + withShareTicket: true, + showShareItems: ['shareAppMessage', 'shareTimeline'], + }).catch(() => { + void Taro.showShareMenu({ withShareTicket: true }).catch(() => {}); + }); + }); + + useEffect(() => { + /* weapp 无 JSSDK 入场 URL */ + }, []); + + return null; +} diff --git a/apps/mini-user/src/lib/client-location.weapp.ts b/apps/mini-user/src/lib/client-location.weapp.ts new file mode 100644 index 0000000..3735458 --- /dev/null +++ b/apps/mini-user/src/lib/client-location.weapp.ts @@ -0,0 +1,25 @@ +import Taro from '@tarojs/taro'; + +export type ClientGpsLocation = { + province?: string; + city?: string; + district?: string; + latitude: number; + longitude: number; + address?: string; +}; + +export async function tryGetClientGpsLocation(): Promise { + try { + const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => { + Taro.getLocation({ + type: 'gcj02', + success: resolve, + fail: reject, + }); + }); + return { latitude: loc.latitude, longitude: loc.longitude }; + } catch { + return null; + } +} diff --git a/apps/mini-user/src/lib/pay-ready.weapp.ts b/apps/mini-user/src/lib/pay-ready.weapp.ts new file mode 100644 index 0000000..83471d2 --- /dev/null +++ b/apps/mini-user/src/lib/pay-ready.weapp.ts @@ -0,0 +1,25 @@ +import { goLogin } from './auth-nav'; +import { isLoggedIn } from './api'; +import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat'; + +export async function ensurePayReady(returnPath: string): Promise { + if (!isLoggedIn()) { + goLogin(returnPath); + return false; + } + + try { + const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); + if (!needsWechatAuthForPay(config, profile)) { + return true; + } + goLogin(returnPath, { needWechat: '1' }); + return false; + } catch (e) { + const msg = e instanceof Error ? e.message : ''; + if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) { + goLogin(returnPath); + } + return false; + } +} diff --git a/apps/mini-user/src/lib/pay-wechat.weapp.ts b/apps/mini-user/src/lib/pay-wechat.weapp.ts new file mode 100644 index 0000000..c19a2aa --- /dev/null +++ b/apps/mini-user/src/lib/pay-wechat.weapp.ts @@ -0,0 +1,89 @@ +import type { + ClientRuntimeConfig, + WechatJsapiPrepayParams, + WechatLoginResult, + WechatPayOrderResult, +} from '@dukang/shared-types'; +import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types'; +import Taro from '@tarojs/taro'; +import { request, saveAuth, type UserProfile } from './api'; +import { isWechatEnv } from './weixin'; + +export function isMiniWechatEnv(): boolean { + return true; +} + +export function isWechatAuthRequiredError(err: unknown): boolean { + return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED; +} + +export async function fetchClientConfig(): Promise { + return request('/common/client-config'); +} + +export async function fetchUserProfile(): Promise { + return request('/auth/me'); +} + +export function needsWechatAuthForPay( + config: ClientRuntimeConfig, + profile: UserProfile | null, +): boolean { + if (!isWxAuthorizeEnabled(config)) return false; + return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat; +} + +export function saveWechatLoginResult(result: WechatLoginResult): boolean { + if (!result.accessToken) return false; + saveAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); + return true; +} + +export async function authorizeWechatForPay(): Promise { + throw new Error('请使用小程序微信授权'); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise { + for (let i = 0; i < maxAttempts; i += 1) { + const order = await request<{ payStatus?: string }>(`/trade/orders/${orderId}`); + if (order.payStatus === 'PAID') return true; + await sleep(2000); + } + return false; +} + +async function invokeMiniPay(prepay: WechatJsapiPrepayParams) { + await Taro.requestPayment({ + timeStamp: prepay.timeStamp, + nonceStr: prepay.nonceStr, + package: prepay.package, + signType: prepay.signType, + paySign: prepay.paySign, + }); +} + +export async function payOrder(orderId: string): Promise<'paid' | 'pending'> { + const result = await request(`/trade/orders/${orderId}/pay`, { + method: 'POST', + }); + + if (result.mode === 'jsapi' && result.prepay) { + await invokeMiniPay(result.prepay as WechatJsapiPrepayParams); + const paid = await waitOrderPaid(orderId); + return paid ? 'paid' : 'pending'; + } + + return 'paid'; +} + +export type WechatBindResult = + | { ok: true; profile?: UserProfile } + | { ok: false; needBindPhone: true; wxSessionKey: string } + | { ok: false; redirecting: true }; diff --git a/apps/mini-user/src/lib/region-data.ts b/apps/mini-user/src/lib/region-data.ts index f3f0f62..2e9bde2 100644 --- a/apps/mini-user/src/lib/region-data.ts +++ b/apps/mini-user/src/lib/region-data.ts @@ -1,20 +1,8 @@ -import { regionData } from 'element-china-area-data'; +import rawTree from './region-tree.json'; export type RegionTree = Record>; -function buildRegionTree(): RegionTree { - const tree: RegionTree = {}; - for (const province of regionData) { - const cities: Record = {}; - for (const city of province.children ?? []) { - cities[city.label] = (city.children ?? []).map((district) => district.label); - } - tree[province.label] = cities; - } - return tree; -} - -export const REGION_TREE: RegionTree = buildRegionTree(); +export const REGION_TREE = rawTree as RegionTree; export const PROVINCES = Object.keys(REGION_TREE); export const REGION_ALL = '全市'; diff --git a/apps/mini-user/src/lib/region-tree.json b/apps/mini-user/src/lib/region-tree.json new file mode 100644 index 0000000..ea28cd2 --- /dev/null +++ b/apps/mini-user/src/lib/region-tree.json @@ -0,0 +1 @@ +{"北京市":{"市辖区":["东城区","西城区","朝阳区","丰台区","石景山区","海淀区","门头沟区","房山区","通州区","顺义区","昌平区","大兴区","怀柔区","平谷区","密云区","延庆区"]},"天津市":{"市辖区":["和平区","河东区","河西区","南开区","河北区","红桥区","东丽区","西青区","津南区","北辰区","武清区","宝坻区","滨海新区","宁河区","静海区","蓟州区"]},"河北省":{"石家庄市":["长安区","桥西区","新华区","井陉矿区","裕华区","藁城区","鹿泉区","栾城区","井陉县","正定县","行唐县","灵寿县","高邑县","深泽县","赞皇县","无极县","平山县","元氏县","赵县","石家庄高新技术产业开发区","石家庄循环化工园区","辛集市","晋州市","新乐市"],"唐山市":["路南区","路北区","古冶区","开平区","丰南区","丰润区","曹妃甸区","滦南县","乐亭县","迁西县","玉田县","河北唐山芦台经济开发区","唐山市汉沽管理区","唐山高新技术产业开发区","河北唐山海港经济开发区","遵化市","迁安市","滦州市"],"秦皇岛市":["海港区","山海关区","北戴河区","抚宁区","青龙满族自治县","昌黎县","卢龙县","秦皇岛市经济技术开发区","北戴河新区"],"邯郸市":["邯山区","丛台区","复兴区","峰峰矿区","肥乡区","永年区","临漳县","成安县","大名县","涉县","磁县","邱县","鸡泽县","广平县","馆陶县","魏县","曲周县","邯郸经济技术开发区","邯郸冀南新区","武安市"],"邢台市":["襄都区","信都区","任泽区","南和区","临城县","内丘县","柏乡县","隆尧县","宁晋县","巨鹿县","新河县","广宗县","平乡县","威县","清河县","临西县","河北邢台经济开发区","南宫市","沙河市"],"保定市":["竞秀区","莲池区","满城区","清苑区","徐水区","涞水县","阜平县","定兴县","唐县","高阳县","容城县","涞源县","望都县","安新县","易县","曲阳县","蠡县","顺平县","博野县","雄县","保定高新技术产业开发区","保定白沟新城","涿州市","定州市","安国市","高碑店市"],"张家口市":["桥东区","桥西区","宣化区","下花园区","万全区","崇礼区","张北县","康保县","沽源县","尚义县","蔚县","阳原县","怀安县","怀来县","涿鹿县","赤城县","张家口经济开发区","张家口市察北管理区","张家口市塞北管理区"],"承德市":["双桥区","双滦区","鹰手营子矿区","承德县","兴隆县","滦平县","隆化县","丰宁满族自治县","宽城满族自治县","围场满族蒙古族自治县","承德高新技术产业开发区","平泉市"],"沧州市":["新华区","运河区","沧县","青县","东光县","海兴县","盐山县","肃宁县","南皮县","吴桥县","献县","孟村回族自治县","河北沧州经济开发区","沧州高新技术产业开发区","沧州渤海新区","泊头市","任丘市","黄骅市","河间市"],"廊坊市":["安次区","广阳区","固安县","永清县","香河县","大城县","文安县","大厂回族自治县","廊坊经济技术开发区","霸州市","三河市"],"衡水市":["桃城区","冀州区","枣强县","武邑县","武强县","饶阳县","安平县","故城县","景县","阜城县","河北衡水高新技术产业开发区","衡水滨湖新区","深州市"]},"山西省":{"太原市":["小店区","迎泽区","杏花岭区","尖草坪区","万柏林区","晋源区","清徐县","阳曲县","娄烦县","山西转型综合改革示范区","古交市"],"大同市":["新荣区","平城区","云冈区","云州区","阳高县","天镇县","广灵县","灵丘县","浑源县","左云县","山西大同经济开发区"],"阳泉市":["城区","矿区","郊区","平定县","盂县"],"长治市":["潞州区","上党区","屯留区","潞城区","襄垣县","平顺县","黎城县","壶关县","长子县","武乡县","沁县","沁源县"],"晋城市":["城区","沁水县","阳城县","陵川县","泽州县","高平市"],"朔州市":["朔城区","平鲁区","山阴县","应县","右玉县","山西朔州经济开发区","怀仁市"],"晋中市":["榆次区","太谷区","榆社县","左权县","和顺县","昔阳县","寿阳县","祁县","平遥县","灵石县","介休市"],"运城市":["盐湖区","临猗县","万荣县","闻喜县","稷山县","新绛县","绛县","垣曲县","夏县","平陆县","芮城县","永济市","河津市"],"忻州市":["忻府区","定襄县","五台县","代县","繁峙县","宁武县","静乐县","神池县","五寨县","岢岚县","河曲县","保德县","偏关县","五台山风景名胜区","原平市"],"临汾市":["尧都区","曲沃县","翼城县","襄汾县","洪洞县","古县","安泽县","浮山县","吉县","乡宁县","大宁县","隰县","永和县","蒲县","汾西县","侯马市","霍州市"],"吕梁市":["离石区","文水县","交城县","兴县","临县","柳林县","石楼县","岚县","方山县","中阳县","交口县","孝义市","汾阳市"]},"内蒙古自治区":{"呼和浩特市":["新城区","回民区","玉泉区","赛罕区","土默特左旗","托克托县","和林格尔县","清水河县","武川县","呼和浩特经济技术开发区"],"包头市":["东河区","昆都仑区","青山区","石拐区","白云鄂博矿区","九原区","土默特右旗","固阳县","达尔罕茂明安联合旗","包头稀土高新技术产业开发区"],"乌海市":["海勃湾区","海南区","乌达区"],"赤峰市":["红山区","元宝山区","松山区","阿鲁科尔沁旗","巴林左旗","巴林右旗","林西县","克什克腾旗","翁牛特旗","喀喇沁旗","宁城县","敖汉旗"],"通辽市":["科尔沁区","科尔沁左翼中旗","科尔沁左翼后旗","开鲁县","库伦旗","奈曼旗","扎鲁特旗","通辽经济技术开发区","霍林郭勒市"],"鄂尔多斯市":["东胜区","康巴什区","达拉特旗","准格尔旗","鄂托克前旗","鄂托克旗","杭锦旗","乌审旗","伊金霍洛旗"],"呼伦贝尔市":["海拉尔区","扎赉诺尔区","阿荣旗","莫力达瓦达斡尔族自治旗","鄂伦春自治旗","鄂温克族自治旗","陈巴尔虎旗","新巴尔虎左旗","新巴尔虎右旗","满洲里市","牙克石市","扎兰屯市","额尔古纳市","根河市"],"巴彦淖尔市":["临河区","五原县","磴口县","乌拉特前旗","乌拉特中旗","乌拉特后旗","杭锦后旗"],"乌兰察布市":["集宁区","卓资县","化德县","商都县","兴和县","凉城县","察哈尔右翼前旗","察哈尔右翼中旗","察哈尔右翼后旗","四子王旗","丰镇市"],"兴安盟":["乌兰浩特市","阿尔山市","科尔沁右翼前旗","科尔沁右翼中旗","扎赉特旗","突泉县"],"锡林郭勒盟":["二连浩特市","锡林浩特市","阿巴嘎旗","苏尼特左旗","苏尼特右旗","东乌珠穆沁旗","西乌珠穆沁旗","太仆寺旗","镶黄旗","正镶白旗","正蓝旗","多伦县","乌拉盖管理区管委会"],"阿拉善盟":["阿拉善左旗","阿拉善右旗","额济纳旗","内蒙古阿拉善高新技术产业开发区"]},"辽宁省":{"沈阳市":["和平区","沈河区","大东区","皇姑区","铁西区","苏家屯区","浑南区","沈北新区","于洪区","辽中区","康平县","法库县","新民市"],"大连市":["中山区","西岗区","沙河口区","甘井子区","旅顺口区","金州区","普兰店区","长海县","瓦房店市","庄河市"],"鞍山市":["铁东区","铁西区","立山区","千山区","台安县","岫岩满族自治县","海城市"],"抚顺市":["新抚区","东洲区","望花区","顺城区","抚顺县","新宾满族自治县","清原满族自治县"],"本溪市":["平山区","溪湖区","明山区","南芬区","本溪满族自治县","桓仁满族自治县"],"丹东市":["元宝区","振兴区","振安区","宽甸满族自治县","东港市","凤城市"],"锦州市":["古塔区","凌河区","太和区","黑山县","义县","凌海市","北镇市"],"营口市":["站前区","西市区","鲅鱼圈区","老边区","盖州市","大石桥市"],"阜新市":["海州区","新邱区","太平区","清河门区","细河区","阜新蒙古族自治县","彰武县"],"辽阳市":["白塔区","文圣区","宏伟区","弓长岭区","太子河区","辽阳县","灯塔市"],"盘锦市":["双台子区","兴隆台区","大洼区","盘山县"],"铁岭市":["银州区","清河区","铁岭县","西丰县","昌图县","调兵山市","开原市"],"朝阳市":["双塔区","龙城区","朝阳县","建平县","喀喇沁左翼蒙古族自治县","北票市","凌源市"],"葫芦岛市":["连山区","龙港区","南票区","绥中县","建昌县","兴城市"]},"吉林省":{"长春市":["南关区","宽城区","朝阳区","二道区","绿园区","双阳区","九台区","农安县","长春经济技术开发区","长春净月高新技术产业开发区","长春高新技术产业开发区","长春汽车经济技术开发区","榆树市","德惠市","公主岭市"],"吉林市":["昌邑区","龙潭区","船营区","丰满区","永吉县","吉林经济开发区","吉林高新技术产业开发区","吉林中国新加坡食品区","蛟河市","桦甸市","舒兰市","磐石市"],"四平市":["铁西区","铁东区","梨树县","伊通满族自治县","双辽市"],"辽源市":["龙山区","西安区","东丰县","东辽县"],"通化市":["东昌区","二道江区","通化县","辉南县","柳河县","梅河口市","集安市"],"白山市":["浑江区","江源区","抚松县","靖宇县","长白朝鲜族自治县","临江市"],"松原市":["宁江区","前郭尔罗斯蒙古族自治县","长岭县","乾安县","吉林松原经济开发区","扶余市"],"白城市":["洮北区","镇赉县","通榆县","吉林白城经济开发区","洮南市","大安市"],"延边朝鲜族自治州":["延吉市","图们市","敦化市","珲春市","龙井市","和龙市","汪清县","安图县"]},"黑龙江省":{"哈尔滨市":["道里区","南岗区","道外区","平房区","松北区","香坊区","呼兰区","阿城区","双城区","依兰县","方正县","宾县","巴彦县","木兰县","通河县","延寿县","尚志市","五常市"],"齐齐哈尔市":["龙沙区","建华区","铁锋区","昂昂溪区","富拉尔基区","碾子山区","梅里斯达斡尔族区","龙江县","依安县","泰来县","甘南县","富裕县","克山县","克东县","拜泉县","讷河市"],"鸡西市":["鸡冠区","恒山区","滴道区","梨树区","城子河区","麻山区","鸡东县","虎林市","密山市"],"鹤岗市":["向阳区","工农区","南山区","兴安区","东山区","兴山区","萝北县","绥滨县"],"双鸭山市":["尖山区","岭东区","四方台区","宝山区","集贤县","友谊县","宝清县","饶河县"],"大庆市":["萨尔图区","龙凤区","让胡路区","红岗区","大同区","肇州县","肇源县","林甸县","杜尔伯特蒙古族自治县","大庆高新技术产业开发区"],"伊春市":["伊美区","乌翠区","友好区","嘉荫县","汤旺县","丰林县","大箐山县","南岔县","金林区","铁力市"],"佳木斯市":["向阳区","前进区","东风区","郊区","桦南县","桦川县","汤原县","同江市","富锦市","抚远市"],"七台河市":["新兴区","桃山区","茄子河区","勃利县"],"牡丹江市":["东安区","阳明区","爱民区","西安区","林口县","绥芬河市","海林市","宁安市","穆棱市","东宁市"],"黑河市":["爱辉区","逊克县","孙吴县","北安市","五大连池市","嫩江市"],"绥化市":["北林区","望奎县","兰西县","青冈县","庆安县","明水县","绥棱县","安达市","肇东市","海伦市"],"大兴安岭地区":["漠河市","呼玛县","塔河县","加格达奇区","松岭区","新林区","呼中区"]},"上海市":{"市辖区":["黄浦区","徐汇区","长宁区","静安区","普陀区","虹口区","杨浦区","闵行区","宝山区","嘉定区","浦东新区","金山区","松江区","青浦区","奉贤区","崇明区"]},"江苏省":{"南京市":["玄武区","秦淮区","建邺区","鼓楼区","浦口区","栖霞区","雨花台区","江宁区","六合区","溧水区","高淳区"],"无锡市":["锡山区","惠山区","滨湖区","梁溪区","新吴区","江阴市","宜兴市"],"徐州市":["鼓楼区","云龙区","贾汪区","泉山区","铜山区","丰县","沛县","睢宁县","徐州经济技术开发区","新沂市","邳州市"],"常州市":["天宁区","钟楼区","新北区","武进区","金坛区","溧阳市"],"苏州市":["虎丘区","吴中区","相城区","姑苏区","吴江区","苏州工业园区","常熟市","张家港市","昆山市","太仓市"],"南通市":["通州区","崇川区","海门区","如东县","南通经济技术开发区","启东市","如皋市","海安市"],"连云港市":["连云区","海州区","赣榆区","东海县","灌云县","灌南县","连云港经济技术开发区"],"淮安市":["淮安区","淮阴区","清江浦区","洪泽区","涟水县","盱眙县","金湖县","淮安经济技术开发区"],"盐城市":["亭湖区","盐都区","大丰区","响水县","滨海县","阜宁县","射阳县","建湖县","盐城经济技术开发区","东台市"],"扬州市":["广陵区","邗江区","江都区","宝应县","扬州经济技术开发区","仪征市","高邮市"],"镇江市":["京口区","润州区","丹徒区","镇江新区","丹阳市","扬中市","句容市"],"泰州市":["海陵区","高港区","姜堰区","兴化市","靖江市","泰兴市"],"宿迁市":["宿城区","宿豫区","沭阳县","泗阳县","泗洪县","宿迁经济技术开发区"]},"浙江省":{"杭州市":["上城区","拱墅区","西湖区","滨江区","萧山区","余杭区","富阳区","临安区","临平区","钱塘区","桐庐县","淳安县","建德市"],"宁波市":["海曙区","江北区","北仑区","镇海区","鄞州区","奉化区","象山县","宁海县","余姚市","慈溪市"],"温州市":["鹿城区","龙湾区","瓯海区","洞头区","永嘉县","平阳县","苍南县","文成县","泰顺县","瑞安市","乐清市","龙港市"],"嘉兴市":["南湖区","秀洲区","嘉善县","海盐县","海宁市","平湖市","桐乡市"],"湖州市":["吴兴区","南浔区","德清县","长兴县","安吉县"],"绍兴市":["越城区","柯桥区","上虞区","新昌县","诸暨市","嵊州市"],"金华市":["婺城区","金东区","武义县","浦江县","磐安县","兰溪市","义乌市","东阳市","永康市"],"衢州市":["柯城区","衢江区","常山县","开化县","龙游县","江山市"],"舟山市":["定海区","普陀区","岱山县","嵊泗县"],"台州市":["椒江区","黄岩区","路桥区","三门县","天台县","仙居县","温岭市","临海市","玉环市"],"丽水市":["莲都区","青田县","缙云县","遂昌县","松阳县","云和县","庆元县","景宁畲族自治县","龙泉市"]},"安徽省":{"合肥市":["瑶海区","庐阳区","蜀山区","包河区","长丰县","肥东县","肥西县","庐江县","合肥高新技术产业开发区","合肥经济技术开发区","合肥新站高新技术产业开发区","巢湖市"],"芜湖市":["镜湖区","鸠江区","弋江区","湾沚区","繁昌区","南陵县","芜湖经济技术开发区","安徽芜湖三山经济开发区","无为市"],"蚌埠市":["龙子湖区","蚌山区","禹会区","淮上区","怀远县","五河县","固镇县","蚌埠市高新技术开发区","蚌埠市经济开发区"],"淮南市":["大通区","田家庵区","谢家集区","八公山区","潘集区","凤台县","寿县"],"马鞍山市":["花山区","雨山区","博望区","当涂县","含山县","和县"],"淮北市":["杜集区","相山区","烈山区","濉溪县"],"铜陵市":["铜官区","义安区","郊区","枞阳县"],"安庆市":["迎江区","大观区","宜秀区","怀宁县","太湖县","宿松县","望江县","岳西县","安徽安庆经济开发区","桐城市","潜山市"],"黄山市":["屯溪区","黄山区","徽州区","歙县","休宁县","黟县","祁门县"],"滁州市":["琅琊区","南谯区","来安县","全椒县","定远县","凤阳县","中新苏滁高新技术产业开发区","滁州经济技术开发区","天长市","明光市"],"阜阳市":["颍州区","颍东区","颍泉区","临泉县","太和县","阜南县","颍上县","阜阳合肥现代产业园区","阜阳经济技术开发区","界首市"],"宿州市":["埇桥区","砀山县","萧县","灵璧县","泗县","宿州马鞍山现代产业园区","宿州经济技术开发区"],"六安市":["金安区","裕安区","叶集区","霍邱县","舒城县","金寨县","霍山县"],"亳州市":["谯城区","涡阳县","蒙城县","利辛县"],"池州市":["贵池区","东至县","石台县","青阳县"],"宣城市":["宣州区","郎溪县","泾县","绩溪县","旌德县","宣城市经济开发区","宁国市","广德市"]},"福建省":{"福州市":["鼓楼区","台江区","仓山区","马尾区","晋安区","长乐区","闽侯县","连江县","罗源县","闽清县","永泰县","平潭县","福清市"],"厦门市":["思明区","海沧区","湖里区","集美区","同安区","翔安区"],"莆田市":["城厢区","涵江区","荔城区","秀屿区","仙游县"],"三明市":["三元区","沙县区","明溪县","清流县","宁化县","大田县","尤溪县","将乐县","泰宁县","建宁县","永安市"],"泉州市":["鲤城区","丰泽区","洛江区","泉港区","惠安县","安溪县","永春县","德化县","金门县","石狮市","晋江市","南安市"],"漳州市":["芗城区","龙文区","龙海区","长泰区","云霄县","漳浦县","诏安县","东山县","南靖县","平和县","华安县"],"南平市":["延平区","建阳区","顺昌县","浦城县","光泽县","松溪县","政和县","邵武市","武夷山市","建瓯市"],"龙岩市":["新罗区","永定区","长汀县","上杭县","武平县","连城县","漳平市"],"宁德市":["蕉城区","霞浦县","古田县","屏南县","寿宁县","周宁县","柘荣县","福安市","福鼎市"]},"江西省":{"南昌市":["东湖区","西湖区","青云谱区","青山湖区","新建区","红谷滩区","南昌县","安义县","进贤县"],"景德镇市":["昌江区","珠山区","浮梁县","乐平市"],"萍乡市":["安源区","湘东区","莲花县","上栗县","芦溪县"],"九江市":["濂溪区","浔阳区","柴桑区","武宁县","修水县","永修县","德安县","都昌县","湖口县","彭泽县","瑞昌市","共青城市","庐山市"],"新余市":["渝水区","分宜县"],"鹰潭市":["月湖区","余江区","贵溪市"],"赣州市":["章贡区","南康区","赣县区","信丰县","大余县","上犹县","崇义县","安远县","定南县","全南县","宁都县","于都县","兴国县","会昌县","寻乌县","石城县","瑞金市","龙南市"],"吉安市":["吉州区","青原区","吉安县","吉水县","峡江县","新干县","永丰县","泰和县","遂川县","万安县","安福县","永新县","井冈山市"],"宜春市":["袁州区","奉新县","万载县","上高县","宜丰县","靖安县","铜鼓县","丰城市","樟树市","高安市"],"抚州市":["临川区","东乡区","南城县","黎川县","南丰县","崇仁县","乐安县","宜黄县","金溪县","资溪县","广昌县"],"上饶市":["信州区","广丰区","广信区","玉山县","铅山县","横峰县","弋阳县","余干县","鄱阳县","万年县","婺源县","德兴市"]},"山东省":{"济南市":["历下区","市中区","槐荫区","天桥区","历城区","长清区","章丘区","济阳区","莱芜区","钢城区","平阴县","商河县","济南高新技术产业开发区"],"青岛市":["市南区","市北区","黄岛区","崂山区","李沧区","城阳区","即墨区","胶州市","平度市","莱西市"],"淄博市":["淄川区","张店区","博山区","临淄区","周村区","桓台县","高青县","沂源县"],"枣庄市":["市中区","薛城区","峄城区","台儿庄区","山亭区","滕州市"],"东营市":["东营区","河口区","垦利区","利津县","广饶县","东营经济技术开发区","东营港经济开发区"],"烟台市":["芝罘区","福山区","牟平区","莱山区","蓬莱区","烟台高新技术产业开发区","烟台经济技术开发区","龙口市","莱阳市","莱州市","招远市","栖霞市","海阳市"],"潍坊市":["潍城区","寒亭区","坊子区","奎文区","临朐县","昌乐县","潍坊滨海经济技术开发区","青州市","诸城市","寿光市","安丘市","高密市","昌邑市"],"济宁市":["任城区","兖州区","微山县","鱼台县","金乡县","嘉祥县","汶上县","泗水县","梁山县","济宁高新技术产业开发区","曲阜市","邹城市"],"泰安市":["泰山区","岱岳区","宁阳县","东平县","新泰市","肥城市"],"威海市":["环翠区","文登区","威海火炬高技术产业开发区","威海经济技术开发区","威海临港经济技术开发区","荣成市","乳山市"],"日照市":["东港区","岚山区","五莲县","莒县","日照经济技术开发区"],"临沂市":["兰山区","罗庄区","河东区","沂南县","郯城县","沂水县","兰陵县","费县","平邑县","莒南县","蒙阴县","临沭县","临沂高新技术产业开发区"],"德州市":["德城区","陵城区","宁津县","庆云县","临邑县","齐河县","平原县","夏津县","武城县","德州天衢新区","乐陵市","禹城市"],"聊城市":["东昌府区","茌平区","阳谷县","莘县","东阿县","冠县","高唐县","临清市"],"滨州市":["滨城区","沾化区","惠民县","阳信县","无棣县","博兴县","邹平市"],"菏泽市":["牡丹区","定陶区","曹县","单县","成武县","巨野县","郓城县","鄄城县","东明县","菏泽经济技术开发区","菏泽高新技术开发区"]},"河南省":{"郑州市":["中原区","二七区","管城回族区","金水区","上街区","惠济区","中牟县","郑州经济技术开发区","郑州高新技术产业开发区","郑州航空港经济综合实验区","巩义市","荥阳市","新密市","新郑市","登封市"],"开封市":["龙亭区","顺河回族区","鼓楼区","禹王台区","祥符区","杞县","通许县","尉氏县","兰考县"],"洛阳市":["老城区","西工区","瀍河回族区","涧西区","偃师区","孟津区","洛龙区","新安县","栾川县","嵩县","汝阳县","宜阳县","洛宁县","伊川县","洛阳高新技术产业开发区"],"平顶山市":["新华区","卫东区","石龙区","湛河区","宝丰县","叶县","鲁山县","郏县","平顶山高新技术产业开发区","平顶山市城乡一体化示范区","舞钢市","汝州市"],"安阳市":["文峰区","北关区","殷都区","龙安区","安阳县","汤阴县","滑县","内黄县","安阳高新技术产业开发区","林州市"],"鹤壁市":["鹤山区","山城区","淇滨区","浚县","淇县","鹤壁经济技术开发区"],"新乡市":["红旗区","卫滨区","凤泉区","牧野区","新乡县","获嘉县","原阳县","延津县","封丘县","新乡高新技术产业开发区","新乡经济技术开发区","新乡市平原城乡一体化示范区","卫辉市","辉县市","长垣市"],"焦作市":["解放区","中站区","马村区","山阳区","修武县","博爱县","武陟县","温县","焦作城乡一体化示范区","沁阳市","孟州市"],"濮阳市":["华龙区","清丰县","南乐县","范县","台前县","濮阳县","河南濮阳工业园区","濮阳经济技术开发区"],"许昌市":["魏都区","建安区","鄢陵县","襄城县","许昌经济技术开发区","禹州市","长葛市"],"漯河市":["源汇区","郾城区","召陵区","舞阳县","临颍县","漯河经济技术开发区"],"三门峡市":["湖滨区","陕州区","渑池县","卢氏县","河南三门峡经济开发区","义马市","灵宝市"],"南阳市":["宛城区","卧龙区","南召县","方城县","西峡县","镇平县","内乡县","淅川县","社旗县","唐河县","新野县","桐柏县","南阳高新技术产业开发区","南阳市城乡一体化示范区","邓州市"],"商丘市":["梁园区","睢阳区","民权县","睢县","宁陵县","柘城县","虞城县","夏邑县","豫东综合物流产业聚集区","河南商丘经济开发区","永城市"],"信阳市":["浉河区","平桥区","罗山县","光山县","新县","商城县","固始县","潢川县","淮滨县","息县","信阳高新技术产业开发区"],"周口市":["川汇区","淮阳区","扶沟县","西华县","商水县","沈丘县","郸城县","太康县","鹿邑县","周口临港开发区","项城市"],"驻马店市":["驿城区","西平县","上蔡县","平舆县","正阳县","确山县","泌阳县","汝南县","遂平县","新蔡县","河南驻马店经济开发区"],"省直辖县级行政区划":["济源市"]},"湖北省":{"武汉市":["江岸区","江汉区","硚口区","汉阳区","武昌区","青山区","洪山区","东西湖区","汉南区","蔡甸区","江夏区","黄陂区","新洲区"],"黄石市":["黄石港区","西塞山区","下陆区","铁山区","阳新县","大冶市"],"十堰市":["茅箭区","张湾区","郧阳区","郧西县","竹山县","竹溪县","房县","丹江口市"],"宜昌市":["西陵区","伍家岗区","点军区","猇亭区","夷陵区","远安县","兴山县","秭归县","长阳土家族自治县","五峰土家族自治县","宜都市","当阳市","枝江市"],"襄阳市":["襄城区","樊城区","襄州区","南漳县","谷城县","保康县","老河口市","枣阳市","宜城市"],"鄂州市":["梁子湖区","华容区","鄂城区"],"荆门市":["东宝区","掇刀区","沙洋县","钟祥市","京山市"],"孝感市":["孝南区","孝昌县","大悟县","云梦县","应城市","安陆市","汉川市"],"荆州市":["沙市区","荆州区","公安县","江陵县","荆州经济技术开发区","石首市","洪湖市","松滋市","监利市"],"黄冈市":["黄州区","团风县","红安县","罗田县","英山县","浠水县","蕲春县","黄梅县","龙感湖管理区","麻城市","武穴市"],"咸宁市":["咸安区","嘉鱼县","通城县","崇阳县","通山县","赤壁市"],"随州市":["曾都区","随县","广水市"],"恩施土家族苗族自治州":["恩施市","利川市","建始县","巴东县","宣恩县","咸丰县","来凤县","鹤峰县"],"省直辖县级行政区划":["仙桃市","潜江市","天门市","神农架林区"]},"湖南省":{"长沙市":["芙蓉区","天心区","岳麓区","开福区","雨花区","望城区","长沙县","浏阳市","宁乡市"],"株洲市":["荷塘区","芦淞区","石峰区","天元区","渌口区","攸县","茶陵县","炎陵县","醴陵市"],"湘潭市":["雨湖区","岳塘区","湘潭县","湖南湘潭高新技术产业园区","湘潭昭山示范区","湘潭九华示范区","湘乡市","韶山市"],"衡阳市":["珠晖区","雁峰区","石鼓区","蒸湘区","南岳区","衡阳县","衡南县","衡山县","衡东县","祁东县","湖南衡阳松木经济开发区","湖南衡阳高新技术产业园区","耒阳市","常宁市"],"邵阳市":["双清区","大祥区","北塔区","新邵县","邵阳县","隆回县","洞口县","绥宁县","新宁县","城步苗族自治县","武冈市","邵东市"],"岳阳市":["岳阳楼区","云溪区","君山区","岳阳县","华容县","湘阴县","平江县","岳阳市屈原管理区","汨罗市","临湘市"],"常德市":["武陵区","鼎城区","安乡县","汉寿县","澧县","临澧县","桃源县","石门县","常德市西洞庭管理区","津市市"],"张家界市":["永定区","武陵源区","慈利县","桑植县"],"益阳市":["资阳区","赫山区","南县","桃江县","安化县","益阳市大通湖管理区","湖南益阳高新技术产业园区","沅江市"],"郴州市":["北湖区","苏仙区","桂阳县","宜章县","永兴县","嘉禾县","临武县","汝城县","桂东县","安仁县","资兴市"],"永州市":["零陵区","冷水滩区","东安县","双牌县","道县","江永县","宁远县","蓝山县","新田县","江华瑶族自治县","永州经济技术开发区","永州市回龙圩管理区","祁阳市"],"怀化市":["鹤城区","中方县","沅陵县","辰溪县","溆浦县","会同县","麻阳苗族自治县","新晃侗族自治县","芷江侗族自治县","靖州苗族侗族自治县","通道侗族自治县","怀化市洪江管理区","洪江市"],"娄底市":["娄星区","双峰县","新化县","冷水江市","涟源市"],"湘西土家族苗族自治州":["吉首市","泸溪县","凤凰县","花垣县","保靖县","古丈县","永顺县","龙山县"]},"广东省":{"广州市":["荔湾区","越秀区","海珠区","天河区","白云区","黄埔区","番禺区","花都区","南沙区","从化区","增城区"],"韶关市":["武江区","浈江区","曲江区","始兴县","仁化县","翁源县","乳源瑶族自治县","新丰县","乐昌市","南雄市"],"深圳市":["罗湖区","福田区","南山区","宝安区","龙岗区","盐田区","龙华区","坪山区","光明区"],"珠海市":["香洲区","斗门区","金湾区"],"汕头市":["龙湖区","金平区","濠江区","潮阳区","潮南区","澄海区","南澳县"],"佛山市":["禅城区","南海区","顺德区","三水区","高明区"],"江门市":["蓬江区","江海区","新会区","台山市","开平市","鹤山市","恩平市"],"湛江市":["赤坎区","霞山区","坡头区","麻章区","遂溪县","徐闻县","廉江市","雷州市","吴川市"],"茂名市":["茂南区","电白区","高州市","化州市","信宜市"],"肇庆市":["端州区","鼎湖区","高要区","广宁县","怀集县","封开县","德庆县","四会市"],"惠州市":["惠城区","惠阳区","博罗县","惠东县","龙门县"],"梅州市":["梅江区","梅县区","大埔县","丰顺县","五华县","平远县","蕉岭县","兴宁市"],"汕尾市":["城区","海丰县","陆河县","陆丰市"],"河源市":["源城区","紫金县","龙川县","连平县","和平县","东源县"],"阳江市":["江城区","阳东区","阳西县","阳春市"],"清远市":["清城区","清新区","佛冈县","阳山县","连山壮族瑶族自治县","连南瑶族自治县","英德市","连州市"],"东莞市":["东城街道","南城街道","万江街道","莞城街道","石碣镇","石龙镇","茶山镇","石排镇","企石镇","横沥镇","桥头镇","谢岗镇","东坑镇","常平镇","寮步镇","樟木头镇","大朗镇","黄江镇","清溪镇","塘厦镇","凤岗镇","大岭山镇","长安镇","虎门镇","厚街镇","沙田镇","道滘镇","洪梅镇","麻涌镇","望牛墩镇","中堂镇","高埗镇","松山湖","东莞港","东莞生态园","东莞滨海湾新区"],"中山市":["石岐街道","东区街道","中山港街道","西区街道","南区街道","五桂山街道","民众街道","南朗街道","黄圃镇","东凤镇","古镇镇","沙溪镇","坦洲镇","港口镇","三角镇","横栏镇","南头镇","阜沙镇","三乡镇","板芙镇","大涌镇","神湾镇","小榄镇"],"潮州市":["湘桥区","潮安区","饶平县"],"揭阳市":["榕城区","揭东区","揭西县","惠来县","普宁市"],"云浮市":["云城区","云安区","新兴县","郁南县","罗定市"]},"广西壮族自治区":{"南宁市":["兴宁区","青秀区","江南区","西乡塘区","良庆区","邕宁区","武鸣区","隆安县","马山县","上林县","宾阳县","横州市"],"柳州市":["城中区","鱼峰区","柳南区","柳北区","柳江区","柳城县","鹿寨县","融安县","融水苗族自治县","三江侗族自治县"],"桂林市":["秀峰区","叠彩区","象山区","七星区","雁山区","临桂区","阳朔县","灵川县","全州县","兴安县","永福县","灌阳县","龙胜各族自治县","资源县","平乐县","恭城瑶族自治县","荔浦市"],"梧州市":["万秀区","长洲区","龙圩区","苍梧县","藤县","蒙山县","岑溪市"],"北海市":["海城区","银海区","铁山港区","合浦县"],"防城港市":["港口区","防城区","上思县","东兴市"],"钦州市":["钦南区","钦北区","灵山县","浦北县"],"贵港市":["港北区","港南区","覃塘区","平南县","桂平市"],"玉林市":["玉州区","福绵区","容县","陆川县","博白县","兴业县","北流市"],"百色市":["右江区","田阳区","田东县","德保县","那坡县","凌云县","乐业县","田林县","西林县","隆林各族自治县","靖西市","平果市"],"贺州市":["八步区","平桂区","昭平县","钟山县","富川瑶族自治县"],"河池市":["金城江区","宜州区","南丹县","天峨县","凤山县","东兰县","罗城仫佬族自治县","环江毛南族自治县","巴马瑶族自治县","都安瑶族自治县","大化瑶族自治县"],"来宾市":["兴宾区","忻城县","象州县","武宣县","金秀瑶族自治县","合山市"],"崇左市":["江州区","扶绥县","宁明县","龙州县","大新县","天等县","凭祥市"]},"海南省":{"海口市":["秀英区","龙华区","琼山区","美兰区"],"三亚市":["海棠区","吉阳区","天涯区","崖州区"],"三沙市":["西沙群岛","南沙群岛","中沙群岛的岛礁及其海域"],"儋州市":["那大镇","和庆镇","南丰镇","大成镇","雅星镇","兰洋镇","光村镇","木棠镇","海头镇","峨蔓镇","王五镇","白马井镇","中和镇","排浦镇","东成镇","新州镇","洋浦经济开发区","华南热作学院"],"省直辖县级行政区划":["五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]},"重庆市":{"市辖区":["万州区","涪陵区","渝中区","大渡口区","江北区","沙坪坝区","九龙坡区","南岸区","北碚区","綦江区","大足区","渝北区","巴南区","黔江区","长寿区","江津区","合川区","永川区","南川区","璧山区","铜梁区","潼南区","荣昌区","开州区","梁平区","武隆区"],"县":["城口县","丰都县","垫江县","忠县","云阳县","奉节县","巫山县","巫溪县","石柱土家族自治县","秀山土家族苗族自治县","酉阳土家族苗族自治县","彭水苗族土家族自治县"]},"四川省":{"成都市":["锦江区","青羊区","金牛区","武侯区","成华区","龙泉驿区","青白江区","新都区","温江区","双流区","郫都区","新津区","金堂县","大邑县","蒲江县","都江堰市","彭州市","邛崃市","崇州市","简阳市"],"自贡市":["自流井区","贡井区","大安区","沿滩区","荣县","富顺县"],"攀枝花市":["东区","西区","仁和区","米易县","盐边县"],"泸州市":["江阳区","纳溪区","龙马潭区","泸县","合江县","叙永县","古蔺县"],"德阳市":["旌阳区","罗江区","中江县","广汉市","什邡市","绵竹市"],"绵阳市":["涪城区","游仙区","安州区","三台县","盐亭县","梓潼县","北川羌族自治县","平武县","江油市"],"广元市":["利州区","昭化区","朝天区","旺苍县","青川县","剑阁县","苍溪县"],"遂宁市":["船山区","安居区","蓬溪县","大英县","射洪市"],"内江市":["市中区","东兴区","威远县","资中县","隆昌市"],"乐山市":["市中区","沙湾区","五通桥区","金口河区","犍为县","井研县","夹江县","沐川县","峨边彝族自治县","马边彝族自治县","峨眉山市"],"南充市":["顺庆区","高坪区","嘉陵区","南部县","营山县","蓬安县","仪陇县","西充县","阆中市"],"眉山市":["东坡区","彭山区","仁寿县","洪雅县","丹棱县","青神县"],"宜宾市":["翠屏区","南溪区","叙州区","江安县","长宁县","高县","珙县","筠连县","兴文县","屏山县"],"广安市":["广安区","前锋区","岳池县","武胜县","邻水县","华蓥市"],"达州市":["通川区","达川区","宣汉县","开江县","大竹县","渠县","万源市"],"雅安市":["雨城区","名山区","荥经县","汉源县","石棉县","天全县","芦山县","宝兴县"],"巴中市":["巴州区","恩阳区","通江县","南江县","平昌县"],"资阳市":["雁江区","安岳县","乐至县"],"阿坝藏族羌族自治州":["马尔康市","汶川县","理县","茂县","松潘县","九寨沟县","金川县","小金县","黑水县","壤塘县","阿坝县","若尔盖县","红原县"],"甘孜藏族自治州":["康定市","泸定县","丹巴县","九龙县","雅江县","道孚县","炉霍县","甘孜县","新龙县","德格县","白玉县","石渠县","色达县","理塘县","巴塘县","乡城县","稻城县","得荣县"],"凉山彝族自治州":["西昌市","会理市","木里藏族自治县","盐源县","德昌县","会东县","宁南县","普格县","布拖县","金阳县","昭觉县","喜德县","冕宁县","越西县","甘洛县","美姑县","雷波县"]},"贵州省":{"贵阳市":["南明区","云岩区","花溪区","乌当区","白云区","观山湖区","开阳县","息烽县","修文县","清镇市"],"六盘水市":["钟山区","六枝特区","水城区","盘州市"],"遵义市":["红花岗区","汇川区","播州区","桐梓县","绥阳县","正安县","道真仡佬族苗族自治县","务川仡佬族苗族自治县","凤冈县","湄潭县","余庆县","习水县","赤水市","仁怀市"],"安顺市":["西秀区","平坝区","普定县","镇宁布依族苗族自治县","关岭布依族苗族自治县","紫云苗族布依族自治县"],"毕节市":["七星关区","大方县","金沙县","织金县","纳雍县","威宁彝族回族苗族自治县","赫章县","黔西市"],"铜仁市":["碧江区","万山区","江口县","玉屏侗族自治县","石阡县","思南县","印江土家族苗族自治县","德江县","沿河土家族自治县","松桃苗族自治县"],"黔西南布依族苗族自治州":["兴义市","兴仁市","普安县","晴隆县","贞丰县","望谟县","册亨县","安龙县"],"黔东南苗族侗族自治州":["凯里市","黄平县","施秉县","三穗县","镇远县","岑巩县","天柱县","锦屏县","剑河县","台江县","黎平县","榕江县","从江县","雷山县","麻江县","丹寨县"],"黔南布依族苗族自治州":["都匀市","福泉市","荔波县","贵定县","瓮安县","独山县","平塘县","罗甸县","长顺县","龙里县","惠水县","三都水族自治县"]},"云南省":{"昆明市":["五华区","盘龙区","官渡区","西山区","东川区","呈贡区","晋宁区","富民县","宜良县","石林彝族自治县","嵩明县","禄劝彝族苗族自治县","寻甸回族彝族自治县","安宁市"],"曲靖市":["麒麟区","沾益区","马龙区","陆良县","师宗县","罗平县","富源县","会泽县","宣威市"],"玉溪市":["红塔区","江川区","通海县","华宁县","易门县","峨山彝族自治县","新平彝族傣族自治县","元江哈尼族彝族傣族自治县","澄江市"],"保山市":["隆阳区","施甸县","龙陵县","昌宁县","腾冲市"],"昭通市":["昭阳区","鲁甸县","巧家县","盐津县","大关县","永善县","绥江县","镇雄县","彝良县","威信县","水富市"],"丽江市":["古城区","玉龙纳西族自治县","永胜县","华坪县","宁蒗彝族自治县"],"普洱市":["思茅区","宁洱哈尼族彝族自治县","墨江哈尼族自治县","景东彝族自治县","景谷傣族彝族自治县","镇沅彝族哈尼族拉祜族自治县","江城哈尼族彝族自治县","孟连傣族拉祜族佤族自治县","澜沧拉祜族自治县","西盟佤族自治县"],"临沧市":["临翔区","凤庆县","云县","永德县","镇康县","双江拉祜族佤族布朗族傣族自治县","耿马傣族佤族自治县","沧源佤族自治县"],"楚雄彝族自治州":["楚雄市","禄丰市","双柏县","牟定县","南华县","姚安县","大姚县","永仁县","元谋县","武定县"],"红河哈尼族彝族自治州":["个旧市","开远市","蒙自市","弥勒市","屏边苗族自治县","建水县","石屏县","泸西县","元阳县","红河县","金平苗族瑶族傣族自治县","绿春县","河口瑶族自治县"],"文山壮族苗族自治州":["文山市","砚山县","西畴县","麻栗坡县","马关县","丘北县","广南县","富宁县"],"西双版纳傣族自治州":["景洪市","勐海县","勐腊县"],"大理白族自治州":["大理市","漾濞彝族自治县","祥云县","宾川县","弥渡县","南涧彝族自治县","巍山彝族回族自治县","永平县","云龙县","洱源县","剑川县","鹤庆县"],"德宏傣族景颇族自治州":["瑞丽市","芒市","梁河县","盈江县","陇川县"],"怒江傈僳族自治州":["泸水市","福贡县","贡山独龙族怒族自治县","兰坪白族普米族自治县"],"迪庆藏族自治州":["香格里拉市","德钦县","维西傈僳族自治县"]},"西藏自治区":{"拉萨市":["城关区","堆龙德庆区","达孜区","林周县","当雄县","尼木县","曲水县","墨竹工卡县","格尔木藏青工业园区","拉萨经济技术开发区","西藏文化旅游创意园区","达孜工业园区"],"日喀则市":["桑珠孜区","南木林县","江孜县","定日县","萨迦县","拉孜县","昂仁县","谢通门县","白朗县","仁布县","康马县","定结县","仲巴县","亚东县","吉隆县","聂拉木县","萨嘎县","岗巴县"],"昌都市":["卡若区","江达县","贡觉县","类乌齐县","丁青县","察雅县","八宿县","左贡县","芒康县","洛隆县","边坝县"],"林芝市":["巴宜区","工布江达县","墨脱县","波密县","察隅县","朗县","米林市"],"山南市":["乃东区","扎囊县","贡嘎县","桑日县","琼结县","曲松县","措美县","洛扎县","加查县","隆子县","浪卡子县","错那市"],"那曲市":["色尼区","嘉黎县","比如县","聂荣县","安多县","申扎县","索县","班戈县","巴青县","尼玛县","双湖县"],"阿里地区":["普兰县","札达县","噶尔县","日土县","革吉县","改则县","措勤县"]},"陕西省":{"西安市":["新城区","碑林区","莲湖区","灞桥区","未央区","雁塔区","阎良区","临潼区","长安区","高陵区","鄠邑区","蓝田县","周至县"],"铜川市":["王益区","印台区","耀州区","宜君县"],"宝鸡市":["渭滨区","金台区","陈仓区","凤翔区","岐山县","扶风县","眉县","陇县","千阳县","麟游县","凤县","太白县"],"咸阳市":["秦都区","杨陵区","渭城区","三原县","泾阳县","乾县","礼泉县","永寿县","长武县","旬邑县","淳化县","武功县","兴平市","彬州市"],"渭南市":["临渭区","华州区","潼关县","大荔县","合阳县","澄城县","蒲城县","白水县","富平县","韩城市","华阴市"],"延安市":["宝塔区","安塞区","延长县","延川县","志丹县","吴起县","甘泉县","富县","洛川县","宜川县","黄龙县","黄陵县","子长市"],"汉中市":["汉台区","南郑区","城固县","洋县","西乡县","勉县","宁强县","略阳县","镇巴县","留坝县","佛坪县"],"榆林市":["榆阳区","横山区","府谷县","靖边县","定边县","绥德县","米脂县","佳县","吴堡县","清涧县","子洲县","神木市"],"安康市":["汉滨区","汉阴县","石泉县","宁陕县","紫阳县","岚皋县","平利县","镇坪县","白河县","旬阳市"],"商洛市":["商州区","洛南县","丹凤县","商南县","山阳县","镇安县","柞水县"]},"甘肃省":{"兰州市":["城关区","七里河区","西固区","安宁区","红古区","永登县","皋兰县","榆中县","兰州新区"],"嘉峪关市":["雄关街道","钢城街道","新城镇","峪泉镇","文殊镇"],"金昌市":["金川区","永昌县"],"白银市":["白银区","平川区","靖远县","会宁县","景泰县"],"天水市":["秦州区","麦积区","清水县","秦安县","甘谷县","武山县","张家川回族自治县"],"武威市":["凉州区","民勤县","古浪县","天祝藏族自治县"],"张掖市":["甘州区","肃南裕固族自治县","民乐县","临泽县","高台县","山丹县"],"平凉市":["崆峒区","泾川县","灵台县","崇信县","庄浪县","静宁县","华亭市"],"酒泉市":["肃州区","金塔县","瓜州县","肃北蒙古族自治县","阿克塞哈萨克族自治县","玉门市","敦煌市"],"庆阳市":["西峰区","庆城县","环县","华池县","合水县","正宁县","宁县","镇原县"],"定西市":["安定区","通渭县","陇西县","渭源县","临洮县","漳县","岷县"],"陇南市":["武都区","成县","文县","宕昌县","康县","西和县","礼县","徽县","两当县"],"临夏回族自治州":["临夏市","临夏县","康乐县","永靖县","广河县","和政县","东乡族自治县","积石山保安族东乡族撒拉族自治县"],"甘南藏族自治州":["合作市","临潭县","卓尼县","舟曲县","迭部县","玛曲县","碌曲县","夏河县"]},"青海省":{"西宁市":["城东区","城中区","城西区","城北区","湟中区","大通回族土族自治县","湟源县"],"海东市":["乐都区","平安区","民和回族土族自治县","互助土族自治县","化隆回族自治县","循化撒拉族自治县"],"海北藏族自治州":["门源回族自治县","祁连县","海晏县","刚察县"],"黄南藏族自治州":["同仁市","尖扎县","泽库县","河南蒙古族自治县"],"海南藏族自治州":["共和县","同德县","贵德县","兴海县","贵南县"],"果洛藏族自治州":["玛沁县","班玛县","甘德县","达日县","久治县","玛多县"],"玉树藏族自治州":["玉树市","杂多县","称多县","治多县","囊谦县","曲麻莱县"],"海西蒙古族藏族自治州":["格尔木市","德令哈市","茫崖市","乌兰县","都兰县","天峻县","大柴旦行政委员会"]},"宁夏回族自治区":{"银川市":["兴庆区","西夏区","金凤区","永宁县","贺兰县","灵武市"],"石嘴山市":["大武口区","惠农区","平罗县"],"吴忠市":["利通区","红寺堡区","盐池县","同心县","青铜峡市"],"固原市":["原州区","西吉县","隆德县","泾源县","彭阳县"],"中卫市":["沙坡头区","中宁县","海原县"]},"新疆维吾尔自治区":{"乌鲁木齐市":["天山区","沙依巴克区","新市区","水磨沟区","头屯河区","达坂城区","米东区","乌鲁木齐县"],"克拉玛依市":["独山子区","克拉玛依区","白碱滩区","乌尔禾区"],"吐鲁番市":["高昌区","鄯善县","托克逊县"],"哈密市":["伊州区","巴里坤哈萨克自治县","伊吾县"],"昌吉回族自治州":["昌吉市","阜康市","呼图壁县","玛纳斯县","奇台县","吉木萨尔县","木垒哈萨克自治县"],"博尔塔拉蒙古自治州":["博乐市","阿拉山口市","精河县","温泉县"],"巴音郭楞蒙古自治州":["库尔勒市","轮台县","尉犁县","若羌县","且末县","焉耆回族自治县","和静县","和硕县","博湖县"],"阿克苏地区":["阿克苏市","库车市","温宿县","沙雅县","新和县","拜城县","乌什县","阿瓦提县","柯坪县"],"克孜勒苏柯尔克孜自治州":["阿图什市","阿克陶县","阿合奇县","乌恰县"],"喀什地区":["喀什市","疏附县","疏勒县","英吉沙县","泽普县","莎车县","叶城县","麦盖提县","岳普湖县","伽师县","巴楚县","塔什库尔干塔吉克自治县"],"和田地区":["和田市","和田县","墨玉县","皮山县","洛浦县","策勒县","于田县","民丰县"],"伊犁哈萨克自治州":["伊宁市","奎屯市","霍尔果斯市","伊宁县","察布查尔锡伯自治县","霍城县","巩留县","新源县","昭苏县","特克斯县","尼勒克县"],"塔城地区":["塔城市","乌苏市","沙湾市","额敏县","托里县","裕民县","和布克赛尔蒙古自治县"],"阿勒泰地区":["阿勒泰市","布尔津县","富蕴县","福海县","哈巴河县","青河县","吉木乃县"],"自治区直辖县级行政区划":["石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]}} \ No newline at end of file diff --git a/apps/mini-user/src/lib/user-location.weapp.ts b/apps/mini-user/src/lib/user-location.weapp.ts new file mode 100644 index 0000000..a3ec5c7 --- /dev/null +++ b/apps/mini-user/src/lib/user-location.weapp.ts @@ -0,0 +1,232 @@ +import Taro from '@tarojs/taro'; +import { request } from './api'; +import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data'; +import { FALLBACK_CITY_CODE } from './product-images'; + +export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; +const USER_COORDS_KEY = 'dukang_user_coords'; +const LOCATION_DENIED_KEY = 'dukang_location_denied'; + +export type ResolvedUserCity = { + province: string; + city: string; + district: string; + cityCode?: string; + cityName?: string; + openCity: boolean; + region: RegionSelection; + displayCity: string; +}; + +export type UserCoords = { latitude: number; longitude: number }; + +type GpsCityCache = ResolvedUserCity & { timestamp: number }; + +const FALLBACK_CITY: ResolvedUserCity = { + province: DEFAULT_REGION.province, + city: DEFAULT_REGION.city, + district: REGION_ALL, + cityCode: FALLBACK_CITY_CODE, + cityName: '郑州市', + openCity: true, + region: DEFAULT_REGION, + displayCity: '郑州市', +}; + +function isLocationDenied(): boolean { + try { + return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1'; + } catch { + return false; + } +} + +function markLocationDenied() { + try { + Taro.setStorageSync(LOCATION_DENIED_KEY, '1'); + } catch { + /* ignore */ + } +} + +function clearLocationDenied() { + try { + Taro.removeStorageSync(LOCATION_DENIED_KEY); + } catch { + /* ignore */ + } +} + +function isDenyMessage(errMsg?: string): boolean { + return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test( + errMsg || '', + ); +} + +function readCache(): GpsCityCache | null { + try { + const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(String(raw)) as GpsCityCache; + if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null; + return parsed; + } catch { + return null; + } +} + +function writeCache(data: ResolvedUserCity) { + try { + Taro.setStorageSync( + GPS_CITY_STORAGE_KEY, + JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache), + ); + } catch { + /* ignore */ + } +} + +export function writeUserCoords(latitude: number, longitude: number) { + try { + Taro.setStorageSync( + USER_COORDS_KEY, + JSON.stringify({ latitude, longitude, timestamp: Date.now() }), + ); + } catch { + /* ignore */ + } +} + +export function readCachedUserCoords(): UserCoords | null { + try { + const raw = Taro.getStorageSync(USER_COORDS_KEY); + if (!raw) return null; + const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number }; + if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null; + if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null; + return { latitude: parsed.latitude, longitude: parsed.longitude }; + } catch { + return null; + } +} + +export function toCityWideRegion(region: RegionSelection): RegionSelection { + return { + province: region.province, + city: region.city, + district: REGION_ALL, + }; +} + +function cacheFallbackAndMaybeDeny(denied: boolean) { + if (denied) markLocationDenied(); + writeCache(FALLBACK_CITY); +} + +async function reportLocationToServer(payload: { + latitude?: number; + longitude?: number; + sdk: 'jssdk' | 'geolocation'; + status: 'success' | 'fail'; + errMsg?: string; +}) { + return request<{ + province?: string; + city?: string; + district?: string; + cityCode?: string; + cityName?: string; + openCity?: boolean; + }>('/common/wechat/location', { + method: 'POST', + data: payload, + }); +} + +function toResolved(data: { + province?: string; + city?: string; + district?: string; + cityCode?: string; + cityName?: string; + openCity?: boolean; +}): ResolvedUserCity | null { + if (!data.province || !data.city) return null; + const region = regionFromGeo(data.province, data.city, data.district); + const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`); + return { + province: data.province, + city: data.city, + district: data.district ?? '', + cityCode: data.cityCode, + cityName: data.cityName, + openCity: !!data.openCity, + region, + displayCity, + }; +} + +async function promptLocationAuthOnce() { + await Taro.showModal({ + title: '位置授权', + content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。', + confirmText: '知道了', + showCancel: false, + }).catch(() => {}); +} + +function getMiniLocation(): Promise { + return new Promise((resolve, reject) => { + Taro.getLocation({ + type: 'gcj02', + success: resolve, + fail: reject, + }); + }); +} + +export async function resolveUserCity(force = false): Promise { + if (!force) { + if (isLocationDenied()) { + const cached = readCache(); + return cached ?? FALLBACK_CITY; + } + const cached = readCache(); + if (cached) return cached; + } + + try { + const loc = await getMiniLocation(); + writeUserCoords(loc.latitude, loc.longitude); + const data = await reportLocationToServer({ + latitude: loc.latitude, + longitude: loc.longitude, + sdk: 'jssdk', + status: 'success', + }); + const resolved = toResolved(data); + if (resolved) { + clearLocationDenied(); + writeCache(resolved); + return resolved; + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + const denied = isDenyMessage(errMsg); + if (denied && !isLocationDenied()) { + await promptLocationAuthOnce(); + } + await reportLocationToServer({ + sdk: 'jssdk', + status: 'fail', + errMsg: errMsg.slice(0, 200), + }).catch(() => {}); + cacheFallbackAndMaybeDeny(denied); + } + + return FALLBACK_CITY; +} + +export function getCityCodeForCatalog(resolved: ResolvedUserCity): string { + return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE; +} diff --git a/apps/mini-user/src/lib/wechat-auth.weapp.ts b/apps/mini-user/src/lib/wechat-auth.weapp.ts new file mode 100644 index 0000000..1e5ab71 --- /dev/null +++ b/apps/mini-user/src/lib/wechat-auth.weapp.ts @@ -0,0 +1,80 @@ +import type { WechatLoginResult } from '@dukang/shared-types'; +import Taro from '@tarojs/taro'; +import { + fetchMiniWechatUserInfo, + mergeWxDisplayProfile, + needsWxProfileFill, + syncMiniWechatProfile, +} from './mini-wechat-profile'; +import { + fetchClientConfig, + fetchUserProfile, + needsWechatAuthForPay, + saveWechatLoginResult, + type WechatBindResult, +} from './pay-wechat'; +import { isWechatEnv } from './weixin'; + +export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill }; + +export type WechatAuthEnsureResult = + | { ok: true } + | { ok: false; redirecting: true } + | { ok: false; needBindPhone: true; wxSessionKey: string }; + +export async function loginWithWechat(): Promise { + const res = await Taro.login(); + if (!res.code) { + throw new Error(res.errMsg || '微信登录失败,未获取到 code'); + } + const { request } = await import('./api'); + return request('/auth/login/wechat', { + method: 'POST', + data: { code: res.code, platform: 'mini' }, + }); +} + +export async function checkNeedsWechatAuth(): Promise { + const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); + return needsWechatAuthForPay(config, profile); +} + +export async function ensureWechatAuthForPay(): Promise { + if (!isWechatEnv()) return { ok: true }; + if (!(await checkNeedsWechatAuth())) return { ok: true }; + return { ok: false, redirecting: true }; +} + +export async function handleWechatAuthCallback(): Promise { + return null; +} + +export async function loginWithWechatSdk(): Promise { + throw new Error('请使用小程序微信授权'); +} + +export function applyWechatLoginResult(result: WechatLoginResult): boolean { + return saveWechatLoginResult(result); +} + +export async function bindWechatForUser( + prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null, +): Promise { + const res = await Taro.login(); + if (!res.code) { + throw new Error(res.errMsg || '微信授权失败'); + } + const { request } = await import('./api'); + const data = await request('/auth/wechat/bind', { + method: 'POST', + data: { code: res.code, platform: 'mini' }, + }); + if (data.needBindPhone && data.wxSessionKey) { + return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey }; + } + await syncMiniWechatProfile(prefetchedWxProfile); + const profile = await fetchUserProfile(); + return { ok: true, profile }; +} + +export type { WechatBindResult }; diff --git a/apps/mini-user/src/lib/wechat-share.weapp.ts b/apps/mini-user/src/lib/wechat-share.weapp.ts new file mode 100644 index 0000000..d2ea582 --- /dev/null +++ b/apps/mini-user/src/lib/wechat-share.weapp.ts @@ -0,0 +1,165 @@ +import Taro from '@tarojs/taro'; +import { + applyShareTitleTemplate, + DEFAULT_SHARE_DESC, + DEFAULT_SHARE_HINT, + DEFAULT_SHARE_TITLE, + resolveMiniShareRuntime, + type ClientRuntimeConfig, + type MiniShareRuntime, + type MiniShareSceneConfig, +} from '@dukang/shared-types'; +import { toast } from './api'; +import { getBrandAssetsSync, loadBrandAssets } from './brand-assets'; +import { fetchClientConfig } from './pay-wechat'; + +export type ShareScene = + | 'home' + | 'stores' + | 'storeDetail' + | 'benefit' + | 'mine' + | 'productDetail' + | 'orderDetail'; + +export { DEFAULT_SHARE_TITLE, DEFAULT_SHARE_DESC }; + +export const WECHAT_SHARE_HINT = DEFAULT_SHARE_HINT; + +const FALLBACK_SHARE: MiniShareRuntime = resolveMiniShareRuntime({}); + +let shareCached: MiniShareRuntime | null = null; + +export function getShareRuntimeSync(): MiniShareRuntime { + return shareCached ?? FALLBACK_SHARE; +} + +export function applyShareFromClientConfig(config?: ClientRuntimeConfig | null) { + if (config?.share) { + shareCached = config.share; + return shareCached; + } + return getShareRuntimeSync(); +} + +export async function loadShareConfig(force = false): Promise { + if (!force && shareCached) return shareCached; + try { + const cfg = await fetchClientConfig(); + if (cfg?.share) { + shareCached = cfg.share; + return shareCached; + } + } catch { + /* ignore */ + } + shareCached = FALLBACK_SHARE; + return shareCached; +} + +export function getDefaultShareImageUrl(): string { + const share = getShareRuntimeSync(); + return share.default.imageUrl || getBrandAssetsSync().brandLogoUrl; +} + +export function getShareHint(): string { + return getShareRuntimeSync().hint || DEFAULT_SHARE_HINT; +} + +export function prefetchShareBrandAssets() { + void loadBrandAssets(); + void loadShareConfig(); +} + +function sceneConfig(scene?: ShareScene): MiniShareSceneConfig { + const runtime = getShareRuntimeSync(); + if (!scene) return runtime.default; + return runtime[scene] ?? runtime.default; +} + +export type PageSharePayload = { + title?: string; + desc?: string; + path?: string; + imgUrl?: string; + link?: string; +}; + +export function buildSceneSharePayload( + scene: ShareScene, + options?: { + path?: string; + dynamicTitle?: string | null; + dynamicDesc?: string | null; + dynamicImageUrl?: string | null; + titleVars?: Record; + }, +): PageSharePayload { + const def = getShareRuntimeSync().default; + const sc = sceneConfig(scene); + let title = (sc.title || '').trim(); + if (title && options?.titleVars) { + const vars = options.titleVars; + const missingRequired = Object.entries(vars).some( + ([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()), + ); + title = missingRequired ? '' : applyShareTitleTemplate(title, vars); + } + if (!title) { + title = (options?.dynamicTitle || '').trim() || def.title; + } + const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc; + const imgUrl = + (sc.imageUrl || '').trim() || + (options?.dynamicImageUrl || '').trim() || + def.imageUrl || + getDefaultShareImageUrl(); + return { + title, + desc, + path: options?.path, + imgUrl, + }; +} + +export async function applyWechatShare(): Promise { + /* weapp 使用原生分享菜单,无 H5 JSSDK */ +} + +export async function handleShareButtonClick( + payload?: PageSharePayload, +): Promise<{ showGuide: boolean }> { + try { + await Taro.showShareMenu({ + withShareTicket: true, + showShareItems: ['shareAppMessage', 'shareTimeline'], + }); + } catch { + try { + await Taro.showShareMenu({ withShareTicket: true }); + } catch { + /* ignore */ + } + } + toast(getShareHint()); + void payload; + return { showGuide: false }; +} + +export function toWeappShareMessage(payload?: PageSharePayload) { + const def = getShareRuntimeSync().default; + return { + title: payload?.title || def.title, + path: payload?.path || '/pages/home/index', + imageUrl: payload?.imgUrl || getDefaultShareImageUrl(), + }; +} + +export function toWeappShareTimeline(payload?: PageSharePayload, query = '') { + const def = getShareRuntimeSync().default; + return { + title: payload?.title || def.title, + query, + imageUrl: payload?.imgUrl || getDefaultShareImageUrl(), + }; +} diff --git a/apps/mini-user/src/lib/weixin.weapp.ts b/apps/mini-user/src/lib/weixin.weapp.ts new file mode 100644 index 0000000..e2bd874 --- /dev/null +++ b/apps/mini-user/src/lib/weixin.weapp.ts @@ -0,0 +1,4 @@ +/** 小程序端恒为微信环境,避免引入 H5 JSSDK 门面 */ +export function isWechatEnv() { + return true; +} diff --git a/apps/mini-user/src/pages/address-edit/index.tsx b/apps/mini-user/src/pages/address-edit/index.tsx index c512ddd..ec8fffc 100644 --- a/apps/mini-user/src/pages/address-edit/index.tsx +++ b/apps/mini-user/src/pages/address-edit/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { View, Text, Input, Textarea, Switch } from '@tarojs/components'; +import '../../styles/address.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/addresses/index.tsx b/apps/mini-user/src/pages/addresses/index.tsx index 21c2807..e5fa98a 100644 --- a/apps/mini-user/src/pages/addresses/index.tsx +++ b/apps/mini-user/src/pages/addresses/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/address.css'; import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/benefit-detail/index.tsx b/apps/mini-user/src/pages/benefit-detail/index.tsx index 1051461..a3aec0d 100644 --- a/apps/mini-user/src/pages/benefit-detail/index.tsx +++ b/apps/mini-user/src/pages/benefit-detail/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/redeem.css'; import Taro from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/customer-service/index.tsx b/apps/mini-user/src/pages/customer-service/index.tsx index b6b6b3e..73be477 100644 --- a/apps/mini-user/src/pages/customer-service/index.tsx +++ b/apps/mini-user/src/pages/customer-service/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/redeem.css'; import Taro from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/invoice-apply/index.config.ts b/apps/mini-user/src/pages/invoice-apply/index.config.ts new file mode 100644 index 0000000..d1553c6 --- /dev/null +++ b/apps/mini-user/src/pages/invoice-apply/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '申请发票', +}); diff --git a/apps/mini-user/src/pages/invoice-apply/index.tsx b/apps/mini-user/src/pages/invoice-apply/index.tsx new file mode 100644 index 0000000..2d5eeb1 --- /dev/null +++ b/apps/mini-user/src/pages/invoice-apply/index.tsx @@ -0,0 +1,296 @@ +import { useCallback, useEffect, useState } from 'react'; +import { View, Text, Input, Radio } from '@tarojs/components'; +import '../../styles/invoice.css'; +import Taro, { useDidShow, useRouter } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import SubPageHeader from '../../components/SubPageHeader'; +import { request, toast } from '../../lib/api'; +import { usePageView } from '../../lib/usePageView'; +import { + INVOICE_KIND_LABELS, + INVOICE_TITLE_TYPE_LABELS, + type InvoiceKind, + type UserInvoiceTitleDto, +} from '@dukang/shared-types'; + +const isH5 = process.env.TARO_ENV === 'h5'; + +type InvoiceStatus = { + exists: boolean; + status?: string | null; +}; + +type OrderBrief = { + orderNo?: string; + productName?: string; + productSpec?: string; + quantity?: number; + payAmount?: number; + status?: string; +}; + +const INVOICE_STATUS_LABELS: Record = { + PENDING: '开票中', + ISSUED: '已开票', + REJECTED: '已驳回', +}; + +export default function InvoiceApplyPage() { + const router = useRouter(); + const orderId = router.params.orderId ?? ''; + usePageView('user_invoice_apply_view', orderId ? { orderId } : undefined); + + const [titles, setTitles] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [invoiceKind, setInvoiceKind] = useState('NORMAL'); + const [emailOverride, setEmailOverride] = useState(''); + const [phoneOverride, setPhoneOverride] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [invoiceStatus, setInvoiceStatus] = useState(null); + const [order, setOrder] = useState(null); + + const load = useCallback(() => { + if (!orderId) return; + setLoading(true); + Promise.all([ + request(`/trade/orders/${orderId}/invoice-titles`).catch(() => []), + request(`/trade/orders/${orderId}/invoice-status`).catch(() => ({ exists: false })), + request(`/trade/orders/${orderId}`).catch(() => ({})), + ]) + .then(([list, status, orderData]) => { + const titlesList = Array.isArray(list) ? list : []; + setTitles(titlesList); + setInvoiceStatus(status); + setOrder(orderData || {}); + const def = titlesList.find((t) => t.isDefault); + setSelectedId(def?.id || (titlesList[0]?.id ?? '')); + }) + .catch(() => { + setTitles([]); + setInvoiceStatus({ exists: false }); + }) + .finally(() => setLoading(false)); + }, [orderId]); + + useEffect(() => { + void load(); + }, [load]); + + useDidShow(() => { + void load(); + }); + + const selected = titles.find((t) => t.id === selectedId); + + async function submit() { + if (!orderId || submitting) return; + if (order?.status && order.status !== 'COMPLETED') { + toast('仅已完成订单可申请发票'); + return; + } + if (!selected) { + toast('请先选择发票抬头'); + return; + } + const email = (selected.email || emailOverride).trim(); + const phone = (selected.phone || phoneOverride).trim(); + if (!email) { + toast('请填写接收邮箱'); + return; + } + if (!phone) { + toast('请填写联系电话'); + return; + } + if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') { + toast('专用发票仅支持企业抬头'); + return; + } + setSubmitting(true); + try { + await request(`/trade/orders/${orderId}/invoices`, { + method: 'POST', + data: { + titleId: selectedId, + invoiceKind, + email, + phone, + }, + }); + toast('发票申请已提交', 'success'); + setTimeout(() => { + Taro.navigateBack().catch(() => { + Taro.redirectTo({ url: '/pages/orders/index?tab=completed' }); + }); + }, 1200); + } catch (e) { + toast(e instanceof Error ? e.message : '申请失败'); + } finally { + setSubmitting(false); + } + } + + function goAddTitle() { + Taro.navigateTo({ + url: '/pages/invoice-titles/index', + }); + } + + const alreadyApplied = invoiceStatus?.exists; + const orderCompletable = !order?.status || order.status === 'COMPLETED'; + const canApply = !alreadyApplied && orderCompletable; + const productLine = order + ? [order.productName, order.productSpec, order.quantity ? `×${order.quantity}` : ''] + .filter(Boolean) + .join(' ') + : ''; + + return ( + + {isH5 ? ( + Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))} + /> + ) : null} + + {order?.orderNo ? ( + + 订单编号 + {order.orderNo} + {productLine ? ( + {productLine} + ) : null} + {order.payAmount != null ? ( + + 实付 ¥{Number(order.payAmount).toFixed(2)} + + ) : null} + + ) : null} + + {alreadyApplied ? ( + + + + 已申请发票 + + 状态:{INVOICE_STATUS_LABELS[invoiceStatus?.status ?? ''] || invoiceStatus?.status || '处理中'} + + + + ) : null} + + {!alreadyApplied && !orderCompletable ? ( + + + 当前订单不可开票 + 仅已完成订单可申请发票 + + + ) : null} + + {canApply ? ( + + 发票类型 + + {(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => ( + setInvoiceKind(k)} + > + {INVOICE_KIND_LABELS[k]} + + ))} + + + ) : null} + + {canApply ? ( + + 选择发票抬头 + + + 新增抬头 + + + ) : null} + + {loading ? 加载中… : null} + {canApply && !loading && titles.length === 0 ? ( + + 暂无发票抬头 + + 去添加 + + + ) : null} + + {canApply && !loading && titles.length > 0 ? ( + + {titles.map((t) => ( + setSelectedId(t.id)} + > + + + {t.titleName} + + {INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType} + + {t.isDefault ? 默认 : null} + + {t.taxNo ? 税号:{t.taxNo} : null} + {t.email ? 邮箱:{t.email} : null} + + setSelectedId(t.id)} + /> + + ))} + + ) : null} + + {canApply && selected && !selected.email ? ( + + 接收邮箱 + setEmailOverride(e.detail.value)} + /> + + ) : null} + {canApply && selected && !selected.phone ? ( + + 联系电话 + setPhoneOverride(e.detail.value)} + /> + + ) : null} + + + {canApply && titles.length > 0 ? ( + + { if (!submitting) void submit(); }} + > + {submitting ? '提交中…' : '提交申请'} + + + ) : null} + + ); +} diff --git a/apps/mini-user/src/pages/invoice-titles/index.config.ts b/apps/mini-user/src/pages/invoice-titles/index.config.ts new file mode 100644 index 0000000..797714d --- /dev/null +++ b/apps/mini-user/src/pages/invoice-titles/index.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + navigationBarTitleText: '发票抬头', + enablePullDownRefresh: true, + backgroundTextStyle: 'dark', +}); diff --git a/apps/mini-user/src/pages/invoice-titles/index.tsx b/apps/mini-user/src/pages/invoice-titles/index.tsx new file mode 100644 index 0000000..5d5c15e --- /dev/null +++ b/apps/mini-user/src/pages/invoice-titles/index.tsx @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useState } from 'react'; +import { View, Text, Input, Switch, Image } from '@tarojs/components'; +import '../../styles/invoice.css'; +import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import SubPageHeader from '../../components/SubPageHeader'; +import { request, toast } from '../../lib/api'; +import { usePageView } from '../../lib/usePageView'; +import { + INVOICE_TITLE_TYPE_LABELS, + type InvoiceTitleType, + type UpsertInvoiceTitleRequest, + type UserInvoiceTitleDto, +} from '@dukang/shared-types'; +import iconEdit from '../../assets/icons/编辑.png'; +import iconDefault from '../../assets/icons/默认.png'; +import iconDelete from '../../assets/icons/删除.png'; + +const isH5 = process.env.TARO_ENV === 'h5'; + +const ACTION_ICONS = { + edit: iconEdit, + star: iconDefault, + trash: iconDelete, +} as const; + +type EditDraft = UpsertInvoiceTitleRequest & { id?: string }; + +function TitleActionIcon({ + kind, + label, + onClick, +}: { + kind: keyof typeof ACTION_ICONS; + label: string; + onClick: () => void; +}) { + return ( + + + + ); +} + +const EMPTY_DRAFT: EditDraft = { + titleType: 'PERSONAL', + titleName: '', + taxNo: '', + email: '', + phone: '', + addressPhone: '', + bankAccount: '', + isDefault: false, +}; + +export default function InvoiceTitlesPage() { + usePageView('user_invoice_titles_view'); + const [titles, setTitles] = useState([]); + const [loading, setLoading] = useState(true); + const [sheetOpen, setSheetOpen] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [saving, setSaving] = useState(false); + + const load = useCallback(() => { + setLoading(true); + return request('/trade/invoice-titles') + .then((data) => setTitles(Array.isArray(data) ? data : [])) + .catch((e) => { + toast(e instanceof Error ? e.message : '加载失败'); + setTitles([]); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + void load(); + }, [load]); + + useDidShow(() => { + void load(); + }); + + usePullDownRefresh(() => { + void load().finally(() => Taro.stopPullDownRefresh()); + }); + + function openCreate() { + setDraft({ ...EMPTY_DRAFT }); + setSheetOpen(true); + } + + function openEdit(t: UserInvoiceTitleDto) { + setDraft({ + id: t.id, + titleType: t.titleType, + titleName: t.titleName, + taxNo: t.taxNo ?? '', + email: t.email ?? '', + phone: t.phone ?? '', + addressPhone: t.addressPhone ?? '', + bankAccount: t.bankAccount ?? '', + isDefault: t.isDefault, + }); + setSheetOpen(true); + } + + async function save() { + if (saving) return; + const titleName = draft.titleName.trim(); + if (!titleName) { + toast('请填写发票抬头名称'); + return; + } + if (draft.titleType === 'ENTERPRISE' && !draft.taxNo?.trim()) { + toast('企业抬头须填写税号'); + return; + } + setSaving(true); + try { + const payload: UpsertInvoiceTitleRequest = { + titleType: draft.titleType, + titleName, + taxNo: draft.taxNo?.trim() || null, + email: draft.email?.trim() || null, + phone: draft.phone?.trim() || null, + addressPhone: draft.addressPhone?.trim() || null, + bankAccount: draft.bankAccount?.trim() || null, + isDefault: !!draft.isDefault, + }; + if (draft.id) { + await request(`/trade/invoice-titles/${draft.id}`, { + method: 'PUT', + data: payload, + }); + toast('已更新', 'success'); + } else { + await request('/trade/invoice-titles', { method: 'POST', data: payload }); + toast('已添加', 'success'); + } + setSheetOpen(false); + await load(); + } catch (e) { + toast(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function setDefault(t: UserInvoiceTitleDto) { + try { + await request(`/trade/invoice-titles/${t.id}`, { + method: 'PUT', + data: { + titleType: t.titleType, + titleName: t.titleName, + taxNo: t.taxNo, + email: t.email, + phone: t.phone, + addressPhone: t.addressPhone, + bankAccount: t.bankAccount, + isDefault: true, + }, + }); + toast('已设为默认', 'success'); + await load(); + } catch (e) { + toast(e instanceof Error ? e.message : '设置失败'); + } + } + + async function remove(id: string) { + const ok = await Taro.showModal({ title: '提示', content: '确认删除该发票抬头?' }); + if (!ok.confirm) return; + try { + await request(`/trade/invoice-titles/${id}`, { method: 'DELETE' }); + toast('已删除', 'success'); + await load(); + } catch (e) { + toast(e instanceof Error ? e.message : '删除失败'); + } + } + + return ( + + {isH5 ? ( + Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))} + /> + ) : null} + + {loading ? 加载中… : null} + {!loading && titles.length === 0 ? ( + 暂无发票抬头,点击右上角添加 + ) : null} + {!loading && + titles.map((t) => ( + + + {t.titleName} + + {INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType} + + {t.isDefault ? 默认 : null} + + {t.taxNo ? 税号:{t.taxNo} : null} + {t.email ? 邮箱:{t.email} : null} + {t.phone ? 电话:{t.phone} : null} + {t.addressPhone ? ( + 地址电话:{t.addressPhone} + ) : null} + {t.bankAccount ? ( + 开户行账号:{t.bankAccount} + ) : null} + + openEdit(t)} /> + {!t.isDefault ? ( + void setDefault(t)} /> + ) : null} + void remove(t.id)} /> + + + ))} + + + + + 添加抬头 + + + {sheetOpen ? ( + + !saving && setSheetOpen(false)} /> + + + {draft.id ? '编辑发票抬头' : '添加发票抬头'} + + + + 抬头类型 + + {(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => ( + setDraft({ ...draft, titleType: tp })} + > + {INVOICE_TITLE_TYPE_LABELS[tp]} + + ))} + + + + + 抬头名称 + setDraft({ ...draft, titleName: e.detail.value })} + /> + + + {draft.titleType === 'ENTERPRISE' ? ( + + 税号 + setDraft({ ...draft, taxNo: e.detail.value })} + /> + + ) : null} + + + 接收邮箱 + setDraft({ ...draft, email: e.detail.value })} + /> + + + + 联系电话 + setDraft({ ...draft, phone: e.detail.value })} + /> + + + {draft.titleType === 'ENTERPRISE' ? ( + <> + + 地址电话 + setDraft({ ...draft, addressPhone: e.detail.value })} + /> + + + 开户行账号 + setDraft({ ...draft, bankAccount: e.detail.value })} + /> + + + ) : null} + + + 设为默认抬头 + setDraft({ ...draft, isDefault: !!e.detail.value })} + /> + + + + !saving && setSheetOpen(false)} + > + 取消 + + { if (!saving) void save(); }} + > + {saving ? '保存中…' : '保存'} + + + + + ) : null} + + ); +} diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index b2f2ff7..7279c9e 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { View, Text, Input, Image } from '@tarojs/components'; +import '../../styles/login.css'; import Taro, { useRouter } from '@tarojs/taro'; import { SmsScene, diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index e8396ac..3d86ec6 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -56,6 +56,7 @@ const SERVICES = [ { icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' }, { icon: iconQualification, label: '资质公示', action: 'qualification' as const }, { icon: iconAbout, label: '关于我们', action: 'about' as const }, + { icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' }, ] as const; const isWeapp = process.env.TARO_ENV === 'weapp'; 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 5181df9..ff552e3 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index f16545b..5e5e92f 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index be227d2..d30012a 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; @@ -27,6 +28,7 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import { usePageView } from '../../lib/usePageView'; +import type { InvoiceStatus } from '@dukang/shared-types'; const isWeapp = process.env.TARO_ENV === 'weapp'; @@ -58,6 +60,7 @@ type OrderDetail = { deliveryType?: string; items?: OrderItem[]; wechatConfirm?: WechatConfirmPayload | null; + invoiceStatus?: InvoiceStatus | null; delivery?: { provider?: string; trackingNo?: string; @@ -150,6 +153,9 @@ export default function OrderDetailPage() { const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canConfirmReceive = !!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); + const canInvoice = + !!order && !isReship && order.status === 'COMPLETED' && order.invoiceStatus !== 'PENDING' && order.invoiceStatus !== 'ISSUED'; + const invoicePending = !!order && !isReship && order.status === 'COMPLETED' && order.invoiceStatus === 'PENDING'; const canViewLogistics = !!order && order.deliveryType !== 'ON_SITE_PICKUP' && @@ -403,6 +409,22 @@ export default function OrderDetailPage() { {confirming ? '提交中…' : '确认收货'} ) : null} + {canInvoice ? ( + Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })} + > + 申请发票 + + ) : null} + {invoicePending ? ( + Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })} + > + 开票中 + + ) : null} ) : null} diff --git a/apps/mini-user/src/pages/order-logistics/index.tsx b/apps/mini-user/src/pages/order-logistics/index.tsx index 6cc9ccf..817e01c 100644 --- a/apps/mini-user/src/pages/order-logistics/index.tsx +++ b/apps/mini-user/src/pages/order-logistics/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx index 7c3f6cf..61c9e2a 100644 --- a/apps/mini-user/src/pages/orders/index.tsx +++ b/apps/mini-user/src/pages/orders/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; @@ -7,7 +8,7 @@ import { request, toast } from '../../lib/api'; import { buildPayUrl } from '../../lib/checkout-nav'; import { usePageView } from '../../lib/usePageView'; -import { ORDER_STATUS_LABELS } from '@dukang/shared-types'; +import { ORDER_STATUS_LABELS, type InvoiceStatus } from '@dukang/shared-types'; const TABS = [ { key: 'all', label: '全部订单' }, @@ -50,6 +51,7 @@ type OrderRow = { isProxyOrder?: boolean; proxyPartnerName?: string | null; items?: OrderItem[]; + invoiceStatus?: InvoiceStatus | null; }; export default function OrdersPage() { @@ -125,6 +127,9 @@ export default function OrdersPage() { const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1; const unitPrice = Number(item?.unitPrice ?? 0); const canPay = o.status === 'PENDING_PAY' && !o.originOrderId; + const invoiceBlocking = o.invoiceStatus === 'PENDING' || o.invoiceStatus === 'ISSUED'; + const canInvoice = o.status === 'COMPLETED' && !o.originOrderId && !invoiceBlocking; + const invoicePending = o.status === 'COMPLETED' && o.invoiceStatus === 'PENDING'; const isProxy = o.isProxyOrder || o.orderType === 'PROXY'; return ( @@ -177,6 +182,28 @@ export default function OrdersPage() { 付款 ) : null} + {canInvoice ? ( + { + e.stopPropagation(); + Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` }); + }} + > + 申请发票 + + ) : null} + {invoicePending ? ( + { + e.stopPropagation(); + Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` }); + }} + > + 开票中 + + ) : null} ); diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx index 113d70a..bc4f3f6 100644 --- a/apps/mini-user/src/pages/pay/index.tsx +++ b/apps/mini-user/src/pages/pay/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/pickup-receive/index.tsx b/apps/mini-user/src/pages/pickup-receive/index.tsx index 6fcdc15..59e6892 100644 --- a/apps/mini-user/src/pages/pickup-receive/index.tsx +++ b/apps/mini-user/src/pages/pickup-receive/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/order.css'; import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/privacy-policy/index.tsx b/apps/mini-user/src/pages/privacy-policy/index.tsx index d6857bd..6133021 100644 --- a/apps/mini-user/src/pages/privacy-policy/index.tsx +++ b/apps/mini-user/src/pages/privacy-policy/index.tsx @@ -1,4 +1,5 @@ import { View, Text } from '@tarojs/components'; +import '../../styles/legal.css'; import { getLegalDocument } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index b083fcd..06e9b01 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/product-detail.css'; import Taro, { useDidShow, usePageScroll, diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx index 0e7e23b..0a16f03 100644 --- a/apps/mini-user/src/pages/redeem-code/index.tsx +++ b/apps/mini-user/src/pages/redeem-code/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/redeem.css'; import Taro, { useRouter } from '@tarojs/taro'; import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx index 890640b..0d6f61a 100644 --- a/apps/mini-user/src/pages/redeem-success/index.tsx +++ b/apps/mini-user/src/pages/redeem-success/index.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/redeem.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/redeem/index.tsx b/apps/mini-user/src/pages/redeem/index.tsx index c7af0c0..1576ea5 100644 --- a/apps/mini-user/src/pages/redeem/index.tsx +++ b/apps/mini-user/src/pages/redeem/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { View, Text, Input } from '@tarojs/components'; +import '../../styles/redeem.css'; import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index fc50b28..430914d 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; +import '../../styles/store-detail.css'; import Taro, { useDidShow, useLoad, diff --git a/apps/mini-user/src/pages/store-package-detail/index.tsx b/apps/mini-user/src/pages/store-package-detail/index.tsx index 10d0dfc..2825b75 100644 --- a/apps/mini-user/src/pages/store-package-detail/index.tsx +++ b/apps/mini-user/src/pages/store-package-detail/index.tsx @@ -1,5 +1,7 @@ import { useCallback, useState } from 'react'; import { View, Text } from '@tarojs/components'; +import '../../styles/store-detail.css'; +import '../../styles/product-detail.css'; import Taro, { useLoad, useRouter } from '@tarojs/taro'; import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; diff --git a/apps/mini-user/src/pages/user-agreement/index.tsx b/apps/mini-user/src/pages/user-agreement/index.tsx index f2d8c27..7d135a9 100644 --- a/apps/mini-user/src/pages/user-agreement/index.tsx +++ b/apps/mini-user/src/pages/user-agreement/index.tsx @@ -1,4 +1,5 @@ import { View, Text } from '@tarojs/components'; +import '../../styles/legal.css'; import { getLegalDocument } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; diff --git a/apps/mini-user/src/styles/invoice.css b/apps/mini-user/src/styles/invoice.css new file mode 100644 index 0000000..f98e045 --- /dev/null +++ b/apps/mini-user/src/styles/invoice.css @@ -0,0 +1,415 @@ +/* 发票抬头管理 + 申请发票 */ +.invoice-titles-page, +.invoice-apply-page { + background: var(--color-background); + min-height: 100vh; +} + +.invoice-titles-body, +.invoice-apply-body { + padding: 12px var(--space-page) 80px; +} + +/* —— 发票抬头列表 —— */ +.invoice-title-card { + background: var(--color-card, #fff); + border-radius: var(--radius-lg, 12px); + padding: 16px; + margin-bottom: 12px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +.invoice-title-card-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.invoice-title-name { + font-size: 16px; + font-weight: 600; + color: var(--color-on-surface, #1a1a1a); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invoice-title-type { + font-size: 12px; + color: var(--color-on-surface-variant, #666); + background: var(--color-surface-variant, #f0eee9); + padding: 2px 8px; + border-radius: 4px; + flex-shrink: 0; +} + +.invoice-title-default { + font-size: 11px; + color: #fff; + background: var(--color-primary, #A61D24); + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; +} + +.invoice-title-line { + display: block; + font-size: 13px; + color: var(--color-on-surface-variant, #888); + line-height: 1.8; +} + +.invoice-title-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--color-outline-variant, #eee); +} + +.invoice-title-icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.invoice-title-icon-btn--hover { + opacity: 0.72; +} + +.invoice-title-icon-img { + width: 20px; + height: 20px; + display: block; + pointer-events: none; +} + +/* FAB */ +.invoice-titles-fab { + position: fixed; + right: 20px; + bottom: calc(24px + env(safe-area-inset-bottom, 0px)); + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 24px; + border-radius: 999px; + background: var(--color-primary, #A61D24); + color: #fff; + font-size: 15px; + font-weight: 600; + box-shadow: 0 4px 16px rgba(166, 29, 36, 0.3); +} + +/* —— 编辑/新增底部弹层 —— */ +.invoice-title-sheet-mask { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.invoice-title-sheet-mask-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); +} + +.invoice-title-sheet { + position: relative; + background: var(--color-card, #fff); + border-radius: 16px 16px 0 0; + padding: 20px var(--space-page, 16px) calc(20px + env(safe-area-inset-bottom, 0px)); + max-height: 85vh; + overflow-y: auto; +} + +.invoice-title-sheet-title { + display: block; + font-size: 18px; + font-weight: 600; + text-align: center; + margin-bottom: 16px; +} + +.invoice-title-field { + margin-bottom: 14px; +} + +.invoice-title-label { + display: block; + font-size: 13px; + color: var(--color-on-surface-variant, #666); + margin-bottom: 6px; +} + +.invoice-title-input { + width: 100%; + height: 44px; + padding: 0 12px; + border: 1px solid var(--color-outline, #ddd); + border-radius: 8px; + font-size: 15px; + background: var(--color-surface, #fafafa); + box-sizing: border-box; +} + +.invoice-title-type-row { + display: flex; + gap: 10px; +} + +.invoice-title-type-chip { + flex: 1; + text-align: center; + padding: 8px 0; + border: 1px solid var(--color-outline, #ddd); + border-radius: 8px; + font-size: 14px; + color: var(--color-on-surface-variant, #666); +} + +.invoice-title-type-chip.active { + border-color: var(--color-primary, #A61D24); + color: var(--color-primary, #A61D24); + background: rgba(166, 29, 36, 0.06); + font-weight: 600; +} + +.invoice-title-default-row { + display: flex; + align-items: center; + justify-content: space-between; + margin: 16px 0; +} + +.invoice-title-sheet-actions { + display: flex; + gap: 12px; + margin-top: 20px; +} + +.invoice-title-sheet-cancel, +.invoice-title-sheet-save { + flex: 1; + text-align: center; + padding: 12px 0; + border-radius: 8px; + font-size: 15px; + font-weight: 600; +} + +.invoice-title-sheet-cancel { + background: var(--color-surface-variant, #f0eee9); + color: var(--color-on-surface-variant, #666); +} + +.invoice-title-sheet-save { + background: var(--color-primary, #A61D24); + color: #fff; +} + +.invoice-title-sheet-save.is-disabled { + opacity: 0.55; + pointer-events: none; +} + +/* —— 申请发票页 —— */ +.invoice-apply-order { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--color-card, #fff); + border-radius: var(--radius-lg, 12px); + margin-bottom: 16px; +} + +.invoice-apply-order-label { + font-size: 13px; + color: var(--color-on-surface-variant, #888); + flex-shrink: 0; +} + +.invoice-apply-order-no { + font-size: 14px; + color: var(--color-on-surface, #333); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invoice-apply-order-meta { + width: 100%; + font-size: 13px; + color: var(--color-on-surface-variant, #888); +} + +.invoice-apply-status-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: rgba(76, 175, 80, 0.08); + border-radius: var(--radius-lg, 12px); + margin-bottom: 16px; +} + +.invoice-apply-status-icon { + font-size: 24px; + color: #4caf50; + flex-shrink: 0; +} + +.invoice-apply-status-title { + display: block; + font-size: 16px; + font-weight: 600; + color: #2e7d32; +} + +.invoice-apply-status-desc { + display: block; + font-size: 13px; + color: #4caf50; + margin-top: 2px; +} + +.invoice-apply-section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.invoice-apply-section-title > text:first-child { + font-size: 15px; + font-weight: 600; + color: var(--color-on-surface, #1a1a1a); +} + +.invoice-apply-add-link { + font-size: 14px; + color: var(--color-primary, #A61D24); + font-weight: 500; +} + +.invoice-apply-empty-titles { + text-align: center; + padding: 40px 0; +} + +.invoice-apply-empty-cta { + display: inline-block; + margin-top: 12px; + padding: 8px 24px; + border-radius: 999px; + background: var(--color-primary, #A61D24); + color: #fff; + font-size: 14px; + font-weight: 500; +} + +.invoice-apply-title-list { + margin-bottom: 16px; +} + +.invoice-apply-title-item { + display: flex; + align-items: center; + gap: 12px; + background: var(--color-card, #fff); + border-radius: var(--radius-lg, 12px); + padding: 16px; + margin-bottom: 10px; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.invoice-apply-title-item.is-selected { + border-color: var(--color-primary, #A61D24); +} + +.invoice-apply-title-info { + flex: 1; + min-width: 0; +} + +.invoice-apply-title-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.invoice-apply-title-name { + font-size: 16px; + font-weight: 600; + color: var(--color-on-surface, #1a1a1a); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invoice-apply-title-type { + font-size: 12px; + color: var(--color-on-surface-variant, #666); + background: var(--color-surface-variant, #f0eee9); + padding: 2px 8px; + border-radius: 4px; + flex-shrink: 0; +} + +.invoice-apply-title-default { + font-size: 11px; + color: #fff; + background: var(--color-primary, #A61D24); + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; +} + +.invoice-apply-title-sub { + display: block; + font-size: 13px; + color: var(--color-on-surface-variant, #888); + line-height: 1.6; +} + +.invoice-apply-footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + padding: 12px var(--space-page, 16px) calc(12px + env(safe-area-inset-bottom, 0px)); + background: var(--color-card, #fff); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); +} + +.invoice-apply-submit { + text-align: center; + padding: 14px 0; + border-radius: 999px; + background: var(--color-primary, #A61D24); + color: #fff; + font-size: 16px; + font-weight: 600; +} + +.invoice-apply-submit.is-disabled { + opacity: 0.55; + pointer-events: none; +} diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 9080c46..e093513 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -422,6 +422,64 @@ justify-content: center; } +.order-list-invoice-btn { + flex-shrink: 0; + height: 26px; + padding: 0 10px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-heritage-red, #A61D24); + color: var(--color-heritage-red, #A61D24); + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; +} + +.order-list-invoice-status { + flex-shrink: 0; + height: 26px; + padding: 0 10px; + border-radius: 999px; + background: rgba(166, 29, 36, 0.06); + color: var(--color-heritage-red, #A61D24); + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; +} + +.order-detail-invoice-btn { + flex-shrink: 0; + height: 40px; + padding: 0 16px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-heritage-red, #A61D24); + color: var(--color-heritage-red, #A61D24); + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.order-detail-invoice-status { + flex-shrink: 0; + height: 40px; + padding: 0 16px; + border-radius: 999px; + background: rgba(166, 29, 36, 0.06); + color: var(--color-heritage-red, #A61D24); + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + .pay-status { text-align: center; padding: 48px 24px; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 265babc..3a5dfbe 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -15,6 +15,7 @@ export * from './user-log'; export * from './store-log'; export * from './store-package'; +export * from './store-info-change'; export * from './partner-log'; export * from './promo'; export * from './hq-permissions'; diff --git a/packages/shared-types/src/invoice.ts b/packages/shared-types/src/invoice.ts index edd531f..595a5dd 100644 --- a/packages/shared-types/src/invoice.ts +++ b/packages/shared-types/src/invoice.ts @@ -52,3 +52,41 @@ export interface InvoiceDto { /** 待开票超过 2 个工作日(总部列表标红用) */ overdue?: boolean; } + +/** 用户发票抬头(C 端"我的 → 发票管理") */ +export interface UserInvoiceTitleDto { + id: string; + titleType: InvoiceTitleType; + titleName: string; + taxNo?: string | null; + email?: string | null; + phone?: string | null; + addressPhone?: string | null; + bankAccount?: string | null; + isDefault: boolean; + createdAt: string; +} + +/** 新建 / 编辑发票抬头的入参 */ +export interface UpsertInvoiceTitleRequest { + titleType: InvoiceTitleType; + titleName: string; + taxNo?: string | null; + email?: string | null; + phone?: string | null; + addressPhone?: string | null; + bankAccount?: string | null; + isDefault?: boolean; +} + +/** 用户发票抬头可变更字段(用于列表/编辑页绑定) */ +export const INVOICE_TITLE_CHANGEABLE_FIELDS = [ + 'titleName', + 'taxNo', + 'email', + 'phone', + 'addressPhone', + 'bankAccount', + 'isDefault', +] as const; + diff --git a/packages/shared-types/src/store-info-change.ts b/packages/shared-types/src/store-info-change.ts new file mode 100644 index 0000000..a13a3de --- /dev/null +++ b/packages/shared-types/src/store-info-change.ts @@ -0,0 +1,64 @@ +/** 门店基础信息变更(v3.5.1) */ + +export type StoreInfoChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +export type StoreInfoChangeSubmitterType = 'PARTNER' | 'SHOP' | 'HQ_DIRECT_ADMIN'; + +export const STORE_INFO_CHANGE_STATUS_LABELS: Record = { + PENDING: '审核中', + APPROVED: '已通过', + REJECTED: '已驳回', +}; + +/** 门店基础信息可变字段白名单(提交变更 / 总部审核覆盖时使用,均为 Store 标量列) */ +export const STORE_INFO_CHANGEABLE_FIELDS = [ + 'name', + 'contactPhone', + 'address', + 'intro', + 'benefitUsageRule', + 'latitude', + 'longitude', + 'openTime', + 'closeTime', + 'openTime2', + 'closeTime2', + 'avgPrice', +] as const; + +export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; + +/** 单条记录的字段详情(用于审核页 diff 对比) */ +export interface StoreInfoChangeFieldDiff { + field: StoreInfoChangeableField; + /** 变更前(线上现行) */ + live: unknown; + /** 变更后(提交值) */ + proposed: unknown; +} + +export interface StoreInfoChangeRequestDto { + id: string; + storeId: string; + storeName?: string; + status: StoreInfoChangeStatus; + /** 变更的字段数组 */ + changedFields: StoreInfoChangeableField[]; + /** 字段前后对比(详情用) */ + diffs?: StoreInfoChangeFieldDiff[]; + submitterType: StoreInfoChangeSubmitterType; + submitterId: string; + submitterName?: string; + rejectReason?: string | null; + reviewedAt?: string | null; + createdAt: string; +} + +export interface StoreInfoChangeSummaryDto { + pendingCount: number; +} + +export interface StoreInfoChangeAuditAction { + action: 'APPROVE' | 'REJECT'; + rejectReason?: string; +} diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index 073ab14..dfedac7 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -1,4 +1,5 @@ import type { WechatJsapiPrepayParams } from './wechat'; +import type { InvoiceStatus } from './invoice'; export interface OrderDto { id: string; @@ -14,6 +15,8 @@ export interface OrderDto { isProxyOrder?: boolean; proxyPartnerName?: string | null; proxyPartnerPhone?: string | null; + /** 该订单最新发票状态;无申请时为 null */ + invoiceStatus?: InvoiceStatus | null; } export interface OrderPreviewRequest { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ceed905..ed7b4f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,19 +224,19 @@ importers: version: link:../../packages/weixin-sdk '@tarojs/components': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) '@tarojs/helper': specifier: 4.2.0 version: 4.2.0 '@tarojs/plugin-framework-react': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15)) '@tarojs/plugin-html': specifier: 4.2.0 version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) '@tarojs/plugin-platform-h5': specifier: 4.2.0 - version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15)) '@tarojs/plugin-platform-weapp': specifier: 4.2.0 version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) @@ -245,7 +245,7 @@ importers: version: 4.2.0(react@18.3.1) '@tarojs/router': specifier: 4.2.0 - version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))) + version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))) '@tarojs/runtime': specifier: 4.2.0 version: 4.2.0 @@ -254,7 +254,7 @@ importers: version: 4.2.0 '@tarojs/taro': specifier: 4.2.0 - version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -309,19 +309,16 @@ importers: version: link:../../packages/weixin-sdk '@tarojs/components': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/helper': specifier: 4.2.0 version: 4.2.0 '@tarojs/plugin-framework-react': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15)) - '@tarojs/plugin-html': - specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) + version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/plugin-platform-h5': specifier: 4.2.0 - version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/plugin-platform-weapp': specifier: 4.2.0 version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) @@ -330,7 +327,7 @@ importers: version: 4.2.0(react@18.3.1) '@tarojs/router': specifier: 4.2.0 - version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))) + version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))) '@tarojs/runtime': specifier: 4.2.0 version: 4.2.0 @@ -339,10 +336,7 @@ importers: version: 4.2.0 '@tarojs/taro': specifier: 4.2.0 - version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) - element-china-area-data: - specifier: ^6.1.0 - version: 6.1.0 + version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) qrcode: specifier: ^1.5.4 version: 1.5.4 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index bc4faae..149cbc2 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { + "predev": "pnpm --dir ../../packages/domain build", + "prebuild": "pnpm --dir ../../packages/domain build", "build": "nest build", "dev": "nest start --watch", "start": "node dist/main", diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 505a7f3..cf1e858 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -302,6 +302,18 @@ enum StorePackageSubmitterType { SHOP } +enum StoreInfoChangeStatus { + PENDING + APPROVED + REJECTED +} + +enum StoreInfoChangeSubmitterType { + PARTNER + SHOP + HQ_DIRECT_ADMIN +} + enum UserSourceType { ORGANIC PROMO_CODE @@ -1179,6 +1191,7 @@ model User { ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser") orders Order[] invoices UserInvoice[] + invoiceTitles UserInvoiceTitle[] benefitCoupons BenefitCoupon[] redeemRecords RedeemRecord[] redeemPendingRecords RedeemPendingRecord[] @@ -1299,6 +1312,7 @@ model Store { visibilityPhones StoreVisibilityPhone[] packages StorePackage[] packageChangeRequests StorePackageChangeRequest[] + infoChangeRequests StoreInfoChangeRequest[] @@index([cityId, status]) @@index([partnerAccountId]) @@ -1362,6 +1376,52 @@ model StorePackageChangeRequest { @@map("store_package_change_request") } +/// 用户发票抬头(C 端"我的 → 发票管理") +model UserInvoiceTitle { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + titleType InvoiceTitleType @map("title_type") // PERSONAL | ENTERPRISE + titleName String @map("title_name") @db.VarChar(128) + taxNo String? @map("tax_no") @db.VarChar(32) + email String? @db.VarChar(128) + phone String? @db.VarChar(20) + addressPhone String? @map("address_phone") @db.VarChar(256) + bankAccount String? @map("bank_account") @db.VarChar(256) + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, isDefault]) + @@map("user_invoice_title") +} + +/// 门店基础信息变更请求(合伙人/门店端提交 → 总部审核) +model StoreInfoChangeRequest { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + status StoreInfoChangeStatus @default(PENDING) + /// 变更前 Store 全量快照 + liveSnapshot Json @map("live_snapshot") + /// 提交时希望变更的字段集合(白名单内) + proposedSnapshot Json @map("proposed_snapshot") + /// 变更的字段名数组 + changedFields Json @map("changed_fields") + submitterType StoreInfoChangeSubmitterType @map("submitter_type") + submitterId BigInt @map("submitter_id") @db.UnsignedBigInt + rejectReason String? @map("reject_reason") @db.VarChar(512) + reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) + reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@index([storeId, status]) + @@index([status, createdAt]) + @@map("store_info_change_request") +} + model StoreAccount { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt phone String @unique @db.VarChar(20) @@ -1495,6 +1555,7 @@ model Order { @@index([fulfillmentWarehouseId]) @@index([proxyPartnerAccountId]) @@index([isTest]) + @@index([status, payExpireAt]) @@map("user_order") } diff --git a/server/dukang-api/src/common/compat/v31-compat.ts b/server/dukang-api/src/common/compat/v31-compat.ts index ec7c7fb..de4b264 100644 --- a/server/dukang-api/src/common/compat/v31-compat.ts +++ b/server/dukang-api/src/common/compat/v31-compat.ts @@ -44,6 +44,23 @@ export function mapOrderCompat(order: T) { + const { invoices, ...rest } = order; + return { + ...mapOrderCompat(rest), + invoiceStatus: invoices?.[0]?.status ?? null, + }; +} + /** 对外联系电话;未单独配置时回退登录手机号 */ export function resolveStoreContactPhone(store: { phone?: string | null; diff --git a/server/dukang-api/src/common/filters/http-exception.filter.ts b/server/dukang-api/src/common/filters/http-exception.filter.ts index ae45d01..7f05a41 100644 --- a/server/dukang-api/src/common/filters/http-exception.filter.ts +++ b/server/dukang-api/src/common/filters/http-exception.filter.ts @@ -55,10 +55,14 @@ export class HttpExceptionFilter implements ExceptionFilter { dedupeTtlSec: 120, }); } + const resObj = + typeof res === 'object' && res !== null ? (res as Record) : null; + const reason = (resObj?.reason as string | undefined) ?? null; response.status(status).json({ code: status, message: msgText, data: null, + ...(reason != null ? { reason } : {}), }); return; } diff --git a/server/dukang-api/src/common/guards/hq-auth.guard.ts b/server/dukang-api/src/common/guards/hq-auth.guard.ts index 26dee18..ebd65eb 100644 --- a/server/dukang-api/src/common/guards/hq-auth.guard.ts +++ b/server/dukang-api/src/common/guards/hq-auth.guard.ts @@ -9,8 +9,8 @@ import { JwtAuthGuard } from './jwt-auth.guard'; @Injectable() export class HqAuthGuard extends JwtAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const ok = super.canActivate(context); + async canActivate(context: ExecutionContext): Promise { + const ok = await super.canActivate(context); if (!ok) return false; const req = context.switchToHttp().getRequest(); diff --git a/server/dukang-api/src/common/guards/jwt-auth.guard.ts b/server/dukang-api/src/common/guards/jwt-auth.guard.ts index 989db9d..4b38e75 100644 --- a/server/dukang-api/src/common/guards/jwt-auth.guard.ts +++ b/server/dukang-api/src/common/guards/jwt-auth.guard.ts @@ -1,11 +1,13 @@ import { CanActivate, ExecutionContext, + ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types'; +import { PrismaService } from '../prisma/prisma.module'; export interface AuthUser { actorType: string; @@ -17,11 +19,20 @@ export interface AuthUser { storeId?: bigint; } +/** + * v3.5.1 #9:门店/合伙人账号停用或解绑后,运行时接口强制拦截。 + * 仅对 STORE / PARTNER actor 做 DB 状态校验;USER / HQ 跳过(不影响 C 端/小程序/总部)。 + * 不通过时抛 ForbiddenException({ reason: 'ACCOUNT_DISABLED' }),由 HttpExceptionFilter 透传 reason, + * 前端据此 clearAuth() 并跳登录页。 + */ @Injectable() export class JwtAuthGuard implements CanActivate { - constructor(protected readonly jwtService: JwtService) {} + constructor( + protected readonly jwtService: JwtService, + protected readonly prisma: PrismaService, + ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const auth = req.headers.authorization as string | undefined; if (!auth?.startsWith('Bearer ')) { @@ -47,10 +58,81 @@ export class JwtAuthGuard implements CanActivate { ? { storeId: BigInt(payload.storeId) } : {}), } satisfies AuthUser; - return true; } catch (err) { if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException('Invalid token'); } + + // 账号启停 / 绑定态校验(仅门店与合伙人) + await this.assertAccountActive(req.user); + + return true; + } + + private async assertAccountActive(user: AuthUser): Promise { + if (user.actorType === 'STORE') { + const acc = await this.prisma.storeAccount.findUnique({ + where: { id: user.actorId }, + select: { status: true, isTest: true }, + }); + if (!acc) { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '门店账号不存在或已停用,请重新登录', + }); + } + // 测试门店账号跳过强校验,避免测试环境自锁 + if (acc.isTest) return; + if (acc.status !== 'ACTIVE') { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '门店账号已停用,请重新登录', + }); + } + if (user.storeId != null) { + const store = await this.prisma.store.findUnique({ + where: { id: user.storeId }, + select: { status: true, isTest: true }, + }); + if (!store) { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '门店不存在或已关闭,请重新登录', + }); + } + if (!store.isTest && store.status !== 'OPEN') { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '门店已停用或关闭,请重新登录', + }); + } + } + return; + } + + if (user.actorType === 'PARTNER') { + const acc = await this.prisma.partnerAccount.findUnique({ + where: { id: user.actorId }, + select: { status: true, bindingStatus: true, isTest: true }, + }); + if (!acc) { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '合伙人账号不存在或已停用,请重新登录', + }); + } + // 测试合伙人账号跳过强校验,避免测试环境自锁 + if (acc.isTest) return; + if (acc.status !== 'ACTIVE' || acc.bindingStatus !== 'ACTIVE') { + throw new ForbiddenException({ + reason: 'ACCOUNT_DISABLED', + message: '合伙人账号已停用或解绑,请重新登录', + }); + } + return; + } + + // USER / HQ 不在此校验(按需求仅门店 + 合伙人) + return; } } diff --git a/server/dukang-api/src/common/guards/partner-primary.guard.ts b/server/dukang-api/src/common/guards/partner-primary.guard.ts index c23501c..4d8f029 100644 --- a/server/dukang-api/src/common/guards/partner-primary.guard.ts +++ b/server/dukang-api/src/common/guards/partner-primary.guard.ts @@ -15,7 +15,7 @@ export class PartnerPrimaryGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - this.jwtAuthGuard.canActivate(context); + await this.jwtAuthGuard.canActivate(context); const req = context.switchToHttp().getRequest(); const user = req.user as AuthUser; if (user.actorType !== 'PARTNER') { diff --git a/server/dukang-api/src/jobs/jobs.module.ts b/server/dukang-api/src/jobs/jobs.module.ts index 93e36a5..35cd8ec 100644 --- a/server/dukang-api/src/jobs/jobs.module.ts +++ b/server/dukang-api/src/jobs/jobs.module.ts @@ -6,6 +6,7 @@ import { SettlementModule } from '../modules/settlement/settlement.module'; import { DeliveryProcessor } from './delivery.processor'; import { SettlementScheduler } from './settlement.scheduler'; import { MonitorScheduler } from './monitor.scheduler'; +import { OrderExpiryScheduler } from './order-expiry.scheduler'; import { DELIVERY_QUEUE } from './jobs.constants'; @Module({ @@ -15,6 +16,6 @@ import { DELIVERY_QUEUE } from './jobs.constants'; TradeModule, SettlementModule, ], - providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler], + providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler, OrderExpiryScheduler], }) export class JobsModule {} diff --git a/server/dukang-api/src/jobs/order-expiry.scheduler.ts b/server/dukang-api/src/jobs/order-expiry.scheduler.ts new file mode 100644 index 0000000..3944a7c --- /dev/null +++ b/server/dukang-api/src/jobs/order-expiry.scheduler.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { TradeService } from '../modules/trade/trade.service'; +import { AlertService } from '../common/alert/alert.service'; + +/** + * v3.5.1 #8:订单 30 分钟未支付自动取消。 + * 每分钟扫描一次:将 status=PENDING_PAY 且 payExpireAt 已过期且非测试订单 翻为 CANCELLED。 + * 下单时 trade.service 已写入 payExpireAt = now+30min,无需新增字段。 + */ +@Injectable() +export class OrderExpiryScheduler { + private readonly logger = new Logger(OrderExpiryScheduler.name); + + constructor( + private readonly trade: TradeService, + private readonly alert: AlertService, + ) {} + + @Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' }) + async handleExpiredPendingOrders() { + try { + const n = await this.trade.cancelExpiredPendingOrders(200); + if (n > 0) this.logger.log(`Auto-cancelled ${n} expired pending orders`); + } catch (e) { + this.logger.error('Order expiry job failed', e instanceof Error ? e.stack : e); + this.alert.notify({ + level: 'P1', + category: 'job', + title: '订单自动取消任务失败', + detail: e instanceof Error ? e.message : String(e), + dedupeKey: `job_order_expiry_fail|${new Date().toISOString().slice(0, 10)}`, + }); + } + } +} diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts index 6364143..e2bd1aa 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts @@ -20,6 +20,12 @@ export class AdminOrdersController { return this.ordersService.list(query); } + /** 必须写在 :id 之前,否则 big-screen 会被当成订单 ID */ + @Get('big-screen') + bigScreen(@Query('limit') limit?: string) { + return this.ordersService.listBigScreen(limit); + } + @Post('batch-delete') @UseGuards(HqPermissionGuard) @RequireHqPermissions('orders_delete') diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 4137694..6450ad3 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -1,3 +1,4 @@ +import { maskContactPhone } from '@dukang/domain'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -24,6 +25,34 @@ export class AdminOrdersService { private readonly adminRedeemService: AdminRedeemService, ) {} + /** v3.5.1 #1:发布会大屏,返回全部订单(按时间倒序,上限 2000) */ + async listBigScreen(limit?: string) { + const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000); + const orders = await this.prisma.order.findMany({ + where: { + isTest: false, + payAmount: { gte: 100 }, + }, + orderBy: { createdAt: 'desc' }, + take, + include: { user: { select: { phone: true } } }, + }); + return { + items: orders.map((o) => { + const spec = o.productSpec ? ` ${o.productSpec}` : ''; + const phone = o.user?.phone || o.receiverPhone; + return { + id: o.id.toString(), + orderNo: o.orderNo, + payAmount: Number(o.payAmount), + items: `${o.productName}${spec} × ${o.quantity}瓶`, + createdAt: o.createdAt.toISOString(), + userPhoneMasked: phone ? maskContactPhone(phone) : null, + }; + }), + }; + } + async list(query: AdminOrdersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 521e7b3..8e6dade 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -96,12 +96,22 @@ export class AdminStoresService { // 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用 const pendingByStore = new Map(); + // 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用 + const pendingInfoByStore = new Map(); if (items.length) { - const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({ - where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' }, - select: { id: true, storeId: true }, - }); + const storeIds = items.map((s) => s.id); + const [pendingReqs, pendingInfoReqs] = await Promise.all([ + this.prisma.storePackageChangeRequest.findMany({ + where: { storeId: { in: storeIds }, status: 'PENDING' }, + select: { id: true, storeId: true }, + }), + this.prisma.storeInfoChangeRequest.findMany({ + where: { storeId: { in: storeIds }, status: 'PENDING' }, + select: { id: true, storeId: true }, + }), + ]); for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString()); + for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString()); } return serializeBigInt({ @@ -113,6 +123,7 @@ export class AdminStoresService { visibilityPhones: visibilityPhones.map((p) => p.phone), // 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃 pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null, + pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null, partner: s.partnerAccount, account: s.bindings[0]?.storeAccount ?? null, bindings: undefined, diff --git a/server/dukang-api/src/modules/store/store-info-change.controller.ts b/server/dukang-api/src/modules/store/store-info-change.controller.ts new file mode 100644 index 0000000..bc155a1 --- /dev/null +++ b/server/dukang-api/src/modules/store/store-info-change.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { StoreInfoChangeService } from './store-info-change.service'; + +@Controller('partner/stores') +@UseGuards(JwtAuthGuard) +export class PartnerStoreInfoChangeController { + constructor(private readonly svc: StoreInfoChangeService) {} + + @Post(':storeId/info-change-request') + submit( + @CurrentUser() user: AuthUser, + @Param('storeId') storeId: string, + @Body() body: Record, + ) { + return this.svc.submitChange({ + submitterType: 'PARTNER', + submitterId: user.actorId, + storeId: BigInt(storeId), + fields: body, + }); + } + + @Get(':storeId/info-change-requests') + list(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) { + return this.svc.listPartnerRequests(BigInt(storeId), user.actorId); + } +} + +@Controller('shop/store') +@UseGuards(JwtAuthGuard, ShopStoreGuard) +export class ShopStoreInfoChangeController { + constructor(private readonly svc: StoreInfoChangeService) {} + + @Post('info-change-request') + submit(@CurrentUser() user: AuthUser, @Body() body: Record) { + return this.svc.submitChange({ + submitterType: 'SHOP', + submitterId: user.actorId, + storeId: user.storeId!, + fields: body, + }); + } +} + +@Controller('admin/store-info-change-requests') +@UseGuards(HqAuthGuard) +export class AdminStoreInfoChangeController { + constructor(private readonly svc: StoreInfoChangeService) {} + + @Get('summary') + summary() { + return this.svc.adminSummary(); + } + + @Get() + list( + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.svc.adminList({ + status: (status as 'PENDING' | 'APPROVED' | 'REJECTED') || undefined, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + }); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.svc.adminDetail(BigInt(id)); + } + + @Put(':id/audit') + audit( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string }, + ) { + return this.svc.audit({ + id: BigInt(id), + action: body.action, + rejectReason: body.rejectReason, + reviewerId: user.actorId, + }); + } +} diff --git a/server/dukang-api/src/modules/store/store-info-change.service.ts b/server/dukang-api/src/modules/store/store-info-change.service.ts new file mode 100644 index 0000000..f41ca8d --- /dev/null +++ b/server/dukang-api/src/modules/store/store-info-change.service.ts @@ -0,0 +1,341 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + isStoreContactPhone, + STORE_CONTACT_PHONE_HINT, +} from '@dukang/domain'; +import { + STORE_INFO_CHANGEABLE_FIELDS, + type StoreInfoChangeFieldDiff, + type StoreInfoChangeRequestDto, + type StoreInfoChangeStatus, + type StoreInfoChangeSubmitterType, +} from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { StoreService } from './store.service'; +import { PartnerCityService } from '../city-scope/partner-city.service'; + +type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; + +function normalizeOptionalTextField(value: unknown): string | null { + if (value == null) return null; + const s = String(value).trim(); + if (!s || /^null$/i.test(s)) return null; + return s; +} + +function normalizeBusinessHour(value: unknown): string | null { + if (value == null || String(value).trim() === '') return null; + const s = String(value).trim(); + if (!/^\d{1,2}:\d{2}$/.test(s)) { + throw new BadRequestException('营业时间格式应为 HH:MM,如 09:00'); + } + return s; +} + +function coerceNumberOrNull(value: unknown): number | null { + if (value == null || String(value).trim() === '') return null; + const n = Number(value); + if (!Number.isFinite(n)) throw new BadRequestException('数值字段格式不正确'); + return n; +} + +/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */ +function buildProposedSnapshot(fields: Record): Record { + const out: Record = {}; + for (const field of STORE_INFO_CHANGEABLE_FIELDS) { + if (!(field in fields)) continue; + const raw = fields[field]; + switch (field) { + case 'latitude': + case 'longitude': + case 'avgPrice': + out[field] = coerceNumberOrNull(raw); + break; + case 'openTime': + case 'closeTime': + case 'openTime2': + case 'closeTime2': + out[field] = normalizeBusinessHour(raw); + break; + case 'intro': + case 'benefitUsageRule': + out[field] = normalizeOptionalTextField(raw); + break; + default: + out[field] = raw == null ? null : String(raw); + } + } + return out; +} + +/** 取 live store 上白名单字段的当前值(用于快照与 diff) */ +function pickLiveFields(store: Record): Record { + const out: Record = {}; + for (const field of STORE_INFO_CHANGEABLE_FIELDS) { + const v = store[field]; + out[field] = v == null ? null : v; + } + return out; +} + +function looseEqual(a: unknown, b: unknown): boolean { + if (a == null && b == null) return true; + return String(a) === String(b); +} + +function computeChangedFields( + live: Record, + proposed: Record, +): ChangeableField[] { + const changed: ChangeableField[] = []; + for (const field of STORE_INFO_CHANGEABLE_FIELDS) { + if (!(field in proposed)) continue; + if (!looseEqual(live[field], proposed[field])) changed.push(field as ChangeableField); + } + return changed; +} + +@Injectable() +export class StoreInfoChangeService { + private readonly logger = new Logger(StoreInfoChangeService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly storeService: StoreService, + private readonly partnerCityService: PartnerCityService, + ) {} + + /** 合伙人 / 门店端 提交基础信息变更 */ + async submitChange(input: { + submitterType: StoreInfoChangeSubmitterType; + submitterId: bigint; + storeId: bigint; + fields: Record; + }): Promise { + // 1) 校验归属 + let store: Record; + if (input.submitterType === 'PARTNER') { + const primary = await this.partnerCityService.resolvePrimaryAccount(input.submitterId); + const found = await this.prisma.store.findFirst({ + where: { id: input.storeId, partnerAccountId: primary.id }, + }); + if (!found) throw new NotFoundException('门店不存在或无权操作'); + store = found as unknown as Record; + } else { + // SHOP / HQ_DIRECT_ADMIN:先校验门店绑定/存在 + if (input.submitterType === 'SHOP') { + await this.storeService.getShopStore(input.submitterId, input.storeId); + } + const found = await this.prisma.store.findUnique({ where: { id: input.storeId } }); + if (!found) throw new NotFoundException('门店不存在'); + store = found as unknown as Record; + } + + if (store.status === 'CLOSED') { + throw new BadRequestException('门店已关闭,不可提交变更'); + } + + // 2) 规整 proposed + diff + const proposed = buildProposedSnapshot(input.fields); + const live = pickLiveFields(store); + const changedFields = computeChangedFields(live, proposed); + if (changedFields.length === 0) { + throw new BadRequestException('没有检测到需要变更的字段'); + } + + // 3) 基础校验 + if (proposed.name != null && !String(proposed.name).trim()) { + throw new BadRequestException('请填写门店名称'); + } + if (proposed.contactPhone != null && !isStoreContactPhone(String(proposed.contactPhone))) { + throw new BadRequestException(STORE_CONTACT_PHONE_HINT); + } + if (proposed.intro != null && (String(proposed.intro).length < 2 || String(proposed.intro).length > 500)) { + throw new BadRequestException('门店简介须为 2~500 字'); + } + if ( + proposed.benefitUsageRule != null && + String(proposed.benefitUsageRule).length > 1000 + ) { + throw new BadRequestException('好客权益券使用规则最多 1000 字'); + } + if ( + (proposed.latitude != null || proposed.longitude != null) && + (proposed.latitude == null || proposed.longitude == null) + ) { + throw new BadRequestException('经纬度须同时提供'); + } + + // 4) 同门店已有 PENDING 则替换(最新优先) + await this.prisma.storeInfoChangeRequest.deleteMany({ + where: { storeId: input.storeId, status: 'PENDING' }, + }); + + const created = await this.prisma.storeInfoChangeRequest.create({ + data: { + storeId: input.storeId, + status: 'PENDING', + liveSnapshot: live as object, + proposedSnapshot: proposed as object, + changedFields: changedFields as unknown as never, + submitterType: input.submitterType, + submitterId: input.submitterId, + }, + }); + + this.logger.log( + `Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`, + ); + return serializeBigInt(this.toDto(created as unknown as Record)); + } + + /** 合伙人端查看本门店历史变更 */ + async listPartnerRequests( + storeId: bigint, + partnerAccountId: bigint, + ): Promise { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const owned = await this.prisma.store.findFirst({ + where: { id: storeId, partnerAccountId: primary.id }, + select: { id: true }, + }); + if (!owned) throw new NotFoundException('门店不存在或无权操作'); + const rows = await this.prisma.storeInfoChangeRequest.findMany({ + where: { storeId }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record))); + } + + /** 总部列表 */ + async adminList(opts: { + status?: StoreInfoChangeStatus; + page?: number; + pageSize?: number; + }): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> { + const page = Math.max(1, opts.page || 1); + const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100); + const where = opts.status ? { status: opts.status } : {}; + const [rows, total] = await Promise.all([ + this.prisma.storeInfoChangeRequest.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { store: { select: { name: true } } }, + }), + this.prisma.storeInfoChangeRequest.count({ where }), + ]); + const items = rows.map((r) => + serializeBigInt( + this.toDto(r as unknown as Record, (r as { store?: { name?: string } }).store?.name), + ), + ); + return { items, total, page, pageSize }; + } + + /** 总部待审总数(与套餐审核汇总,用于统一 badge) */ + async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> { + const [infoPending, packagePending] = await Promise.all([ + this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }), + this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }), + ]); + return { pendingCount: infoPending, packagePendingCount: packagePending }; + } + + /** 总部详情(含字段级 diff) */ + async adminDetail(id: bigint): Promise { + const row = await this.prisma.storeInfoChangeRequest.findUnique({ + where: { id }, + include: { store: { select: { name: true } } }, + }); + if (!row) throw new NotFoundException('变更请求不存在'); + const dto = this.toDto( + row as unknown as Record, + (row as { store?: { name?: string } }).store?.name, + ); + const live = (row as { liveSnapshot?: Record }).liveSnapshot || {}; + const proposed = (row as { proposedSnapshot?: Record }).proposedSnapshot || {}; + const changed = ((row as { changedFields?: ChangeableField[] }).changedFields as ChangeableField[]) || []; + const diffs: StoreInfoChangeFieldDiff[] = changed.map((field) => ({ + field, + live: live[field] ?? null, + proposed: proposed[field] ?? null, + })); + return { ...dto, diffs }; + } + + /** 总部审核通过/驳回 */ + async audit(input: { + id: bigint; + action: 'APPROVE' | 'REJECT'; + rejectReason?: string; + reviewerId: bigint; + }): Promise { + const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } }); + if (!row) throw new NotFoundException('变更请求不存在'); + if (row.status !== 'PENDING') { + throw new BadRequestException('该变更请求已处理'); + } + + if (input.action === 'REJECT') { + const updated = await this.prisma.storeInfoChangeRequest.update({ + where: { id: input.id }, + data: { + status: 'REJECTED', + rejectReason: normalizeOptionalTextField(input.rejectReason) || '总部驳回', + reviewerId: input.reviewerId, + reviewedAt: new Date(), + }, + }); + this.logger.log(`Store info change rejected id=${input.id}`); + return serializeBigInt(this.toDto(updated as unknown as Record)); + } + + // APPROVE:将 proposedSnapshot 写入 Store(白名单内) + const proposed = (row as { proposedSnapshot?: Record }).proposedSnapshot || {}; + const data: Record = {}; + for (const field of STORE_INFO_CHANGEABLE_FIELDS) { + if (!(field in proposed)) continue; + const v = proposed[field]; + data[field] = v == null ? null : v; + } + await this.prisma.store.update({ where: { id: row.storeId }, data: data as never }); + + const updated = await this.prisma.storeInfoChangeRequest.update({ + where: { id: input.id }, + data: { + status: 'APPROVED', + reviewerId: input.reviewerId, + reviewedAt: new Date(), + }, + }); + this.logger.log(`Store info change approved id=${input.id} storeId=${row.storeId}`); + return serializeBigInt(this.toDto(updated as unknown as Record)); + } + + private toDto( + row: Record, + storeName?: string, + ): StoreInfoChangeRequestDto { + return { + id: String(row.id), + storeId: String(row.storeId), + storeName, + status: row.status as StoreInfoChangeStatus, + changedFields: ((row.changedFields as ChangeableField[]) || []).map(String) as never, + submitterType: row.submitterType as StoreInfoChangeSubmitterType, + submitterId: String(row.submitterId), + rejectReason: (row.rejectReason as string | null) ?? null, + reviewedAt: row.reviewedAt ? (row.reviewedAt as Date).toISOString() : null, + createdAt: (row.createdAt as Date).toISOString(), + }; + } +} diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts index da16598..059fb47 100644 --- a/server/dukang-api/src/modules/store/store.module.ts +++ b/server/dukang-api/src/modules/store/store.module.ts @@ -24,6 +24,12 @@ import { ShopStorePackageController, } from './store-package.controller'; import { StorePackageService } from './store-package.service'; +import { + AdminStoreInfoChangeController, + PartnerStoreInfoChangeController, + ShopStoreInfoChangeController, +} from './store-info-change.controller'; +import { StoreInfoChangeService } from './store-info-change.service'; @Module({ imports: [ @@ -47,8 +53,11 @@ import { StorePackageService } from './store-package.service'; ShopDashboardController, AdminStorePackageController, AdminStorePackageAuditController, + PartnerStoreInfoChangeController, + ShopStoreInfoChangeController, + AdminStoreInfoChangeController, ], - providers: [StoreService, StoreCategoryService, StorePackageService], + providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService], exports: [StoreService, StoreCategoryService, StorePackageService], }) export class StoreModule {} diff --git a/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts b/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts index 2c6c12a..b672406 100644 --- a/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts +++ b/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, } from 'class-validator'; export class CreateAfterSaleTicketDto { @@ -37,18 +38,26 @@ export class CreatePackageDisputeDto { } export class CreateInvoiceDto { + /** v3.5.1:从已保存的发票抬头中选择(若存在则覆盖下方抬头字段) */ + @IsOptional() + @IsString() + titleId?: string; + + @ValidateIf((o: CreateInvoiceDto) => !o.titleId) @IsString() @IsIn(['PERSONAL', 'ENTERPRISE']) - titleType: string; + titleType?: string; + @IsOptional() @IsString() @IsIn(['NORMAL', 'SPECIAL']) - invoiceKind: string; + invoiceKind?: string; + @ValidateIf((o: CreateInvoiceDto) => !o.titleId) @IsString() @IsNotEmpty() @MaxLength(128) - titleName: string; + titleName?: string; @IsOptional() @IsString() @@ -65,13 +74,15 @@ export class CreateInvoiceDto { @MaxLength(256) bankAccount?: string; + @ValidateIf((o: CreateInvoiceDto) => !o.titleId) @IsEmail() - email: string; + email?: string; + @ValidateIf((o: CreateInvoiceDto) => !o.titleId) @IsString() @IsNotEmpty() @MaxLength(20) - phone: string; + phone?: string; @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/trade/invoice-title.controller.ts b/server/dukang-api/src/modules/trade/invoice-title.controller.ts new file mode 100644 index 0000000..a9428ff --- /dev/null +++ b/server/dukang-api/src/modules/trade/invoice-title.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { + type UpsertInvoiceTitleRequest, + type UserInvoiceTitleDto, +} from '@dukang/shared-types'; +import { InvoiceTitleService } from './invoice-title.service'; +import { TradeService } from './trade.service'; + +@Controller('trade') +@UseGuards(JwtAuthGuard) +export class TradeInvoiceTitleController { + constructor( + private readonly titleSvc: InvoiceTitleService, + private readonly tradeService: TradeService, + ) {} + + @Get('invoice-titles') + list(@CurrentUser() user: AuthUser): Promise { + return this.titleSvc.listTitles(user.actorId); + } + + @Post('invoice-titles') + create( + @CurrentUser() user: AuthUser, + @Body() body: UpsertInvoiceTitleRequest, + ): Promise { + return this.titleSvc.createTitle(user.actorId, body); + } + + @Put('invoice-titles/:id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: UpsertInvoiceTitleRequest, + ): Promise { + return this.titleSvc.updateTitle(user.actorId, BigInt(id), body); + } + + @Delete('invoice-titles/:id') + remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.titleSvc.deleteTitle(user.actorId, BigInt(id)); + } + + /** 订单可用的发票抬头(用户全部抬头) */ + @Get('orders/:orderId/invoice-titles') + orderTitles(@CurrentUser() user: AuthUser): Promise { + return this.titleSvc.listTitles(user.actorId); + } + + /** 订单是否已申请发票 */ + @Get('orders/:orderId/invoice-status') + orderInvoiceStatus(@CurrentUser() user: AuthUser, @Param('orderId') orderId: string) { + return this.tradeService.getOrderInvoiceStatus(user.actorId, BigInt(orderId)); + } +} diff --git a/server/dukang-api/src/modules/trade/invoice-title.service.ts b/server/dukang-api/src/modules/trade/invoice-title.service.ts new file mode 100644 index 0000000..b3a9fbc --- /dev/null +++ b/server/dukang-api/src/modules/trade/invoice-title.service.ts @@ -0,0 +1,123 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + type UpsertInvoiceTitleRequest, + type UserInvoiceTitleDto, +} from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; + +@Injectable() +export class InvoiceTitleService { + constructor(private readonly prisma: PrismaService) {} + + async listTitles(userId: bigint): Promise { + const rows = await this.prisma.userInvoiceTitle.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }], + }); + return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record))); + } + + async createTitle( + userId: bigint, + body: UpsertInvoiceTitleRequest, + ): Promise { + this.validate(body); + if (body.isDefault) { + await this.prisma.userInvoiceTitle.updateMany({ + where: { userId, isDefault: true }, + data: { isDefault: false }, + }); + } + const created = await this.prisma.userInvoiceTitle.create({ + data: { + userId, + titleType: body.titleType, + titleName: body.titleName.trim(), + taxNo: body.taxNo?.trim() || null, + email: body.email?.trim() || null, + phone: body.phone?.trim() || null, + addressPhone: body.addressPhone?.trim() || null, + bankAccount: body.bankAccount?.trim() || null, + isDefault: !!body.isDefault, + }, + }); + return serializeBigInt(this.toDto(created as unknown as Record)); + } + + async updateTitle( + userId: bigint, + id: bigint, + body: UpsertInvoiceTitleRequest, + ): Promise { + const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } }); + if (!existing) throw new NotFoundException('发票抬头不存在'); + this.validate(body, true); + + const isDefault = body.isDefault ?? existing.isDefault; + if (isDefault && !existing.isDefault) { + await this.prisma.userInvoiceTitle.updateMany({ + where: { userId, isDefault: true }, + data: { isDefault: false }, + }); + } + + const updated = await this.prisma.userInvoiceTitle.update({ + where: { id }, + data: { + titleType: body.titleType, + titleName: body.titleName.trim(), + taxNo: body.taxNo?.trim() || null, + email: body.email?.trim() || null, + phone: body.phone?.trim() || null, + addressPhone: body.addressPhone?.trim() || null, + bankAccount: body.bankAccount?.trim() || null, + isDefault, + }, + }); + return serializeBigInt(this.toDto(updated as unknown as Record)); + } + + async deleteTitle(userId: bigint, id: bigint): Promise<{ id: string }> { + const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } }); + if (!existing) throw new NotFoundException('发票抬头不存在'); + const referenced = await this.prisma.userInvoice.findFirst({ where: { userId, titleName: existing.titleName } }); + if (referenced) { + throw new ConflictException('该抬头已有发票申请记录,无法删除'); + } + await this.prisma.userInvoiceTitle.delete({ where: { id } }); + return { id: id.toString() }; + } + + private validate(body: UpsertInvoiceTitleRequest, isUpdate = false) { + if (!body.titleName?.trim()) { + throw new BadRequestException('请填写抬头名称'); + } + if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) { + throw new BadRequestException('企业抬头须填写税号'); + } + if (isUpdate && body.titleType === undefined) { + throw new BadRequestException('titleType 必填'); + } + } + + private toDto(row: Record): UserInvoiceTitleDto { + return { + id: String(row.id), + titleType: row.titleType as UserInvoiceTitleDto['titleType'], + titleName: String(row.titleName), + taxNo: (row.taxNo as string | null) ?? null, + email: (row.email as string | null) ?? null, + phone: (row.phone as string | null) ?? null, + addressPhone: (row.addressPhone as string | null) ?? null, + bankAccount: (row.bankAccount as string | null) ?? null, + isDefault: !!row.isDefault, + createdAt: (row.createdAt as Date).toISOString(), + }; + } +} diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts index 1618c33..d651d8e 100644 --- a/server/dukang-api/src/modules/trade/trade.module.ts +++ b/server/dukang-api/src/modules/trade/trade.module.ts @@ -18,6 +18,8 @@ import { TradePackageDisputeController, } from './trade.controller'; import { TradeService } from './trade.service'; +import { TradeInvoiceTitleController } from './invoice-title.controller'; +import { InvoiceTitleService } from './invoice-title.service'; @Module({ imports: [ @@ -39,8 +41,9 @@ import { TradeService } from './trade.service'; PartnerOrderController, PartnerProxyOrderController, PartnerReshipmentController, + TradeInvoiceTitleController, ], - providers: [TradeService], + providers: [TradeService, InvoiceTitleService], exports: [TradeService], }) export class TradeModule {} diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index dcd47a2..d73ef00 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, + Logger, NotFoundException, forwardRef, } from '@nestjs/common'; @@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service'; import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util'; import { extractClientIp } from '../../common/geo/client-ip.util'; import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers'; -import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; +import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service'; import { AlertService } from '../../common/alert/alert.service'; @@ -57,6 +58,9 @@ export class TradeService { private readonly alert: AlertService, ) {} + private readonly logger = new Logger(TradeService.name); + + async preview( userId: bigint, body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean }, @@ -753,14 +757,23 @@ export class TradeService { const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, - include: { benefitCoupon: true, imageResource: true }, + include: { + benefitCoupon: true, + imageResource: true, + invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 }, + }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.order.count({ where }), ]); - return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize }; + return { + list: serializeBigInt(list.map(mapUserOrderWithInvoice)), + total, + page, + pageSize, + }; } async getOrder(userId: bigint, orderId: bigint) { @@ -772,6 +785,7 @@ export class TradeService { imageResource: true, product: true, fulfillmentWarehouse: { select: { id: true, name: true } }, + invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -779,7 +793,10 @@ export class TradeService { where: orderStatusLogWhere(orderId), orderBy: { createdAt: 'desc' }, }); - const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }); + const mapped = mapUserOrderWithInvoice({ + ...order, + statusLogs: mapStatusLogCompat(statusLogs), + }); return serializeBigInt({ ...mapped, wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order), @@ -987,14 +1004,15 @@ export class TradeService { userId: bigint, orderId: bigint, body: { - titleType: string; - invoiceKind: string; - titleName: string; - taxNo?: string; - addressPhone?: string; - bankAccount?: string; - email: string; - phone: string; + titleId?: string; + titleType?: string; + invoiceKind?: string; + titleName?: string; + taxNo?: string | null; + addressPhone?: string | null; + bankAccount?: string | null; + email?: string; + phone?: string; remark?: string; }, ) { @@ -1009,14 +1027,49 @@ export class TradeService { }); if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请'); - if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) { + let resolved = { + titleType: body.titleType || '', + titleName: body.titleName || '', + taxNo: body.taxNo ?? null, + addressPhone: body.addressPhone ?? null, + bankAccount: body.bankAccount ?? null, + email: body.email || '', + phone: body.phone || '', + }; + if (body.titleId) { + const title = await this.prisma.userInvoiceTitle.findFirst({ + where: { id: BigInt(body.titleId), userId }, + }); + if (!title) throw new BadRequestException('发票抬头不存在'); + resolved = { + titleType: title.titleType, + titleName: title.titleName, + taxNo: title.taxNo, + addressPhone: title.addressPhone, + bankAccount: title.bankAccount, + email: title.email || body.email || '', + phone: title.phone || body.phone || '', + }; + } + + if (!resolved.titleName.trim()) { + throw new BadRequestException('请填写抬头名称'); + } + if (!resolved.email.trim()) { + throw new BadRequestException('请填写接收邮箱'); + } + if (!resolved.phone.trim()) { + throw new BadRequestException('请填写联系电话'); + } + if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) { throw new BadRequestException('企业抬头须填写税号'); } - if (body.invoiceKind === 'SPECIAL') { - if (body.titleType !== 'ENTERPRISE') { + const invoiceKind = body.invoiceKind || 'NORMAL'; + if (invoiceKind === 'SPECIAL') { + if (resolved.titleType !== 'ENTERPRISE') { throw new BadRequestException('专用发票仅支持企业抬头'); } - if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) { + if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) { throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号'); } } @@ -1026,20 +1079,29 @@ export class TradeService { invoiceNo: this.generateInvoiceNo(), orderId, userId, - titleType: body.titleType as never, - invoiceKind: body.invoiceKind as never, - titleName: body.titleName.trim(), - taxNo: body.taxNo?.trim() || null, - addressPhone: body.addressPhone?.trim() || null, - bankAccount: body.bankAccount?.trim() || null, - email: body.email.trim(), - phone: body.phone.trim(), + titleType: resolved.titleType as never, + invoiceKind: invoiceKind as never, + titleName: resolved.titleName.trim(), + taxNo: resolved.taxNo?.trim() || null, + addressPhone: resolved.addressPhone?.trim() || null, + bankAccount: resolved.bankAccount?.trim() || null, + email: resolved.email.trim(), + phone: resolved.phone.trim(), remark: body.remark?.trim() || null, }, }); return serializeBigInt({ ...invoice, orderNo: order.orderNo }); } + /** v3.5.1 #3:查询某订单是否已申请发票 */ + async getOrderInvoiceStatus(userId: bigint, orderId: bigint) { + const inv = await this.prisma.userInvoice.findFirst({ + where: { orderId, userId }, + select: { id: true, status: true }, + }); + return { exists: !!inv, status: inv?.status ?? null }; + } + async listInvoices(userId: bigint, page = 1, pageSize = 20) { const where = { userId }; const [items, total] = await Promise.all([ @@ -1096,14 +1158,15 @@ export class TradeService { async adminCreateInvoice( body: { orderNo: string; - titleType: string; - invoiceKind: string; - titleName: string; + titleId?: string; + titleType?: string; + invoiceKind?: string; + titleName?: string; taxNo?: string; addressPhone?: string; bankAccount?: string; - email: string; - phone: string; + email?: string; + phone?: string; remark?: string; }, ) { @@ -1819,6 +1882,59 @@ export class TradeService { return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' }); } + /** + * v3.5.1 #8:订单 30 分钟未支付自动取消。 + * 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。 + * 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。 + */ + async cancelExpiredPendingOrders(limit = 200): Promise { + const now = new Date(); + const expired = await this.prisma.order.findMany({ + where: { + status: 'PENDING_PAY', + payStatus: 'UNPAID', + payExpireAt: { lt: now }, + isTest: false, + }, + take: limit, + select: { id: true }, + orderBy: { payExpireAt: 'asc' }, + }); + if (expired.length === 0) return 0; + + let cancelled = 0; + for (const o of expired) { + try { + await this.prisma.$transaction(async (tx) => { + const updated = await tx.order.updateMany({ + where: { id: o.id, status: 'PENDING_PAY' }, + data: { status: 'CANCELLED', cancelledAt: now }, + }); + if (updated.count === 0) return; // 已被并发处理 + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ + orderId: o.id, + fromStatus: 'PENDING_PAY', + toStatus: 'CANCELLED', + operator: 'SYSTEM_AUTO_EXPIRE', + remark: '30 分钟未支付自动取消', + }), + }); + }); + cancelled++; + } catch (e) { + this.logger.error( + `cancelExpiredPendingOrders failed for order ${o.id}`, + e instanceof Error ? e.stack : e, + ); + } + } + if (cancelled > 0) { + this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`); + } + return cancelled; + } + /** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */ async getHqProxyOrderOptions() { const [products, promoCodes] = await Promise.all([ diff --git a/server/dukang-api/tsconfig.json b/server/dukang-api/tsconfig.json index 8115a25..2107a7d 100644 --- a/server/dukang-api/tsconfig.json +++ b/server/dukang-api/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", + "moduleResolution": "node16", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, diff --git a/杜康好客-v3.5.1-工单迭代开发文档.md b/杜康好客-v3.5.1-工单迭代开发文档.md new file mode 100644 index 0000000..d62d61a --- /dev/null +++ b/杜康好客-v3.5.1-工单迭代开发文档.md @@ -0,0 +1,663 @@ +# 杜康好客 v3.5.1 开发方案 + +> 9 个修改点 · 涉及 4 端(admin-web / h5-shop / mini-user / h5-partner)+ 1 后端 +> **落地对照(已实现)**:[`杜康好客-v3.5.1-开发文档.md`](./杜康好客-v3.5.1-开发文档.md) +> 本文为开工方案与探索记录;与实现不一致时以落地对照为准。 +> 协作:模块边界仍按 AGENTS.md;`store` 模块 OWNER 为 B+D,**当前由 jacy-dukang 全权**,可直接写 `store_*`/`redeem` 表 + +--- + +## 0. 总体一览 + +| 编号 | 模块 | 改动范围 | 估时 | +|------|------|----------|------| +| #1 | 大屏(订单轮播+脱敏) | 后端新增 + admin-web 新页 + OrdersPage 加按钮 | 0.5d | +| #2 | 门店端核销后即时刷新 | h5-shop RedeemSuccess/Mine/Withdraw 三处 | 0.3d | +| #3 | 用户端"发票抬头"列表 | 后端新表新接口 + mini-user 新 2 页 + 我的页入口 | 1d | +| #4 | 用户端订单"申请发票" | 后端新接口 + mini-user 订单列表/详情按钮 | 0.5d | +| #5 | 合伙人端门店编辑改"提交变更" | 后端新表新接口 + h5-partner StoreDetailPage | 1d | +| #6 | "套餐审核" 改 "审核通知" | admin-web Layout + StorePackageAuditsPage 改造 | 0.3d | +| #7 | 门店列表 提现/对比 按钮(信息变更) | admin-web StoresPage + 后端 admin list 返回字段 | 0.3d | +| #8 | 订单 30 分钟未支付自动取消 | 后端新增定时任务(复用 `payExpireAt` + 现有取消写法) | 0.5d | +| #9 | 城市合伙人/门店接口强校验 status+bindstatus,否则强制退登录 | 后端 `JwtAuthGuard` 注入状态校验 + `HttpExceptionFilter` 透传 + 两端前端跳登录 | 0.5d | + +跨模块串联点:**#5 + #6 + #7** 共享同一个 `StoreInfoChangeRequest` 模型与 `AUDIT_NOTICE_CHANGED_EVENT`;**#9** 串联后端守卫 + 前端两端 `api.ts` 的退出逻辑。 + +> **#8 / #9 补充说明** +> - #8:下单时 `trade.service.ts` 已写 `payExpireAt = now + 30min`(line 206/1590/1935),支付/查询也已校验过期(line 2056/2145),但**缺少把 DB 状态翻成 `CANCELLED` 的定时任务**,本次补上。 +> - #9:需求里的"门店 status + bindstatus"在模型上对应 **StoreAccount.status(门店账号启停,须 `ACTIVE`)+ Store.status(门店启停,须 `OPEN`)**;"合伙人 status + bindstatus"对应 **PartnerAccount.status(须 `ACTIVE`)+ PartnerAccount.bindingStatus(须 `ACTIVE`)**。两端不共用同一模型,校验需分别查。详见 §10。 + +--- + +## 1. 关键决策(已与用户对齐) + +| 决策点 | 选择 | +|--------|------| +| 审核中心合并策略 | 合并套餐审核 + 信息变更 → 统一进「审核通知」 | +| 大屏实时性 | 3 秒 polling(无 SSE/WS) | +| 审核通过生效方式 | 直接覆盖 `store` 表 + 审核中隐藏(前端展示 liveSnapshot) | +| 申请发票范围 | 仅 `COMPLETED` 订单 | + +--- + +## 2. 数据模型新增(共 2 张表) + +### 2.1 `UserInvoiceTitle`(发票抬头) + +```prisma +model UserInvoiceTitle { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + titleType InvoiceTitleType @map("title_type") // PERSONAL | ENTERPRISE + titleName String @map("title_name") @db.VarChar(128) + taxNo String? @map("tax_no") @db.VarChar(32) + email String? @db.VarChar(128) + phone String? @db.VarChar(20) + addressPhone String? @map("address_phone") @db.VarChar(256) + bankAccount String? @map("bank_account") @db.VarChar(256) + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + invoices UserInvoice[] // 反向关联,已有 + + @@index([userId, isDefault]) + @@map("user_invoice_title") +} +``` + +> 同时在 `model User { ... }` 增加 `invoiceTitles UserInvoiceTitle[]` + +### 2.2 `StoreInfoChangeRequest`(门店信息变更请求) + +```prisma +enum StoreInfoChangeStatus { PENDING APPROVED REJECTED } +enum StoreInfoChangeSubmitterType { PARTNER SHOP HQ_DIRECT_ADMIN } + +model StoreInfoChangeRequest { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + status StoreInfoChangeStatus @default(PENDING) + /// liveSnapshot = 变更前 Store 全量快照;proposedSnapshot = 提单时希望变更的字段集合 + liveSnapshot Json @map("live_snapshot") + proposedSnapshot Json @map("proposed_snapshot") + /// 可变字段白名单:name/contactPhone/address/intro/benefitUsageRule/coverUrl/envPhotoUrls/openTime/closeTime/openTime2/closeTime2/avgPrice + changedFields Json @map("changed_fields") + submitterType StoreInfoChangeSubmitterType @map("submitter_type") + submitterId BigInt @map("submitter_id") @db.UnsignedBigInt + rejectReason String? @map("reject_reason") @db.VarChar(512) + reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) + reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@index([storeId, status]) + @@index([status, createdAt]) + @@map("store_info_change_request") +} +``` + +> 同时在 `model Store { ... }` 增加 `infoChangeRequests StoreInfoChangeRequest[]` + +**Prisma 迁移策略**: +- 修改 `server/dukang-api/prisma/schema.prisma` +- 执行 `pnpm db:generate`、`cd server/dukang-api && npx prisma db push` +- 重启 `pnpm dev:api` + +--- + +## 3. 后端新增/修改清单 + +### 3.1 新建 `modules/store/store-info-change.{controller,service}.ts` + +| 路由 | 方法 | 角色 | 说明 | +|------|------|------|------| +| `POST /partner/stores/:storeId/info-change-request` | body: `{ fields: Partial }` | PARTNER | 提交基本资料变更 | +| `GET /partner/stores/:storeId/info-change-requests` | — | PARTNER | 看本门店历史变更 | +| `POST /shop/store/info-change-request` | 同上 | SHOP | 门店端提交变更 | +| `GET /admin/store-info-change-requests?status=&page=` | — | HQ | 列表 | +| `GET /admin/store-info-change-requests/summary` | — | HQ | 返回 `pendingCount`(与套餐审核汇总汇总求和) | +| `GET /admin/store-info-change-requests/:id` | — | HQ | 详情(live + proposed diff) | +| `PUT /admin/store-info-change-requests/:id/audit` | `{ action, rejectReason? }` | HQ | 通过/驳回 | + +服务函数(`StoreInfoChangeService`): +```ts +submit({ storeId, actorId, actorType, fields }) { + // 1) 校验 store 归属 + // 2) 拉 live = prisma.store.findUnique,diff 出 changedFields + // 3) prisma.storeInfoChangeRequest.create(live/proposed JSON) + // 4) 同 store 已有 PENDING 时直接替换(最新优先) + // 5) 触发 AUDIT_NOTICE_CHANGED_EVENT +} + +audit({ id, action, reason, reviewerId }) { + if (action === APPROVED) { + // 把 proposedSnapshot 字段写到 store(白名单) + // store 通过 storeService.updateByHq()(如无此方法,需新建;不能直接 prisma 改 store) + // store 信息变更后,partner 端 Strip Pending + } + // 写状态/驳回原因 + // 触发 AUDIT_NOTICE_CHANGED_EVENT +} +``` + +--- + +### 3.2 新建 `modules/trade/invoice-title.{controller,service}.ts` + +| 路由 | 方法 | 说明 | +|------|------|------| +| `GET /trade/invoice-titles` | — | 当前用户的抬头列表 | +| `POST /trade/invoice-titles` | body | 新建 | +| `PUT /trade/invoice-titles/:id` | body | 编辑;可同时 `isDefault=true` 触发其他置 false | +| `DELETE /trade/invoice-titles/:id` | — | 删除(已被 UserInvoice 引用的禁止删除,返回 409) | +| `GET /trade/orders/:orderId/invoice-titles` | — | 订单所有可用的抬头(用户的 + 默认) | + +> 利用已有 `UserInvoice` 表;`UserInvoice.titleId` 可选填空(也可保留 `titleName/taxNo` 冗余快照——**建议保留**:每张发票保存当时的抬头快照,避免抬头被删后历史记录走样) + +--- + +### 3.3 修改 `modules/trade/trade.controller.ts` — 新增(订单可用发票接口) + +| 路由 | 方法 | 说明 | +|------|------|------| +| `GET /trade/orders/:orderId/invoice-status` | — | 返回当前订单是否已开票(`EXISTS \| NONE`) | +| `GET /trade/invoices` | — | 我的发票申请列表 | + +服务函数基于 `UserInvoice` 现有查询扩展。 + +--- + +### 3.4 修改 `modules/trade/admin-big-screen.controller.ts`(**新建**) + +| 路由 | 方法 | 说明 | +|------|------|------| +| `GET /admin/orders/big-screen?limit=20` | — | 返回最近 N 条订单:订单号、金额、商品名/规格/数量、下单人 phone(**强制脱敏**)、下单时间 | + +关键实现: +```ts +const orders = await prisma.order.findMany({ + orderBy: { createdAt: 'desc' }, + take: Math.min(Number(limit) || 20, 50), + include: { items: true, user: { select: { phone: true } } } +}); +return orders.map(o => ({ + id: o.id, orderNo: o.orderNo, payAmount: o.payAmount, + createdAt: o.createdAt, + userPhoneMasked: o.user?.phone ? maskPhone(o.user.phone) : null, + items: o.items.map(it => `${it.productName} × ${it.quantity}`).join(','), +})); +``` + +> 复用于 `packages/domain` 中已存在的 `maskPhone` 函数。新建 admin-big-screen.controller.ts 即可,不动 trade.controller.ts。 + +--- + +### 3.5 修改 `modules/store/store.controller.ts` 或 trade ops 模块 + +- 现有 `/partner/stores/:storeId/basic` (PUT) 是**直接生效**的,需保留为 backward-compat;**新增** `/partner/stores/:storeId/info-change-request`(见 3.1) +- 现有门店端 `/shop/store` (GET) 不变;本次不修改 + +--- + +### 3.6 shared-types 包 + +`packages/shared-types/src/invoice-title.ts` 新增: +```ts +export interface UserInvoiceTitleDto { + id: string; titleType: InvoiceTitleType; titleName: string; + taxNo?: string; email?: string; phone?: string; + addressPhone?: string; bankAccount?: string; isDefault: boolean; +} +export const INVOICE_CHANGEABLE_FIELDS = [ + 'titleName', 'taxNo', 'email', 'phone', 'addressPhone', 'bankAccount', 'isDefault' +] as const; +``` + +`packages/shared-types/src/store-info-change.ts` 新增: +```ts +export type StoreInfoChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; +export type StoreInfoChangeSubmitterType = 'PARTNER' | 'SHOP' | 'HQ_DIRECT_ADMIN'; +export interface StoreInfoChangeRequestDto { ... } +export interface StoreInfoChangeSummaryDto { pendingCount: number; } +export const STORE_INFO_CHANGEABLE_FIELDS = [ + 'name', 'contactPhone', 'address', 'intro', 'benefitUsageRule', + 'coverUrl', 'envPhotoUrls', 'openTime', 'closeTime', + 'openTime2', 'closeTime2', 'avgPrice' +] as const; +``` + +--- + +### 3.7 shared-events 全局事件 + +新增 `packages/shared-events/src/admin-events.ts` 或在 admin-web 的 `lib/admin-events.ts` 增加: +```ts +export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed'; +export const STORE_REDEEM_SUCCESS_EVENT = 'dukang:shop-redeem-success'; +``` + +--- + +### 3.8 新增 `jobs/order-expiry.scheduler.ts`(#8 自动取消过期订单) + +> 关键事实:下单时 `trade.service.ts` 已写 `payExpireAt = now + 30min`,支付/查询也已校验过期,但**没有定时任务**真正把 `status` 翻成 `CANCELLED`。后端 `src/jobs/` 已用 `@Cron`(`@nestjs/schedule`)范式(`settlement.scheduler.ts`),`ScheduleModule` 已在 `jobs.module.ts` 注册。 + +**新建 `server/dukang-api/src/jobs/order-expiry.scheduler.ts`**: +```ts +@Injectable() +export class OrderExpiryScheduler { + private readonly logger = new Logger(OrderExpiryScheduler.name); + constructor(private readonly trade: TradeService) {} + // 每分钟扫描一次;订单量不大,1 分钟粒度足够"30 分钟"语义 + @Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' }) + async handleExpiredPendingOrders() { + try { + const n = await this.trade.cancelExpiredPendingOrders(200); + if (n > 0) this.logger.log(`Auto-cancelled ${n} expired pending orders`); + } catch (e) { + this.logger.error('Order expiry job failed', e instanceof Error ? e.stack : e); + } + } +} +``` + +**`TradeService.cancelExpiredPendingOrders(limit = 200)`(新增方法)**: +```ts +async cancelExpiredPendingOrders(limit = 200): Promise { + const expired = await this.prisma.order.findMany({ + where: { + status: 'PENDING_PAY', + payStatus: 'UNPAID', + payExpireAt: { lt: new Date() }, + isTest: false, // 跳过测试订单,避免污染数据 + }, + take: limit, + select: { id: true }, + }); + for (const o of expired) { + await this.prisma.$transaction(async (tx) => { + await tx.order.update({ + where: { id: o.id }, + data: { status: 'CANCELLED', cancelledAt: new Date() }, + }); + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ // 复用现有导入(见 line 1810) + orderId: o.id, fromStatus: 'PENDING_PAY', toStatus: 'CANCELLED', + operator: 'SYSTEM_AUTO_EXPIRE', remark: '30 分钟未支付自动取消', + }), + }); + }); + } + return expired.length; +} +``` + +**注册**:`server/dukang-api/src/jobs/jobs.module.ts` 的 `providers` 增加 `OrderExpiryScheduler`(`TradeModule` 已 import,无需改 imports)。 + +> **待确认**:取消时是否需回滚已占用的 `benefitCoupon`?现有 `cancelPartnerProxyOrder`(line 1791)仅翻状态、未回滚权益券。若业务要求自动取消也释放权益,此处需补 `benefitCoupon.update({ status: 'ACTIVE' })`。 + +--- + +### 3.9 修改 `JwtAuthGuard` + `HttpExceptionFilter`(#9 强校验,详见 §10) + +> 完整探查见 §10。要点: + +- **`server/dukang-api/src/common/guards/jwt-auth.guard.ts`**: + - `canActivate` 改为 `async`;注入 `PrismaService`。 + - 在 `req.user` 组装完成后,按 `actorType` 分支(**仅 STORE / PARTNER,USER 跳过**): + - `STORE`:查 `storeAccount(actorId)` → `status` 须 `ACTIVE`;若 `req.user.storeId` 存在,查 `Store` → `status` 须 `OPEN`。 + - `PARTNER`:查 `partnerAccount(actorId)` → `status === 'ACTIVE'` 且 `bindingStatus === 'ACTIVE'`。 + - 不通过 → `throw new ForbiddenException({ reason: 'ACCOUNT_DISABLED', message: '账号已停用或解绑,请重新登录' })`。 + - 覆盖率 100%:`/shop/*`、`/partner/*` 全部经过 `JwtAuthGuard`(含仅用 `JwtAuthGuard` 的合伙人路由);登录/refresh 走 `OptionalJwtAuthGuard`,不受影响。 +- **`server/dukang-api/src/common/filters/http-exception.filter.ts`**(line 58-60 的 `response.status(status).json({...})`): + - 增加透传 `reason` 字段:`reason: (res && typeof res === 'object' && (res as any).reason) ?? null`,使前端能精确识别"账号停用"。 + +--- + +## 4. 前端新增/修改清单 + +### 4.1 admin-web(管理后台) + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `src/pages/OrdersPage.tsx` | 头部加 `
+ + + + + {items.map(o => ( + + + + + + + ))} + +
下单金额下单商品下单时间下单人
¥{o.payAmount.toLocaleString()}{o.items}{formatHM(o.createdAt)}{o.userPhoneMasked}
+
+ ); +} +``` + +CSS 暗色 + 蓝色辉光 + 全屏(`h1 字号 96px、background #001529`),仿参考图。 + +--- + +## 6. 关键交互流程图 + +### 6.1 #1 大屏实时轮播 +``` +[admin BigScreenPage mounted] + ↓ +request GET /admin/orders/big-screen?limit=20 + ↓ +[Backend] findMany Order desc createdAt, take 20, mask phone + ↓ +setInterval(3000) → 重复 +``` + +### 6.2 #2 门店端核销即时刷新 +``` +[RedeemSuccessPage] onMount → window.dispatchEvent(STORE_REDEEM_SUCCESS_EVENT) + ↓ +[MinePage useEffect] 收到 → setLoading → request /shop/store +[WithdrawPage useEffect] 收到 → request /shop/withdraw/summary +``` + +### 6.3 #5/#6/#7 审核通知闭环 +``` +[Partner StoreDetailPage] 点击"提交变更" + ↓ +PUT /partner/stores/:id/info-change-request {fields} + ↓ +[Backend] 写入 StoreInfoChangeRequest + 触发 AUDIT_NOTICE_CHANGED_EVENT + ↓ +[StoreDetailPage] 显示"有变更待总部审核"banner + ↓ +[admin StorePackageAuditsPage] 进入 → 切到「门店信息变更」Tab → 列表 + ↓ +[admin 点"通过"] → PUT .../audit {action:APPROVE} + ↓ +[Backend] proposedSnapshot 写入 store → 触发 AUDIT_NOTICE_CHANGED_EVENT + ↓ +[StoreDetailPage] banner 消失 +``` + +--- + +## 7. 验证 DoD + +- [ ] `pnpm db:generate && db:validate && npx prisma db push` 无报错 +- [ ] 现有 admin/stores 接口能取到 `pendingInfoChangeId` 字段;旧调用方不报错 +- [ ] 订单「申请发票」按钮仅在 `COMPLETED` 时出现;提交后调用 `/trade/orders/:id/invoices` +- [ ] 门店端在 A 设备核销 → B 设备 MinePage(如多 Tab)≤3s 看到变化(A 设备本身立即) +- [ ] 大屏 3s 内出现新单;手机号中间 4 位为 `****` +- [ ] 合伙人提交门店变更后,门店 C 端/合伙人端展示 liveSnapshot(不立即生效),admin 端能审核通过/驳回 +- [ ] 审核通知 badge = 套餐变更 PENDING + 信息变更 PENDING +- [ ] #8:构造 `payExpireAt` 已过期的待支付订单,≤1 分钟内状态自动变 `CANCELLED` 且 `cancelledAt` 写入、状态日志 `SYSTEM_AUTO_EXPIRE` 存在 +- [ ] #9:将某门店 `StoreAccount.status` 置非 `ACTIVE`(或 `Store.status` 非 `OPEN`),其门店端任意接口返回 `reason: ACCOUNT_DISABLED`,前端立即 `clearAuth()` 并跳 `/login` +- [ ] #9:将某合伙人 `bindingStatus` 置非 `ACTIVE`,合伙人端任意接口触发强退登录;`mini-user`(USER actor)不受影响 +- [ ] `pnpm lint && pnpm test` 无新增错误 + +--- + +## 8. 任务拆分(建议 DLV 命名) + +``` +DLV-W1-M1 #1 大屏(后端 + admin-web 新页) +DLV-W1-M2 #2 门店端核销事件总线 +DLV-W1-M3 #6 审核通知菜单改 + Tabs 改造(先做,让 #5/#7 共享 UI) +DLV-W1-M4 #5 合伙人端门店"提交变更"(StoreInfoChangeRequest 全套) +DLV-W1-M5 #7 门店列表 提现/对比 按钮(信息变更) +DLV-W1-M6 #3 发票抬头 mini-user 列表 + 编辑 +DLV-W1-M7 #4 订单申请发票(依赖 M6) +DLV-W1-M8 #8 订单 30 分钟未支付自动取消(后端定时任务,独立) +DLV-W1-M9 #9 城市合伙人/门店接口强校验 + 前端强制退登录 +``` + +依赖关系:M1 独立;M2 独立;M3 无依赖;M4 / M5 依赖 M3;M6 无依赖;M7 依赖 M6;M8 独立;M9 独立(后端守卫 + 前端两端,互不阻塞)。 + +--- + +## 9. 待确认的设计细节(如需开工前敲定) + +1. 发票抬头列表页是否需要"设为默认"按钮? +2. 门店信息变更驳回后,原表单是否需要提示驳回原因? +3. 大屏是否需要支持"暂停轮播"按钮? +4. 用户端 `My → 发票管理` 是否需要再放"申请记录"二级入口? +5. **#8 权益回滚**:自动取消订单时是否需要把已占用的 `benefitCoupon` 置回 `ACTIVE`?(现有手动取消 `cancelPartnerProxyOrder` 未回滚,建议保持一致或明确要回滚) +6. **#8 扫描粒度**:每分钟一次(`*/1 * * * *`)是否可接受?还是希望 2~5 分钟一次? +7. **#9 校验范围**:需求只提"城市合伙人 + 门店"。是否也要覆盖 `h5-shop` 的**城市合伙人代运营账号**(`proxyPartnerAccountId` 场景)?目前按"仅 STORE + PARTNER actor"实现。 +8. **#9 提示文案**:强退登录时前端是否需要 toast 提示"账号已停用/解绑,请重新登录"再跳登录页,还是静默跳回? +9. **#9 测试订单**:`isTest` 门店/合伙人账号是否要跳过强校验(避免测试环境自锁)? + +--- + +## 10. 状态校验能力探索报告(城市合伙人 / 门店 接口强制退出到登录页) + +> 本需求:所有 `/shop/*` 与 `/partner/*` 接口需校验 status / bindstatus 是否为开启,否则强制前端退到登录页。以下是按代码实际探查的结果与落地建议。 + +### 10.1 模型字段(server/dukang-api/prisma/schema.prisma) + +- **Store**(line 1245):只有 `status StoreStatus @default(PAUSED)`(line 1268),**没有 bindstatus 字段**。 + - `enum StoreStatus`(line 282):`OPEN / PAUSED / CLOSED`。"开启" = `OPEN`。 +- **PartnerAccount**(line 1030):`status AccountStatus @default(ACTIVE)`(line 1042)+ `bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")`(line 1049)。 + - `enum AccountStatus`(line 244):`ACTIVE / DISABLED`。 + - `enum CityPartnerStatus`(line 203):`ACTIVE / PAUSED`。"已绑定/开启" = `ACTIVE`。 +- **StoreAccount**(门店登录账号,line 1365):`status AccountStatus @default(ACTIVE)`(line 1378)——这是门店端真正的"停用开关",登录时已被校验(`auth.service.ts:1050` `if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用')`)。 +- 结论:需求中的"门店 status + bindstatus"在模型上对应 **StoreAccount.status(账号启停)+ Store.status(门店启停 OPEN)**;"合伙人 status + bindstatus"对应 **PartnerAccount.status + PartnerAccount.bindingStatus**。两端不共用同一模型。 + +### 10.2 后端鉴权守卫(server/dukang-api/src/common/guards/) + +- 全局错误格式 `http-exception.filter.ts:58-62`:`{ code: status, message, data: null }`;成功 `response.interceptor.ts:13-17`:`{ code:0, message:'ok', data }`。注册于 `main.ts:87-88`。 +- **JwtAuthGuard**(jwt-auth.guard.ts:21-56):校验 Bearer + `x-client-app` + actorType,写入 `req.user: AuthUser{actorType, actorId, clientApp, storeId?}`。**无 DB 查询、无 status 校验**。 +- 路由守卫分布: + - `/shop/*` 统一 `@UseGuards(JwtAuthGuard, ShopStoreGuard)`(如 store.controller.ts:186-187、settlement.controller.ts:48-49、redeem.controller.ts:54-55)。 + - `/partner/*` 部分 `@UseGuards(JwtAuthGuard, PartnerPrimaryGuard/PartnerPermissionGuard)`,也有**仅** `@UseGuards(JwtAuthGuard)` 的(settlement.controller.ts:573 `partner/me`、store.controller.ts:75/86、store-package.controller.ts:8)。 +- 现有守卫**均未校验 status/bindstatus**(运行时): + - `ShopStoreGuard`(shop-store.guard.ts:1-15):仅校验 `user.storeId` 存在。 + - `PartnerPrimaryGuard`(partner-primary.guard.ts:11-32):查 `partnerAccount` + 校验 `isPrimary==1`。 + - `PartnerPermissionGuard`(partner-permission.guard.ts:14-45):查 `partnerAccount` + 校验权限。 + - `ShopPrimaryGuard`(shop-primary.guard.ts):查 `storeAccount.isPrimary`。 +- **关键缺口**:登录后账号被停用/解绑,运行时接口不拦截(仅登录时校验过 StoreAccount.status)。 + +### 10.3 前端登录态与退出 + +- **h5-shop/src/lib/api.ts**:token 存 localStorage(ACCESS_TOKEN/REFRESH_TOKEN),profile 存 STORE_PROFILE;`clearAuth`(136-145)清 token。 + - `rawRequest`(191-227):`json.code !== 0` 抛 Error,`err.status = (>=500?res.status:json.code)`(401→401,403→403)。 + - `requestWithAuthRetry`(249-270):仅 `err.status===401` 且非豁免路径时尝试 refresh;失败则 `clearAuth()` 并 throw。**shop 端 request 没有跳登录页逻辑**。 + - 登录路由 `/login`(AuthGate.tsx:5、多处 `navigate('/login')`)。 +- **h5-partner/src/lib/api.ts**:结构同构;`request`(217-242)在 `err.status===401` 且本地有 token 时 `clearAuth()` + `showPartnerToast` + `window.location.href = toAppPath('/login')`(不在 /login 时)。**合伙人端已具备"401 强制跳登录"**。 + - 登录路由 `toAppPath('/login')`。 +- 结论:partner 端天然可接 401 强退;shop 端缺这一步。 + +### 10.4 请求封装 + +- 两端共用 fetch 封装(rawRequest / requestWithAuthRetry / request),无 axios。业务 code 与 HTTP 码复用同一 `code` 字段(filter 把 HTTP status 当 code)。 +- `requestWithAuthRetry` 只对 401 重试,**对 403/自定义 code 直接 throw → 不会触发 refresh 死循环**(这是设计专属 code 的基础)。 + +### 10.5 落地建议 + +**后端**:在 `JwtAuthGuard.canActivate` 内(或新建 `AccountActiveGuard` 全局)按 actorType 分支做 DB 校验: +- `STORE`:查 `StoreAccount(actorId)` → `status` 必须 `ACTIVE`;若 `user.storeId` 存在,查 `Store` → `status` 必须 `OPEN`。 +- `PARTNER`:查 `PartnerAccount(actorId)` → `status===ACTIVE` 且 `bindingStatus===ACTIVE`。 +- 抛 **`ForbiddenException` 携带专属业务 code(如 `ACCOUNT_DISABLED = 499`)** 而非 401,避免误触前端 refresh 重试。需扩展 `HttpExceptionFilter` 透传该 bizCode。 +- 选 JwtAuthGuard 内部做可保证 100% 覆盖(所有 /shop、/partner 都过它,含仅用 JwtAuthGuard 的 partner/me);`USER` actorType 跳过,不影响 C 端/小程序。 + +**前端**:当 `json.code === ACCOUNT_DISABLED` 时,h5-partner 与 h5-shop 均 `clearAuth()` + 跳 `/login`(partner 已在 401 分支,shop 需补 redirect)。若不想改 filter,可复用 403:但 partner 现只对 401 跳、对 403 不跳,故**专属 code 最稳**。 + +### 10.6 风险点 + +- **性能**:每个 /shop、/partner 请求多 1 次 DB 查询;用 `select` 仅取 status 字段即可,流量低可接受。 +- **登录流程不受影响**:`/shop/auth/login`、`/shop/auth/token/refresh` 等用 OptionalJwtAuthGuard 或无 guard,不会被拦;但 `/shop/auth/me`、`select-store` 用 JwtAuthGuard,停用账号访问会被拦→前端跳登录(符合预期)。 +- **范围**:HQ/admin 走 HqAuthGuard、mini-user 为 USER actor,均不受影响(按需求只做 shop + partner)。 +- **门店校验建议同时覆盖 StoreAccount.status 与 Store.status**,否则只停门店不停账号仍可操作。 + + +--- + +## 11. 订单 30 分钟未支付自动取消 — 代码探索报告 + +> 本报告为 v3.5.1 功能「订单 30 分钟未支付自动改为取消」的实现前探索,供开发方案设计参考。 + +## 1. 订单状态枚举 +- Prisma 定义 `server/dukang-api/prisma/schema.prisma:321-331`: + ```prisma + enum OrderStatus { + PENDING_PAY // 待付款 + PENDING_SHIP + OUT_WAREHOUSE + SHIPPING + PENDING_RECEIVE + COMPLETED + CANCELLED // 已取消 + REFUNDING + REFUNDED + } + ``` +- TS 同步定义 `packages/shared-types/src/enums.ts:71-81`,字符串值完全一致: + `PENDING_PAY = 'PENDING_PAY'`,`CANCELLED = 'CANCELLED'`。 +- 待付款 = `PENDING_PAY`;已取消 = `CANCELLED`(无 CLOSED / UNPAID 形态,PayStatus 才是 UNPAID)。 + +## 2. 订单创建流程 +- `TradeService.createOrder` `src/modules/trade/trade.service.ts:153`;初始 `status: 'PENDING_PAY'`(:232)、`payStatus: 'UNPAID'`(:233)。 +- 已写入支付截止时间:`const payExpireAt = new Date(Date.now() + 30 * 60 * 1000)`(:206),并落库 `payExpireAt`(:264)。 +- 三条建单路径均已设置该字段:`createOrder`(:206)、`createPartnerProxyOrder`(:1590)、`createHqProxyOrder`(:1935)。 +- 下单时间 `createdAt`(`schema.prisma:1400/1473`);支付时间 `paidAt`(:1455);取消时间 `cancelledAt`(:1458,已存在可复用,无需新增字段)。 + +## 3. 现有定时任务机制 +- 已启用 `@nestjs/schedule`:`src/jobs/jobs.module.ts:13` `ScheduleModule.forRoot()`,并注册 BullMQ `DELIVERY_QUEUE`(`jobs.constants.ts`,用于 `delivery.processor.ts`)。 +- `@Cron` 完整示例 `src/jobs/settlement.scheduler.ts`: + ```ts + @Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' }) + async handleDailyBills() { + try { /* ... */ } + catch (e) { this.alert.notify({ level: 'P0', category: 'job', title: '...', dedupeKey: '...' }); } + } + ``` +- **关键缺口**:`src/jobs/monitor.scheduler.ts:54` `scanStuckOrders()` 已每 5 分钟(`*/5 * * * *`)扫描超时未付款订单,但**只 alert 不取消**(:61-81)。这正是自动取消要补的环节。 +- `payExpireAt` 字段已存在,**无需新增迁移**;但 Order 表无 `(status, payExpireAt)` 复合索引(现有索引见 `schema.prisma:1488-1497`)。 + +## 4. 取消订单的业务影响 +- 现有取消入口仅 `cancelPartnerProxyOrder`(:1791,限 PROXY 单):事务内 `update status: 'CANCELLED', cancelledAt: new Date()`(:1807)+ 写 `commonEvent` 状态日志(:1809)。 +- 通用状态机 `applyStatusTransition`(:1324):翻 `order.status` + 写 `commonEvent`(`buildOrderStatusEvent`),但**不处理 `cancelledAt` / `payStatus`**。 +- grep 确认建单与取消均**无库存占用/权益发放**(无 stock/inventory/decrement/release 调用,仅 `benefitCoupon` 出现在查询 include)。→ 自动取消只需翻状态+写日志,**无需回滚库存/释放权益**。 +- 建议:新增 `TradeService.autoCancelExpiredOrders()`,复用 `applyStatusTransition` 但补 `cancelledAt` 写入(或在内联事务中同时置 `status/ cancelledAt/ payStatus`)。 + +## 5. 查询未支付订单 +- 现成写法(`monitor.scheduler.ts:61`): + ```ts + this.prisma.order.findMany({ + where: { status: 'PENDING_PAY', payExpireAt: { lt: now } }, + select: { orderNo: true }, take: sample, orderBy: { payExpireAt: 'asc' }, + }); + ``` +- 建议加 `payStatus: 'UNPAID'` 过滤更稳妥;字段即 `payExpireAt`(`DateTime?`)。扫描前建议新增 `@@index([status, payExpireAt])`。 + +## 6. 实现建议 +- 在 `MonitorScheduler` 新增 `@Cron`(如每 1 分钟 `*/1 * * * *`)或扩展 `scanStuckOrders`,调用 `TradeService.autoCancelExpiredOrders()`:分页取 `status:PENDING_PAY && payExpireAt **2026-08-19** · admin-web / h5-shop / mini-user / h5-partner / API +> 工单方案(探索与拆分):[`杜康好客-v3.5.1-工单迭代开发文档.md`](./杜康好客-v3.5.1-工单迭代开发文档.md) +> 目标:发布会大屏、C 端发票抬头与申请、门店信息变更审核、待支付自动取消、门店/合伙人运行时强退登录。 + +## 范围 + +| # | 项 | 交付 | +|---|----|------| +| 1 | 发布会订单大屏 | `GET /admin/orders/big-screen`;独立全屏页 `/orders/big-screen`;订单页「大屏」入口;超管「测试」连播假单 | +| 2 | 门店端核销即时刷新 | `shop:redeem-success` 事件总线;Mine / 提现页监听后立刻重拉 | +| 3 | C 端发票抬头 | 表 `user_invoice_title` + CRUD;我的 → 发票管理;图标操作(编辑 / 默认 / 删除) | +| 4 | C 端申请发票 | 仅已完成订单;列表/详情「申请发票」与「开票中」互斥 | +| 5 | 合伙人门店「提交变更」 | 表 `store_info_change_request`;审核通过才覆盖线上门店 | +| 6 | 审核通知 | 原「套餐审核」菜单改为「审核通知」;套餐变更 + 信息变更双 Tab;badge 合计 PENDING | +| 7 | 门店列表快捷入口 | `pendingInfoChangeId`;「审核信息 / 对比」;「提现」进结算页 | +| 8 | 待支付 30 分钟自动取消 | Cron 每分钟;`payExpireAt` 已过期且非测试单 → `CANCELLED` | +| 9 | 门店 / 合伙人运行时强退 | `JwtAuthGuard` 校验启停;`reason: ACCOUNT_DISABLED`;两端清登录并跳登录页 | + +**Prisma 迁移(发版不可 `--skip-db`)**:`user_invoice_title`、`store_info_change_request`;`user_order` 增 `@@index([status, payExpireAt])`。 + +--- + +## 1. 发布会订单大屏 + +- 路由:`/orders/big-screen`,**不走** AdminLayout(无侧栏)。 +- 数据:`GET /admin/orders/big-screen?limit=`(上限 2000)。过滤 `isTest=false` 且 `payAmount >= 100`;手机号 `maskContactPhone` 中间四位 `****`。 +- 展示:一屏锁死、订单上滚;新成交按金额档位动效(首次进入不撒花)。 +- 入口:订单监控「大屏」;超管另有「测试」,经 `BroadcastChannel` / `localStorage` 连播三级假单。 + +| 位置 | 说明 | +|------|------| +| `GET /admin/orders/big-screen` | `admin-orders.controller.ts`(须写在 `:id` 之前) | +| `apps/admin-web/src/pages/BigScreenPage.tsx` | 大屏页 | +| `apps/admin-web/src/lib/admin-events.ts` | 测试成交 payload | +| `apps/admin-web/src/pages/OrdersPage.tsx` | 「大屏」「测试」按钮 | + +--- + +## 2. 门店端核销即时刷新 + +核销成功页 `onMounted` 派发 `shop:redeem-success`;「我的」与「申请提现」监听后重拉接口,同 Tab 立即看到余额变化。 + +| 位置 | 说明 | +|------|------| +| `apps/h5-shop/src/lib/useRedeemSuccessBus.ts` | `notifyRedeemSuccess` / `useRedeemSuccessListener` | +| `RedeemSuccessPage` / `MinePage` / `WithdrawPage` | 广播与刷新 | + +--- + +## 3 / 4. C 端发票抬头与申请发票 + +**仅 `COMPLETED` 且非售后补发单**可申请。已有 `PENDING` / `ISSUED` 发票则不可再申(驳回后可重申)。 + +### 抬头 + +- 我的 → **发票管理** → `/pages/invoice-titles/index` +- 卡片操作:`编辑.png` / `默认.png` / `删除.png`(无文字按钮) +- 新增/编辑走底部弹层(无独立编辑页) +- 小程序标题只用原生 `navigationBarTitleText`,不再画二级 title(见 `.cursor/rules/mini-user-weapp-nav-title.mdc`) + +### 申请 + +- 入口:订单列表卡片、订单详情底栏 +- **互斥**:无进行中/已开具发票 →「申请发票」;状态 `PENDING` →「开票中」(点进申请页看进度);`ISSUED` 两者都不显示 +- 列表接口带 `invoiceStatus`(最新一条发票) + +| 接口 | 说明 | +|------|------| +| `GET/POST /trade/invoice-titles` | 列表 / 新建 | +| `PUT/DELETE /trade/invoice-titles/:id` | 编辑 / 删除(已被发票引用则 409) | +| `GET /trade/orders/:orderId/invoice-titles` | 申请页可选抬头 | +| `GET /trade/orders/:orderId/invoice-status` | `{ exists, status }` | +| `POST /trade/orders/:id/invoices` | 提交申请(可传 `titleId`) | +| `GET /trade/orders` | 列表项增加 `invoiceStatus` | + +| 前端 | 说明 | +|------|------| +| `apps/mini-user/src/pages/invoice-titles/index.tsx` | 抬头列表 | +| `apps/mini-user/src/pages/invoice-apply/index.tsx` | 申请发票 | +| `apps/mini-user/src/pages/orders/index.tsx` | 申请 / 开票中互斥 | +| `apps/mini-user/src/pages/order-detail/index.tsx` | 同上 | + +--- + +## 5 / 6 / 7. 门店信息变更与审核通知 + +合伙人改基本资料不再直接写 `store`,改为提交变更;审核通过后用 `proposedSnapshot` 覆盖线上。审核期间 C 端 / 合伙人端仍展示线上 live 数据。 + +可变字段白名单见 `STORE_INFO_CHANGEABLE_FIELDS`(`packages/shared-types/src/store-info-change.ts`)。 + +- 合伙人详情底栏文案:**提交变更**;有 PENDING 时黄条「基础信息变更审核中」 +- 总部「套餐审核」改名 **审核通知**:Tab「套餐变更 | 门店信息变更」;菜单 badge = 两类 PENDING 之和 +- 门店列表:`pendingInfoChangeId` →「审核信息 / 对比」;「提现」→ `/finance/store-bills?storeId=` +- 门店端提交接口已备(`POST /shop/store/info-change-request`),**本期无门店 H5 入口** + +| 接口 | 角色 | 说明 | +|------|------|------| +| `POST /partner/stores/:storeId/info-change-request` | 合伙人 | 提交 | +| `GET /partner/stores/:storeId/info-change-requests` | 合伙人 | 历史(用于 PENDING banner) | +| `POST /shop/store/info-change-request` | 门店 | 已实现,本期无 UI | +| `GET /admin/store-info-change-requests` | 总部 | 列表 | +| `GET /admin/store-info-change-requests/summary` | 总部 | `pendingCount` | +| `GET /admin/store-info-change-requests/:id` | 总部 | 详情 + 字段 diff | +| `PUT /admin/store-info-change-requests/:id/audit` | 总部 | `APPROVE` / `REJECT` | +| `GET /admin/stores` | 总部 | 增加 `pendingInfoChangeId` | + +通过 / 驳回后派发既有 `admin:package-audit-changed` / `AUDIT_NOTICE_CHANGED_EVENT`,侧栏 badge 刷新。 + +--- + +## 8. 待支付 30 分钟自动取消 + +下单路径已写 `payExpireAt = now + 30min`。本次补定时翻状态(此前 monitor 只告警不取消)。 + +- Cron:`*/1 * * * *` Asia/Shanghai,`OrderExpiryScheduler` +- 条件:`PENDING_PAY` + `UNPAID` + `payExpireAt < now` + `isTest=false` +- 动作:条件更新 `CANCELLED` + `cancelledAt`;日志 `operator=SYSTEM_AUTO_EXPIRE`,备注「30 分钟未支付自动取消」 +- **不回滚**权益券(与手动取消代下单一致;建单时未占用库存) +- 每批最多 200 条;`updateMany` 带 `status=PENDING_PAY` 防并发重复 + +| 位置 | 说明 | +|------|------| +| `server/dukang-api/src/jobs/order-expiry.scheduler.ts` | 定时任务 | +| `TradeService.cancelExpiredPendingOrders` | 业务取消 | +| `schema.prisma` Order | `@@index([status, payExpireAt])` | + +--- + +## 9. 门店 / 合伙人接口强制退登录 + +登录后账号被停用 / 解绑时,后续任意鉴权接口拦截,前端清 token 并回登录页。 + +校验(仅 `STORE` / `PARTNER`;`USER` / `HQ` 跳过): + +| 端 | 条件 | +|----|------| +| 门店 | `StoreAccount.status === ACTIVE`;已选店则 `Store.status === OPEN` | +| 合伙人 | `PartnerAccount.status === ACTIVE` 且 `bindingStatus === ACTIVE` | + +`isTest=true` 的账号 / 门店**跳过**,避免测试环境自锁。 + +响应:HTTP 403,`{ code, message, reason: 'ACCOUNT_DISABLED' }`(`HttpExceptionFilter` 透传 `reason`,避免走 401 refresh)。 + +| 位置 | 说明 | +|------|------| +| `jwt-auth.guard.ts` `assertAccountActive` | 运行时校验 | +| `http-exception.filter.ts` | 透传 `reason` | +| `apps/h5-shop/src/lib/api.ts` | `ACCOUNT_DISABLED` → `clearAuth` + `/login` | +| `apps/h5-partner/src/lib/api.ts` | 同上(`toAppPath('/login')`) | + +--- + +## 验收 + +- [ ] 大屏:非测试且实付 ≥100 的订单上屏,手机号中间四位 `****`;「测试」仅超管可见且能连播动效 +- [ ] 门店端核销成功后,「我的 / 提现」无需下拉即可看到余额变化 +- [ ] 发票管理可增删改、设默认;小程序无二级导航标题 +- [ ] 仅已完成订单出现「申请发票」;提交后卡片改为「开票中」,不再出现申请按钮 +- [ ] 合伙人提交门店资料后线上不立即变;总部审核通过后覆盖;驳回后可改再提 +- [ ] 审核通知 badge = 套餐 PENDING + 信息变更 PENDING;门店列表有「审核信息 / 对比 / 提现」 +- [ ] 构造已过期待支付正式单,约 1 分钟内变为已取消,且有 `SYSTEM_AUTO_EXPIRE` 日志;测试单不被取消 +- [ ] 停用门店账号或关闭门店后,门店端接口 `reason=ACCOUNT_DISABLED` 并退登录;合伙人 `bindingStatus` 非 ACTIVE 同理;C 端不受影响 +- [ ] `pnpm db:generate && npx prisma db push` 无报错;`pnpm lint` 无新增错误 + +## 发版注意 + +- 必须跑 Prisma(两张新表 + 订单索引),不可 `--skip-db` +- 发版时 mini-user `package.json` / `src/lib/client-version.ts` 升 **`3.5.1`**(当前仓库仍为 `3.4.15`) +- HQ 开发计划创建版本 `v3.5.1` 并关联本迭代任务 -- 2.53.0