@@ -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
|
||||||
|
// ❌ 未配页标题 + 再画一层 SubPageHeader(weapp 会双标题)
|
||||||
|
<SubPageHeader title="申请发票" />
|
||||||
|
```
|
||||||
|
|
||||||
|
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
|
||||||
@@ -139,6 +139,7 @@ C 端门店仅 status=OPEN
|
|||||||
|
|
||||||
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
||||||
**微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。
|
**微信小程序 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`。
|
||||||
|
|
||||||
## 环境与发版
|
## 环境与发版
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import OrdersPage from './pages/OrdersPage';
|
import OrdersPage from './pages/OrdersPage';
|
||||||
|
import BigScreenPage from './pages/BigScreenPage';
|
||||||
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||||
import StoresPage from './pages/StoresPage';
|
import StoresPage from './pages/StoresPage';
|
||||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||||
@@ -65,6 +66,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/orders/big-screen"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BigScreenPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
|
|||||||
@@ -104,3 +104,605 @@ body,
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-width: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
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;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '门店',
|
label: '门店',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/stores', label: '门店列表' },
|
{ key: '/stores', label: '门店列表' },
|
||||||
{ key: '/store-package-audits', label: '套餐审核' },
|
{ key: '/store-package-audits', label: '审核通知' },
|
||||||
{ key: '/store-ratings', label: '门店评价' },
|
{ key: '/store-ratings', label: '门店评价' },
|
||||||
{ key: '/store-categories', label: '门店分类' },
|
{ key: '/store-categories', label: '门店分类' },
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
@@ -244,14 +244,14 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
|||||||
.filter(Boolean) as MenuProps['items'];
|
.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;
|
if (!items) return items;
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
if ('children' in item && Array.isArray(item.children)) {
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
|
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||||
} as MenuItem;
|
} as MenuItem;
|
||||||
}
|
}
|
||||||
if (String(item.key) === '/store-package-audits') {
|
if (String(item.key) === '/store-package-audits') {
|
||||||
@@ -259,7 +259,7 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number
|
|||||||
...item,
|
...item,
|
||||||
label: (
|
label: (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
套餐审核
|
审核通知
|
||||||
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -276,11 +276,14 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
||||||
|
|
||||||
function refreshPackagePendingCount() {
|
function refreshAuditPendingCount() {
|
||||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
Promise.all([
|
||||||
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
|
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(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,13 +292,17 @@ export default function AdminLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshPackagePendingCount();
|
refreshAuditPendingCount();
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = () => refreshPackagePendingCount();
|
const onChanged = () => refreshAuditPendingCount();
|
||||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -320,8 +327,8 @@ export default function AdminLayout() {
|
|||||||
!profile || profile.adminRole === 'SUPER_ADMIN'
|
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||||
? MENU_ITEMS
|
? MENU_ITEMS
|
||||||
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
return attachPackageAuditBadge(base, packagePendingCount);
|
return attachAuditBadge(base, auditPendingCount);
|
||||||
}, [profile, packagePendingCount]);
|
}, [profile, auditPendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,5 +1,77 @@
|
|||||||
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
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() {
|
export function notifyPackageAuditChanged() {
|
||||||
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||||
|
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAuditNoticeChanged() {
|
||||||
|
notifyPackageAuditChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { triggerBigScreenDemo } from '../lib/admin-events';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
@@ -191,6 +192,7 @@ export default function OrdersPage() {
|
|||||||
const [trackOpen, setTrackOpen] = useState(false);
|
const [trackOpen, setTrackOpen] = useState(false);
|
||||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (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' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
<Space>
|
<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 ? (
|
{canProxyOrder ? (
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
代下单
|
代下单
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
|||||||
export default function StoreBillsPage() {
|
export default function StoreBillsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||||
|
const initialStoreId = searchParams.get('storeId') || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
kind: initialKind,
|
kind: initialKind,
|
||||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||||
|
storeId: initialStoreId,
|
||||||
});
|
});
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -97,8 +99,9 @@ export default function StoreBillsPage() {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
kind: filters.kind || undefined,
|
kind: filters.kind || undefined,
|
||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
|
storeId: filters.storeId || undefined,
|
||||||
});
|
});
|
||||||
}, [filters.kind, filters.status, form]);
|
}, [filters.kind, filters.status, filters.storeId, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -359,6 +362,7 @@ export default function StoreBillsPage() {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
kind: filters.kind || undefined,
|
kind: filters.kind || undefined,
|
||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
|
storeId: filters.storeId || undefined,
|
||||||
}}
|
}}
|
||||||
onFinish={(v: {
|
onFinish={(v: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Image,
|
Image,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type {
|
import type {
|
||||||
|
StoreInfoChangeFieldDiff,
|
||||||
|
StoreInfoChangeRequestDto,
|
||||||
|
StoreInfoChangeStatus,
|
||||||
StorePackageAuditDetailDto,
|
StorePackageAuditDetailDto,
|
||||||
StorePackageAuditSummaryDto,
|
StorePackageAuditSummaryDto,
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
@@ -22,7 +27,10 @@ import type {
|
|||||||
StorePackageItemDto,
|
StorePackageItemDto,
|
||||||
StorePackageViewDto,
|
StorePackageViewDto,
|
||||||
} from '@dukang/shared-types';
|
} 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 { request, type Paginated } from '../lib/api';
|
||||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
import { fmtTime } from '../lib/constants';
|
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() {
|
export default function StorePackageAuditsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
@@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||||
|
const infoRequestId = searchParams.get('infoRequestId');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rid = searchParams.get('requestId');
|
const rid = searchParams.get('requestId');
|
||||||
if (rid) void openDetail(rid);
|
if (rid) void openDetail(rid);
|
||||||
@@ -438,33 +732,52 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>套餐变更审核</Typography.Title>
|
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Tabs
|
||||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
activeKey={activeTab}
|
||||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
onChange={setActiveTab}
|
||||||
{s === 'PENDING' ? (
|
items={[
|
||||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
{
|
||||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
key: 'package',
|
||||||
</Badge>
|
label: '套餐审核',
|
||||||
) : s ? (
|
children: (
|
||||||
HQ_PACKAGE_STATUS_LABELS[s]
|
<>
|
||||||
) : (
|
<Space style={{ marginBottom: 16 }}>
|
||||||
'全部'
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
)}
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
</Button>
|
{s === 'PENDING' ? (
|
||||||
))}
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||||
</Space>
|
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||||
<Table
|
</Badge>
|
||||||
rowKey="id"
|
) : s ? (
|
||||||
loading={loading}
|
HQ_PACKAGE_STATUS_LABELS[s]
|
||||||
columns={columns}
|
) : (
|
||||||
dataSource={items}
|
'全部'
|
||||||
pagination={{
|
)}
|
||||||
current: page,
|
</Button>
|
||||||
total,
|
))}
|
||||||
pageSize: 20,
|
</Space>
|
||||||
onChange: (p) => void reload(p, status),
|
<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
|
<Drawer
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ type StoreRow = {
|
|||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
|
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
|
||||||
pendingPackageAuditId?: string | null;
|
pendingPackageAuditId?: string | null;
|
||||||
|
/** 该门店当前待审核信息变更的 requestId(无则为空) */
|
||||||
|
pendingInfoChangeId?: string | null;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
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: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 220,
|
width: 280,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} wrap>
|
<Space size={0} wrap>
|
||||||
@@ -791,6 +793,31 @@ export default function StoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -156,10 +156,19 @@ async function rawRequest<T>(
|
|||||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
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) {
|
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.status = json.code === 401 ? 401 : json.code;
|
||||||
|
err.reason = json.reason;
|
||||||
if (json.code === 400) {
|
if (json.code === 400) {
|
||||||
reportApiError(
|
reportApiError(
|
||||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||||
@@ -224,8 +233,19 @@ export async function request<T>(
|
|||||||
try {
|
try {
|
||||||
return await requestWithAuthRetry<T>(path, fetchOptions);
|
return await requestWithAuthRetry<T>(path, fetchOptions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error & { status?: number };
|
const err = e as Error & { status?: number; reason?: string };
|
||||||
const message = err.message || '请求失败';
|
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 (err.status === 401) {
|
||||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
clearAuth({ keepProfile: true });
|
clearAuth({ keepProfile: true });
|
||||||
@@ -260,7 +280,14 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
|
|||||||
touchPartnerSession();
|
touchPartnerSession();
|
||||||
return { authenticated: true, partner };
|
return { authenticated: true, partner };
|
||||||
} catch (e) {
|
} 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) {
|
if (err.status === 401) {
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await refreshSession();
|
||||||
if (refreshed?.partner) {
|
if (refreshed?.partner) {
|
||||||
@@ -277,3 +304,17 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
|
|||||||
|
|
||||||
/** @deprecated 使用 PartnerSessionPayload */
|
/** @deprecated 使用 PartnerSessionPayload */
|
||||||
export type PartnerAuthPayload = 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function formatWechatError(e: unknown): string {
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession, refresh, account } = usePartnerSession();
|
const { applySession, refresh, account } = usePartnerSession();
|
||||||
const [params] = useSearchParams();
|
const [params, setSearchParams] = useSearchParams();
|
||||||
const quick = params.get('quick') === '1';
|
const quick = params.get('quick') === '1';
|
||||||
const savedProfile = getPartnerProfile();
|
const savedProfile = getPartnerProfile();
|
||||||
const remembered = loadRememberedPhone();
|
const remembered = loadRememberedPhone();
|
||||||
@@ -120,6 +120,18 @@ export default function LoginPage() {
|
|||||||
.catch(() => setWxAuthorize(false));
|
.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 quickName = savedProfile?.name ?? '城市合伙人';
|
||||||
const quickCompany = savedProfile?.companyName ?? '';
|
const quickCompany = savedProfile?.companyName ?? '';
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
const quickPhone = savedProfile?.phone || phone;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
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 { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
@@ -60,6 +60,7 @@ export default function StoreDetailPage() {
|
|||||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||||
|
const [pendingInfoChange, setPendingInfoChange] = useState(false);
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -97,6 +98,12 @@ export default function StoreDetailPage() {
|
|||||||
setStore(null);
|
setStore(null);
|
||||||
setLoadError(e instanceof Error ? e.message : '加载失败');
|
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||||
});
|
});
|
||||||
|
// 拉取该门店的信息变更审核记录,若有 PENDING 则展示「审核中」横幅
|
||||||
|
listStoreInfoChangeRequests(id)
|
||||||
|
.then((reqs) =>
|
||||||
|
setPendingInfoChange(Array.isArray(reqs) && reqs.some((r) => r.status === 'PENDING')),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
async function changeStatus(next: StoreStatusValue) {
|
async function changeStatus(next: StoreStatusValue) {
|
||||||
@@ -162,26 +169,24 @@ export default function StoreDetailPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setActionError('');
|
setActionError('');
|
||||||
try {
|
try {
|
||||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
// v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店
|
||||||
method: 'PUT',
|
await submitStoreInfoChangeRequest(id, {
|
||||||
body: JSON.stringify({
|
name: form.name.trim(),
|
||||||
name: form.name.trim(),
|
contactPhone: form.contactPhone.trim(),
|
||||||
contactPhone: form.contactPhone.trim(),
|
address: form.address.trim(),
|
||||||
address: form.address.trim(),
|
intro: form.intro.trim(),
|
||||||
intro: form.intro.trim(),
|
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
...(form.latitude.trim() && form.longitude.trim()
|
? {
|
||||||
? {
|
latitude: Number(form.latitude),
|
||||||
latitude: Number(form.latitude),
|
longitude: Number(form.longitude),
|
||||||
longitude: Number(form.longitude),
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
applyStore(data);
|
setPendingInfoChange(true);
|
||||||
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
|
toastSuccess('变更已提交,等待总部审核');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(e instanceof Error ? e.message : '保存失败');
|
setActionError(e instanceof Error ? e.message : '提交失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -279,6 +284,16 @@ export default function StoreDetailPage() {
|
|||||||
{auditStatus === 'APPROVED' && (
|
{auditStatus === 'APPROVED' && (
|
||||||
<p className="label-md text-muted">总部审核已通过,可将门店设为营业中。</p>
|
<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>
|
</section>
|
||||||
|
|
||||||
{canMutate && !auditPending && status !== 'CLOSED' && (
|
{canMutate && !auditPending && status !== 'CLOSED' && (
|
||||||
@@ -481,8 +496,8 @@ export default function StoreDetailPage() {
|
|||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">send</span>
|
||||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
{saving ? '提交中…' : '提交变更'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ async function rawRequest<T>(
|
|||||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
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 {
|
try {
|
||||||
json = await res.json();
|
json = await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -213,8 +213,12 @@ async function rawRequest<T>(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (json.code !== 0) {
|
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.status = res.status >= 500 ? res.status : json.code;
|
||||||
|
err.reason = json.reason;
|
||||||
if (json.code === 400) {
|
if (json.code === 400) {
|
||||||
reportApiError(
|
reportApiError(
|
||||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||||
@@ -254,7 +258,15 @@ async function requestWithAuthRetry<T>(
|
|||||||
try {
|
try {
|
||||||
return await rawRequest<T>(path, options);
|
return await rawRequest<T>(path, options);
|
||||||
} catch (e) {
|
} 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 =
|
const canRecover =
|
||||||
err.status === 401 &&
|
err.status === 401 &&
|
||||||
!retried &&
|
!retried &&
|
||||||
@@ -303,7 +315,14 @@ export async function ensureSession(): Promise<{
|
|||||||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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) {
|
if (err.status === 401) {
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await refreshSession();
|
||||||
if (refreshed) {
|
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);
|
||||||
|
}
|
||||||
@@ -86,6 +86,16 @@ export default function LoginPage() {
|
|||||||
if (hint) setMsg(hint);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||||
void handleShopWechatCallbackOnce()
|
void handleShopWechatCallbackOnce()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
||||||
|
|
||||||
export default function MinePage() {
|
export default function MinePage() {
|
||||||
useStorePageView('store_mine_view');
|
useStorePageView('store_mine_view');
|
||||||
@@ -43,6 +44,11 @@ export default function MinePage() {
|
|||||||
void loadMine();
|
void loadMine();
|
||||||
}, [loadMine]);
|
}, [loadMine]);
|
||||||
|
|
||||||
|
// v3.5.1 #2:核销成功后即时刷新门店信息(余额等)
|
||||||
|
useRedeemSuccessListener(() => {
|
||||||
|
void loadMine();
|
||||||
|
}, [loadMine]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||||
void handleShopWechatCallbackOnce()
|
void handleShopWechatCallbackOnce()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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(String(result.createdAt)).toLocaleString('zh-CN')
|
||||||
: new Date().toLocaleString('zh-CN');
|
: new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
// v3.5.1 #2:核销成功后广播事件,通知门店信息页 / 提现页即时刷新
|
||||||
|
useEffect(() => {
|
||||||
|
notifyRedeemSuccess({
|
||||||
|
redeemNo: redeemNo !== '—' ? redeemNo : undefined,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}, [redeemNo, amount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-success-page">
|
<div className="shop-success-page">
|
||||||
<header className="shop-success-header">
|
<header className="shop-success-header">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
||||||
|
|
||||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ export default function WithdrawPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// v3.5.1 #2:核销成功后即时刷新可提余额与提现按钮状态
|
||||||
|
useRedeemSuccessListener(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (statusFilter === 'all') return items;
|
if (statusFilter === 'all') return items;
|
||||||
return items.filter((r) => r.status === statusFilter);
|
return items.filter((r) => r.status === statusFilter);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const isDevMode =
|
|||||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN =
|
const API_ORIGIN =
|
||||||
process.env.VITE_API_TARGET ??
|
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'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export default defineConfig(async () => ({
|
|||||||
},
|
},
|
||||||
sourceRoot: 'src',
|
sourceRoot: 'src',
|
||||||
outputRoot: 'dist',
|
outputRoot: 'dist',
|
||||||
plugins: ['@tarojs/plugin-framework-react', '@tarojs/plugin-html'],
|
plugins: ['@tarojs/plugin-framework-react'],
|
||||||
alias: {
|
alias: {
|
||||||
// 指向包目录(不是 index.js),否则 react-dom/client 会变成 index.js/client
|
// 指向包目录(不是 index.js),否则 react-dom/client 会变成 index.js/client
|
||||||
react: REACT_DIR,
|
react: REACT_DIR,
|
||||||
@@ -98,6 +98,9 @@ export default defineConfig(async () => ({
|
|||||||
mini: {
|
mini: {
|
||||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||||
debugReact: isDevMode,
|
debugReact: isDevMode,
|
||||||
|
optimizeMainPackage: {
|
||||||
|
enable: true,
|
||||||
|
},
|
||||||
postcss: {
|
postcss: {
|
||||||
pxtransform: { enable: true, config: {} },
|
pxtransform: { enable: true, config: {} },
|
||||||
cssModules: { enable: false },
|
cssModules: { enable: false },
|
||||||
|
|||||||
@@ -17,11 +17,9 @@
|
|||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"@dukang/weixin-sdk": "workspace:*",
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
"element-china-area-data": "^6.1.0",
|
|
||||||
"@tarojs/components": "4.2.0",
|
"@tarojs/components": "4.2.0",
|
||||||
"@tarojs/helper": "4.2.0",
|
"@tarojs/helper": "4.2.0",
|
||||||
"@tarojs/plugin-framework-react": "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-h5": "4.2.0",
|
||||||
"@tarojs/plugin-platform-weapp": "4.2.0",
|
"@tarojs/plugin-platform-weapp": "4.2.0",
|
||||||
"@tarojs/react": "4.2.0",
|
"@tarojs/react": "4.2.0",
|
||||||
|
|||||||
@@ -4,27 +4,41 @@ export default defineAppConfig({
|
|||||||
'pages/stores/index',
|
'pages/stores/index',
|
||||||
'pages/benefit/index',
|
'pages/benefit/index',
|
||||||
'pages/mine/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: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
navigationBarBackgroundColor: '#FAF9F7',
|
navigationBarBackgroundColor: '#FAF9F7',
|
||||||
|
|||||||
@@ -5,13 +5,7 @@
|
|||||||
@import './styles/stores.css';
|
@import './styles/stores.css';
|
||||||
@import './styles/benefit.css';
|
@import './styles/benefit.css';
|
||||||
@import './styles/mine.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/address.css';
|
||||||
@import './styles/redeem.css';
|
|
||||||
|
|
||||||
page,
|
page,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import './lib/intl-polyfill';
|
import './lib/intl-polyfill';
|
||||||
import './lib/text-encoding-polyfill';
|
|
||||||
import { PropsWithChildren, useRef } from 'react';
|
import { PropsWithChildren, useRef } from 'react';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 828 B |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 198 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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[]>>;
|
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||||
|
|
||||||
function buildRegionTree(): RegionTree {
|
export const REGION_TREE = rawTree as 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 PROVINCES = Object.keys(REGION_TREE);
|
export const PROVINCES = Object.keys(REGION_TREE);
|
||||||
export const REGION_ALL = '全市';
|
export const REGION_ALL = '全市';
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** 小程序端恒为微信环境,避免引入 H5 JSSDK 门面 */
|
||||||
|
export function isWechatEnv() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
|
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
|
||||||
|
import '../../styles/address.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/address.css';
|
||||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/redeem.css';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/redeem.css';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
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,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Input, Image } from '@tarojs/components';
|
import { View, Text, Input, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/login.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import {
|
import {
|
||||||
SmsScene,
|
SmsScene,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const SERVICES = [
|
|||||||
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
|
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
|
||||||
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
|
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
|
||||||
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
|
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
|
||||||
|
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
|
import type { InvoiceStatus } from '@dukang/shared-types';
|
||||||
|
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ type OrderDetail = {
|
|||||||
deliveryType?: string;
|
deliveryType?: string;
|
||||||
items?: OrderItem[];
|
items?: OrderItem[];
|
||||||
wechatConfirm?: WechatConfirmPayload | null;
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
|
invoiceStatus?: InvoiceStatus | null;
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
trackingNo?: string;
|
trackingNo?: string;
|
||||||
@@ -150,6 +153,9 @@ export default function OrderDetailPage() {
|
|||||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||||
const canConfirmReceive =
|
const canConfirmReceive =
|
||||||
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
!!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 =
|
const canViewLogistics =
|
||||||
!!order &&
|
!!order &&
|
||||||
order.deliveryType !== 'ON_SITE_PICKUP' &&
|
order.deliveryType !== 'ON_SITE_PICKUP' &&
|
||||||
@@ -403,6 +409,22 @@ export default function OrderDetailPage() {
|
|||||||
{confirming ? '提交中…' : '确认收货'}
|
{confirming ? '提交中…' : '确认收货'}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
@@ -7,7 +8,7 @@ import { request, toast } from '../../lib/api';
|
|||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
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 = [
|
const TABS = [
|
||||||
{ key: 'all', label: '全部订单' },
|
{ key: 'all', label: '全部订单' },
|
||||||
@@ -50,6 +51,7 @@ type OrderRow = {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
items?: OrderItem[];
|
items?: OrderItem[];
|
||||||
|
invoiceStatus?: InvoiceStatus | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
@@ -125,6 +127,9 @@ export default function OrdersPage() {
|
|||||||
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
|
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
|
||||||
const unitPrice = Number(item?.unitPrice ?? 0);
|
const unitPrice = Number(item?.unitPrice ?? 0);
|
||||||
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
|
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';
|
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -177,6 +182,28 @@ export default function OrdersPage() {
|
|||||||
付款
|
付款
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/legal.css';
|
||||||
import { getLegalDocument } from '@dukang/shared-types';
|
import { getLegalDocument } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/product-detail.css';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
usePageScroll,
|
usePageScroll,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/redeem.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/redeem.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Input } from '@tarojs/components';
|
import { View, Text, Input } from '@tarojs/components';
|
||||||
|
import '../../styles/redeem.css';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import '../../styles/store-detail.css';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
useLoad,
|
useLoad,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/store-detail.css';
|
||||||
|
import '../../styles/product-detail.css';
|
||||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import '../../styles/legal.css';
|
||||||
import { getLegalDocument } from '@dukang/shared-types';
|
import { getLegalDocument } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -422,6 +422,64 @@
|
|||||||
justify-content: center;
|
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 {
|
.pay-status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 24px;
|
padding: 48px 24px;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from './user-log';
|
|||||||
|
|
||||||
export * from './store-log';
|
export * from './store-log';
|
||||||
export * from './store-package';
|
export * from './store-package';
|
||||||
|
export * from './store-info-change';
|
||||||
export * from './partner-log';
|
export * from './partner-log';
|
||||||
export * from './promo';
|
export * from './promo';
|
||||||
export * from './hq-permissions';
|
export * from './hq-permissions';
|
||||||
|
|||||||
@@ -52,3 +52,41 @@ export interface InvoiceDto {
|
|||||||
/** 待开票超过 2 个工作日(总部列表标红用) */
|
/** 待开票超过 2 个工作日(总部列表标红用) */
|
||||||
overdue?: boolean;
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WechatJsapiPrepayParams } from './wechat';
|
import type { WechatJsapiPrepayParams } from './wechat';
|
||||||
|
import type { InvoiceStatus } from './invoice';
|
||||||
|
|
||||||
export interface OrderDto {
|
export interface OrderDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +15,8 @@ export interface OrderDto {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
proxyPartnerPhone?: string | null;
|
proxyPartnerPhone?: string | null;
|
||||||
|
/** 该订单最新发票状态;无申请时为 null */
|
||||||
|
invoiceStatus?: InvoiceStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderPreviewRequest {
|
export interface OrderPreviewRequest {
|
||||||
|
|||||||
@@ -224,19 +224,19 @@ importers:
|
|||||||
version: link:../../packages/weixin-sdk
|
version: link:../../packages/weixin-sdk
|
||||||
'@tarojs/components':
|
'@tarojs/components':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/helper':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
'@tarojs/plugin-framework-react':
|
'@tarojs/plugin-framework-react':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/plugin-html':
|
||||||
specifier: 4.2.0
|
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)
|
||||||
'@tarojs/plugin-platform-h5':
|
'@tarojs/plugin-platform-h5':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/plugin-platform-weapp':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@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)
|
version: 4.2.0(react@18.3.1)
|
||||||
'@tarojs/router':
|
'@tarojs/router':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/runtime':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@@ -254,7 +254,7 @@ importers:
|
|||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
'@tarojs/taro':
|
'@tarojs/taro':
|
||||||
specifier: 4.2.0
|
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:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
@@ -309,19 +309,16 @@ importers:
|
|||||||
version: link:../../packages/weixin-sdk
|
version: link:../../packages/weixin-sdk
|
||||||
'@tarojs/components':
|
'@tarojs/components':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/helper':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
'@tarojs/plugin-framework-react':
|
'@tarojs/plugin-framework-react':
|
||||||
specifier: 4.2.0
|
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))
|
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-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':
|
'@tarojs/plugin-platform-h5':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/plugin-platform-weapp':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@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)
|
version: 4.2.0(react@18.3.1)
|
||||||
'@tarojs/router':
|
'@tarojs/router':
|
||||||
specifier: 4.2.0
|
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':
|
'@tarojs/runtime':
|
||||||
specifier: 4.2.0
|
specifier: 4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@@ -339,10 +336,7 @@ importers:
|
|||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
'@tarojs/taro':
|
'@tarojs/taro':
|
||||||
specifier: 4.2.0
|
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))
|
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))
|
||||||
element-china-area-data:
|
|
||||||
specifier: ^6.1.0
|
|
||||||
version: 6.1.0
|
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "pnpm --dir ../../packages/domain build",
|
||||||
|
"prebuild": "pnpm --dir ../../packages/domain build",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"start": "node dist/main",
|
"start": "node dist/main",
|
||||||
|
|||||||
@@ -302,6 +302,18 @@ enum StorePackageSubmitterType {
|
|||||||
SHOP
|
SHOP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StoreInfoChangeStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreInfoChangeSubmitterType {
|
||||||
|
PARTNER
|
||||||
|
SHOP
|
||||||
|
HQ_DIRECT_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
enum UserSourceType {
|
enum UserSourceType {
|
||||||
ORGANIC
|
ORGANIC
|
||||||
PROMO_CODE
|
PROMO_CODE
|
||||||
@@ -1179,6 +1191,7 @@ model User {
|
|||||||
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
||||||
orders Order[]
|
orders Order[]
|
||||||
invoices UserInvoice[]
|
invoices UserInvoice[]
|
||||||
|
invoiceTitles UserInvoiceTitle[]
|
||||||
benefitCoupons BenefitCoupon[]
|
benefitCoupons BenefitCoupon[]
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
redeemPendingRecords RedeemPendingRecord[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
@@ -1299,6 +1312,7 @@ model Store {
|
|||||||
visibilityPhones StoreVisibilityPhone[]
|
visibilityPhones StoreVisibilityPhone[]
|
||||||
packages StorePackage[]
|
packages StorePackage[]
|
||||||
packageChangeRequests StorePackageChangeRequest[]
|
packageChangeRequests StorePackageChangeRequest[]
|
||||||
|
infoChangeRequests StoreInfoChangeRequest[]
|
||||||
|
|
||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@ -1362,6 +1376,52 @@ model StorePackageChangeRequest {
|
|||||||
@@map("store_package_change_request")
|
@@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 {
|
model StoreAccount {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
phone String @unique @db.VarChar(20)
|
phone String @unique @db.VarChar(20)
|
||||||
@@ -1495,6 +1555,7 @@ model Order {
|
|||||||
@@index([fulfillmentWarehouseId])
|
@@index([fulfillmentWarehouseId])
|
||||||
@@index([proxyPartnerAccountId])
|
@@index([proxyPartnerAccountId])
|
||||||
@@index([isTest])
|
@@index([isTest])
|
||||||
|
@@index([status, payExpireAt])
|
||||||
@@map("user_order")
|
@@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: {
|
export function resolveStoreContactPhone(store: {
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
dedupeTtlSec: 120,
|
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({
|
response.status(status).json({
|
||||||
code: status,
|
code: status,
|
||||||
message: msgText,
|
message: msgText,
|
||||||
data: null,
|
data: null,
|
||||||
|
...(reason != null ? { reason } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { JwtAuthGuard } from './jwt-auth.guard';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
|
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const ok = super.canActivate(context);
|
const ok = await super.canActivate(context);
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
|
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
actorType: string;
|
actorType: string;
|
||||||
@@ -17,11 +19,20 @@ export interface AuthUser {
|
|||||||
storeId?: bigint;
|
storeId?: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3.5.1 #9:门店/合伙人账号停用或解绑后,运行时接口强制拦截。
|
||||||
|
* 仅对 STORE / PARTNER actor 做 DB 状态校验;USER / HQ 跳过(不影响 C 端/小程序/总部)。
|
||||||
|
* 不通过时抛 ForbiddenException({ reason: 'ACCOUNT_DISABLED' }),由 HttpExceptionFilter 透传 reason,
|
||||||
|
* 前端据此 clearAuth() 并跳登录页。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
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 req = context.switchToHttp().getRequest();
|
||||||
const auth = req.headers.authorization as string | undefined;
|
const auth = req.headers.authorization as string | undefined;
|
||||||
if (!auth?.startsWith('Bearer ')) {
|
if (!auth?.startsWith('Bearer ')) {
|
||||||
@@ -47,10 +58,81 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
? { storeId: BigInt(payload.storeId) }
|
? { storeId: BigInt(payload.storeId) }
|
||||||
: {}),
|
: {}),
|
||||||
} satisfies AuthUser;
|
} satisfies AuthUser;
|
||||||
return true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnauthorizedException) throw err;
|
if (err instanceof UnauthorizedException) throw err;
|
||||||
throw new UnauthorizedException('Invalid token');
|
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> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
this.jwtAuthGuard.canActivate(context);
|
await this.jwtAuthGuard.canActivate(context);
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
const user = req.user as AuthUser;
|
const user = req.user as AuthUser;
|
||||||
if (user.actorType !== 'PARTNER') {
|
if (user.actorType !== 'PARTNER') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SettlementModule } from '../modules/settlement/settlement.module';
|
|||||||
import { DeliveryProcessor } from './delivery.processor';
|
import { DeliveryProcessor } from './delivery.processor';
|
||||||
import { SettlementScheduler } from './settlement.scheduler';
|
import { SettlementScheduler } from './settlement.scheduler';
|
||||||
import { MonitorScheduler } from './monitor.scheduler';
|
import { MonitorScheduler } from './monitor.scheduler';
|
||||||
|
import { OrderExpiryScheduler } from './order-expiry.scheduler';
|
||||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -15,6 +16,6 @@ import { DELIVERY_QUEUE } from './jobs.constants';
|
|||||||
TradeModule,
|
TradeModule,
|
||||||
SettlementModule,
|
SettlementModule,
|
||||||
],
|
],
|
||||||
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler],
|
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler, OrderExpiryScheduler],
|
||||||
})
|
})
|
||||||
export class JobsModule {}
|
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);
|
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')
|
@Post('batch-delete')
|
||||||
@UseGuards(HqPermissionGuard)
|
@UseGuards(HqPermissionGuard)
|
||||||
@RequireHqPermissions('orders_delete')
|
@RequireHqPermissions('orders_delete')
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { maskContactPhone } from '@dukang/domain';
|
||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
@@ -24,6 +25,34 @@ export class AdminOrdersService {
|
|||||||
private readonly adminRedeemService: AdminRedeemService,
|
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) {
|
async list(query: AdminOrdersQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
|||||||
@@ -96,12 +96,22 @@ export class AdminStoresService {
|
|||||||
|
|
||||||
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
|
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
|
||||||
const pendingByStore = new Map<string, string>();
|
const pendingByStore = new Map<string, string>();
|
||||||
|
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
|
||||||
|
const pendingInfoByStore = new Map<string, string>();
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({
|
const storeIds = items.map((s) => s.id);
|
||||||
where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' },
|
const [pendingReqs, pendingInfoReqs] = await Promise.all([
|
||||||
select: { id: true, storeId: true },
|
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 pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
|
||||||
|
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
@@ -113,6 +123,7 @@ export class AdminStoresService {
|
|||||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||||
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||||||
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||||||
|
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
|
||||||
partner: s.partnerAccount,
|
partner: s.partnerAccount,
|
||||||
account: s.bindings[0]?.storeAccount ?? null,
|
account: s.bindings[0]?.storeAccount ?? null,
|
||||||
bindings: undefined,
|
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,
|
ShopStorePackageController,
|
||||||
} from './store-package.controller';
|
} from './store-package.controller';
|
||||||
import { StorePackageService } from './store-package.service';
|
import { StorePackageService } from './store-package.service';
|
||||||
|
import {
|
||||||
|
AdminStoreInfoChangeController,
|
||||||
|
PartnerStoreInfoChangeController,
|
||||||
|
ShopStoreInfoChangeController,
|
||||||
|
} from './store-info-change.controller';
|
||||||
|
import { StoreInfoChangeService } from './store-info-change.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -47,8 +53,11 @@ import { StorePackageService } from './store-package.service';
|
|||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
AdminStorePackageController,
|
AdminStorePackageController,
|
||||||
AdminStorePackageAuditController,
|
AdminStorePackageAuditController,
|
||||||
|
PartnerStoreInfoChangeController,
|
||||||
|
ShopStoreInfoChangeController,
|
||||||
|
AdminStoreInfoChangeController,
|
||||||
],
|
],
|
||||||
providers: [StoreService, StoreCategoryService, StorePackageService],
|
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService],
|
||||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateAfterSaleTicketDto {
|
export class CreateAfterSaleTicketDto {
|
||||||
@@ -37,18 +38,26 @@ export class CreatePackageDisputeDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateInvoiceDto {
|
export class CreateInvoiceDto {
|
||||||
|
/** v3.5.1:从已保存的发票抬头中选择(若存在则覆盖下方抬头字段) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleId?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||||
titleType: string;
|
titleType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['NORMAL', 'SPECIAL'])
|
@IsIn(['NORMAL', 'SPECIAL'])
|
||||||
invoiceKind: string;
|
invoiceKind?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(128)
|
@MaxLength(128)
|
||||||
titleName: string;
|
titleName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -65,13 +74,15 @@ export class CreateInvoiceDto {
|
|||||||
@MaxLength(256)
|
@MaxLength(256)
|
||||||
bankAccount?: string;
|
bankAccount?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
phone: string;
|
phone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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,
|
TradePackageDisputeController,
|
||||||
} from './trade.controller';
|
} from './trade.controller';
|
||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
|
import { TradeInvoiceTitleController } from './invoice-title.controller';
|
||||||
|
import { InvoiceTitleService } from './invoice-title.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -39,8 +41,9 @@ import { TradeService } from './trade.service';
|
|||||||
PartnerOrderController,
|
PartnerOrderController,
|
||||||
PartnerProxyOrderController,
|
PartnerProxyOrderController,
|
||||||
PartnerReshipmentController,
|
PartnerReshipmentController,
|
||||||
|
TradeInvoiceTitleController,
|
||||||
],
|
],
|
||||||
providers: [TradeService],
|
providers: [TradeService, InvoiceTitleService],
|
||||||
exports: [TradeService],
|
exports: [TradeService],
|
||||||
})
|
})
|
||||||
export class TradeModule {}
|
export class TradeModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service';
|
|||||||
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
|
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
|
||||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
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 { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||||
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
|
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
|
||||||
import { AlertService } from '../../common/alert/alert.service';
|
import { AlertService } from '../../common/alert/alert.service';
|
||||||
@@ -57,6 +58,9 @@ export class TradeService {
|
|||||||
private readonly alert: AlertService,
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private readonly logger = new Logger(TradeService.name);
|
||||||
|
|
||||||
|
|
||||||
async preview(
|
async preview(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
||||||
@@ -753,14 +757,23 @@ export class TradeService {
|
|||||||
const [list, total] = await Promise.all([
|
const [list, total] = await Promise.all([
|
||||||
this.prisma.order.findMany({
|
this.prisma.order.findMany({
|
||||||
where,
|
where,
|
||||||
include: { benefitCoupon: true, imageResource: true },
|
include: {
|
||||||
|
benefitCoupon: true,
|
||||||
|
imageResource: true,
|
||||||
|
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
}),
|
}),
|
||||||
this.prisma.order.count({ where }),
|
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) {
|
async getOrder(userId: bigint, orderId: bigint) {
|
||||||
@@ -772,6 +785,7 @@ export class TradeService {
|
|||||||
imageResource: true,
|
imageResource: true,
|
||||||
product: true,
|
product: true,
|
||||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||||
|
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -779,7 +793,10 @@ export class TradeService {
|
|||||||
where: orderStatusLogWhere(orderId),
|
where: orderStatusLogWhere(orderId),
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
|
const mapped = mapUserOrderWithInvoice({
|
||||||
|
...order,
|
||||||
|
statusLogs: mapStatusLogCompat(statusLogs),
|
||||||
|
});
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...mapped,
|
...mapped,
|
||||||
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
|
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
|
||||||
@@ -987,14 +1004,15 @@ export class TradeService {
|
|||||||
userId: bigint,
|
userId: bigint,
|
||||||
orderId: bigint,
|
orderId: bigint,
|
||||||
body: {
|
body: {
|
||||||
titleType: string;
|
titleId?: string;
|
||||||
invoiceKind: string;
|
titleType?: string;
|
||||||
titleName: string;
|
invoiceKind?: string;
|
||||||
taxNo?: string;
|
titleName?: string;
|
||||||
addressPhone?: string;
|
taxNo?: string | null;
|
||||||
bankAccount?: string;
|
addressPhone?: string | null;
|
||||||
email: string;
|
bankAccount?: string | null;
|
||||||
phone: string;
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -1009,14 +1027,49 @@ export class TradeService {
|
|||||||
});
|
});
|
||||||
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
|
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('企业抬头须填写税号');
|
throw new BadRequestException('企业抬头须填写税号');
|
||||||
}
|
}
|
||||||
if (body.invoiceKind === 'SPECIAL') {
|
const invoiceKind = body.invoiceKind || 'NORMAL';
|
||||||
if (body.titleType !== 'ENTERPRISE') {
|
if (invoiceKind === 'SPECIAL') {
|
||||||
|
if (resolved.titleType !== 'ENTERPRISE') {
|
||||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
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('专用发票须填写税号、地址电话与开户行账号');
|
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1026,20 +1079,29 @@ export class TradeService {
|
|||||||
invoiceNo: this.generateInvoiceNo(),
|
invoiceNo: this.generateInvoiceNo(),
|
||||||
orderId,
|
orderId,
|
||||||
userId,
|
userId,
|
||||||
titleType: body.titleType as never,
|
titleType: resolved.titleType as never,
|
||||||
invoiceKind: body.invoiceKind as never,
|
invoiceKind: invoiceKind as never,
|
||||||
titleName: body.titleName.trim(),
|
titleName: resolved.titleName.trim(),
|
||||||
taxNo: body.taxNo?.trim() || null,
|
taxNo: resolved.taxNo?.trim() || null,
|
||||||
addressPhone: body.addressPhone?.trim() || null,
|
addressPhone: resolved.addressPhone?.trim() || null,
|
||||||
bankAccount: body.bankAccount?.trim() || null,
|
bankAccount: resolved.bankAccount?.trim() || null,
|
||||||
email: body.email.trim(),
|
email: resolved.email.trim(),
|
||||||
phone: body.phone.trim(),
|
phone: resolved.phone.trim(),
|
||||||
remark: body.remark?.trim() || null,
|
remark: body.remark?.trim() || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
|
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) {
|
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
|
||||||
const where = { userId };
|
const where = { userId };
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -1096,14 +1158,15 @@ export class TradeService {
|
|||||||
async adminCreateInvoice(
|
async adminCreateInvoice(
|
||||||
body: {
|
body: {
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
titleType: string;
|
titleId?: string;
|
||||||
invoiceKind: string;
|
titleType?: string;
|
||||||
titleName: string;
|
invoiceKind?: string;
|
||||||
|
titleName?: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
bankAccount?: string;
|
bankAccount?: string;
|
||||||
email: string;
|
email?: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -1819,6 +1882,59 @@ export class TradeService {
|
|||||||
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
|
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 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||||
async getHqProxyOrderOptions() {
|
async getHqProxyOrderOptions() {
|
||||||
const [products, promoCodes] = await Promise.all([
|
const [products, promoCodes] = await Promise.all([
|
||||||
|
|||||||