v3.5.1 版本更新 #19

Merged
jacy merged 2 commits from dev into main 2026-08-19 16:01:32 +08:00
103 changed files with 5764 additions and 185 deletions
@@ -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' ? (
<SubPageHeader title="申请发票" onBack={...} />
) : null}
```
```tsx
// ❌ 未配页标题 + 再画一层 SubPageHeaderweapp 会双标题)
<SubPageHeader title="申请发票" />
```
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
+1
View File
@@ -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`
## 环境与发版
+9
View File
@@ -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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/orders/big-screen"
element={
<RequireAuth>
<BigScreenPage />
</RequireAuth>
}
/>
<Route
element={
<RequireAuth>
+602
View File
@@ -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;
}
}
+21 -14
View File
@@ -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: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
</span>
),
@@ -276,11 +276,14 @@ export default function AdminLayout() {
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(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 (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
+72
View File
@@ -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();
}
+466
View File
@@ -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<AmountTier, number> = { 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 (
<div className="big-screen-hero">
<div className="big-screen-clock">{formatClock(now)}</div>
<div className="big-screen-date">{formatDateLine(now)}</div>
</div>
);
}
function OrderRow({
order,
latest,
}: {
order: BigScreenOrder;
latest?: boolean;
}) {
const tier = amountTier(order.payAmount);
return (
<div
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
>
{latest ? <span className="big-screen-row-mark" /> : null}
<span className="big-screen-amount">¥ {formatAmount(order.payAmount)}</span>
<span className="big-screen-items">{order.items || '—'}</span>
<span className="big-screen-time">{formatHm(order.createdAt)}</span>
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
</div>
);
}
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<AmountTier, string[]> = {
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<HTMLCanvasElement | null>(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 (
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
<div className="big-screen-fx-amount">¥ {formatAmount(displayAmount)}</div>
<div className="big-screen-fx-items">{order.items || '—'}</div>
<div className="big-screen-fx-meta">
<span>{order.userPhoneMasked || '—'}</span>
<span>{formatHm(order.createdAt)}</span>
</div>
</div>
</div>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null);
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const demoSeenRef = useRef<Set<string>>(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<BigScreenDemoPayload>) => {
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 (
<div className="big-screen-page">
<div className="big-screen-stars" aria-hidden />
<div className="big-screen-frame" aria-hidden>
<span className="big-screen-corner big-screen-corner--tl" />
<span className="big-screen-corner big-screen-corner--tr" />
<span className="big-screen-corner big-screen-corner--bl" />
<span className="big-screen-corner big-screen-corner--br" />
</div>
<header className="big-screen-header">
<div className="big-screen-brand">
<h1 className="big-screen-title"></h1>
<span className="big-screen-subtitle">· </span>
</div>
<div className="big-screen-live-wrap">
<span className="big-screen-live-label"></span>
<span className="big-screen-live">
<span className="big-screen-live-dot" />
LIVE
</span>
</div>
</header>
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
) : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
>
{trackItems.map((o, idx) => (
<OrderRow
key={`${o.id}-${idx}`}
order={o}
latest={idx % items.length === 0 && o.id === latestId}
/>
))}
</div>
)}
</div>
</div>
{celebrate ? (
<CelebrateFx
key={celebrate.id}
order={celebrate}
onDone={playNext}
/>
) : null}
</div>
);
}
+14
View File
@@ -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() {
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{isSuperAdmin ? (
<Button
onClick={() => {
window.open('/orders/big-screen', 'dukang-big-screen');
triggerBigScreenDemo();
message.success('已发送测试成交动效(三级 / 二级 / 一级)');
}}
>
</Button>
) : null}
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
+5 -1
View File
@@ -63,10 +63,12 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
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<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -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<Paginated<StoreOption>>(`/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;
@@ -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<string, string> = {
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<StoreInfoChangeRequestDto[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('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<string | null>(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<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
`/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<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
dataIndex: 'status',
render: (v: StoreInfoChangeStatus) => (
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '变更字段',
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
))
: '—',
},
{
title: '提交方',
render: (_, row) =>
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
},
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{
title: '操作',
render: (_, row) => (
<Space>
<Button type="link" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'PENDING' ? (
<>
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
</Button>
<Button
type="link"
danger
onClick={() => {
setActiveId(row.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : (
row.rejectReason || null
)}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button
key={s || 'all'}
type={status === s ? 'primary' : 'default'}
onClick={() => setStatus(s)}
>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
STORE_INFO_CHANGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
/>
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
}
>
{detailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
{detail.diffs && detail.diffs.length ? (
<Descriptions column={1} bordered size="small">
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
>
<span>
<Typography.Text delete type="secondary">
{fmtFieldValue(d.field, d.live)}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>
{fmtFieldValue(d.field, d.proposed)}
</Typography.Text>
</span>
</Descriptions.Item>
))}
</Descriptions>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</>
) : null}
</Drawer>
<Modal
title="驳回信息变更"
open={rejectOpen}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!activeId) return;
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit(activeId, 'REJECT', rejectReason.trim());
setRejectOpen(false);
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
@@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() {
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
const [activeTab, setActiveTab] = useState<string>(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 (
<div>
<Typography.Title level={4}></Typography.Title>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
<Typography.Title level={4}></Typography.Title>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'package',
label: '套餐审核',
children: (
<>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
/>
</>
),
},
{
key: 'info',
label: '信息变更',
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
},
]}
/>
<Drawer
+28 -1
View File
@@ -192,6 +192,8 @@ type StoreRow = {
visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -767,7 +769,7 @@ export default function StoresPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
width: 280,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
@@ -791,6 +793,31 @@ export default function StoresPage() {
</Button>
</>
) : null}
{row.pendingInfoChangeId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
</>
) : null}
<Button
type="link"
size="small"
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
>
</Button>
</Space>
),
},
+45 -4
View File
@@ -156,10 +156,19 @@ async function rawRequest<T>(
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<T>(
try {
return await requestWithAuthRetry<T>(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<string, unknown>) {
return request('PARTNER_H5', `/partner/stores/${storeId}/info-change-request`, {
method: 'POST',
body: JSON.stringify(fields),
});
}
export function listStoreInfoChangeRequests(storeId: string) {
return request<Array<{ status?: string }>>(
'PARTNER_H5',
`/partner/stores/${storeId}/info-change-requests`,
);
}
+13 -1
View File
@@ -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;
+36 -21
View File
@@ -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<string, unknown>) {
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<Record<string, unknown>>('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' && (
<p className="label-md text-muted"></p>
)}
{pendingInfoChange && (
<div style={{ marginTop: 12, background: 'rgba(245,166,35,0.08)', border: '1px solid rgba(245,166,35,0.3)', borderRadius: 8, padding: 12 }}>
<p className="body-md" style={{ fontWeight: 600, marginBottom: 4 }}>
</p>
<p className="label-md text-muted" style={{ margin: 0 }}>
线
</p>
</div>
)}
</section>
{canMutate && !auditPending && status !== 'CLOSED' && (
@@ -481,8 +496,8 @@ export default function StoreDetailPage() {
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
<span className="material-symbols-outlined">send</span>
{saving ? '提交中…' : '提交变更'}
</button>
)}
</footer>
+23 -4
View File
@@ -202,7 +202,7 @@ async function rawRequest<T>(
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<T>(
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<T>(
try {
return await rawRequest<T>(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) {
@@ -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<RedeemSuccessPayload>).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);
}
+10
View File
@@ -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()
+6
View File
@@ -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()
+10 -1
View File
@@ -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 (
<div className="shop-success-page">
<header className="shop-success-header">
+6
View File
@@ -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);
+5 -2
View File
@@ -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 },
-2
View File
@@ -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",
+34 -20
View File
@@ -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',
-6
View File
@@ -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 {
-1
View File
@@ -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';
+73
View File
@@ -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<string, string> };
};
}) => {
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<string, string>;
};
};
}
).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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -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;
}
@@ -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<ClientGpsLocation | null> {
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;
}
}
+25
View File
@@ -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<boolean> {
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;
}
}
@@ -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<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('/common/client-config');
}
export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('/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<WechatLoginResult | void> {
throw new Error('请使用小程序微信授权');
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise<boolean> {
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<WechatPayOrderResult>(`/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 };
+2 -14
View File
@@ -1,20 +1,8 @@
import { regionData } from 'element-china-area-data';
import rawTree from './region-tree.json';
export type RegionTree = Record<string, Record<string, string[]>>;
function buildRegionTree(): RegionTree {
const tree: RegionTree = {};
for (const province of regionData) {
const cities: Record<string, string[]> = {};
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 = '全市';
File diff suppressed because one or more lines are too long
@@ -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<Taro.getLocation.SuccessCallbackResult> {
return new Promise((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
}
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
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;
}
@@ -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<WechatLoginResult | void> {
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
}
const { request } = await import('./api');
return request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
}
export async function checkNeedsWechatAuth(): Promise<boolean> {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
return needsWechatAuthForPay(config, profile);
}
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
if (!isWechatEnv()) return { ok: true };
if (!(await checkNeedsWechatAuth())) return { ok: true };
return { ok: false, redirecting: true };
}
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
return null;
}
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
throw new Error('请使用小程序微信授权');
}
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
return saveWechatLoginResult(result);
}
export async function bindWechatForUser(
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
): Promise<WechatBindResult> {
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
const { request } = await import('./api');
const data = await request<WechatLoginResult>('/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 };
@@ -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<MiniShareRuntime> {
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<string, string | undefined | null>;
},
): 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<void> {
/* 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(),
};
}
+4
View File
@@ -0,0 +1,4 @@
/** 小程序端恒为微信环境,避免引入 H5 JSSDK 门面 */
export function isWechatEnv() {
return true;
}
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '申请发票',
});
@@ -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<string, string> = {
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<UserInvoiceTitleDto[]>([]);
const [selectedId, setSelectedId] = useState('');
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
const [emailOverride, setEmailOverride] = useState('');
const [phoneOverride, setPhoneOverride] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [invoiceStatus, setInvoiceStatus] = useState<InvoiceStatus | null>(null);
const [order, setOrder] = useState<OrderBrief | null>(null);
const load = useCallback(() => {
if (!orderId) return;
setLoading(true);
Promise.all([
request<UserInvoiceTitleDto[]>(`/trade/orders/${orderId}/invoice-titles`).catch(() => []),
request<InvoiceStatus>(`/trade/orders/${orderId}/invoice-status`).catch(() => ({ exists: false })),
request<OrderBrief>(`/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 (
<PageShell variant="sub" className="invoice-apply-page">
{isH5 ? (
<SubPageHeader
title="申请发票"
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
/>
) : null}
<View className="invoice-apply-body">
{order?.orderNo ? (
<View className="invoice-apply-order">
<Text className="invoice-apply-order-label"></Text>
<Text className="invoice-apply-order-no">{order.orderNo}</Text>
{productLine ? (
<Text className="invoice-apply-order-meta">{productLine}</Text>
) : null}
{order.payAmount != null ? (
<Text className="invoice-apply-order-meta">
¥{Number(order.payAmount).toFixed(2)}
</Text>
) : null}
</View>
) : null}
{alreadyApplied ? (
<View className="invoice-apply-status-card">
<Text className="invoice-apply-status-icon"></Text>
<View>
<Text className="invoice-apply-status-title"></Text>
<Text className="invoice-apply-status-desc">
{INVOICE_STATUS_LABELS[invoiceStatus?.status ?? ''] || invoiceStatus?.status || '处理中'}
</Text>
</View>
</View>
) : null}
{!alreadyApplied && !orderCompletable ? (
<View className="invoice-apply-status-card">
<View>
<Text className="invoice-apply-status-title"></Text>
<Text className="invoice-apply-status-desc"></Text>
</View>
</View>
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</Text>
))}
</View>
</View>
) : null}
{canApply ? (
<View className="invoice-apply-section-title">
<Text></Text>
<Text className="invoice-apply-add-link" onClick={goAddTitle}>
+
</Text>
</View>
) : null}
{loading ? <View className="u-empty"></View> : null}
{canApply && !loading && titles.length === 0 ? (
<View className="invoice-apply-empty-titles">
<Text className="u-empty"></Text>
<View className="invoice-apply-empty-cta" onClick={goAddTitle}>
</View>
</View>
) : null}
{canApply && !loading && titles.length > 0 ? (
<View className="invoice-apply-title-list">
{titles.map((t) => (
<View
key={t.id}
className={`invoice-apply-title-item${selectedId === t.id ? ' is-selected' : ''}`}
onClick={() => setSelectedId(t.id)}
>
<View className="invoice-apply-title-info">
<View className="invoice-apply-title-head">
<Text className="invoice-apply-title-name">{t.titleName}</Text>
<Text className="invoice-apply-title-type">
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
</Text>
{t.isDefault ? <Text className="invoice-apply-title-default"></Text> : null}
</View>
{t.taxNo ? <Text className="invoice-apply-title-sub">{t.taxNo}</Text> : null}
{t.email ? <Text className="invoice-apply-title-sub">{t.email}</Text> : null}
</View>
<Radio
checked={selectedId === t.id}
color="#A61D24"
onClick={() => setSelectedId(t.id)}
/>
</View>
))}
</View>
) : null}
{canApply && selected && !selected.email ? (
<View className="invoice-title-field" style={{ marginTop: 16 }}>
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
placeholder="电子发票将发送至此邮箱"
value={emailOverride}
onInput={(e) => setEmailOverride(e.detail.value)}
/>
</View>
) : null}
{canApply && selected && !selected.phone ? (
<View className="invoice-title-field" style={{ marginTop: 12 }}>
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
type="number"
placeholder="请填写联系电话"
value={phoneOverride}
onInput={(e) => setPhoneOverride(e.detail.value)}
/>
</View>
) : null}
</View>
{canApply && titles.length > 0 ? (
<View className="invoice-apply-footer">
<View
className={`invoice-apply-submit${submitting ? ' is-disabled' : ''}`}
onClick={() => { if (!submitting) void submit(); }}
>
{submitting ? '提交中…' : '提交申请'}
</View>
</View>
) : null}
</PageShell>
);
}
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '发票抬头',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
});
@@ -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 (
<View
className="invoice-title-icon-btn"
hoverClass="invoice-title-icon-btn--hover"
aria-label={label}
onClick={onClick}
>
<Image className="invoice-title-icon-img" src={ACTION_ICONS[kind]} mode="aspectFit" />
</View>
);
}
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<UserInvoiceTitleDto[]>([]);
const [loading, setLoading] = useState(true);
const [sheetOpen, setSheetOpen] = useState(false);
const [draft, setDraft] = useState<EditDraft>(EMPTY_DRAFT);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
setLoading(true);
return request<UserInvoiceTitleDto[]>('/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 (
<PageShell variant="sub" className="invoice-titles-page">
{isH5 ? (
<SubPageHeader
title="发票抬头"
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
/>
) : null}
<View className="invoice-titles-body">
{loading ? <View className="u-empty"></View> : null}
{!loading && titles.length === 0 ? (
<View className="u-empty"></View>
) : null}
{!loading &&
titles.map((t) => (
<View key={t.id} className="invoice-title-card">
<View className="invoice-title-card-head">
<Text className="invoice-title-name">{t.titleName}</Text>
<Text className="invoice-title-type">
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
</Text>
{t.isDefault ? <Text className="invoice-title-default"></Text> : null}
</View>
{t.taxNo ? <Text className="invoice-title-line">{t.taxNo}</Text> : null}
{t.email ? <Text className="invoice-title-line">{t.email}</Text> : null}
{t.phone ? <Text className="invoice-title-line">{t.phone}</Text> : null}
{t.addressPhone ? (
<Text className="invoice-title-line">{t.addressPhone}</Text>
) : null}
{t.bankAccount ? (
<Text className="invoice-title-line">{t.bankAccount}</Text>
) : null}
<View className="invoice-title-actions">
<TitleActionIcon kind="edit" label="编辑" onClick={() => openEdit(t)} />
{!t.isDefault ? (
<TitleActionIcon kind="star" label="设为默认" onClick={() => void setDefault(t)} />
) : null}
<TitleActionIcon kind="trash" label="删除" onClick={() => void remove(t.id)} />
</View>
</View>
))}
</View>
<View className="invoice-titles-fab" onClick={openCreate}>
<Text>+ </Text>
</View>
{sheetOpen ? (
<View className="invoice-title-sheet-mask">
<View className="invoice-title-sheet-mask-backdrop" onClick={() => !saving && setSheetOpen(false)} />
<View className="invoice-title-sheet">
<Text className="invoice-title-sheet-title">
{draft.id ? '编辑发票抬头' : '添加发票抬头'}
</Text>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
<Text
key={tp}
className={`invoice-title-type-chip${
draft.titleType === tp ? ' active' : ''
}`}
onClick={() => setDraft({ ...draft, titleType: tp })}
>
{INVOICE_TITLE_TYPE_LABELS[tp]}
</Text>
))}
</View>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="个人姓名或企业名称"
value={draft.titleName}
onInput={(e) => setDraft({ ...draft, titleName: e.detail.value })}
/>
</View>
{draft.titleType === 'ENTERPRISE' ? (
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={32}
placeholder="企业税号"
value={draft.taxNo || ''}
onInput={(e) => setDraft({ ...draft, taxNo: e.detail.value })}
/>
</View>
) : null}
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="电子发票将发送至此邮箱"
value={draft.email || ''}
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
/>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
type="number"
maxlength={20}
placeholder="选填"
value={draft.phone || ''}
onInput={(e) => setDraft({ ...draft, phone: e.detail.value })}
/>
</View>
{draft.titleType === 'ENTERPRISE' ? (
<>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
value={draft.addressPhone || ''}
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
/>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
value={draft.bankAccount || ''}
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
/>
</View>
</>
) : null}
<View className="invoice-title-default-row">
<Text className="invoice-title-label"></Text>
<Switch
checked={!!draft.isDefault}
color="#A61D24"
onChange={(e) => setDraft({ ...draft, isDefault: !!e.detail.value })}
/>
</View>
<View className="invoice-title-sheet-actions">
<Text
className="invoice-title-sheet-cancel"
onClick={() => !saving && setSheetOpen(false)}
>
</Text>
<Text
className={`invoice-title-sheet-save${saving ? ' is-disabled' : ''}`}
onClick={() => { if (!saving) void save(); }}
>
{saving ? '保存中…' : '保存'}
</Text>
</View>
</View>
</View>
) : null}
</PageShell>
);
}
+1
View File
@@ -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,
+1
View File
@@ -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';
@@ -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';
@@ -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';
@@ -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 ? '提交中…' : '确认收货'}
</View>
) : null}
{canInvoice ? (
<View
className="order-detail-invoice-btn"
onClick={() => Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })}
>
</View>
) : null}
{invoicePending ? (
<View
className="order-detail-invoice-status"
onClick={() => Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })}
>
</View>
) : null}
</View>
) : null}
</PageShell>
@@ -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';
+28 -1
View File
@@ -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() {
</View>
) : null}
{canInvoice ? (
<View
className="order-list-invoice-btn"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` });
}}
>
</View>
) : null}
{invoicePending ? (
<View
className="order-list-invoice-status"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` });
}}
>
</View>
) : null}
</View>
</View>
);
+1
View File
@@ -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';
@@ -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';
@@ -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';
@@ -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,
@@ -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';
@@ -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';
@@ -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';
@@ -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,
@@ -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';
@@ -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';
+415
View File
@@ -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;
}
+58
View File
@@ -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;
+1
View File
@@ -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';
+38
View File
@@ -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;
@@ -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<StoreInfoChangeStatus, string> = {
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;
}
+3
View File
@@ -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 {
+10 -16
View File
@@ -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
+2
View File
@@ -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",
+61
View File
@@ -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")
}
@@ -44,6 +44,23 @@ export function mapOrderCompat<T extends OrderLike & {
};
}
/** C 端订单列表/详情:附带最新发票状态,不把 invoices 原样透出 */
export function mapUserOrderWithInvoice<
T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
invoices?: { status: string }[];
},
>(order: T) {
const { invoices, ...rest } = order;
return {
...mapOrderCompat(rest),
invoiceStatus: invoices?.[0]?.status ?? null,
};
}
/** 对外联系电话;未单独配置时回退登录手机号 */
export function resolveStoreContactPhone(store: {
phone?: string | null;
@@ -55,10 +55,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
dedupeTtlSec: 120,
});
}
const resObj =
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
const reason = (resObj?.reason as string | undefined) ?? null;
response.status(status).json({
code: status,
message: msgText,
data: null,
...(reason != null ? { reason } : {}),
});
return;
}
@@ -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<boolean> {
const ok = await super.canActivate(context);
if (!ok) return false;
const req = context.switchToHttp().getRequest();
@@ -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<boolean> {
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<void> {
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;
}
}
@@ -15,7 +15,7 @@ export class PartnerPrimaryGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
this.jwtAuthGuard.canActivate(context);
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
+2 -1
View File
@@ -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 {}
@@ -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)}`,
});
}
}
}
@@ -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')
@@ -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;
@@ -96,12 +96,22 @@ export class AdminStoresService {
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
const pendingByStore = new Map<string, string>();
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
const pendingInfoByStore = new Map<string, string>();
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,
@@ -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<string, unknown>,
) {
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<string, unknown>) {
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,
});
}
}
@@ -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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>,
proposed: Record<string, unknown>,
): 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<string, unknown>;
}): Promise<StoreInfoChangeRequestDto> {
// 1) 校验归属
let store: Record<string, unknown>;
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<string, unknown>;
} 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<string, unknown>;
}
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<string, unknown>));
}
/** 合伙人端查看本门店历史变更 */
async listPartnerRequests(
storeId: bigint,
partnerAccountId: bigint,
): Promise<StoreInfoChangeRequestDto[]> {
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<string, unknown>)));
}
/** 总部列表 */
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<string, unknown>, (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<StoreInfoChangeRequestDto> {
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<string, unknown>,
(row as { store?: { name?: string } }).store?.name,
);
const live = (row as { liveSnapshot?: Record<string, unknown> }).liveSnapshot || {};
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).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<StoreInfoChangeRequestDto> {
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<string, unknown>));
}
// APPROVE:将 proposedSnapshot 写入 Store(白名单内)
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
const data: Record<string, unknown> = {};
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<string, unknown>));
}
private toDto(
row: Record<string, unknown>,
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(),
};
}
}
@@ -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 {}
@@ -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()
@@ -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<UserInvoiceTitleDto[]> {
return this.titleSvc.listTitles(user.actorId);
}
@Post('invoice-titles')
create(
@CurrentUser() user: AuthUser,
@Body() body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
return this.titleSvc.createTitle(user.actorId, body);
}
@Put('invoice-titles/:id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
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<UserInvoiceTitleDto[]> {
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));
}
}
@@ -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<UserInvoiceTitleDto[]> {
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<string, unknown>)));
}
async createTitle(
userId: bigint,
body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
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<string, unknown>));
}
async updateTitle(
userId: bigint,
id: bigint,
body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
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<string, unknown>));
}
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<string, unknown>): 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(),
};
}
}
@@ -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 {}
@@ -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<number> {
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([

Some files were not shown because too many files have changed in this diff Show More