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() { 订单监控 + window.open('/orders/big-screen', 'dukang-big-screen')}>大屏 + {isSuperAdmin ? ( + { + window.open('/orders/big-screen', 'dukang-big-screen'); + triggerBigScreenDemo(); + message.success('已发送测试成交动效(三级 / 二级 / 一级)'); + }} + > + 测试 + + ) : null} {canProxyOrder ? ( setProxyOpen(true)}> 代下单 diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 89b01a1..60577a8 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -63,10 +63,12 @@ const KIND_COLORS: Record = { export default function StoreBillsPage() { const [searchParams] = useSearchParams(); const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; + const initialStoreId = searchParams.get('storeId') || ''; const [form] = Form.useForm(); const [filters, setFilters] = useState>({ kind: initialKind, status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '', + storeId: initialStoreId, }); const [stores, setStores] = useState([]); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( @@ -97,8 +99,9 @@ export default function StoreBillsPage() { form.setFieldsValue({ kind: filters.kind || undefined, status: filters.status || undefined, + storeId: filters.storeId || undefined, }); - }, [filters.kind, filters.status, form]); + }, [filters.kind, filters.status, filters.storeId, form]); useEffect(() => { void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -359,6 +362,7 @@ export default function StoreBillsPage() { initialValues={{ kind: filters.kind || undefined, status: filters.status || undefined, + storeId: filters.storeId || undefined, }} onFinish={(v: { kind?: string; diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index 5c22607..c13cc3a 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -3,18 +3,23 @@ import { useSearchParams } from 'react-router-dom'; import { Badge, Button, + Descriptions, Drawer, Image, Input, Modal, Space, Table, + Tabs, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type { + StoreInfoChangeFieldDiff, + StoreInfoChangeRequestDto, + StoreInfoChangeStatus, StorePackageAuditDetailDto, StorePackageAuditSummaryDto, StorePackageChangeRequestDto, @@ -22,7 +27,10 @@ import type { StorePackageItemDto, StorePackageViewDto, } from '@dukang/shared-types'; -import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; +import { + STORE_INFO_CHANGE_STATUS_LABELS, + normalizeStorePackageImageUrls, +} from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; import { notifyPackageAuditChanged } from '../lib/admin-events'; import { fmtTime } from '../lib/constants'; @@ -297,6 +305,289 @@ function PackageDetailCard({ ); } +const INFO_CHANGE_FIELD_LABELS: Record = { + name: '门店名称', + contactPhone: '联系电话', + address: '详细地址', + intro: '门店简介', + benefitUsageRule: '权益券使用规则', + latitude: '纬度', + longitude: '经度', + openTime: '营业开始', + closeTime: '营业结束', + openTime2: '第二段开始', + closeTime2: '第二段结束', + avgPrice: '人均费用', +}; + +function fmtFieldValue(field: string, v: unknown): string { + if (v == null || String(v).trim() === '') return '(空)'; + if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') { + return String(v); + } + return String(v); +} + +function InfoChangeAuditPanel({ + initialRequestId, +}: { + initialRequestId?: string | null; +}) { + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [status, setStatus] = useState('PENDING'); + const [pendingCount, setPendingCount] = useState(0); + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + const [activeId, setActiveId] = useState(null); + + async function reload(nextPage = page, nextStatus = status) { + setLoading(true); + try { + const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' }); + if (nextStatus) qs.set('status', nextStatus); + const [data, summary] = await Promise.all([ + request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>( + `/admin/store-info-change-requests?${qs}`, + ), + request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'), + ]); + setItems(data.items); + setTotal(data.total); + setPage(data.page ?? nextPage); + setPendingCount(summary.pendingCount ?? 0); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void reload(1, status); + }, [status]); + + useEffect(() => { + if (initialRequestId) void openDetail(initialRequestId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function openDetail(id: string) { + setDetailOpen(true); + setDetailLoading(true); + setDetail(null); + try { + const data = await request( + `/admin/store-info-change-requests/${id}`, + ); + setDetail(data); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载详情失败'); + setDetailOpen(false); + } finally { + setDetailLoading(false); + } + } + + async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) { + try { + await request(`/admin/store-info-change-requests/${id}/audit`, { + method: 'PUT', + body: JSON.stringify( + action === 'REJECT' ? { action, rejectReason: reason } : { action }, + ), + }); + message.success(action === 'APPROVE' ? '已通过' : '已驳回'); + setDetailOpen(false); + notifyPackageAuditChanged(); + void reload(page, status); + } catch (e) { + message.error(e instanceof Error ? e.message : '操作失败'); + } + } + + const columns: ColumnsType = [ + { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, + { + title: '状态', + dataIndex: 'status', + render: (v: StoreInfoChangeStatus) => ( + {STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v} + ), + }, + { + title: '变更字段', + render: (_, row) => + row.changedFields?.length + ? row.changedFields.map((f) => ( + {INFO_CHANGE_FIELD_LABELS[f] ?? f} + )) + : '—', + }, + { + title: '提交方', + render: (_, row) => + row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部', + }, + { title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) }, + { + title: '操作', + render: (_, row) => ( + + void openDetail(row.id)}> + 查看 + + {row.status === 'PENDING' ? ( + <> + void audit(row.id, 'APPROVE')}> + 通过 + + { + setActiveId(row.id); + setRejectReason(''); + setRejectOpen(true); + }} + > + 驳回 + + > + ) : ( + row.rejectReason || null + )} + + ), + }, + ]; + + return ( + + + {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( + setStatus(s)} + > + {s === 'PENDING' ? ( + + {STORE_INFO_CHANGE_STATUS_LABELS.PENDING} + + ) : s ? ( + STORE_INFO_CHANGE_STATUS_LABELS[s] + ) : ( + '全部' + )} + + ))} + + void reload(p, status), + }} + /> + + setDetailOpen(false)} + extra={ + detail?.status === 'PENDING' ? ( + + void audit(detail.id, 'APPROVE')}>通过 + { + setActiveId(detail.id); + setRejectReason(''); + setRejectOpen(true); + }} + > + 驳回 + + + ) : 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) => ( - setStatus(s)}> - {s === 'PENDING' ? ( - - {HQ_PACKAGE_STATUS_LABELS.PENDING} - - ) : s ? ( - HQ_PACKAGE_STATUS_LABELS[s] - ) : ( - '全部' - )} - - ))} - - void reload(p, status), - }} + 审核通知 + + + {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( + setStatus(s)}> + {s === 'PENDING' ? ( + + {HQ_PACKAGE_STATUS_LABELS.PENDING} + + ) : s ? ( + HQ_PACKAGE_STATUS_LABELS[s] + ) : ( + '全部' + )} + + ))} + + void reload(p, status), + }} + /> + > + ), + }, + { + key: 'info', + label: '信息变更', + children: , + }, + ]} /> ( @@ -791,6 +793,31 @@ export default function StoresPage() { > ) : null} + {row.pendingInfoChangeId ? ( + <> + navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)} + > + 审核信息 + + navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)} + > + 对比 + + > + ) : null} + navigate(`/finance/store-bills?storeId=${row.id}`)} + > + 提现 + ), }, 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() { navigate('/stores')}>返回 {canMutate && ( void saveBasic()} disabled={readOnly || saving}> - save - {saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'} + send + {saving ? '提交中…' : '提交变更'} )} 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 79eabd6..13d0e66 100644 Binary files a/apps/mini-user/src/assets/icons/store-open-badge.png and b/apps/mini-user/src/assets/icons/store-open-badge.png differ diff --git a/apps/mini-user/src/assets/icons/关于我们.png b/apps/mini-user/src/assets/icons/关于我们.png index 55b77c0..3c7ff02 100644 Binary files a/apps/mini-user/src/assets/icons/关于我们.png and b/apps/mini-user/src/assets/icons/关于我们.png differ diff --git a/apps/mini-user/src/assets/icons/删除.png b/apps/mini-user/src/assets/icons/删除.png new file mode 100644 index 0000000..bf20b2c Binary files /dev/null and b/apps/mini-user/src/assets/icons/删除.png differ diff --git a/apps/mini-user/src/assets/icons/可用门店.png b/apps/mini-user/src/assets/icons/可用门店.png index 85cab48..8ea8057 100644 Binary files a/apps/mini-user/src/assets/icons/可用门店.png and b/apps/mini-user/src/assets/icons/可用门店.png differ diff --git a/apps/mini-user/src/assets/icons/地址管理.png b/apps/mini-user/src/assets/icons/地址管理.png index 5264644..af73854 100644 Binary files a/apps/mini-user/src/assets/icons/地址管理.png and b/apps/mini-user/src/assets/icons/地址管理.png differ diff --git a/apps/mini-user/src/assets/icons/已付款.png b/apps/mini-user/src/assets/icons/已付款.png index c958b91..929a68a 100644 Binary files a/apps/mini-user/src/assets/icons/已付款.png and b/apps/mini-user/src/assets/icons/已付款.png differ diff --git a/apps/mini-user/src/assets/icons/已完成.png b/apps/mini-user/src/assets/icons/已完成.png index d0d2b30..3c5e5f9 100644 Binary files a/apps/mini-user/src/assets/icons/已完成.png and b/apps/mini-user/src/assets/icons/已完成.png differ diff --git a/apps/mini-user/src/assets/icons/待付款.png b/apps/mini-user/src/assets/icons/待付款.png index ae6ff54..6082e77 100644 Binary files a/apps/mini-user/src/assets/icons/待付款.png and b/apps/mini-user/src/assets/icons/待付款.png differ diff --git a/apps/mini-user/src/assets/icons/编辑.png b/apps/mini-user/src/assets/icons/编辑.png new file mode 100644 index 0000000..32d85d0 Binary files /dev/null and b/apps/mini-user/src/assets/icons/编辑.png differ diff --git a/apps/mini-user/src/assets/icons/联系客服.png b/apps/mini-user/src/assets/icons/联系客服.png index 2802a56..aee6535 100644 Binary files a/apps/mini-user/src/assets/icons/联系客服.png and b/apps/mini-user/src/assets/icons/联系客服.png differ diff --git a/apps/mini-user/src/assets/icons/资质公示.png b/apps/mini-user/src/assets/icons/资质公示.png index 29a5ddb..19e3a6b 100644 Binary files a/apps/mini-user/src/assets/icons/资质公示.png and b/apps/mini-user/src/assets/icons/资质公示.png differ diff --git a/apps/mini-user/src/assets/icons/默认.png b/apps/mini-user/src/assets/icons/默认.png new file mode 100644 index 0000000..4fac377 Binary files /dev/null and b/apps/mini-user/src/assets/icons/默认.png differ diff --git a/apps/mini-user/src/components/WechatShareReady.weapp.tsx b/apps/mini-user/src/components/WechatShareReady.weapp.tsx new file mode 100644 index 0000000..7b8a467 --- /dev/null +++ b/apps/mini-user/src/components/WechatShareReady.weapp.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { type PageSharePayload } from '../lib/wechat-share'; + +/** 小程序:开启右上角分享菜单(不引入 H5 JSSDK) */ +export default function WechatShareReady({ payload: _payload }: { payload?: PageSharePayload }) { + useDidShow(() => { + 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` | 头部加 `` "大屏" → `window.open('/orders/big-screen?token=xxx','_blank')` | +| 2 | `src/pages/BigScreenPage.tsx`(**新建**) | 暗色科技风,"杜康好客 · 发布仪式现场" + LIVE 标识 + 大时钟 + 订单表格轮播;3s polling | +| 3 | `src/App.tsx` | 加路由 `} />` | +| 4 | `src/layouts/AdminLayout.tsx` | 菜单项 label `'套餐审核'` → `'审核通知'`;`attachPackageAuditBadge` 改名为 `attachAuditNoticeBadge`,订阅 `AUDIT_NOTICE_CHANGED_EVENT` + 调 `summary` 端点(含合并计数) | +| 5 | `src/pages/StorePackageAuditsPage.tsx` | 标题 `套餐变更审核` → `审核通知`;加 ``:套餐变更 / 门店信息变更;下方按 `kind` 调不同接口;详情抽屉兼容两类 | +| 6 | `src/pages/StoresPage.tsx` | 列表中加 `pendingInfoChangeId` 字段;操作列在已有 `pendingPackageAuditId` 旁补 `审核信息 / 对比`;提现按钮(指向 `/finance/store-bills?storeId=`)已存在,本次仅调整文案 | + +> BigScreenPage 不依赖 AdminLayout(避免侧边栏干扰),可在路由外层做独立壳层。 + +--- + +### 4.2 h5-shop(门店端) + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `src/pages/RedeemSuccessPage.tsx` | 提交成功 onMounted 派发 `window.dispatchEvent(new CustomEvent(STORE_REDEEM_SUCCESS_EVENT, { detail: { storeId, amount }}))`;在 navigate(-1) 之前 | +| 2 | `src/pages/MinePage.tsx` | 监听 `STORE_REDEEM_SUCCESS_EVENT` → 重跑 `loadMine()`(已存在) | +| 3 | `src/pages/WithdrawPage.tsx` | 同上监听 → 重跑 `load()` | +| 4 | `src/lib/useRedeemSuccessBus.ts`(**新建**) | 简单 hook:`useRedeemSuccessListener(handler)` 复用 | + +--- + +### 4.3 mini-user(用户小程序) + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `src/pages/mine/index.tsx` | SERVICES 数组加一项 `{ icon: iconInvoice, label: '发票管理', url: '/pages/invoice-titles/index' }` | +| 2 | `src/pages/invoice-titles/index.tsx`(**新建**) | 列表(默认抬头星标 / 编辑 / 删除 / 设为默认)+ 进入"申请发票"快捷入口 | +| 3 | `src/pages/invoice-title-edit/index.tsx`(**新建**) | 编辑/新建抬头表单 | +| 4 | `src/pages/orders/index.tsx` | 仅 COMPLETED 订单下方加 `申请发票` → 跳发票申请页 | +| 5 | `src/pages/order-detail/index.tsx` | 订单状态 COMPLETED 时,详情页 actionbar 增加"申请发票"按钮 | +| 6 | `src/pages/invoice-apply/index.tsx`(**新建**) | 复用 SubPageHeader 组件;展示该订单明细 + 已开票则提示已存在 + 抬头选择器 + 提交 | +| 7 | `src/app.config.ts` | pages 数组增加 3 个新页(`invoice-titles/index`、`invoice-title-edit/index`、`invoice-apply/index`) | +| 8 | `src/lib/api.ts` | 增加 `requestInvoiceTitles()`、`submitInvoiceApplication()` 等 helper | + +--- + +### 4.4 h5-partner(合伙人端) + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `src/pages/StoreDetailPage.tsx` | 底部保存按钮 `保存修改` → `提交变更`;点击后调 `PUT /partner/stores/:id/info-change-request` 而不是 `/basic` | +| 2 | 同上 | 顶部加一个 `auditBanner`:当 `store.infoChangePending === true` 时提示"有变更待总部审核"(调 list 接口得到 PENDING) | +| 3 | `src/lib/api.ts` | 加 `submitStoreInfoChangeRequest`、`listStoreInfoChangeRequests` | + +> **兼容老路径**:保留 `/partner/stores/:id/basic`(PUT)以兼容历史脚本;新按钮走新接口。 + +--- + +### 4.5 h5-shop / h5-partner 强制退出登录(#9,详 §10) + +> 后端返回 `{ code: 403, reason: 'ACCOUNT_DISABLED', message }` 后,前端需 `clearAuth()` + 跳 `/login`。合伙人端现仅 401 跳登录、门店端缺此逻辑,两端都要补。 + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `apps/h5-shop/src/lib/api.ts` | `rawRequest`(~191-227)抛错时附加 `err.reason = json.reason ?? null`;`requestWithAuthRetry` 的不可恢复分支(~262 前)与 `ensureSession` catch(~305)增加 `if (err.reason === 'ACCOUNT_DISABLED') { clearAuth(); window.location.href = '/login'; }` | +| 2 | `apps/h5-partner/src/lib/api.ts` | `rawRequest` 同样附加 `err.reason`;在现有 401 重定向分支旁增加 `reason === 'ACCOUNT_DISABLED'` 的 `clearAuth() + window.location.href = toAppPath('/login')`(复用既有登录路由) | + +> 注意:`ACCOUNT_DISABLED` 用专属 `reason` 字段而非复用 403,避免与未来其它 403(权限不足)混淆;`code` 仍为 403 不破坏现有 `err.status` 判断。 + +--- + +## 5. 大屏页面设计要点(#1) + +```tsx +// BigScreenPage.tsx 骨架(admin-web 不依赖 AntD) +export default function BigScreenPage() { + const [items, setItems] = useState([]); + useEffect(() => { + const fetchData = () => request<{ items: BigScreenOrder[] }>( + '/admin/orders/big-screen?limit=20' + ).then(d => setItems(d.items)); + fetchData(); + const id = setInterval(fetchData, 3000); + return () => clearInterval(id); + }, []); + return ( + {/* 全屏暗色 */} + + 杜康好客 + 发布仪式现场 + ● LIVE + + {/* setInterval(每秒) */} + + + 下单金额下单商品下单时间下单人 + + + {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` 并关联本迭代任务
总部审核已通过,可将门店设为营业中。
+ 基础信息变更审核中 +
+ 已提交的资料变更正在等待总部审核,审核通过后才会覆盖门店线上信息。审核期间可继续修改并重新提交。 +