Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb7ab7abb | |||
| 905e145af3 |
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
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,7 +139,6 @@ 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,7 +5,6 @@ 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';
|
||||||
@@ -66,14 +65,6 @@ 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,638 +104,3 @@ 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;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-list-head,
|
|
||||||
.big-screen-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-amount--6d {
|
|
||||||
font-size: 0.86em;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
/* 全屏高度黄金分割点(≈0.382),略偏上 */
|
|
||||||
top: 38.2%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 2;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(145, 190, 230, 0.42);
|
|
||||||
padding: 0 40px;
|
|
||||||
font-size: 60px;
|
|
||||||
letter-spacing: 6px;
|
|
||||||
font-weight: 400;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: min(420px, 92vw);
|
|
||||||
max-width: min(920px, 92vw);
|
|
||||||
padding: 28px 40px 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-align: center;
|
|
||||||
background: rgba(8, 22, 48, 0.82);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: min(560px, 94vw);
|
|
||||||
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: clamp(44px, 8.5vw, 64px);
|
|
||||||
font-weight: 800;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: #e6f7ff;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-fx-amount--6d {
|
|
||||||
font-size: clamp(36px, 7vw, 52px) !important;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: clamp(52px, 10vw, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
|
||||||
font-size: clamp(44px, 8.5vw, 68px) !important;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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-row {
|
|
||||||
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.big-screen-amount--6d {
|
|
||||||
font-size: 0.82em;
|
|
||||||
}
|
|
||||||
.big-screen-fx-amount {
|
|
||||||
font-size: clamp(36px, 8vw, 44px);
|
|
||||||
}
|
|
||||||
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
|
||||||
font-size: clamp(40px, 9vw, 56px);
|
|
||||||
}
|
|
||||||
.big-screen-fx-amount--6d {
|
|
||||||
font-size: clamp(32px, 6.5vw, 40px) !important;
|
|
||||||
}
|
|
||||||
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
|
||||||
font-size: clamp(34px, 7vw, 48px) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,616 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
|
||||||
} from 'antd';
|
|
||||||
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { request } from '../lib/api';
|
|
||||||
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
|
||||||
import OssUpload from './OssUpload';
|
|
||||||
|
|
||||||
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
|
||||||
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
|
||||||
type SkuRow = {
|
|
||||||
id?: string;
|
|
||||||
specValueIds: string[];
|
|
||||||
skuCode?: string;
|
|
||||||
barcode69: string;
|
|
||||||
price: number;
|
|
||||||
benefitAmount?: number;
|
|
||||||
status: string;
|
|
||||||
allowOnlinePurchase: boolean;
|
|
||||||
allowCrossCityDelivery: boolean;
|
|
||||||
allowOnSitePickup: boolean;
|
|
||||||
saleUnit: 'BOTTLE' | 'BOX';
|
|
||||||
bottlesPerUnit: number;
|
|
||||||
isDefault: boolean;
|
|
||||||
sortOrder?: number;
|
|
||||||
specText?: string;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function cartesian(attrs: SpecAttr[]): string[][] {
|
|
||||||
if (!attrs.length) return [[]];
|
|
||||||
return attrs.reduce<string[][]>(
|
|
||||||
(acc, attr) => {
|
|
||||||
const next: string[][] = [];
|
|
||||||
const values = attr.values.filter((v) => v.name?.trim());
|
|
||||||
for (const prev of acc) {
|
|
||||||
for (const v of values) {
|
|
||||||
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
|
||||||
next.push([...prev, vid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next.length ? next : acc;
|
|
||||||
},
|
|
||||||
[[]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyOf(ids: string[]) {
|
|
||||||
return [...ids].sort().join('_');
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
productId: string;
|
|
||||||
initialAttrs: SpecAttr[];
|
|
||||||
initialSkus: SkuRow[];
|
|
||||||
onSaved: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
|
||||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
|
||||||
initialAttrs.length
|
|
||||||
? initialAttrs
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const [skus, setSkus] = useState<SkuRow[]>(
|
|
||||||
initialSkus.length
|
|
||||||
? initialSkus.map((s) => ({
|
|
||||||
...s,
|
|
||||||
specValueIds: s.specValueIds ?? [],
|
|
||||||
status: s.status || 'ON_SALE',
|
|
||||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
|
||||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
|
||||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
|
||||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
|
||||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
|
||||||
isDefault: !!s.isDefault,
|
|
||||||
imageUrl: s.imageUrl ?? '',
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
specValueIds: [],
|
|
||||||
barcode69: '',
|
|
||||||
price: 0,
|
|
||||||
status: 'ON_SALE',
|
|
||||||
allowOnlinePurchase: true,
|
|
||||||
allowCrossCityDelivery: true,
|
|
||||||
allowOnSitePickup: false,
|
|
||||||
saleUnit: 'BOTTLE',
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
isDefault: true,
|
|
||||||
imageUrl: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
|
||||||
|
|
||||||
function regenerateMatrix() {
|
|
||||||
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
|
||||||
const next: SkuRow[] = combos.map((ids, i) => {
|
|
||||||
const existing = byKey.get(keyOf(ids));
|
|
||||||
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
|
||||||
return {
|
|
||||||
specValueIds: ids,
|
|
||||||
barcode69: '',
|
|
||||||
price: skus[0]?.price ?? 0,
|
|
||||||
benefitAmount: skus[0]?.benefitAmount,
|
|
||||||
status: 'DRAFT',
|
|
||||||
allowOnlinePurchase: true,
|
|
||||||
allowCrossCityDelivery: true,
|
|
||||||
allowOnSitePickup: false,
|
|
||||||
saleUnit: 'BOTTLE',
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
isDefault: i === 0,
|
|
||||||
sortOrder: i,
|
|
||||||
imageUrl: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
|
||||||
setSkus(next.length ? next : skus);
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchSku(index: number, patch: Partial<SkuRow>) {
|
|
||||||
setSkus((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
next[index] = { ...next[index], ...patch };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
|
||||||
const specsRes = await request<{
|
|
||||||
specAttrs: SpecAttr[];
|
|
||||||
skus: SkuRow[];
|
|
||||||
}>(`/admin/products/${productId}/specs`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
attrs: attrs.map((a, i) => ({
|
|
||||||
id: a.id,
|
|
||||||
name: a.name.trim(),
|
|
||||||
sortOrder: i,
|
|
||||||
values: a.values
|
|
||||||
.filter((v) => v.name.trim())
|
|
||||||
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 映射临时 id → 真实 id
|
|
||||||
const nameToId = new Map<string, string>();
|
|
||||||
for (const a of specsRes.specAttrs ?? []) {
|
|
||||||
for (const v of a.values ?? []) {
|
|
||||||
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolveIds = (ids: string[]) =>
|
|
||||||
ids.map((id) => {
|
|
||||||
if (!id.startsWith('__new__:')) return id;
|
|
||||||
const [, attrName, valueName] = id.split(':');
|
|
||||||
const real = nameToId.get(`${attrName}:${valueName}`);
|
|
||||||
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
|
||||||
return real;
|
|
||||||
});
|
|
||||||
|
|
||||||
const payloadSkus = skus.map((s, i) => ({
|
|
||||||
id: s.id,
|
|
||||||
specValueIds: resolveIds(s.specValueIds ?? []),
|
|
||||||
barcode69: s.barcode69.trim(),
|
|
||||||
price: s.price,
|
|
||||||
benefitAmount: s.benefitAmount,
|
|
||||||
status: s.status,
|
|
||||||
allowOnlinePurchase: s.allowOnlinePurchase,
|
|
||||||
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
|
||||||
allowOnSitePickup: s.allowOnSitePickup,
|
|
||||||
saleUnit: s.saleUnit,
|
|
||||||
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
|
||||||
isDefault: !!s.isDefault,
|
|
||||||
sortOrder: i,
|
|
||||||
imageUrl: s.imageUrl?.trim() || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const barcodes = payloadSkus.map((row) => row.barcode69);
|
|
||||||
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
|
||||||
if (new Set(barcodes).size !== barcodes.length) {
|
|
||||||
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
|
||||||
}
|
|
||||||
|
|
||||||
await request(`/admin/products/${productId}/skus`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ skus: payloadSkus }),
|
|
||||||
});
|
|
||||||
message.success('规格与 SKU 已保存');
|
|
||||||
onSaved();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const editing = editIndex != null ? skus[editIndex] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Paragraph type="secondary">
|
|
||||||
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
|
||||||
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
|
|
||||||
<Typography.Title level={5}>规格轴</Typography.Title>
|
|
||||||
{attrs.map((attr, ai) => (
|
|
||||||
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
|
||||||
<Space align="start" style={{ width: '100%' }} wrap>
|
|
||||||
<Input
|
|
||||||
style={{ width: 140 }}
|
|
||||||
placeholder="规格名"
|
|
||||||
value={attr.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...attrs];
|
|
||||||
next[ai] = { ...attr, name: e.target.value };
|
|
||||||
setAttrs(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<MinusCircleOutlined />}
|
|
||||||
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
{attr.values.map((val, vi) => (
|
|
||||||
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
|
||||||
<Input
|
|
||||||
placeholder="规格值"
|
|
||||||
value={val.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...attrs];
|
|
||||||
const values = [...attr.values];
|
|
||||||
values[vi] = { ...val, name: e.target.value };
|
|
||||||
next[ai] = { ...attr, values };
|
|
||||||
setAttrs(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<MinusCircleOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
const next = [...attrs];
|
|
||||||
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
|
||||||
setAttrs(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
size="small"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
const next = [...attrs];
|
|
||||||
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
|
||||||
setAttrs(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
添加规格值
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
disabled={attrs.length >= 3}
|
|
||||||
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
|
||||||
>
|
|
||||||
添加规格轴
|
|
||||||
</Button>
|
|
||||||
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
rowKey={(_, i) => String(i)}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 1240 }}
|
|
||||||
dataSource={skus}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: '规格',
|
|
||||||
width: 140,
|
|
||||||
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'SKU',
|
|
||||||
width: 110,
|
|
||||||
render: (_, row) => (
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{row.skuCode || '保存后自动生成'}
|
|
||||||
</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
|
||||||
width: 180,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Input
|
|
||||||
placeholder="本规格独立 69 码"
|
|
||||||
value={row.barcode69}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, barcode69: e.target.value };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '售价',
|
|
||||||
width: 100,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={row.price}
|
|
||||||
onChange={(v) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, price: Number(v) || 0 };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '权益',
|
|
||||||
width: 100,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={row.benefitAmount}
|
|
||||||
onChange={(v) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
width: 110,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={row.status}
|
|
||||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
||||||
onChange={(status) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, status };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '单位',
|
|
||||||
width: 160,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Space>
|
|
||||||
<Select
|
|
||||||
style={{ width: 80 }}
|
|
||||||
value={row.saleUnit}
|
|
||||||
options={[
|
|
||||||
{ value: 'BOTTLE', label: '瓶' },
|
|
||||||
{ value: 'BOX', label: '箱' },
|
|
||||||
]}
|
|
||||||
onChange={(saleUnit) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = {
|
|
||||||
...row,
|
|
||||||
saleUnit,
|
|
||||||
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
|
||||||
};
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{row.saleUnit === 'BOX' ? (
|
|
||||||
<InputNumber
|
|
||||||
min={1}
|
|
||||||
style={{ width: 70 }}
|
|
||||||
value={row.bottlesPerUnit}
|
|
||||||
onChange={(v) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '履约',
|
|
||||||
width: 200,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Space direction="vertical" size={0}>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="线上"
|
|
||||||
unCheckedChildren="线上"
|
|
||||||
checked={row.allowOnlinePurchase}
|
|
||||||
onChange={(checked) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = {
|
|
||||||
...row,
|
|
||||||
allowOnlinePurchase: checked,
|
|
||||||
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
|
||||||
};
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="跨城"
|
|
||||||
unCheckedChildren="跨城"
|
|
||||||
disabled={!row.allowOnlinePurchase}
|
|
||||||
checked={row.allowCrossCityDelivery}
|
|
||||||
onChange={(checked) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, allowCrossCityDelivery: checked };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="现场"
|
|
||||||
unCheckedChildren="现场"
|
|
||||||
checked={row.allowOnSitePickup}
|
|
||||||
onChange={(checked) => {
|
|
||||||
const next = [...skus];
|
|
||||||
next[index] = { ...row, allowOnSitePickup: checked };
|
|
||||||
setSkus(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '默认',
|
|
||||||
width: 70,
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Radio
|
|
||||||
checked={row.isDefault}
|
|
||||||
onChange={() => {
|
|
||||||
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '主图',
|
|
||||||
width: 72,
|
|
||||||
render: (_, row) =>
|
|
||||||
row.imageUrl ? (
|
|
||||||
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">无</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '编辑',
|
|
||||||
width: 80,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_, row, index) => (
|
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
|
||||||
填写
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
|
||||||
保存规格与 SKU
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
|
||||||
open={editIndex != null}
|
|
||||||
onCancel={() => setEditIndex(null)}
|
|
||||||
onOk={() => setEditIndex(null)}
|
|
||||||
okText="完成"
|
|
||||||
width={560}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
{editing && editIndex != null ? (
|
|
||||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary">SKU</Typography.Text>
|
|
||||||
<div>{editing.skuCode || '保存后自动生成'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
|
||||||
<Input
|
|
||||||
placeholder="本规格独立 69 码"
|
|
||||||
value={editing.barcode69}
|
|
||||||
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text>主图</Typography.Text>
|
|
||||||
<OssUpload
|
|
||||||
bizType="COVER"
|
|
||||||
value={editing.imageUrl || ''}
|
|
||||||
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
<div>
|
|
||||||
<Typography.Text>售价</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
value={editing.price}
|
|
||||||
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text>权益</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
value={editing.benefitAmount}
|
|
||||||
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
<div>
|
|
||||||
<Typography.Text>状态</Typography.Text>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={editing.status}
|
|
||||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
||||||
onChange={(status) => patchSku(editIndex, { status })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
<Select
|
|
||||||
style={{ width: 100 }}
|
|
||||||
value={editing.saleUnit}
|
|
||||||
options={[
|
|
||||||
{ value: 'BOTTLE', label: '瓶' },
|
|
||||||
{ value: 'BOX', label: '箱' },
|
|
||||||
]}
|
|
||||||
onChange={(saleUnit) =>
|
|
||||||
patchSku(editIndex, {
|
|
||||||
saleUnit,
|
|
||||||
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{editing.saleUnit === 'BOX' ? (
|
|
||||||
<InputNumber
|
|
||||||
min={1}
|
|
||||||
value={editing.bottlesPerUnit}
|
|
||||||
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
<Space>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="线上"
|
|
||||||
unCheckedChildren="线上"
|
|
||||||
checked={editing.allowOnlinePurchase}
|
|
||||||
onChange={(checked) =>
|
|
||||||
patchSku(editIndex, {
|
|
||||||
allowOnlinePurchase: checked,
|
|
||||||
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="跨城"
|
|
||||||
unCheckedChildren="跨城"
|
|
||||||
disabled={!editing.allowOnlinePurchase}
|
|
||||||
checked={editing.allowCrossCityDelivery}
|
|
||||||
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="现场"
|
|
||||||
unCheckedChildren="现场"
|
|
||||||
checked={editing.allowOnSitePickup}
|
|
||||||
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="默认"
|
|
||||||
unCheckedChildren="默认"
|
|
||||||
checked={editing.isDefault}
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (!checked) return;
|
|
||||||
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
const [productId, setProductId] = useState<string>();
|
const [productId, setProductId] = useState<string>();
|
||||||
const [skuId, setSkuId] = useState<string>();
|
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
@@ -88,37 +87,9 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||||
const selectedSku =
|
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||||
skuOptions.find((s) => s.id === skuId) ||
|
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
|
||||||
skuOptions[0];
|
|
||||||
const allowOnline = selectedSku
|
|
||||||
? selectedSku.allowOnlinePurchase !== false
|
|
||||||
: selectedProduct
|
|
||||||
? selectedProduct.allowOnlinePurchase !== false
|
|
||||||
: true;
|
|
||||||
const allowOnSite = selectedSku
|
|
||||||
? !!selectedSku.allowOnSitePickup
|
|
||||||
: !!selectedProduct?.allowOnSitePickup;
|
|
||||||
const allowCrossCity = selectedSku
|
|
||||||
? selectedSku.allowCrossCityDelivery !== false
|
|
||||||
: selectedProduct
|
|
||||||
? selectedProduct.allowCrossCityDelivery !== false
|
|
||||||
: true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !selectedProduct) return;
|
|
||||||
const def =
|
|
||||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
|
||||||
skuOptions.find((s) => s.isDefault) ||
|
|
||||||
skuOptions[0];
|
|
||||||
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
|
||||||
setSkuId(def.id);
|
|
||||||
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, productId, selectedProduct?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedProduct) return;
|
if (!open || !selectedProduct) return;
|
||||||
@@ -144,7 +115,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
skuId: skuId || undefined,
|
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -154,7 +124,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||||
|
|
||||||
useEffect(() => () => stopPoll(), []);
|
useEffect(() => () => stopPoll(), []);
|
||||||
|
|
||||||
@@ -165,8 +135,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setRegionCodes([]);
|
setRegionCodes([]);
|
||||||
setAddressDetail('');
|
setAddressDetail('');
|
||||||
setQuantity(2);
|
setQuantity(2);
|
||||||
setProductId(undefined);
|
|
||||||
setSkuId(undefined);
|
|
||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
@@ -234,7 +202,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId: productId!,
|
productId: productId!,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
skuId: skuId || undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -250,12 +217,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||||
});
|
});
|
||||||
if (pay.mode === 'mock') {
|
|
||||||
message.success(`支付成功:${order.orderNo}`);
|
|
||||||
resetForm();
|
|
||||||
onSuccess({ id: order.id, orderNo: order.orderNo });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCodeUrl(pay.codeUrl ?? null);
|
setCodeUrl(pay.codeUrl ?? null);
|
||||||
startPoll(order.id);
|
startPoll(order.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -350,10 +311,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
loading={loadingOptions}
|
loading={loadingOptions}
|
||||||
placeholder="选择商品"
|
placeholder="选择商品"
|
||||||
value={productId}
|
value={productId}
|
||||||
onChange={(id) => {
|
onChange={setProductId}
|
||||||
setProductId(id);
|
|
||||||
setSkuId(undefined);
|
|
||||||
}}
|
|
||||||
options={(options?.products ?? []).map((p) => ({
|
options={(options?.products ?? []).map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||||
@@ -361,25 +319,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
<Form.Item label="数量" required>
|
||||||
<Form.Item label="规格" required>
|
|
||||||
<Select
|
|
||||||
placeholder="选择规格"
|
|
||||||
value={skuId}
|
|
||||||
onChange={(id) => {
|
|
||||||
setSkuId(id);
|
|
||||||
const sku = skuOptions.find((s) => s.id === id);
|
|
||||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
|
||||||
}}
|
|
||||||
options={skuOptions.map((s) => ({
|
|
||||||
value: s.id,
|
|
||||||
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
|
|||||||
@@ -1,453 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
|
||||||
import type {
|
|
||||||
StorePackageAuditDetailDto,
|
|
||||||
StorePackageItemDto,
|
|
||||||
StorePackageViewDto,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
|
||||||
import { request } from '../lib/api';
|
|
||||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
|
||||||
import { fmtTime } from '../lib/constants';
|
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<string, string> = {
|
|
||||||
PENDING: '待审核',
|
|
||||||
APPROVED: '已通过',
|
|
||||||
REJECTED: '已驳回',
|
|
||||||
};
|
|
||||||
|
|
||||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
|
||||||
const name = String(pkg.name ?? '').trim();
|
|
||||||
return name ? `name:${name}` : `idx:${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
|
||||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
|
||||||
|
|
||||||
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
|
||||||
const m = a.length;
|
|
||||||
const n = b.length;
|
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
||||||
for (let i = m - 1; i >= 0; i--) {
|
|
||||||
for (let j = n - 1; j >= 0; j--) {
|
|
||||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
|
||||||
let i = 0;
|
|
||||||
let j = 0;
|
|
||||||
while (i < m && j < n) {
|
|
||||||
if (a[i] === b[j]) {
|
|
||||||
raw.push({ type: 'equal', text: a[i] });
|
|
||||||
i++;
|
|
||||||
j++;
|
|
||||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
||||||
raw.push({ type: 'delete', text: a[i] });
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
raw.push({ type: 'insert', text: b[j] });
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
|
||||||
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
|
||||||
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
|
||||||
for (const s of raw) {
|
|
||||||
const last = merged[merged.length - 1];
|
|
||||||
if (last && last.type === s.type) last.text += s.text;
|
|
||||||
else merged.push({ ...s });
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldChanges(
|
|
||||||
live: StorePackageItemDto | StorePackageViewDto,
|
|
||||||
proposed: StorePackageItemDto | StorePackageViewDto,
|
|
||||||
): FieldChange[] {
|
|
||||||
const changes: FieldChange[] = [];
|
|
||||||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
|
||||||
const pushText = (label: string, oldV: string, newV: string) => {
|
|
||||||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
|
||||||
};
|
|
||||||
const pushValue = (label: string, oldV: string, newV: string) => {
|
|
||||||
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
|
||||||
};
|
|
||||||
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
|
||||||
pushText('套餐名称', text(live.name), text(proposed.name));
|
|
||||||
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
|
||||||
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
|
||||||
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
|
||||||
const liveImgs = normalizeStorePackageImageUrls(live);
|
|
||||||
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
|
||||||
if (imageSignature(live) !== imageSignature(proposed)) {
|
|
||||||
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
|
||||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
|
||||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
|
||||||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
|
||||||
const rows: Array<{
|
|
||||||
key: string;
|
|
||||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
|
||||||
live?: StorePackageViewDto;
|
|
||||||
proposed?: StorePackageItemDto;
|
|
||||||
changes?: FieldChange[];
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const l = liveMap.get(key);
|
|
||||||
const p = proposedMap.get(key);
|
|
||||||
if (l && !p) {
|
|
||||||
rows.push({ key, change: 'removed', live: l });
|
|
||||||
} else if (!l && p) {
|
|
||||||
rows.push({ key, change: 'added', proposed: p });
|
|
||||||
} else if (l && p) {
|
|
||||||
const changed =
|
|
||||||
l.price !== p.price ||
|
|
||||||
l.dishes !== p.dishes ||
|
|
||||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
|
||||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
|
||||||
imageSignature(l) !== imageSignature(p);
|
|
||||||
rows.push({
|
|
||||||
key,
|
|
||||||
change: changed ? 'changed' : 'unchanged',
|
|
||||||
live: l,
|
|
||||||
proposed: p,
|
|
||||||
changes: changed ? fieldChanges(l, p) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHANGE_LABELS = {
|
|
||||||
added: { text: '新增', color: 'green' },
|
|
||||||
removed: { text: '删除', color: 'red' },
|
|
||||||
changed: { text: '变更', color: 'orange' },
|
|
||||||
unchanged: { text: '未变', color: 'default' },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
|
||||||
const segs = diffText(oldText, newText);
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 2 }}>
|
|
||||||
<div style={{ lineHeight: 1.6 }}>
|
|
||||||
<Typography.Text type="secondary">原:</Typography.Text>
|
|
||||||
{segs
|
|
||||||
.filter((s) => s.type !== 'insert')
|
|
||||||
.map((s, idx) =>
|
|
||||||
s.type === 'delete' ? (
|
|
||||||
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
|
||||||
{s.text || '(空)'}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ lineHeight: 1.6 }}>
|
|
||||||
<Typography.Text type="secondary">新:</Typography.Text>
|
|
||||||
{segs
|
|
||||||
.filter((s) => s.type !== 'delete')
|
|
||||||
.map((s, idx) =>
|
|
||||||
s.type === 'insert' ? (
|
|
||||||
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
|
||||||
{s.text || '(空)'}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PackageDetailCard({
|
|
||||||
title,
|
|
||||||
pkg,
|
|
||||||
change,
|
|
||||||
changes,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
|
||||||
change?: keyof typeof CHANGE_LABELS;
|
|
||||||
changes?: FieldChange[];
|
|
||||||
}) {
|
|
||||||
const images = normalizeStorePackageImageUrls(pkg);
|
|
||||||
const meta = change ? CHANGE_LABELS[change] : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 12,
|
|
||||||
padding: 12,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space style={{ marginBottom: 8 }} wrap>
|
|
||||||
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
|
|
||||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<strong>{pkg.name}</strong>
|
|
||||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
|
||||||
</div>
|
|
||||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
|
||||||
{pkg.dishes || '—'}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
{pkg.usableTime ? (
|
|
||||||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
|
|
||||||
可用时间:{pkg.usableTime}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
{pkg.otherNotes ? (
|
|
||||||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
|
||||||
其他说明:{pkg.otherNotes}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
{images.length ? (
|
|
||||||
<Image.PreviewGroup>
|
|
||||||
<Space wrap size={8}>
|
|
||||||
{images.map((url) => (
|
|
||||||
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</Image.PreviewGroup>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
|
||||||
)}
|
|
||||||
{changes && changes.length ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
padding: 8,
|
|
||||||
background: '#fff7e6',
|
|
||||||
border: '1px solid #ffe7ba',
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
|
||||||
变更明细
|
|
||||||
</Typography.Text>
|
|
||||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
|
||||||
{changes.map((c) => (
|
|
||||||
<li key={c.label} style={{ marginBottom: 6 }}>
|
|
||||||
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
|
||||||
{c.kind === 'text' ? (
|
|
||||||
<TextDiff oldText={c.old} newText={c.now} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Typography.Text delete type="secondary">
|
|
||||||
{c.old}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text type="secondary"> → </Typography.Text>
|
|
||||||
<Typography.Text strong>{c.now}</Typography.Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorePackageAuditPanelProps = {
|
|
||||||
requestId: string;
|
|
||||||
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
|
|
||||||
showActions?: boolean;
|
|
||||||
onAudited?: () => void;
|
|
||||||
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
|
|
||||||
export default function StorePackageAuditPanel({
|
|
||||||
requestId,
|
|
||||||
showActions = true,
|
|
||||||
onAudited,
|
|
||||||
onDetailLoaded,
|
|
||||||
}: StorePackageAuditPanelProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
|
||||||
const [auditing, setAuditing] = useState(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
|
|
||||||
setDetail(data);
|
|
||||||
onDetailLoaded?.(data);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
|
|
||||||
setDetail(null);
|
|
||||||
onDetailLoaded?.(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [requestId]);
|
|
||||||
|
|
||||||
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
|
|
||||||
if (!detail) return;
|
|
||||||
setAuditing(true);
|
|
||||||
try {
|
|
||||||
await request(`/admin/store-package-audits/${detail.id}/audit`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
|
|
||||||
});
|
|
||||||
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
|
|
||||||
notifyPackageAuditChanged();
|
|
||||||
onAudited?.();
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
|
||||||
} finally {
|
|
||||||
setAuditing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && !detail) {
|
|
||||||
return <Typography.Text type="secondary">加载套餐变更…</Typography.Text>;
|
|
||||||
}
|
|
||||||
if (!detail) {
|
|
||||||
return <Typography.Text type="secondary">暂无套餐审核详情</Typography.Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
|
|
||||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
|
||||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
|
||||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
|
||||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
|
||||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
|
||||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
|
|
||||||
<Space wrap>
|
|
||||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
{showActions && detail.status === 'PENDING' ? (
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
|
|
||||||
通过套餐
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
loading={auditing}
|
|
||||||
onClick={() => {
|
|
||||||
setRejectReason('');
|
|
||||||
setRejectOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
驳回套餐
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
{detail.rejectReason ? (
|
|
||||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
|
||||||
</Typography.Text>
|
|
||||||
<Space wrap>
|
|
||||||
<Tag color="green">新增 {addedCount}</Tag>
|
|
||||||
<Tag color="red">删除 {removedCount}</Tag>
|
|
||||||
<Tag color="orange">变更 {changedCount}</Tag>
|
|
||||||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
<div className="admin-package-audit-cols">
|
|
||||||
<div className="admin-package-audit-col">
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
线上已审核套餐
|
|
||||||
</Typography.Title>
|
|
||||||
{(detail.livePackages ?? []).length ? (
|
|
||||||
(detail.livePackages ?? []).map((pkg, index) => (
|
|
||||||
<PackageDetailCard
|
|
||||||
key={`live-${packageKey(pkg, index)}`}
|
|
||||||
title={`套餐 ${index + 1}`}
|
|
||||||
pkg={pkg}
|
|
||||||
change={changeByKey.get(packageKey(pkg, index))}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-package-audit-col">
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
待审核套餐
|
|
||||||
</Typography.Title>
|
|
||||||
{(detail.packages ?? []).length ? (
|
|
||||||
(detail.packages ?? []).map((pkg, index) => (
|
|
||||||
<PackageDetailCard
|
|
||||||
key={`pending-${packageKey(pkg, index)}`}
|
|
||||||
title={`套餐 ${index + 1}`}
|
|
||||||
pkg={pkg}
|
|
||||||
change={changeByKey.get(packageKey(pkg, index))}
|
|
||||||
changes={changesByKey.get(packageKey(pkg, index))}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="驳回套餐变更"
|
|
||||||
open={rejectOpen}
|
|
||||||
confirmLoading={auditing}
|
|
||||||
onCancel={() => setRejectOpen(false)}
|
|
||||||
onOk={() => {
|
|
||||||
if (!rejectReason.trim()) {
|
|
||||||
message.warning('请填写驳回原因');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={3}
|
|
||||||
value={rejectReason}
|
|
||||||
placeholder="驳回原因"
|
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 供门店详情抽屉 extra 调用 */
|
|
||||||
export async function auditStorePackageRequest(
|
|
||||||
requestId: string,
|
|
||||||
action: 'APPROVE' | 'REJECT',
|
|
||||||
rejectReason?: string,
|
|
||||||
) {
|
|
||||||
await request(`/admin/store-package-audits/${requestId}/audit`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
|
|
||||||
});
|
|
||||||
notifyPackageAuditChanged();
|
|
||||||
}
|
|
||||||
@@ -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 { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
import { 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 attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
function attachPackageAuditBadge(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: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
|
children: attachPackageAuditBadge(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 attachAuditBadge(items: MenuProps['items'], pendingCount: number): Menu
|
|||||||
...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,14 +276,11 @@ 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 [auditPendingCount, setAuditPendingCount] = useState(0);
|
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
||||||
|
|
||||||
function refreshAuditPendingCount() {
|
function refreshPackagePendingCount() {
|
||||||
Promise.all([
|
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
||||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
|
.then((data) => setPackagePendingCount(data.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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,17 +289,13 @@ export default function AdminLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAuditPendingCount();
|
refreshPackagePendingCount();
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = () => refreshAuditPendingCount();
|
const onChanged = () => refreshPackagePendingCount();
|
||||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
return () => {
|
|
||||||
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
|
||||||
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -327,8 +320,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 attachAuditBadge(base, auditPendingCount);
|
return attachPackageAuditBadge(base, packagePendingCount);
|
||||||
}, [profile, auditPendingCount]);
|
}, [profile, packagePendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
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 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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,525 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
||||||
import { request } from '../lib/api';
|
|
||||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
|
||||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
|
||||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
|
||||||
|
|
||||||
export type BigScreenOrder = {
|
|
||||||
id: string;
|
|
||||||
orderNo: string;
|
|
||||||
payAmount: number;
|
|
||||||
items: string;
|
|
||||||
/** 展示用时间(付款成功时间) */
|
|
||||||
createdAt: string;
|
|
||||||
paidAt?: string;
|
|
||||||
userPhoneMasked: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AmountTier = 1 | 2 | 3;
|
|
||||||
|
|
||||||
const POLL_MS = 3000;
|
|
||||||
const ROW_MS = 2500;
|
|
||||||
/** 不足此条数时重复填充,保证滚动连贯 */
|
|
||||||
const MIN_SCROLL_ROWS = 10;
|
|
||||||
const FX_DURATION_MS = 10_000;
|
|
||||||
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
|
||||||
|
|
||||||
const CELEBRATE_SFX: Record<AmountTier, string> = {
|
|
||||||
1: sfxTier1,
|
|
||||||
2: sfxTier2,
|
|
||||||
3: sfxTier3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
|
||||||
let audioUnlocked = false;
|
|
||||||
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
|
||||||
|
|
||||||
function unlockCelebrateAudio() {
|
|
||||||
if (audioUnlocked || !sharedAudio) return;
|
|
||||||
sharedAudio.muted = true;
|
|
||||||
sharedAudio.src = CELEBRATE_SFX[1];
|
|
||||||
void sharedAudio
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
sharedAudio.pause();
|
|
||||||
sharedAudio.currentTime = 0;
|
|
||||||
sharedAudio.muted = false;
|
|
||||||
audioUnlocked = true;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* 等待下次手势 */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
|
||||||
if (!sharedAudio) return () => undefined;
|
|
||||||
try {
|
|
||||||
sharedAudio.pause();
|
|
||||||
sharedAudio.currentTime = 0;
|
|
||||||
sharedAudio.src = CELEBRATE_SFX[tier];
|
|
||||||
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
|
|
||||||
sharedAudio.muted = false;
|
|
||||||
void sharedAudio.play().catch(() => {
|
|
||||||
/* 未解锁时静默失败 */
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
sharedAudio.pause();
|
|
||||||
sharedAudio.currentTime = 0;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatOrderTime(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,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
function formatAmount(n: number): string {
|
|
||||||
const num = Number(n || 0);
|
|
||||||
// 先保留两位小数,再分割处理
|
|
||||||
const fixed = num.toFixed(2);
|
|
||||||
const [intStr, decStr] = fixed.split('.');
|
|
||||||
const intFormatted = Number(intStr).toLocaleString('zh-CN');
|
|
||||||
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function amountCssClass(payAmount: number, base: string): string {
|
|
||||||
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
|
|
||||||
return wide ? `${base} ${base}--6d` : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
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={amountCssClass(order.payAmount, 'big-screen-amount')}>
|
|
||||||
¥ {formatAmount(order.payAmount)}
|
|
||||||
</span>
|
|
||||||
<span className="big-screen-items">{order.items || '—'}</span>
|
|
||||||
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? 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 timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [order.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stop = playCelebrateSfx(tier);
|
|
||||||
return stop;
|
|
||||||
}, [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 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.002 : 0.003) * dt;
|
|
||||||
if (
|
|
||||||
p.rain &&
|
|
||||||
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
|
|
||||||
now - started < FX_DURATION_MS - 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={amountCssClass(displayAmount || order.payAmount, '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>{formatOrderTime(order.paidAt ?? 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 celebratedIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
const queueRef = useRef<BigScreenOrder[]>([]);
|
|
||||||
const celebratingRef = useRef(false);
|
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const dayKeyRef = useRef('');
|
|
||||||
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[]) => {
|
|
||||||
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
|
|
||||||
if (!toCelebrate.length) return;
|
|
||||||
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
|
|
||||||
const ranked = [...toCelebrate].sort((a, b) => {
|
|
||||||
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
|
|
||||||
if (td !== 0) return td;
|
|
||||||
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
|
|
||||||
});
|
|
||||||
queueRef.current.push(...ranked);
|
|
||||||
if (!celebratingRef.current) playNext();
|
|
||||||
},
|
|
||||||
[playNext],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
|
||||||
const now = new Date();
|
|
||||||
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
||||||
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
|
|
||||||
seenIdsRef.current = null;
|
|
||||||
celebratedIdsRef.current = new Set();
|
|
||||||
queueRef.current = [];
|
|
||||||
celebratingRef.current = false;
|
|
||||||
setCelebrate(null);
|
|
||||||
setPaused(false);
|
|
||||||
}
|
|
||||||
dayKeyRef.current = todayKey;
|
|
||||||
|
|
||||||
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
|
|
||||||
.then((d) => {
|
|
||||||
const list = d.items ?? [];
|
|
||||||
setItems(list);
|
|
||||||
setLoadError('');
|
|
||||||
const seen = seenIdsRef.current;
|
|
||||||
if (!seen) {
|
|
||||||
seenIdsRef.current = new Set(list.map((o) => o.id));
|
|
||||||
for (const o of list) celebratedIdsRef.current.add(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(() => {
|
|
||||||
unlockCelebrateAudio();
|
|
||||||
const unlock = () => unlockCelebrateAudio();
|
|
||||||
window.addEventListener('pointerdown', unlock, { once: true });
|
|
||||||
window.addEventListener('keydown', unlock, { once: true });
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('pointerdown', unlock);
|
|
||||||
window.removeEventListener('keydown', unlock);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 = Math.max(MIN_SCROLL_ROWS, 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-body" ref={viewportRef}>
|
|
||||||
{items.length === 0 ? null : (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{celebrate ? (
|
|
||||||
<CelebrateFx
|
|
||||||
key={celebrate.id}
|
|
||||||
order={celebrate}
|
|
||||||
onDone={playNext}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -17,11 +16,9 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
INVOICE_CATEGORY_LABELS,
|
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_KIND_LABELS,
|
||||||
INVOICE_STATUS_LABELS,
|
INVOICE_STATUS_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
type InvoiceCategory,
|
|
||||||
type InvoiceKind,
|
type InvoiceKind,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
type InvoiceTitleType,
|
type InvoiceTitleType,
|
||||||
@@ -37,7 +34,6 @@ type Row = {
|
|||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
invoiceCategory?: InvoiceCategory;
|
|
||||||
titleName: string;
|
titleName: string;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
@@ -57,7 +53,6 @@ type CreateFormValues = {
|
|||||||
orderNo: string;
|
orderNo: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
invoiceCategory?: InvoiceCategory;
|
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -68,22 +63,12 @@ type CreateFormValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const initialStatus = searchParams.get('status')?.trim() || '';
|
|
||||||
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
|
|
||||||
const [filterForm] = Form.useForm();
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
|
||||||
const init: Record<string, string> = {};
|
|
||||||
if (initialStatus) init.status = initialStatus;
|
|
||||||
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
|
|
||||||
return init;
|
|
||||||
});
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/invoices',
|
'/admin/invoices',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -96,29 +81,12 @@ export default function InvoicesPage() {
|
|||||||
const [createForm] = Form.useForm<CreateFormValues>();
|
const [createForm] = Form.useForm<CreateFormValues>();
|
||||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||||
const titleType = Form.useWatch('titleType', createForm);
|
const titleType = Form.useWatch('titleType', createForm);
|
||||||
const deepLinkOpenedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
filterForm.setFieldsValue({
|
|
||||||
status: filters.status || undefined,
|
|
||||||
invoiceNo: filters.invoiceNo || undefined,
|
|
||||||
});
|
|
||||||
}, [filterForm, filters.invoiceNo, filters.status]);
|
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetail(await request(`/admin/invoices/${id}`));
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
|
|
||||||
const first = data?.items?.[0];
|
|
||||||
if (first && String(first.invoiceNo) === initialInvoiceNo) {
|
|
||||||
deepLinkOpenedRef.current = true;
|
|
||||||
void openDetail(first.id);
|
|
||||||
}
|
|
||||||
}, [data, initialInvoiceNo, loading]);
|
|
||||||
|
|
||||||
async function issueWithFile(file: File) {
|
async function issueWithFile(file: File) {
|
||||||
if (!detail) return false;
|
if (!detail) return false;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -160,7 +128,6 @@ export default function InvoicesPage() {
|
|||||||
orderNo: values.orderNo.trim(),
|
orderNo: values.orderNo.trim(),
|
||||||
titleType: values.titleType,
|
titleType: values.titleType,
|
||||||
invoiceKind: values.invoiceKind,
|
invoiceKind: values.invoiceKind,
|
||||||
invoiceCategory: values.invoiceCategory,
|
|
||||||
titleName: values.titleName.trim(),
|
titleName: values.titleName.trim(),
|
||||||
taxNo: values.taxNo?.trim() || undefined,
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
addressPhone: values.addressPhone?.trim() || undefined,
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
@@ -191,17 +158,9 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '票种',
|
title: '票种',
|
||||||
width: 140,
|
width: 120,
|
||||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
width: 88,
|
|
||||||
render: (_, r) =>
|
|
||||||
r.invoiceCategory
|
|
||||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
|
||||||
: '—',
|
|
||||||
},
|
|
||||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -246,7 +205,6 @@ export default function InvoicesPage() {
|
|||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
titleType: 'PERSONAL',
|
titleType: 'PERSONAL',
|
||||||
invoiceKind: 'NORMAL',
|
invoiceKind: 'NORMAL',
|
||||||
invoiceCategory: 'LIQUOR',
|
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -255,7 +213,6 @@ export default function InvoicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
@@ -274,9 +231,6 @@ export default function InvoicesPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="invoiceNo" label="申请单号">
|
|
||||||
<Input allowClear placeholder="发票申请单号" />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -338,11 +292,6 @@ export default function InvoicesPage() {
|
|||||||
<Descriptions.Item label="发票类型">
|
<Descriptions.Item label="发票类型">
|
||||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="类型">
|
|
||||||
{detail.invoiceCategory
|
|
||||||
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
|
||||||
: '—'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
@@ -401,18 +350,6 @@ export default function InvoicesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name="invoiceCategory"
|
|
||||||
label="类型"
|
|
||||||
rules={[{ required: true, message: '请选择类型' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
|
||||||
value: k,
|
|
||||||
label: INVOICE_CATEGORY_LABELS[k],
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="titleType"
|
name="titleType"
|
||||||
label="抬头类型"
|
label="抬头类型"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -162,8 +162,6 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
const [logisticsForm] = Form.useForm();
|
const [logisticsForm] = Form.useForm();
|
||||||
@@ -174,7 +172,6 @@ export default function OrdersPage() {
|
|||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const deepLinkOpenedRef = useRef(false);
|
|
||||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||||
@@ -204,21 +201,6 @@ export default function OrdersPage() {
|
|||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialOrderNo) {
|
|
||||||
form.setFieldsValue({ orderNo: initialOrderNo });
|
|
||||||
}
|
|
||||||
}, [form, initialOrderNo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
|
||||||
const first = data?.items?.[0];
|
|
||||||
if (first && String(first.orderNo) === initialOrderNo) {
|
|
||||||
deepLinkOpenedRef.current = true;
|
|
||||||
void openDetail(first.id);
|
|
||||||
}
|
|
||||||
}, [data, initialOrderNo, loading]);
|
|
||||||
|
|
||||||
async function openRedeemDetail(redeemId: string) {
|
async function openRedeemDetail(redeemId: string) {
|
||||||
setRedeemDetailLoading(true);
|
setRedeemDetailLoading(true);
|
||||||
setRedeemDrawerOpen(true);
|
setRedeemDrawerOpen(true);
|
||||||
@@ -560,7 +542,6 @@ 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>
|
|
||||||
{canProxyOrder ? (
|
{canProxyOrder ? (
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
代下单
|
代下单
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import OssUpload from '../components/OssUpload';
|
|||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
@@ -21,22 +20,6 @@ type ProductDetailContentDto = {
|
|||||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SkuListRow = {
|
|
||||||
id: string;
|
|
||||||
skuCode: string;
|
|
||||||
barcode69: string;
|
|
||||||
specText: string;
|
|
||||||
price: number;
|
|
||||||
benefitAmount: number;
|
|
||||||
status: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
allowOnSitePickup?: boolean;
|
|
||||||
allowOnlinePurchase?: boolean;
|
|
||||||
allowCrossCityDelivery?: boolean;
|
|
||||||
soldBottles?: number;
|
|
||||||
virtual?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
@@ -46,15 +29,9 @@ type Row = {
|
|||||||
aromaType: string;
|
aromaType: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
price: number;
|
price: number;
|
||||||
priceMin?: number;
|
|
||||||
priceMax?: number;
|
|
||||||
soldBottles?: number;
|
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
skuCount?: number;
|
|
||||||
specEnabled?: boolean;
|
|
||||||
skus?: SkuListRow[];
|
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
@@ -236,33 +213,7 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FulfillmentTags(row: {
|
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
||||||
allowOnlinePurchase?: boolean;
|
|
||||||
allowCrossCityDelivery?: boolean;
|
|
||||||
allowOnSitePickup?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Space size={[0, 4]} wrap>
|
|
||||||
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
|
||||||
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
|
||||||
<Tag color="cyan">跨城</Tag>
|
|
||||||
) : null}
|
|
||||||
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
|
||||||
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BaseInfoFields({
|
|
||||||
mode,
|
|
||||||
form,
|
|
||||||
hideFulfillment,
|
|
||||||
}: {
|
|
||||||
mode: 'create' | 'edit';
|
|
||||||
form: FormInstance;
|
|
||||||
/** 多规格商品:履约只在规格 SKU 上编辑 */
|
|
||||||
hideFulfillment?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
@@ -270,13 +221,8 @@ function BaseInfoFields({
|
|||||||
<Form.Item label="SKU">
|
<Form.Item label="SKU">
|
||||||
<Input disabled placeholder="保存后自动生成" />
|
<Input disabled placeholder="保存后自动生成" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||||
name="barcode69"
|
<Input />
|
||||||
label="69码"
|
|
||||||
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
|
||||||
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
|
||||||
>
|
|
||||||
<Input placeholder="默认规格 69 码" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -304,47 +250,39 @@ function BaseInfoFields({
|
|||||||
<Form.Item name="sortOrder" label="排序">
|
<Form.Item name="sortOrder" label="排序">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{hideFulfillment ? (
|
<Form.Item
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
name="allowOnlinePurchase"
|
||||||
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
|
label="允许线上购买"
|
||||||
</Typography.Paragraph>
|
valuePropName="checked"
|
||||||
) : (
|
extra="配送到址(同城)"
|
||||||
<>
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||||
|
{() => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="allowOnlinePurchase"
|
name="allowCrossCityDelivery"
|
||||||
label="允许线上购买"
|
label="允许跨城配送"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="配送到址(同城)"
|
extra="须先开启线上购买"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checkedChildren="开"
|
checkedChildren="开"
|
||||||
unCheckedChildren="关"
|
unCheckedChildren="关"
|
||||||
onChange={(checked) => {
|
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
||||||
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
)}
|
||||||
{() => (
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||||
name="allowCrossCityDelivery"
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
label="允许跨城配送"
|
</Form.Item>
|
||||||
valuePropName="checked"
|
|
||||||
extra="须先开启线上购买"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checkedChildren="开"
|
|
||||||
unCheckedChildren="关"
|
|
||||||
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<VisibilityWhitelistFields form={form} />
|
<VisibilityWhitelistFields form={form} />
|
||||||
<Form.Item name="coverUrl" label="封面">
|
<Form.Item name="coverUrl" label="封面">
|
||||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||||
@@ -388,35 +326,12 @@ export default function ProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = useMemo(() => [
|
const columns: ColumnsType<Row> = useMemo(() => [
|
||||||
|
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
|
||||||
|
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||||
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
|
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
||||||
{
|
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||||
title: '累计销售',
|
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
dataIndex: 'soldBottles',
|
|
||||||
width: 110,
|
|
||||||
render: (v: number | undefined) => `累计 ${v ?? 0} 瓶`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '售价',
|
|
||||||
key: 'priceRange',
|
|
||||||
width: 120,
|
|
||||||
render: (_, row) => {
|
|
||||||
const min = row.priceMin ?? row.price;
|
|
||||||
const max = row.priceMax ?? row.price;
|
|
||||||
if (min === max) return `¥${min}`;
|
|
||||||
return `¥${min} ~ ¥${max}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '规格数',
|
|
||||||
dataIndex: 'skuCount',
|
|
||||||
width: 80,
|
|
||||||
render: (v: number | undefined, row) => {
|
|
||||||
const real = v ?? 0;
|
|
||||||
if (real > 0) return real;
|
|
||||||
return row.skus?.some((s) => s.virtual) ? 1 : 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||||
) },
|
) },
|
||||||
@@ -427,7 +342,23 @@ export default function ProductsPage() {
|
|||||||
render: (v: boolean) =>
|
render: (v: boolean) =>
|
||||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '履约',
|
||||||
|
key: 'fulfillment',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={[0, 4]} wrap>
|
||||||
|
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
||||||
|
<Tag color="cyan">跨城</Tag>
|
||||||
|
) : null}
|
||||||
|
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 120,
|
title: '操作', width: 120,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
@@ -440,7 +371,7 @@ export default function ProductsPage() {
|
|||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认删除该商品?"
|
title="确认删除该商品?"
|
||||||
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
||||||
okText="确认删除"
|
okText="确认删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
@@ -453,46 +384,6 @@ export default function ProductsPage() {
|
|||||||
},
|
},
|
||||||
], [detail, editForm]);
|
], [detail, editForm]);
|
||||||
|
|
||||||
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
|
|
||||||
{
|
|
||||||
title: '规格',
|
|
||||||
dataIndex: 'specText',
|
|
||||||
width: 140,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string, row) => (
|
|
||||||
<Space size={4}>
|
|
||||||
<span>{v || '默认'}</span>
|
|
||||||
{row.isDefault ? <Tag color="blue">默认</Tag> : null}
|
|
||||||
{row.virtual ? <Tag>未建SKU</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
|
|
||||||
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true },
|
|
||||||
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
|
|
||||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
|
|
||||||
{
|
|
||||||
title: '累计销售',
|
|
||||||
dataIndex: 'soldBottles',
|
|
||||||
width: 100,
|
|
||||||
render: (v: number | undefined) => `${v ?? 0} 瓶`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '履约',
|
|
||||||
key: 'fulfillment',
|
|
||||||
width: 160,
|
|
||||||
render: (_, row) => <FulfillmentTags {...row} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 90,
|
|
||||||
render: (s) => (
|
|
||||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
], []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
@@ -500,9 +391,7 @@ export default function ProductsPage() {
|
|||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称">
|
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||||
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -511,46 +400,17 @@ export default function ProductsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
||||||
rowKey="id"
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
className="admin-table-nowrap"
|
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
loading={loading}
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
scroll={{ x: 1080 }}
|
|
||||||
expandable={{
|
|
||||||
rowExpandable: () => true,
|
|
||||||
expandedRowRender: (row) => (
|
|
||||||
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
rowKey="id"
|
|
||||||
pagination={false}
|
|
||||||
columns={skuColumns}
|
|
||||||
dataSource={row.skus ?? []}
|
|
||||||
scroll={{ x: 980 }}
|
|
||||||
style={{ background: 'transparent' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
|
|
||||||
/>
|
|
||||||
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
|
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||||
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
|
||||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = buildProductPayload(v);
|
const payload = buildProductPayload(v);
|
||||||
if (multiSku) {
|
|
||||||
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
|
|
||||||
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
|
|
||||||
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
|
|
||||||
}
|
|
||||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
@@ -560,24 +420,13 @@ export default function ProductsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
||||||
key: 'base',
|
|
||||||
label: '基础信息',
|
|
||||||
children: (
|
|
||||||
<BaseInfoFields
|
|
||||||
mode="edit"
|
|
||||||
form={editForm}
|
|
||||||
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'detail',
|
key: 'detail',
|
||||||
label: '详情页',
|
label: '详情页',
|
||||||
@@ -588,23 +437,6 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'specs',
|
|
||||||
label: '规格与 SKU',
|
|
||||||
children: (
|
|
||||||
<ProductSpecsEditor
|
|
||||||
productId={String(detail.id)}
|
|
||||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
|
||||||
initialSkus={(detail.skus as never) ?? []}
|
|
||||||
onSaved={async () => {
|
|
||||||
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue(mapDetailToForm(d));
|
|
||||||
void reload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]} />
|
]} />
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
@@ -27,14 +26,8 @@ function maskPhone(phone: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const init: Record<string, string | boolean> = {};
|
|
||||||
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
|
|
||||||
return init;
|
|
||||||
});
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/redeem-records',
|
'/admin/redeem-records',
|
||||||
() => {
|
() => {
|
||||||
@@ -50,11 +43,6 @@ export default function RedeemRecordsPage() {
|
|||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const deepLinkOpenedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
|
|
||||||
}, [form, initialRedeemNo]);
|
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
@@ -66,15 +54,6 @@ export default function RedeemRecordsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
|
|
||||||
const first = data?.items?.[0];
|
|
||||||
if (first && String(first.redeemNo) === initialRedeemNo) {
|
|
||||||
deepLinkOpenedRef.current = true;
|
|
||||||
void openDetail(first.id);
|
|
||||||
}
|
|
||||||
}, [data, initialRedeemNo, loading]);
|
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '核销号',
|
title: '核销号',
|
||||||
|
|||||||
@@ -63,15 +63,10 @@ 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 initialStatus =
|
|
||||||
searchParams.get('status')?.trim() ||
|
|
||||||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
|
|
||||||
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: initialStatus,
|
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>(
|
||||||
@@ -102,9 +97,8 @@ 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, filters.storeId, form]);
|
}, [filters.kind, filters.status, 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}`)
|
||||||
@@ -365,7 +359,6 @@ 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,35 +3,29 @@ 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,
|
StorePackageAuditDetailDto,
|
||||||
StoreInfoChangeRequestDto,
|
|
||||||
StoreInfoChangeStatus,
|
|
||||||
StorePackageAuditSummaryDto,
|
StorePackageAuditSummaryDto,
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
StorePackageChangeStatus,
|
StorePackageChangeStatus,
|
||||||
|
StorePackageItemDto,
|
||||||
|
StorePackageViewDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import {
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
|
||||||
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
|
||||||
} 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';
|
||||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
PENDING: '待审核',
|
PENDING: '待审核',
|
||||||
@@ -39,338 +33,266 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
|||||||
REJECTED: '已驳回',
|
REJECTED: '已驳回',
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtFieldValue(field: string, v: unknown): string {
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
if (v == null || String(v).trim() === '') return '(空)';
|
const name = String(pkg.name ?? '').trim();
|
||||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
return String(v);
|
|
||||||
}
|
|
||||||
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
|
||||||
return `${v.length} 张`;
|
|
||||||
}
|
|
||||||
if (field === 'coverUrl') return '见对比图';
|
|
||||||
return String(v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoChangeImageDiff({
|
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||||
field,
|
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||||
live,
|
}
|
||||||
proposed,
|
|
||||||
}: {
|
|
||||||
field: string;
|
|
||||||
live: unknown;
|
|
||||||
proposed: unknown;
|
|
||||||
}) {
|
|
||||||
const liveUrls =
|
|
||||||
field === 'coverUrl'
|
|
||||||
? [String(live || '').trim()].filter(Boolean)
|
|
||||||
: Array.isArray(live)
|
|
||||||
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const proposedUrls =
|
|
||||||
field === 'coverUrl'
|
|
||||||
? [String(proposed || '').trim()].filter(Boolean)
|
|
||||||
: Array.isArray(proposed)
|
|
||||||
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
|
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||||
|
|
||||||
|
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
|
||||||
|
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||||
|
for (let i = m - 1; i >= 0; i--) {
|
||||||
|
for (let j = n - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < m && j < n) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
raw.push({ type: 'equal', text: a[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
raw.push({ type: 'delete', text: a[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
raw.push({ type: 'insert', text: b[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||||
|
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||||
|
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
for (const s of raw) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && last.type === s.type) last.text += s.text;
|
||||||
|
else merged.push({ ...s });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
|
||||||
|
function fieldChanges(
|
||||||
|
live: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
): FieldChange[] {
|
||||||
|
const changes: FieldChange[] = [];
|
||||||
|
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||||
|
const pushText = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||||
|
};
|
||||||
|
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||||
|
};
|
||||||
|
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||||
|
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||||
|
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||||
|
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||||
|
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||||
|
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||||
|
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||||
|
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||||
|
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||||
|
const rows: Array<{
|
||||||
|
key: string;
|
||||||
|
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||||
|
live?: StorePackageViewDto;
|
||||||
|
proposed?: StorePackageItemDto;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const l = liveMap.get(key);
|
||||||
|
const p = proposedMap.get(key);
|
||||||
|
if (l && !p) {
|
||||||
|
rows.push({ key, change: 'removed', live: l });
|
||||||
|
} else if (!l && p) {
|
||||||
|
rows.push({ key, change: 'added', proposed: p });
|
||||||
|
} else if (l && p) {
|
||||||
|
const changed =
|
||||||
|
l.price !== p.price ||
|
||||||
|
l.dishes !== p.dishes ||
|
||||||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||||
|
imageSignature(l) !== imageSignature(p);
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
change: changed ? 'changed' : 'unchanged',
|
||||||
|
live: l,
|
||||||
|
proposed: p,
|
||||||
|
changes: changed ? fieldChanges(l, p) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_LABELS = {
|
||||||
|
added: { text: '新增', color: 'green' },
|
||||||
|
removed: { text: '删除', color: 'red' },
|
||||||
|
changed: { text: '变更', color: 'orange' },
|
||||||
|
unchanged: { text: '未变', color: 'default' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
|
||||||
|
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||||
|
const segs = diffText(oldText, newText);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
<div style={{ marginTop: 2 }}>
|
||||||
<div>
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
<Typography.Text type="secondary">原:</Typography.Text>
|
||||||
变更前
|
{segs
|
||||||
</Typography.Text>
|
.filter((s) => s.type !== 'insert')
|
||||||
{liveUrls.length ? (
|
.map((s, idx) =>
|
||||||
<Image.PreviewGroup>
|
s.type === 'delete' ? (
|
||||||
<Space wrap size={8}>
|
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||||
{liveUrls.map((url) => (
|
{s.text || '(空)'}
|
||||||
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
</Typography.Text>
|
||||||
))}
|
) : (
|
||||||
</Space>
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
</Image.PreviewGroup>
|
),
|
||||||
) : (
|
)}
|
||||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
<Typography.Text type="secondary">新:</Typography.Text>
|
||||||
变更后
|
{segs
|
||||||
</Typography.Text>
|
.filter((s) => s.type !== 'delete')
|
||||||
{proposedUrls.length ? (
|
.map((s, idx) =>
|
||||||
<Image.PreviewGroup>
|
s.type === 'insert' ? (
|
||||||
<Space wrap size={8}>
|
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||||
{proposedUrls.map((url) => (
|
{s.text || '(空)'}
|
||||||
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
</Typography.Text>
|
||||||
))}
|
) : (
|
||||||
</Space>
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
</Image.PreviewGroup>
|
),
|
||||||
) : (
|
)}
|
||||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoChangeAuditPanel({
|
function PackageDetailCard({
|
||||||
initialRequestId,
|
title,
|
||||||
|
pkg,
|
||||||
|
change,
|
||||||
|
changes,
|
||||||
}: {
|
}: {
|
||||||
initialRequestId?: string | null;
|
title?: string;
|
||||||
|
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||||
|
change?: keyof typeof CHANGE_LABELS;
|
||||||
|
changes?: FieldChange[];
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
|
const meta = change ? CHANGE_LABELS[change] : null;
|
||||||
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}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? 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 (
|
return (
|
||||||
<div>
|
<div
|
||||||
<Space style={{ marginBottom: 16 }}>
|
style={{
|
||||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
marginBottom: 12,
|
||||||
<Button
|
padding: 12,
|
||||||
key={s || 'all'}
|
border: '1px solid #f0f0f0',
|
||||||
type={status === s ? 'primary' : 'default'}
|
borderRadius: 8,
|
||||||
onClick={() => setStatus(s)}
|
background: '#fafafa',
|
||||||
>
|
}}
|
||||||
{s === 'PENDING' ? (
|
>
|
||||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
<Space style={{ marginBottom: 8 }} wrap>
|
||||||
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
|
{title ? (
|
||||||
</Badge>
|
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||||
) : 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={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
|
||||||
>
|
|
||||||
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
|
||||||
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
|
|
||||||
) : (
|
|
||||||
<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}
|
) : null}
|
||||||
</Drawer>
|
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||||
|
</Space>
|
||||||
<Modal
|
<div style={{ marginBottom: 8 }}>
|
||||||
title="驳回信息变更"
|
<strong>{pkg.name}</strong>
|
||||||
open={rejectOpen}
|
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||||
onCancel={() => setRejectOpen(false)}
|
</div>
|
||||||
onOk={() => {
|
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
if (!activeId) return;
|
{pkg.dishes || '—'}
|
||||||
if (!rejectReason.trim()) {
|
</Typography.Paragraph>
|
||||||
message.warning('请填写驳回原因');
|
{pkg.usableTime ? (
|
||||||
return;
|
<Typography.Paragraph
|
||||||
}
|
type="secondary"
|
||||||
void audit(activeId, 'REJECT', rejectReason.trim());
|
className="admin-package-audit-text"
|
||||||
setRejectOpen(false);
|
style={{ marginBottom: 4 }}
|
||||||
}}
|
>
|
||||||
>
|
可用时间:{pkg.usableTime}
|
||||||
<Input.TextArea
|
</Typography.Paragraph>
|
||||||
rows={3}
|
) : null}
|
||||||
value={rejectReason}
|
{pkg.otherNotes ? (
|
||||||
placeholder="驳回原因"
|
<Typography.Paragraph
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
type="secondary"
|
||||||
/>
|
className="admin-package-audit-text"
|
||||||
</Modal>
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
其他说明:{pkg.otherNotes}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{images.map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||||
|
)}
|
||||||
|
{changes && changes.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffe7ba',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||||
|
变更明细
|
||||||
|
</Typography.Text>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||||
|
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||||
|
{c.kind === 'text' ? (
|
||||||
|
<TextDiff oldText={c.old} newText={c.now} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Text delete type="secondary">
|
||||||
|
{c.old}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> → </Typography.Text>
|
||||||
|
<Typography.Text strong>{c.now}</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,8 +308,8 @@ export default function StorePackageAuditsPage() {
|
|||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||||
|
|
||||||
async function reload(nextPage = page, nextStatus = status) {
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -416,23 +338,27 @@ export default function StorePackageAuditsPage() {
|
|||||||
void reload(1, status);
|
void reload(1, status);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
// 从门店详情 / 门店列表跳转过来时,带 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) {
|
if (rid) void openDetail(rid);
|
||||||
setActiveRequestId(rid);
|
|
||||||
setDetailOpen(true);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setActiveRequestId(id);
|
|
||||||
setDrawerTitle('套餐变更详情');
|
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
try {
|
||||||
|
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${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) {
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
@@ -452,6 +378,14 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||||
|
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||||
|
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||||
|
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||||
|
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||||
|
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||||
|
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||||
|
|
||||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
{
|
{
|
||||||
@@ -474,7 +408,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="link" onClick={() => openDetail(row.id)}>
|
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||||
查看变更
|
查看变更
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'PENDING' ? (
|
{row.status === 'PENDING' ? (
|
||||||
@@ -504,72 +438,120 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
<Typography.Title level={4}>套餐变更审核</Typography.Title>
|
||||||
<Tabs
|
<Space style={{ marginBottom: 16 }}>
|
||||||
activeKey={activeTab}
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
onChange={setActiveTab}
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
items={[
|
{s === 'PENDING' ? (
|
||||||
{
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||||
key: 'package',
|
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||||
label: '套餐审核',
|
</Badge>
|
||||||
children: (
|
) : s ? (
|
||||||
<>
|
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)}>
|
)}
|
||||||
{s === 'PENDING' ? (
|
</Button>
|
||||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
))}
|
||||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
</Space>
|
||||||
</Badge>
|
<Table
|
||||||
) : s ? (
|
rowKey="id"
|
||||||
HQ_PACKAGE_STATUS_LABELS[s]
|
loading={loading}
|
||||||
) : (
|
columns={columns}
|
||||||
'全部'
|
dataSource={items}
|
||||||
)}
|
pagination={{
|
||||||
</Button>
|
current: page,
|
||||||
))}
|
total,
|
||||||
</Space>
|
pageSize: 20,
|
||||||
<Table
|
onChange: (p) => void reload(p, status),
|
||||||
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
|
||||||
title={drawerTitle}
|
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||||
width={880}
|
width={880}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
destroyOnClose
|
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
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{activeRequestId ? (
|
{detailLoading ? (
|
||||||
<StorePackageAuditPanel
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
requestId={activeRequestId}
|
) : detail ? (
|
||||||
onAudited={() => {
|
<>
|
||||||
setDetailOpen(false);
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
void reload(page, status);
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
}}
|
<Typography.Text type="secondary">
|
||||||
onDetailLoaded={(d) => {
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
</Typography.Text>
|
||||||
}}
|
</Space>
|
||||||
/>
|
{detail.rejectReason ? (
|
||||||
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="green">新增 {addedCount}</Tag>
|
||||||
|
<Tag color="red">删除 {removedCount}</Tag>
|
||||||
|
<Tag color="orange">变更 {changedCount}</Tag>
|
||||||
|
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<div className="admin-package-audit-cols">
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
线上已审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.livePackages ?? []).length ? (
|
||||||
|
(detail.livePackages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`live-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
待审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.packages ?? []).length ? (
|
||||||
|
(detail.packages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`pending-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
changes={changesByKey.get(packageKey(pkg, index))}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
@@ -597,4 +579,3 @@ export default function StorePackageAuditsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -47,9 +46,6 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
|||||||
import AdminStorePackagesSection, {
|
import AdminStorePackagesSection, {
|
||||||
type AdminStorePackagesHandle,
|
type AdminStorePackagesHandle,
|
||||||
} from '../components/AdminStorePackagesSection';
|
} from '../components/AdminStorePackagesSection';
|
||||||
import StorePackageAuditPanel, {
|
|
||||||
auditStorePackageRequest,
|
|
||||||
} from '../components/StorePackageAuditPanel';
|
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -196,8 +192,6 @@ 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 };
|
||||||
@@ -246,8 +240,6 @@ export default function StoresPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialCityId = searchParams.get('cityId') ?? '';
|
const initialCityId = searchParams.get('cityId') ?? '';
|
||||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||||
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
|
||||||
const initialStoreId = searchParams.get('storeId') ?? '';
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
@@ -255,7 +247,6 @@ export default function StoresPage() {
|
|||||||
const init: Record<string, string | boolean> = {};
|
const init: Record<string, string | boolean> = {};
|
||||||
if (initialCityId) init.cityId = initialCityId;
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||||
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
|
|
||||||
return init;
|
return init;
|
||||||
});
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||||
@@ -277,11 +268,7 @@ export default function StoresPage() {
|
|||||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [detailTab, setDetailTab] = useState('basic');
|
|
||||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||||
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
|
|
||||||
const [packageRejectReason, setPackageRejectReason] = useState('');
|
|
||||||
const [packageAuditing, setPackageAuditing] = useState(false);
|
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [auditing, setAuditing] = useState(false);
|
const [auditing, setAuditing] = useState(false);
|
||||||
@@ -297,7 +284,6 @@ export default function StoresPage() {
|
|||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||||
const deepLinkStoreOpenedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -308,12 +294,6 @@ export default function StoresPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialAuditStatus) {
|
|
||||||
form.setFieldsValue({ auditStatus: initialAuditStatus });
|
|
||||||
}
|
|
||||||
}, [form, initialAuditStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cityId = searchParams.get('cityId') ?? '';
|
const cityId = searchParams.get('cityId') ?? '';
|
||||||
const partnerId = searchParams.get('partnerId') ?? '';
|
const partnerId = searchParams.get('partnerId') ?? '';
|
||||||
@@ -366,18 +346,13 @@ export default function StoresPage() {
|
|||||||
.map((n) => ({ value: n.id, label: n.name }));
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
}, [categoryTree, editCategoryParentId]);
|
}, [categoryTree, editCategoryParentId]);
|
||||||
|
|
||||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
async function openStoreDetail(row: StoreRow) {
|
||||||
const [d, cats] = await Promise.all([
|
const [d, cats] = await Promise.all([
|
||||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||||
]);
|
]);
|
||||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||||
setDetail({
|
setDetail(d);
|
||||||
...d,
|
|
||||||
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
|
|
||||||
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
|
|
||||||
});
|
|
||||||
setDetailTab(opts?.tab || 'basic');
|
|
||||||
const category = d.category && typeof d.category === 'object'
|
const category = d.category && typeof d.category === 'object'
|
||||||
? (d.category as { id?: string; parentId?: string | null })
|
? (d.category as { id?: string; parentId?: string | null })
|
||||||
: null;
|
: null;
|
||||||
@@ -464,19 +439,6 @@ export default function StoresPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
|
|
||||||
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
|
||||||
if (row) {
|
|
||||||
deepLinkStoreOpenedRef.current = true;
|
|
||||||
void openStoreDetail(row);
|
|
||||||
} else if (data && (data.items?.length ?? 0) >= 0) {
|
|
||||||
// 列表无该店时仍尝试直拉详情
|
|
||||||
deepLinkStoreOpenedRef.current = true;
|
|
||||||
void openStoreDetail({ id: initialStoreId } as StoreRow);
|
|
||||||
}
|
|
||||||
}, [data, initialStoreId, loading]);
|
|
||||||
|
|
||||||
async function saveStoreDetail() {
|
async function saveStoreDetail() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -805,46 +767,30 @@ export default function StoresPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 280,
|
width: 220,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} wrap>
|
<Space size={0} wrap>
|
||||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||||
{row.pendingPackageAuditId ? (
|
{row.pendingPackageAuditId ? (
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
|
|
||||||
>
|
|
||||||
审核套餐
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{row.pendingInfoChangeId ? (
|
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
|
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||||
>
|
>
|
||||||
审核信息
|
审核套餐
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
|
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||||
>
|
>
|
||||||
对比
|
对比
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
|
|
||||||
>
|
|
||||||
提现
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -930,7 +876,7 @@ export default function StoresPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||||
@@ -967,41 +913,6 @@ export default function StoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{detail.pendingPackageAuditId ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
loading={packageAuditing}
|
|
||||||
onClick={async () => {
|
|
||||||
setPackageAuditing(true);
|
|
||||||
try {
|
|
||||||
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
|
|
||||||
message.success('套餐已通过');
|
|
||||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
|
||||||
void reload();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '套餐审核失败');
|
|
||||||
} finally {
|
|
||||||
setPackageAuditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
通过套餐
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
ghost
|
|
||||||
loading={packageAuditing}
|
|
||||||
onClick={() => {
|
|
||||||
setPackageRejectReason('');
|
|
||||||
setPackageRejectOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
驳回套餐
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
onChange={async (status) => {
|
onChange={async (status) => {
|
||||||
@@ -1016,8 +927,6 @@ export default function StoresPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={detailTab}
|
|
||||||
onChange={setDetailTab}
|
|
||||||
destroyInactiveTabPane={false}
|
destroyInactiveTabPane={false}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -1266,76 +1175,15 @@ export default function StoresPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
label: detail.pendingPackageAuditId ? (
|
label: '套餐',
|
||||||
<Badge dot offset={[4, 0]}>
|
|
||||||
套餐
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
'套餐'
|
|
||||||
),
|
|
||||||
forceRender: true,
|
forceRender: true,
|
||||||
children: (
|
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||||
<>
|
|
||||||
{detail.pendingPackageAuditId ? (
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
待审核套餐变更
|
|
||||||
</Typography.Title>
|
|
||||||
<StorePackageAuditPanel
|
|
||||||
requestId={String(detail.pendingPackageAuditId)}
|
|
||||||
showActions
|
|
||||||
onAudited={() => {
|
|
||||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
|
||||||
void reload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Modal
|
|
||||||
title="驳回套餐变更"
|
|
||||||
open={packageRejectOpen}
|
|
||||||
confirmLoading={packageAuditing}
|
|
||||||
onCancel={() => setPackageRejectOpen(false)}
|
|
||||||
onOk={async () => {
|
|
||||||
if (!detail?.pendingPackageAuditId) return;
|
|
||||||
if (!packageRejectReason.trim()) {
|
|
||||||
message.warning('请填写驳回原因');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPackageAuditing(true);
|
|
||||||
try {
|
|
||||||
await auditStorePackageRequest(
|
|
||||||
String(detail.pendingPackageAuditId),
|
|
||||||
'REJECT',
|
|
||||||
packageRejectReason.trim(),
|
|
||||||
);
|
|
||||||
message.success('套餐已驳回');
|
|
||||||
setPackageRejectOpen(false);
|
|
||||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
|
||||||
void reload();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '套餐驳回失败');
|
|
||||||
} finally {
|
|
||||||
setPackageAuditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={3}
|
|
||||||
value={packageRejectReason}
|
|
||||||
placeholder="驳回原因"
|
|
||||||
onChange={(e) => setPackageRejectReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
<Modal
|
||||||
title="新建门店"
|
title="新建门店"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -249,70 +249,15 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
const collapseItems = useMemo(() => {
|
const collapseItems = useMemo(() => {
|
||||||
if (!meta) return [];
|
if (!meta) return [];
|
||||||
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
return meta.groups.map((group) => ({
|
||||||
global: '全局',
|
key: group.key,
|
||||||
home: '首页',
|
label: group.label,
|
||||||
stores: '门店列表',
|
forceRender: true,
|
||||||
storeDetail: '门店详情',
|
children: (
|
||||||
benefit: '权益页',
|
<div style={{ maxWidth: 720 }}>
|
||||||
mine: '我的',
|
{meta.fields
|
||||||
productDetail: '商品详情',
|
.filter((f) => f.group === group.key)
|
||||||
orderDetail: '订单详情',
|
.map((f) =>
|
||||||
};
|
|
||||||
const SHARE_SUBGROUP_ORDER = [
|
|
||||||
'global',
|
|
||||||
'home',
|
|
||||||
'stores',
|
|
||||||
'storeDetail',
|
|
||||||
'benefit',
|
|
||||||
'mine',
|
|
||||||
'productDetail',
|
|
||||||
'orderDetail',
|
|
||||||
];
|
|
||||||
|
|
||||||
return meta.groups.map((group) => {
|
|
||||||
const fields = meta.fields.filter((f) => f.group === group.key);
|
|
||||||
const hasSubgroups = fields.some((f) => f.subgroup);
|
|
||||||
|
|
||||||
let children: ReactNode;
|
|
||||||
if (group.key === 'wechat_mini_share' && hasSubgroups) {
|
|
||||||
const bySub = new Map<string, SystemConfigFieldMeta[]>();
|
|
||||||
for (const f of fields) {
|
|
||||||
const sk = f.subgroup || 'global';
|
|
||||||
if (!bySub.has(sk)) bySub.set(sk, []);
|
|
||||||
bySub.get(sk)!.push(f);
|
|
||||||
}
|
|
||||||
const orderedKeys = [
|
|
||||||
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
|
|
||||||
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
|
|
||||||
];
|
|
||||||
children = (
|
|
||||||
<Collapse
|
|
||||||
defaultActiveKey={[]}
|
|
||||||
items={orderedKeys.map((sk) => ({
|
|
||||||
key: sk,
|
|
||||||
label: SHARE_SUBGROUP_LABELS[sk] || sk,
|
|
||||||
forceRender: true,
|
|
||||||
children: (
|
|
||||||
<div style={{ maxWidth: 720 }}>
|
|
||||||
{(bySub.get(sk) ?? []).map((f) =>
|
|
||||||
renderField(
|
|
||||||
f,
|
|
||||||
meta.configuredSecrets,
|
|
||||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
|
||||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
|
||||||
) : undefined,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
children = (
|
|
||||||
<div style={{ maxWidth: 720 }}>
|
|
||||||
{fields.map((f) =>
|
|
||||||
renderField(
|
renderField(
|
||||||
f,
|
f,
|
||||||
meta.configuredSecrets,
|
meta.configuredSecrets,
|
||||||
@@ -321,17 +266,9 @@ export default function SystemSettingsPage() {
|
|||||||
) : undefined,
|
) : undefined,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
}
|
}));
|
||||||
|
|
||||||
return {
|
|
||||||
key: group.key,
|
|
||||||
label: group.label,
|
|
||||||
forceRender: true,
|
|
||||||
children,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [meta, mockSmsEnabled, loading]);
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
@@ -22,13 +21,8 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import {
|
import {
|
||||||
WECOM_PUSH_CONDITION_GROUPS,
|
WECOM_PUSH_CONDITION_GROUPS,
|
||||||
WECOM_PUSH_CONDITION_LABELS,
|
WECOM_PUSH_CONDITION_LABELS,
|
||||||
WECOM_TEMPLATE_EVENT_KEYS,
|
|
||||||
WECOM_TEMPLATE_EVENT_LABELS,
|
|
||||||
WECOM_TEMPLATE_PLACEHOLDERS,
|
|
||||||
type WecomMessagePushDto,
|
type WecomMessagePushDto,
|
||||||
type WecomPushCondition,
|
type WecomPushCondition,
|
||||||
type WecomPushTemplateDto,
|
|
||||||
type WecomTemplateEventKey,
|
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
@@ -84,7 +78,7 @@ function WecomPushConditionPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PushRoutesTab() {
|
export default function WecomMessagePushesPage() {
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [form] = Form.useForm<FormValues>();
|
const [form] = Form.useForm<FormValues>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -263,9 +257,11 @@ function PushRoutesTab() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
|
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
||||||
|
.env 中的 Webhook URL。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -336,10 +332,10 @@ function PushRoutesTab() {
|
|||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
<Input placeholder="如:业务待办通知群" />
|
<Input placeholder="如:运营告警" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
<OssUpload />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
@@ -404,208 +400,6 @@ function PushRoutesTab() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplatesTab() {
|
|
||||||
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
|
|
||||||
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [preview, setPreview] = useState('');
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
|
|
||||||
setTemplates(list);
|
|
||||||
setEventKey((prev) => {
|
|
||||||
const current = list.find((t) => t.eventKey === prev) ?? list[0];
|
|
||||||
if (current) {
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: current.title,
|
|
||||||
body: current.body,
|
|
||||||
handleLabel: current.handleLabel,
|
|
||||||
});
|
|
||||||
return current.eventKey;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载模板失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [form]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function selectEvent(key: WecomTemplateEventKey) {
|
|
||||||
setEventKey(key);
|
|
||||||
const row = templates.find((t) => t.eventKey === key);
|
|
||||||
if (row) {
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: row.title,
|
|
||||||
body: row.body,
|
|
||||||
handleLabel: row.handleLabel,
|
|
||||||
});
|
|
||||||
setPreview('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const updated = await request<WecomPushTemplateDto>(
|
|
||||||
`/admin/wecom-push-templates/${eventKey}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(values),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
message.success('模板已保存');
|
|
||||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reset() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const updated = await request<WecomPushTemplateDto>(
|
|
||||||
`/admin/wecom-push-templates/${eventKey}/reset`,
|
|
||||||
{ method: 'POST', body: '{}' },
|
|
||||||
);
|
|
||||||
message.success('已恢复默认文案');
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: updated.title,
|
|
||||||
body: updated.body,
|
|
||||||
handleLabel: updated.handleLabel,
|
|
||||||
});
|
|
||||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
|
||||||
setPreview('');
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '恢复失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testSend() {
|
|
||||||
setTesting(true);
|
|
||||||
try {
|
|
||||||
const res = await request<{ ok: boolean; message: string; preview: string }>(
|
|
||||||
`/admin/wecom-push-templates/${eventKey}/test`,
|
|
||||||
{ method: 'POST', body: '{}' },
|
|
||||||
);
|
|
||||||
setPreview(res.preview || '');
|
|
||||||
message.success(res.message || '已发送');
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '测试失败');
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Typography.Paragraph type="secondary">
|
|
||||||
每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'}
|
|
||||||
;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
|
|
||||||
<Space align="start" style={{ width: '100%' }} size={24} wrap>
|
|
||||||
<div style={{ minWidth: 200 }}>
|
|
||||||
<Typography.Text strong>事件</Typography.Text>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
|
|
||||||
<div key={k} style={{ marginBottom: 4 }}>
|
|
||||||
<Button
|
|
||||||
type={k === eventKey ? 'primary' : 'text'}
|
|
||||||
size="small"
|
|
||||||
onClick={() => selectEvent(k)}
|
|
||||||
block
|
|
||||||
style={{ textAlign: 'left' }}
|
|
||||||
>
|
|
||||||
{WECOM_TEMPLATE_EVENT_LABELS[k]}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 360 }}>
|
|
||||||
<Form form={form} layout="vertical" disabled={loading}>
|
|
||||||
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="body"
|
|
||||||
label="正文(企微 markdown)"
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
|
|
||||||
>
|
|
||||||
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="去处理" />
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
|
|
||||||
<Button loading={saving}>恢复默认</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
<Button loading={testing} onClick={() => void testSend()}>
|
|
||||||
用示例数据测试推送
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{preview ? (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Typography.Text strong>预览</Typography.Text>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
padding: 12,
|
|
||||||
background: '#f5f5f5',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{preview}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WecomMessagePushesPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
|
|
||||||
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module '*.mp3' {
|
|
||||||
const src: string;
|
|
||||||
export default src;
|
|
||||||
}
|
|
||||||
@@ -17,11 +17,6 @@ type Props = {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
/** 计量单位文案,如「张」「个」 */
|
/** 计量单位文案,如「张」「个」 */
|
||||||
unit?: string;
|
unit?: string;
|
||||||
/**
|
|
||||||
* stack:缩略图 + 下方独立上传按钮(合同等)
|
|
||||||
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
|
|
||||||
*/
|
|
||||||
variant?: 'stack' | 'grid';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
function isCancelError(msg: string): boolean {
|
||||||
@@ -47,7 +42,6 @@ export default function MultiOssUploadField({
|
|||||||
label,
|
label,
|
||||||
accept = 'image/*',
|
accept = 'image/*',
|
||||||
unit = '张',
|
unit = '张',
|
||||||
variant = 'stack',
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const pickingRef = useRef(false);
|
const pickingRef = useRef(false);
|
||||||
@@ -59,7 +53,6 @@ export default function MultiOssUploadField({
|
|||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
const remaining = Math.max(0, maxCount - urls.length);
|
const remaining = Math.max(0, maxCount - urls.length);
|
||||||
const inWechat = isWechatEnv();
|
const inWechat = isWechatEnv();
|
||||||
const isGrid = variant === 'grid';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
urlsRef.current = urls;
|
urlsRef.current = urls;
|
||||||
@@ -133,78 +126,11 @@ export default function MultiOssUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPicker() {
|
|
||||||
if (inWechat) void pickWechat();
|
|
||||||
else inputRef.current?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
onChange?.(urls.filter((_, i) => i !== index));
|
onChange?.(urls.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumb = (url: string, index: number) => (
|
|
||||||
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
|
|
||||||
{isPdf(url) ? (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
width: isGrid ? '100%' : 88,
|
|
||||||
height: isGrid ? '100%' : 88,
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '1px solid rgba(0,0,0,0.08)',
|
|
||||||
background: '#f7f7f7',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
|
||||||
description
|
|
||||||
</span>
|
|
||||||
<span className="text-muted">PDF</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: isGrid ? '100%' : 88,
|
|
||||||
height: isGrid ? '100%' : 88,
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: isGrid ? 12 : 8,
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!disabled ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-packages-remove"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 2,
|
|
||||||
right: 2,
|
|
||||||
margin: 0,
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'rgba(0,0,0,0.55)',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onClick={() => removeAt(index)}
|
|
||||||
>
|
|
||||||
删
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-oss-upload">
|
<div className="partner-oss-upload">
|
||||||
<input
|
<input
|
||||||
@@ -220,49 +146,86 @@ export default function MultiOssUploadField({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isGrid ? (
|
{urls.length > 0 ? (
|
||||||
<div className="partner-upload-grid">
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
{urls.map((url, index) => thumb(url, index))}
|
{urls.map((url, index) => (
|
||||||
{remaining > 0 && !disabled ? (
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||||
<button
|
{isPdf(url) ? (
|
||||||
type="button"
|
<a
|
||||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
href={url}
|
||||||
disabled={uploading}
|
target="_blank"
|
||||||
onClick={openPicker}
|
rel="noreferrer"
|
||||||
aria-label={uploading ? '上传中' : `添加${unit}(${urls.length}/${maxCount})`}
|
style={{
|
||||||
>
|
display: 'flex',
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
width: 88,
|
||||||
{uploading ? 'hourglass_top' : 'add'}
|
height: 88,
|
||||||
</span>
|
flexDirection: 'column',
|
||||||
</button>
|
alignItems: 'center',
|
||||||
) : null}
|
justifyContent: 'center',
|
||||||
</div>
|
gap: 2,
|
||||||
) : (
|
borderRadius: 8,
|
||||||
<>
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
{urls.length > 0 ? (
|
background: '#f7f7f7',
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
fontSize: 12,
|
||||||
{urls.map((url, index) => thumb(url, index))}
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">PDF</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-packages-remove"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
margin: 0,
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
>
|
||||||
|
删
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
))}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : null}
|
||||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
<button
|
||||||
onClick={openPicker}
|
type="button"
|
||||||
>
|
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
onClick={() => {
|
||||||
</span>
|
if (inWechat) void pickWechat();
|
||||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
else inputRef.current?.click();
|
||||||
{uploading
|
}}
|
||||||
? '上传中…'
|
>
|
||||||
: remaining <= 0
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||||
? `已达上限 ${maxCount}${unit}`
|
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
</span>
|
||||||
</span>
|
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||||
</button>
|
{uploading
|
||||||
</>
|
? '上传中…'
|
||||||
)}
|
: remaining <= 0
|
||||||
|
? `已达上限 ${maxCount}${unit}`
|
||||||
|
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="partner-form-error" role="alert">
|
<p className="partner-form-error" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -156,19 +156,10 @@ 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: '网络异常' })) as {
|
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
|
||||||
code: number;
|
|
||||||
message?: string;
|
|
||||||
data?: T;
|
|
||||||
reason?: string;
|
|
||||||
};
|
|
||||||
if (json.code !== 0) {
|
if (json.code !== 0) {
|
||||||
const err = new Error(json.message || '请求失败') as Error & {
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
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) },
|
||||||
@@ -233,19 +224,8 @@ 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; reason?: string };
|
const err = e as Error & { status?: number };
|
||||||
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 });
|
||||||
@@ -280,14 +260,7 @@ 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; reason?: string };
|
const err = e as Error & { status?: number };
|
||||||
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) {
|
||||||
@@ -304,17 +277,3 @@ 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, setSearchParams] = useSearchParams();
|
const [params] = 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,18 +120,6 @@ 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;
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export default function ProxyOrderPage() {
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||||
const [productId, setProductId] = useState(draft0.productId);
|
const [productId, setProductId] = useState(draft0.productId);
|
||||||
const [skuId, setSkuId] = useState('');
|
|
||||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||||
@@ -157,37 +156,9 @@ export default function ProxyOrderPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||||
const selectedSku =
|
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||||
skuOptions.find((s) => s.id === skuId) ||
|
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
|
||||||
skuOptions[0];
|
|
||||||
const allowOnline = selectedSku
|
|
||||||
? selectedSku.allowOnlinePurchase !== false
|
|
||||||
: selectedProduct
|
|
||||||
? selectedProduct.allowOnlinePurchase !== false
|
|
||||||
: true;
|
|
||||||
const allowOnSite = selectedSku
|
|
||||||
? !!selectedSku.allowOnSitePickup
|
|
||||||
: !!selectedProduct?.allowOnSitePickup;
|
|
||||||
const allowCrossCity = selectedSku
|
|
||||||
? selectedSku.allowCrossCityDelivery !== false
|
|
||||||
: selectedProduct
|
|
||||||
? selectedProduct.allowCrossCityDelivery !== false
|
|
||||||
: true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProduct) return;
|
|
||||||
const def =
|
|
||||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
|
||||||
skuOptions.find((s) => s.isDefault) ||
|
|
||||||
skuOptions[0];
|
|
||||||
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
|
||||||
setSkuId(def.id);
|
|
||||||
if (def.saleUnit === 'BOX') setQuantity(1);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [productId, selectedProduct?.id, options]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProduct) return;
|
if (!selectedProduct) return;
|
||||||
@@ -213,7 +184,6 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
skuId: skuId || undefined,
|
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -230,7 +200,7 @@ export default function ProxyOrderPage() {
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
|
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
@@ -389,7 +359,6 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
skuId: skuId || undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -555,35 +524,8 @@ export default function ProxyOrderPage() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
|
||||||
<section className="partner-form-section">
|
|
||||||
<label className="partner-form-label">规格</label>
|
|
||||||
<div className="partner-input-wrap">
|
|
||||||
<select
|
|
||||||
className="partner-input"
|
|
||||||
value={skuId}
|
|
||||||
onChange={(e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
setSkuId(id);
|
|
||||||
const sku = skuOptions.find((s) => s.id === id);
|
|
||||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skuOptions.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
|
|
||||||
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">
|
<label className="partner-form-label">数量</label>
|
||||||
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
|
|
||||||
</label>
|
|
||||||
<div className="partner-input-wrap">
|
<div className="partner-input-wrap">
|
||||||
<input
|
<input
|
||||||
className="partner-input"
|
className="partner-input"
|
||||||
|
|||||||
@@ -1007,9 +1007,9 @@ export default function StoreCreatePage() {
|
|||||||
<MultiOssUploadField
|
<MultiOssUploadField
|
||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
maxCount={20}
|
maxCount={20}
|
||||||
variant="grid"
|
|
||||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||||
|
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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, submitStoreInfoChangeRequest, listStoreInfoChangeRequests } from '../lib/api';
|
import { request } 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';
|
||||||
@@ -56,10 +56,10 @@ export default function StoreDetailPage() {
|
|||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
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,12 +97,6 @@ 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) {
|
||||||
@@ -158,140 +152,77 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaChanged(): boolean {
|
async function saveBasic() {
|
||||||
if (!store) return false;
|
|
||||||
const nextCover = coverUrl.trim();
|
|
||||||
const origCover = String(store.coverUrl || '').trim();
|
|
||||||
if (nextCover !== origCover) return true;
|
|
||||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
|
||||||
const origEnv = uniqueEnvUrls(
|
|
||||||
Array.isArray(store.media)
|
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>)
|
|
||||||
.filter((m) => m.bizType === 'ENV')
|
|
||||||
.map((m) => String(m.url || ''))
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
if (nextEnv.length !== origEnv.length) return true;
|
|
||||||
return nextEnv.some((u, i) => u !== origEnv[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function basicChanged(): boolean {
|
|
||||||
if (!store) return false;
|
|
||||||
const liveContact = String(store.contactPhone || store.phone || '').trim();
|
|
||||||
const liveIntro = String(store.intro || '').trim();
|
|
||||||
const liveRuleRaw = String(store.benefitUsageRule || '').trim();
|
|
||||||
const liveRule = liveRuleRaw && !/^null$/i.test(liveRuleRaw) ? liveRuleRaw : '';
|
|
||||||
const liveLat =
|
|
||||||
store.latitude != null && store.latitude !== '' ? String(store.latitude) : '';
|
|
||||||
const liveLng =
|
|
||||||
store.longitude != null && store.longitude !== '' ? String(store.longitude) : '';
|
|
||||||
return (
|
|
||||||
form.name.trim() !== String(store.name || '').trim() ||
|
|
||||||
form.contactPhone.trim() !== liveContact ||
|
|
||||||
form.address.trim() !== String(store.address || '').trim() ||
|
|
||||||
form.intro.trim() !== liveIntro ||
|
|
||||||
form.benefitUsageRule.trim() !== liveRule ||
|
|
||||||
form.latitude.trim() !== liveLat ||
|
|
||||||
form.longitude.trim() !== liveLng
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveChanges() {
|
|
||||||
if (!id || saving || status === 'CLOSED') return;
|
if (!id || saving || status === 'CLOSED') return;
|
||||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
if (auditStatus === 'PENDING') {
|
if (auditStatus === 'PENDING') {
|
||||||
setActionError('门店审核中,暂不可修改资料');
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantMedia = mediaChanged();
|
|
||||||
const wantBasic = basicChanged();
|
|
||||||
if (!wantMedia && !wantBasic) {
|
|
||||||
setActionError('没有检测到需要变更的字段');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextCover = coverUrl.trim();
|
|
||||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
|
||||||
if (wantMedia) {
|
|
||||||
if (!nextCover) {
|
|
||||||
setActionError('请上传门头照');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
|
||||||
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setActionError('');
|
setActionError('');
|
||||||
try {
|
try {
|
||||||
// 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||||
if (auditStatus === 'REJECTED') {
|
method: 'PUT',
|
||||||
if (wantMedia) {
|
body: JSON.stringify({
|
||||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
name: form.name.trim(),
|
||||||
method: 'PUT',
|
contactPhone: form.contactPhone.trim(),
|
||||||
body: JSON.stringify({
|
address: form.address.trim(),
|
||||||
coverUrl: nextCover,
|
intro: form.intro.trim(),
|
||||||
envPhotoUrls: nextEnv,
|
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||||
}),
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
});
|
? {
|
||||||
applyStore(data);
|
latitude: Number(form.latitude),
|
||||||
}
|
longitude: Number(form.longitude),
|
||||||
if (wantBasic) {
|
}
|
||||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
: {}),
|
||||||
method: 'PUT',
|
}),
|
||||||
body: JSON.stringify({
|
});
|
||||||
name: form.name.trim(),
|
applyStore(data);
|
||||||
contactPhone: form.contactPhone.trim(),
|
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
|
||||||
address: form.address.trim(),
|
|
||||||
intro: form.intro.trim(),
|
|
||||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
|
||||||
...(form.latitude.trim() && form.longitude.trim()
|
|
||||||
? {
|
|
||||||
latitude: Number(form.latitude),
|
|
||||||
longitude: Number(form.longitude),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
applyStore(data);
|
|
||||||
}
|
|
||||||
toastSuccess('已重新提交审核');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields: Record<string, unknown> = {};
|
|
||||||
if (wantBasic) {
|
|
||||||
fields.name = form.name.trim();
|
|
||||||
fields.contactPhone = form.contactPhone.trim();
|
|
||||||
fields.address = form.address.trim();
|
|
||||||
fields.intro = form.intro.trim();
|
|
||||||
fields.benefitUsageRule = form.benefitUsageRule.trim() || null;
|
|
||||||
if (form.latitude.trim() && form.longitude.trim()) {
|
|
||||||
fields.latitude = Number(form.latitude);
|
|
||||||
fields.longitude = Number(form.longitude);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (wantMedia) {
|
|
||||||
fields.coverUrl = nextCover;
|
|
||||||
fields.envPhotoUrls = nextEnv;
|
|
||||||
}
|
|
||||||
await submitStoreInfoChangeRequest(id, fields);
|
|
||||||
setPendingInfoChange(true);
|
|
||||||
toastSuccess(
|
|
||||||
wantMedia
|
|
||||||
? '变更已提交(含门头照/环境图),总部审核通过后生效'
|
|
||||||
: '变更已提交,等待总部审核',
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(e instanceof Error ? e.message : '提交失败');
|
setActionError(e instanceof Error ? e.message : '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMedia() {
|
||||||
|
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||||
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
|
if (auditStatus === 'PENDING') {
|
||||||
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCover = coverUrl.trim();
|
||||||
|
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||||
|
if (!nextCover) {
|
||||||
|
setActionError('请上传门头照');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
||||||
|
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMediaSaving(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coverUrl: nextCover,
|
||||||
|
envPhotoUrls: nextEnv,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
applyStore(data);
|
||||||
|
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||||
|
} finally {
|
||||||
|
setMediaSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page partner-home--flush-top">
|
<div className="partner-detail-page partner-home--flush-top">
|
||||||
@@ -348,16 +279,6 @@ 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' && (
|
||||||
@@ -509,13 +430,27 @@ export default function StoreDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canMutate && !readOnly ? (
|
{canMutate && !readOnly ? (
|
||||||
<MultiOssUploadField
|
<>
|
||||||
bizType="STORE_ENV"
|
<MultiOssUploadField
|
||||||
maxCount={20}
|
bizType="STORE_ENV"
|
||||||
variant="grid"
|
maxCount={20}
|
||||||
value={uniqueEnvUrls(envPhotoUrls)}
|
value={uniqueEnvUrls(envPhotoUrls)}
|
||||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
||||||
/>
|
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 16 }}
|
||||||
|
disabled={mediaSaving}
|
||||||
|
onClick={() => void saveMedia()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||||
|
upload
|
||||||
|
</span>
|
||||||
|
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : envPhotos.length > 0 ? (
|
) : envPhotos.length > 0 ? (
|
||||||
<div className="partner-photo-grid">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((url, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
@@ -545,9 +480,9 @@ export default function StoreDetailPage() {
|
|||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<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 saveChanges()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">send</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '提交中…' : '提交变更'}
|
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1622,19 +1622,10 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-upload-grid-thumb {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partner-upload-grid .partner-upload-dashed {
|
.partner-upload-grid .partner-upload-dashed {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-upload-dashed--compact {
|
.partner-upload-dashed--compact {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
|
||||||
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||||
@@ -19,22 +18,25 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const registered = await uploadRedeemPendingPhoto(file);
|
const registered = await uploadRedeemPendingPhoto(file);
|
||||||
setPhotoResourceId(registered.id);
|
setPhotoResourceId(registered.id);
|
||||||
setPreviewUrl(registered.url);
|
setPreviewUrl(registered.url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e instanceof Error ? e.message : '上传失败');
|
setMsg(e instanceof Error ? e.message : '上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickPhoto() {
|
async function pickPhoto() {
|
||||||
|
setMsg('');
|
||||||
if (isWechatEnv()) {
|
if (isWechatEnv()) {
|
||||||
try {
|
try {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -49,7 +51,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : '选图失败';
|
const text = e instanceof Error ? e.message : '选图失败';
|
||||||
if (!/cancel/i.test(text)) {
|
if (!/cancel/i.test(text)) {
|
||||||
toastError(`${text},可改从系统相册选择`);
|
setMsg(`${text},可改从系统相册选择`);
|
||||||
setShowAlbumFallback(true);
|
setShowAlbumFallback(true);
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
@@ -63,10 +65,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
|
|
||||||
async function submitPending() {
|
async function submitPending() {
|
||||||
if (!photoResourceId) {
|
if (!photoResourceId) {
|
||||||
toastError('请先拍摄或上传核销码照片');
|
setMsg('请先拍摄或上传核销码照片');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -78,7 +81,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
});
|
});
|
||||||
setResult(res);
|
setResult(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e instanceof Error ? e.message : '提交失败');
|
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -86,8 +89,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
|
|
||||||
function copyText(text: string) {
|
function copyText(text: string) {
|
||||||
void navigator.clipboard?.writeText(text).then(
|
void navigator.clipboard?.writeText(text).then(
|
||||||
() => toastSuccess('已复制'),
|
() => setMsg('已复制'),
|
||||||
() => toastError('复制失败,请手动长按复制'),
|
() => setMsg('复制失败,请手动长按复制'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +169,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
||||||
import { registerShopToastListener, type ShopToastVariant } from '../lib/toast';
|
|
||||||
|
|
||||||
type ShopToastContextValue = {
|
|
||||||
showToast: (message: string, variant?: ShopToastVariant) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShopToastContext = createContext<ShopToastContextValue | null>(null);
|
|
||||||
|
|
||||||
export function ShopToastProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [toast, setToast] = useState('');
|
|
||||||
const [variant, setVariant] = useState<ShopToastVariant>('error');
|
|
||||||
const timerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const showToast = useCallback((message: string, nextVariant: ShopToastVariant = 'error') => {
|
|
||||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
|
||||||
setVariant(nextVariant);
|
|
||||||
setToast(message);
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setToast('');
|
|
||||||
timerRef.current = null;
|
|
||||||
}, nextVariant === 'error' ? 2800 : 2000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
registerShopToastListener(showToast);
|
|
||||||
return () => {
|
|
||||||
registerShopToastListener(null);
|
|
||||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, [showToast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ShopToastContext.Provider value={{ showToast }}>
|
|
||||||
{children}
|
|
||||||
{toast ? (
|
|
||||||
<div
|
|
||||||
className={`shop-float-toast${variant === 'error' ? ' shop-float-toast--error' : ''}`}
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</ShopToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShopToast(): ShopToastContextValue {
|
|
||||||
const ctx = useContext(ShopToastContext);
|
|
||||||
if (!ctx) throw new Error('useShopToast 必须在 ShopToastProvider 内使用');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -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; reason?: string };
|
let json: { code: number; message?: string; data?: T };
|
||||||
try {
|
try {
|
||||||
json = await res.json();
|
json = await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -213,12 +213,8 @@ async function rawRequest<T>(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (json.code !== 0) {
|
if (json.code !== 0) {
|
||||||
const err = new Error(json.message || '请求失败') as Error & {
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
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) },
|
||||||
@@ -258,15 +254,7 @@ 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; reason?: string };
|
const err = e as Error & { status?: number };
|
||||||
// 账号停用 / 门店关闭 / 合伙人绑定失效:强制退出登录
|
|
||||||
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 &&
|
||||||
@@ -315,14 +303,7 @@ 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; reason?: string };
|
const err = e as Error & { status?: number };
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/** 把接口里的金额(number / 数字字符串 / Prisma Decimal 残影)转成有限数字 */
|
|
||||||
export function toMoneyNumber(value: unknown): number {
|
|
||||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
const n = Number(value);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const o = value as { toNumber?: () => number; toString?: () => string; d?: unknown };
|
|
||||||
if (typeof o.toNumber === 'function') {
|
|
||||||
const n = Number(o.toNumber());
|
|
||||||
if (Number.isFinite(n)) return n;
|
|
||||||
}
|
|
||||||
if (typeof o.toString === 'function' && o.toString !== Object.prototype.toString) {
|
|
||||||
const n = Number(o.toString());
|
|
||||||
if (Number.isFinite(n)) return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMoney(n: number) {
|
|
||||||
return toMoneyNumber(n).toLocaleString('zh-CN', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export type ShopToastVariant = 'success' | 'error';
|
|
||||||
|
|
||||||
type ShopToastListener = (message: string, variant: ShopToastVariant) => void;
|
|
||||||
|
|
||||||
let listener: ShopToastListener | null = null;
|
|
||||||
|
|
||||||
export function registerShopToastListener(fn: ShopToastListener | null) {
|
|
||||||
listener = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showShopToast(message: string, variant: ShopToastVariant = 'error') {
|
|
||||||
const text = message.trim();
|
|
||||||
if (!text || !listener) return;
|
|
||||||
listener(text, variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toastError(message: string) {
|
|
||||||
showShopToast(message, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toastSuccess(message: string) {
|
|
||||||
showShopToast(message, 'success');
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||||
import { ShopToastProvider } from './contexts/ShopToastContext';
|
|
||||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { apiBase } from './lib/api';
|
import { apiBase } from './lib/api';
|
||||||
@@ -20,9 +19,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename={getRouterBasename()}>
|
<BrowserRouter basename={getRouterBasename()}>
|
||||||
<StoreSessionProvider>
|
<StoreSessionProvider>
|
||||||
<ShopToastProvider>
|
<App />
|
||||||
<App />
|
|
||||||
</ShopToastProvider>
|
|
||||||
</StoreSessionProvider>
|
</StoreSessionProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
|||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
|
||||||
|
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||||
|
|
||||||
@@ -47,6 +46,14 @@ import { trackStore } from '../lib/analytics';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function formatMoney(n: number) {
|
||||||
|
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||||
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||||
@@ -469,7 +476,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<span style={{ fontSize: 18 }}>¥</span>
|
<span style={{ fontSize: 18 }}>¥</span>
|
||||||
|
|
||||||
{formatMoney(toMoneyNumber(dash?.todayAmount))}
|
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -589,7 +596,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="shop-home-record-amount">¥{formatMoney(toMoneyNumber(r.amount))}</p>
|
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,16 +86,6 @@ 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,7 +14,6 @@ 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');
|
||||||
@@ -44,11 +43,6 @@ 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()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
@@ -18,6 +17,7 @@ export default function PhoneRedeemPage() {
|
|||||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||||
@@ -41,10 +41,11 @@ export default function PhoneRedeemPage() {
|
|||||||
async function prepareDirectRedeem() {
|
async function prepareDirectRedeem() {
|
||||||
const value = Number(amount);
|
const value = Number(amount);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
toastError('请输入有效核销金额');
|
setMsg('请输入有效核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -53,10 +54,10 @@ export default function PhoneRedeemPage() {
|
|||||||
setPrepared(result);
|
setPrepared(result);
|
||||||
setConfirmCode('');
|
setConfirmCode('');
|
||||||
setConfirmCooldown(60);
|
setConfirmCooldown(60);
|
||||||
toastSuccess(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPrepared(null);
|
setPrepared(null);
|
||||||
toastError(e instanceof Error ? e.message : '发送验证码失败');
|
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,7 @@ export default function PhoneRedeemPage() {
|
|||||||
|
|
||||||
async function sendConfirmSms() {
|
async function sendConfirmSms() {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
toastError('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (storeClosed) {
|
if (storeClosed) {
|
||||||
@@ -87,7 +88,7 @@ export default function PhoneRedeemPage() {
|
|||||||
await prepareDirectRedeem();
|
await prepareDirectRedeem();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
toastError(e instanceof Error ? e.message : '开启营业失败');
|
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
}
|
}
|
||||||
@@ -95,14 +96,15 @@ export default function PhoneRedeemPage() {
|
|||||||
|
|
||||||
async function confirmRedeem() {
|
async function confirmRedeem() {
|
||||||
if (!prepared) {
|
if (!prepared) {
|
||||||
toastError('请先发送核销验证码');
|
setMsg('请先发送核销验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirmCode.trim()) {
|
if (!confirmCode.trim()) {
|
||||||
toastError('请输入确认验证码');
|
setMsg('请输入确认验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -116,7 +118,7 @@ export default function PhoneRedeemPage() {
|
|||||||
state: { result, storeName, user: prepared.user },
|
state: { result, storeName, user: prepared.user },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -223,6 +225,8 @@ export default function PhoneRedeemPage() {
|
|||||||
>
|
>
|
||||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
type RangeKey = 'today' | '7d' | '30d';
|
type RangeKey = 'today' | '7d' | '30d';
|
||||||
type StatusFilter = 'all' | 'pending' | 'paid';
|
type StatusFilter = 'all' | 'pending' | 'paid';
|
||||||
|
|
||||||
|
function formatMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
function inRange(dateStr: string, range: RangeKey) {
|
function inRange(dateStr: string, range: RangeKey) {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -82,8 +85,8 @@ export default function RecordsPage() {
|
|||||||
}, [records, range, statusFilter]);
|
}, [records, range, statusFilter]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const totalAmount = filtered.reduce((s, r) => s + toMoneyNumber(r.amount), 0);
|
const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0);
|
||||||
const totalSettle = filtered.reduce((s, r) => s + toMoneyNumber(r.settleAmount), 0);
|
const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0);
|
||||||
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
||||||
return { totalAmount, totalSettle, rate };
|
return { totalAmount, totalSettle, rate };
|
||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
@@ -178,8 +181,8 @@ export default function RecordsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="shop-records-list">
|
<div className="shop-records-list">
|
||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const amount = toMoneyNumber(r.amount);
|
const amount = Number(r.amount || 0);
|
||||||
const settle = toMoneyNumber(r.settleAmount);
|
const settle = Number(r.settleAmount || 0);
|
||||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
import { toastError } from '../lib/toast';
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
@@ -23,6 +22,7 @@ export default function RedeemConfirmPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
@@ -61,9 +61,10 @@ export default function RedeemConfirmPage() {
|
|||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
setPreview(p);
|
setPreview(p);
|
||||||
|
setMsg('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
toastError(e instanceof Error ? e.message : '无法预览核销码');
|
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||||
const report = await reportRedeemFailure(token, 'preview', e);
|
const report = await reportRedeemFailure(token, 'preview', e);
|
||||||
if (report?.thresholdReached) {
|
if (report?.thresholdReached) {
|
||||||
setFailCount(report.failCount);
|
setFailCount(report.failCount);
|
||||||
@@ -81,10 +82,11 @@ export default function RedeemConfirmPage() {
|
|||||||
|
|
||||||
async function doConfirm() {
|
async function doConfirm() {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
toastError('请先扫码获取核销码');
|
setMsg('请先扫码获取核销码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -93,7 +95,7 @@ export default function RedeemConfirmPage() {
|
|||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
const report = await reportRedeemFailure(token, 'confirm', e);
|
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||||
if (report?.thresholdReached) {
|
if (report?.thresholdReached) {
|
||||||
setFailCount(report.failCount);
|
setFailCount(report.failCount);
|
||||||
@@ -124,12 +126,13 @@ export default function RedeemConfirmPage() {
|
|||||||
});
|
});
|
||||||
setStoreClosed(false);
|
setStoreClosed(false);
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
|
setMsg('');
|
||||||
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
||||||
await loadPreview();
|
await loadPreview();
|
||||||
await doConfirm();
|
await doConfirm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setShowOpenModal(false);
|
setShowOpenModal(false);
|
||||||
toastError(e instanceof Error ? e.message : '开启营业失败');
|
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,8 @@ export default function RedeemConfirmPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
|
|
||||||
{!showWeakNet && (
|
{!showWeakNet && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
|
||||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
function formatAmount(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
export default function RedeemSuccessPage() {
|
export default function RedeemSuccessPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,20 +23,12 @@ export default function RedeemSuccessPage() {
|
|||||||
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
||||||
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
||||||
const userLabel = user?.nickname || user?.phone || '—';
|
const userLabel = user?.nickname || user?.phone || '—';
|
||||||
const amount = toMoneyNumber(result?.amount);
|
const amount = Number(result?.amount ?? 0);
|
||||||
const redeemNo = String(result?.redeemNo || '—');
|
const redeemNo = String(result?.redeemNo || '—');
|
||||||
const createdAt = result?.createdAt
|
const createdAt = result?.createdAt
|
||||||
? 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">
|
||||||
@@ -49,7 +43,7 @@ export default function RedeemSuccessPage() {
|
|||||||
<span className="material-symbols-outlined">check_circle</span>
|
<span className="material-symbols-outlined">check_circle</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="shop-success-title">核销成功</h2>
|
<h2 className="shop-success-title">核销成功</h2>
|
||||||
<p className="shop-success-amount">¥ {formatMoney(amount)}</p>
|
<p className="shop-success-amount">¥ {formatAmount(amount)}</p>
|
||||||
<p className="shop-success-sub">已入账到余额</p>
|
<p className="shop-success-sub">已入账到余额</p>
|
||||||
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
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 { formatMoney, toMoneyNumber } from '../lib/money';
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
|
||||||
|
|
||||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||||
|
|
||||||
|
function formatMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
export default function WithdrawPage() {
|
export default function WithdrawPage() {
|
||||||
useStorePageView('store_withdraw_view');
|
useStorePageView('store_withdraw_view');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -40,11 +42,6 @@ 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);
|
||||||
@@ -110,13 +107,13 @@ export default function WithdrawPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="shop-records-summary-label">可提未出账余额</p>
|
<p className="shop-records-summary-label">可提未出账余额</p>
|
||||||
<p className="shop-records-summary-value">
|
<p className="shop-records-summary-value">
|
||||||
¥ {formatMoney(toMoneyNumber(summary?.availableAmount))}
|
¥ {formatMoney(summary?.availableAmount ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-records-summary-label">今日剩余额度</p>
|
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||||
<p className="shop-records-summary-value">
|
<p className="shop-records-summary-value">
|
||||||
¥ {formatMoney(toMoneyNumber(summary?.remainingDailyLimit))}
|
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +203,7 @@ export default function WithdrawPage() {
|
|||||||
<div className="shop-record-amounts">
|
<div className="shop-record-amounts">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-record-amount-label">提现金额</p>
|
<p className="shop-record-amount-label">提现金额</p>
|
||||||
<p className="shop-record-amount-value red">¥{formatMoney(toMoneyNumber(r.amount))}</p>
|
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-record-amount-label">明细笔数</p>
|
<p className="shop-record-amount-label">明细笔数</p>
|
||||||
|
|||||||
@@ -3190,26 +3190,3 @@ header:has(> .app-page-title:only-child),
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-float-toast {
|
|
||||||
position: fixed;
|
|
||||||
top: 28%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10020;
|
|
||||||
max-width: min(320px, calc(100vw - 40px));
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(0, 0, 0, 0.78);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-float-toast--error {
|
|
||||||
background: rgba(166, 29, 36, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 企业微信客服链接(覆盖 shared-types 默认值)
|
||||||
|
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>杜康好客</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600&family=Inter:wght@500&family=Manrope:wght@600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@dukang/h5-user",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "echo ok"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dukang/client-logging": "workspace:*",
|
||||||
|
"@dukang/shared-types": "workspace:*",
|
||||||
|
"@dukang/shared-ui": "workspace:*",
|
||||||
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
|
"element-china-area-data": "^6.1.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ayPJ4CQqbUcec3jX
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import TabLayout from './layouts/TabLayout';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import LegalPage from './pages/LegalPage';
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import ProductDetailPage from './pages/ProductDetailPage';
|
||||||
|
import OrderConfirmPage from './pages/OrderConfirmPage';
|
||||||
|
import AddressListPage from './pages/AddressListPage';
|
||||||
|
import AddressEditPage from './pages/AddressEditPage';
|
||||||
|
import OrderListPage from './pages/OrderListPage';
|
||||||
|
import OrderDetailPage from './pages/OrderDetailPage';
|
||||||
|
import StoreListPage from './pages/StoreListPage';
|
||||||
|
import StoreDetailPage from './pages/StoreDetailPage';
|
||||||
|
import BenefitPage from './pages/BenefitPage';
|
||||||
|
import BenefitDetailPage from './pages/BenefitDetailPage';
|
||||||
|
import MinePage from './pages/MinePage';
|
||||||
|
import RedeemPage from './pages/RedeemPage';
|
||||||
|
import RedeemCodePage from './pages/RedeemCodePage';
|
||||||
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
|
import PayPage from './pages/PayPage';
|
||||||
|
import CustomerServicePage from './pages/CustomerServicePage';
|
||||||
|
import AfterSalePage from './pages/AfterSalePage';
|
||||||
|
import AfterSaleListPage from './pages/AfterSaleListPage';
|
||||||
|
import InvoiceApplyPage from './pages/InvoiceApplyPage';
|
||||||
|
import InvoiceListPage from './pages/InvoiceListPage';
|
||||||
|
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||||
|
import { capturePromoFromUrl } from './lib/promo';
|
||||||
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
|
|
||||||
|
function PromoBootstrap() {
|
||||||
|
useEffect(() => {
|
||||||
|
capturePromoFromUrl();
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<UserSessionProvider>
|
||||||
|
<PromoBootstrap />
|
||||||
|
<WechatShareBootstrap />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
|
||||||
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
|
<Route element={<TabLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
|
<Route path="/benefit" element={<BenefitPage />} />
|
||||||
|
<Route path="/mine" element={<MinePage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/product/:id" element={<ProductDetailPage />} />
|
||||||
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
|
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||||
|
<Route path="/pay" element={<PayPage />} />
|
||||||
|
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||||
|
<Route path="/after-sale" element={<AfterSalePage />} />
|
||||||
|
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
|
||||||
|
<Route path="/invoices" element={<InvoiceListPage />} />
|
||||||
|
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
|
||||||
|
<Route path="/addresses" element={<AddressListPage />} />
|
||||||
|
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||||
|
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||||
|
<Route path="/orders" element={<OrderListPage />} />
|
||||||
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
|
<Route path="/benefit/:id" element={<BenefitDetailPage />} />
|
||||||
|
<Route path="/redeem" element={<RedeemPage />} />
|
||||||
|
<Route path="/redeem/code" element={<RedeemCodePage />} />
|
||||||
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</UserSessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type AppToastProps = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppToast({ message }: AppToastProps) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <div className="app-toast">{message}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
getCustomerServicePhone,
|
||||||
|
loadCustomerServicePhone,
|
||||||
|
openWecomCustomerService,
|
||||||
|
} from '../lib/customer-service';
|
||||||
|
|
||||||
|
type ContactCustomerSheetProps = {
|
||||||
|
orderId?: string;
|
||||||
|
orderNo?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||||
|
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCustomerServicePhone().then(setPhone);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tel = phone.replace(/-/g, '');
|
||||||
|
|
||||||
|
function openPhone() {
|
||||||
|
track('cs_contact', { type: 'phone', orderId });
|
||||||
|
window.location.href = `tel:${tel}`;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnline() {
|
||||||
|
track('cs_contact', { type: 'wecom_kf', orderId });
|
||||||
|
if (openWecomCustomerService()) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contact-customer-overlay" onClick={onClose}>
|
||||||
|
<div className="contact-customer-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="contact-customer-head">
|
||||||
|
<h3>联系客服</h3>
|
||||||
|
<button type="button" className="contact-customer-close" aria-label="关闭" onClick={onClose}>
|
||||||
|
<span className="material-symbols-outlined">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-customer-options">
|
||||||
|
<button type="button" className="contact-customer-option" onClick={openPhone}>
|
||||||
|
<div className="contact-customer-option-icon">
|
||||||
|
<span className="material-symbols-outlined">call</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-customer-option-body">
|
||||||
|
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||||
|
<p className="contact-customer-option-sub">{phone}</p>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="contact-customer-option" onClick={openOnline}>
|
||||||
|
<div className="contact-customer-option-icon">
|
||||||
|
<span className="material-symbols-outlined">chat</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-customer-option-body">
|
||||||
|
<p className="contact-customer-option-title">在线客服</p>
|
||||||
|
<p className="contact-customer-option-sub">专业客服实时解答</p>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="contact-customer-cancel" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { SmsScene } from '@dukang/shared-types';
|
||||||
|
import { bindPhone, request, type SessionPayload } from '../lib/api';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { useSmsCode } from '../lib/use-sms-code';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
|
||||||
|
type PhoneVerifySheetProps = {
|
||||||
|
open: boolean;
|
||||||
|
/** 打开时预填手机号(如收货地址中的手机号) */
|
||||||
|
defaultPhone?: string;
|
||||||
|
mode?: 'bind_phone' | 'wechat_bind_phone';
|
||||||
|
wxSessionKey?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PhoneVerifySheet({
|
||||||
|
open,
|
||||||
|
defaultPhone,
|
||||||
|
mode = 'bind_phone',
|
||||||
|
wxSessionKey,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: PhoneVerifySheetProps) {
|
||||||
|
const { applySession } = useUserSession();
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPhone('');
|
||||||
|
setCode('');
|
||||||
|
setError('');
|
||||||
|
clearMessages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defaultPhone) {
|
||||||
|
const normalized = normalizePhoneInput(defaultPhone);
|
||||||
|
if (validateMobilePhone(normalized).ok) {
|
||||||
|
setPhone(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, defaultPhone, clearMessages, setError]);
|
||||||
|
|
||||||
|
async function onSendCode() {
|
||||||
|
clearMessages();
|
||||||
|
await sendCode(phone, SmsScene.BIND_PHONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setError('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (mode === 'wechat_bind_phone') {
|
||||||
|
if (!wxSessionKey) {
|
||||||
|
setError('微信会话已过期,请重新授权');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||||
|
});
|
||||||
|
if (data.accessToken) {
|
||||||
|
applySession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken ?? '',
|
||||||
|
deviceKey: data.deviceKey,
|
||||||
|
phoneVerified: !!data.phoneVerified,
|
||||||
|
user: data.user as SessionPayload['user'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const session = await bindPhone(phone, code);
|
||||||
|
applySession(session as SessionPayload);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '验证失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const sheetTitle = title ?? (mode === 'wechat_bind_phone' ? '绑定手机号' : '验证手机号');
|
||||||
|
const sheetDesc =
|
||||||
|
description ??
|
||||||
|
(mode === 'wechat_bind_phone'
|
||||||
|
? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付'
|
||||||
|
: '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="phone-verify-overlay" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} />
|
||||||
|
<div className="phone-verify-sheet">
|
||||||
|
<h3 className="phone-verify-title">{sheetTitle}</h3>
|
||||||
|
<p className="phone-verify-desc">{sheetDesc}</p>
|
||||||
|
<div className="login-field">
|
||||||
|
<span className="login-field-prefix">+86</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
|
setError('');
|
||||||
|
clearMessages();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||||
|
disabled={codeCooldown > 0 || sending}
|
||||||
|
onClick={onSendCode}
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? '发送中...'
|
||||||
|
: codeCooldown > 0
|
||||||
|
? `${codeCooldown}s 后重新获取`
|
||||||
|
: '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(error || sentHint) && (
|
||||||
|
<p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p>
|
||||||
|
)}
|
||||||
|
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
|
||||||
|
{loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="phone-verify-skip" onClick={onClose}>
|
||||||
|
暂不绑定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
images: string[];
|
||||||
|
alt: string;
|
||||||
|
variant?: 'home' | 'detail' | 'store';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductCarousel({ images, alt, variant = 'home' }: Props) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const slides = images.length > 0 ? images : [''];
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el || el.offsetWidth === 0) return;
|
||||||
|
setActiveIndex(Math.round(el.scrollLeft / el.offsetWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapClass =
|
||||||
|
variant === 'store'
|
||||||
|
? 'store-detail-carousel-wrap'
|
||||||
|
: variant === 'detail'
|
||||||
|
? 'detail-carousel-wrap'
|
||||||
|
: 'home-carousel-wrap';
|
||||||
|
const trackClass =
|
||||||
|
variant === 'store'
|
||||||
|
? 'store-detail-carousel'
|
||||||
|
: variant === 'detail'
|
||||||
|
? 'detail-carousel'
|
||||||
|
: 'home-carousel';
|
||||||
|
const dotClass =
|
||||||
|
variant === 'store'
|
||||||
|
? 'store-detail-carousel-dot'
|
||||||
|
: variant === 'detail'
|
||||||
|
? 'detail-carousel-dot'
|
||||||
|
: 'home-carousel-dot';
|
||||||
|
const itemClass =
|
||||||
|
variant === 'store'
|
||||||
|
? 'store-detail-carousel-item'
|
||||||
|
: variant === 'detail'
|
||||||
|
? 'detail-carousel-item'
|
||||||
|
: 'home-carousel-item';
|
||||||
|
const placeholderClass =
|
||||||
|
variant === 'store'
|
||||||
|
? 'store-detail-carousel-placeholder'
|
||||||
|
: variant === 'detail'
|
||||||
|
? 'detail-carousel-placeholder'
|
||||||
|
: 'home-carousel-placeholder';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapClass}>
|
||||||
|
<div className={trackClass} ref={scrollRef} onScroll={onScroll}>
|
||||||
|
{slides.map((src, i) => (
|
||||||
|
<div key={i} className={itemClass}>
|
||||||
|
{src ? (
|
||||||
|
<AppImage src={src} alt={alt} wrapperClassName="app-image--fill" />
|
||||||
|
) : (
|
||||||
|
<div className={placeholderClass} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<div className={variant === 'store' ? 'store-detail-carousel-dots' : variant === 'detail' ? 'detail-carousel-dots' : 'home-carousel-dots'}>
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<span key={i} className={`${dotClass}${i === activeIndex ? ' active' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
REGION_ALL,
|
||||||
|
getCities,
|
||||||
|
getCitiesForPicker,
|
||||||
|
getDistricts,
|
||||||
|
getDistrictsForPicker,
|
||||||
|
getProvincesForPicker,
|
||||||
|
normalizeRegionSelection,
|
||||||
|
toCityLevelRegion,
|
||||||
|
type RegionSelection,
|
||||||
|
} from '../lib/region-data';
|
||||||
|
|
||||||
|
type RegionPickerProps = {
|
||||||
|
open: boolean;
|
||||||
|
value: RegionSelection;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (region: RegionSelection) => void;
|
||||||
|
/** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */
|
||||||
|
levels?: 2 | 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PickerLevel = 'province' | 'city' | 'district';
|
||||||
|
|
||||||
|
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||||
|
{ key: 'province', label: '省份' },
|
||||||
|
{ key: 'city', label: '城市' },
|
||||||
|
{ key: 'district', label: '区县' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
||||||
|
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||||
|
if (levels === 2) {
|
||||||
|
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||||
|
}
|
||||||
|
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
|
||||||
|
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
|
||||||
|
return 'province';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
|
||||||
|
if (tab === 'province') {
|
||||||
|
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
|
||||||
|
}
|
||||||
|
if (tab === 'city') {
|
||||||
|
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
|
||||||
|
}
|
||||||
|
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegionPicker({
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
levels = 3,
|
||||||
|
}: RegionPickerProps) {
|
||||||
|
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||||
|
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||||
|
setDraft(normalized);
|
||||||
|
setActiveTab(initialTab(value, levels));
|
||||||
|
}, [open, value, levels]);
|
||||||
|
|
||||||
|
const listItems = useMemo(() => {
|
||||||
|
if (activeTab === 'province') return getProvincesForPicker();
|
||||||
|
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
||||||
|
return getDistrictsForPicker(draft.province, draft.city);
|
||||||
|
}, [activeTab, draft.province, draft.city]);
|
||||||
|
|
||||||
|
const selectedValue =
|
||||||
|
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||||
|
|
||||||
|
const canConfirm =
|
||||||
|
levels === 2
|
||||||
|
? Boolean(draft.province && draft.city)
|
||||||
|
: Boolean(draft.province && draft.city && draft.district);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
scrollActiveIntoView(listRef.current, selectedValue);
|
||||||
|
}, [open, activeTab, selectedValue, listItems.length]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function scrollActiveIntoView(container: HTMLDivElement | null, label: string) {
|
||||||
|
if (!container || !label) return;
|
||||||
|
const active = container.querySelector<HTMLElement>(`[data-label="${CSS.escape(label)}"]`);
|
||||||
|
active?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProvince(province: string) {
|
||||||
|
if (province === REGION_ALL) {
|
||||||
|
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||||
|
setActiveTab('city');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCities = getCities(province);
|
||||||
|
const city = nextCities[0] ?? '';
|
||||||
|
if (levels === 2) {
|
||||||
|
setDraft({
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district: REGION_ALL,
|
||||||
|
});
|
||||||
|
setActiveTab('city');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDistricts = getDistricts(province, city);
|
||||||
|
setDraft({
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district: nextDistricts[0] ?? '',
|
||||||
|
});
|
||||||
|
setActiveTab('city');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCity(city: string) {
|
||||||
|
if (city === REGION_ALL) {
|
||||||
|
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||||
|
if (levels === 3) setActiveTab('district');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (levels === 2) {
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
city,
|
||||||
|
district: REGION_ALL,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDistricts = getDistricts(draft.province, city);
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
city,
|
||||||
|
district: nextDistricts[0] ?? '',
|
||||||
|
});
|
||||||
|
setActiveTab('district');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDistrict(district: string) {
|
||||||
|
setDraft({ ...draft, district });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectItem(item: string) {
|
||||||
|
if (activeTab === 'province') selectProvince(item);
|
||||||
|
else if (activeTab === 'city') selectCity(item);
|
||||||
|
else selectDistrict(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabClick(tab: PickerLevel) {
|
||||||
|
if (tab === 'city' && !draft.province) return;
|
||||||
|
if (tab === 'district' && (!draft.province || !draft.city)) return;
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!canConfirm) return;
|
||||||
|
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
||||||
|
onConfirm(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="region-picker-overlay" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="region-picker-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="选择地区"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="region-picker-toolbar">
|
||||||
|
<div className="region-picker-tabs" role="tablist">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const disabled =
|
||||||
|
(tab.key === 'city' && !draft.province) ||
|
||||||
|
(tab.key === 'district' && (!draft.province || !draft.city));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||||
|
onClick={() => onTabClick(tab.key)}
|
||||||
|
>
|
||||||
|
{tabLabel(tab.key, draft, tab.label)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="region-picker-list" ref={listRef}>
|
||||||
|
{listItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
data-label={item}
|
||||||
|
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
|
||||||
|
item === REGION_ALL ? ' region-picker-option--all' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
type SubPageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="sub-page-header" aria-label={title}>
|
||||||
|
<button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
|
||||||
|
<span className="material-symbols-outlined">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type TabMainHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
extra?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
|
||||||
|
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
|
||||||
|
if (!extra) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={`tab-main-header${className ? ` ${className}` : ''}`} aria-label={title}>
|
||||||
|
{extra ? <div className="tab-main-header-extra">{extra}</div> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { applyDefaultWechatShare } from '../lib/wechat-share';
|
||||||
|
|
||||||
|
/** 路由变化时刷新微信右上角分享卡片 */
|
||||||
|
export default function WechatShareBootstrap() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void applyDefaultWechatShare().catch(() => {});
|
||||||
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
bootstrapSession,
|
||||||
|
clearAuth,
|
||||||
|
ensureSession,
|
||||||
|
getDeviceKey,
|
||||||
|
request,
|
||||||
|
saveSession,
|
||||||
|
type SessionPayload,
|
||||||
|
type UserProfile,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { touchPromoIfNeeded } from '../lib/promo';
|
||||||
|
|
||||||
|
type UserSessionContextValue = {
|
||||||
|
ready: boolean;
|
||||||
|
profile: UserProfile | null;
|
||||||
|
phoneVerified: boolean;
|
||||||
|
applySession: (session: SessionPayload) => void;
|
||||||
|
refreshProfile: () => Promise<void>;
|
||||||
|
resetSession: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserSessionContext = createContext<UserSessionContextValue | null>(null);
|
||||||
|
|
||||||
|
export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: SessionPayload) => {
|
||||||
|
saveSession(session);
|
||||||
|
if (session.user) setProfile(session.user);
|
||||||
|
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
const me = await request<UserProfile>('USER_H5', '/auth/me');
|
||||||
|
setProfile(me);
|
||||||
|
setPhoneVerified(!!me.phoneVerified);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSession = useCallback(async () => {
|
||||||
|
clearAuth();
|
||||||
|
const session = await bootstrapSession();
|
||||||
|
applySession(session);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const session = await ensureSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
applySession(session);
|
||||||
|
if (!session.user) {
|
||||||
|
await refreshProfile();
|
||||||
|
}
|
||||||
|
await touchPromoIfNeeded();
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
try {
|
||||||
|
const session = await bootstrapSession();
|
||||||
|
applySession(session);
|
||||||
|
await refreshProfile();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applySession, refreshProfile]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
ready,
|
||||||
|
profile,
|
||||||
|
phoneVerified,
|
||||||
|
applySession,
|
||||||
|
refreshProfile,
|
||||||
|
resetSession,
|
||||||
|
}),
|
||||||
|
[ready, profile, phoneVerified, applySession, refreshProfile, resetSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="session-boot">
|
||||||
|
<p className="session-boot-text">加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UserSessionContext.Provider value={value}>{children}</UserSessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserSession() {
|
||||||
|
const ctx = useContext(UserSessionContext);
|
||||||
|
if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalUserSession() {
|
||||||
|
return useContext(UserSessionContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use profile from useUserSession */
|
||||||
|
export { getDeviceKey };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
|
||||||
|
{ to: '/stores', icon: 'storefront', label: '门店', fillActive: true },
|
||||||
|
{ to: '/benefit', icon: 'card_giftcard', label: '好客权益', fillActive: false },
|
||||||
|
{ to: '/mine', icon: 'person', label: '我的', fillActive: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<nav className="app-tabbar">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end={tab.end}
|
||||||
|
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined app-tabbar-icon"
|
||||||
|
style={
|
||||||
|
isActive && tab.fillActive
|
||||||
|
? { fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
<span className="app-tabbar-label">{tab.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createUserTracker, getSessionId } from '@dukang/client-logging';
|
||||||
|
import { apiBase } from './api';
|
||||||
|
|
||||||
|
const tracker = createUserTracker({
|
||||||
|
apiBase,
|
||||||
|
clientApp: 'USER_H5',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { getSessionId };
|
||||||
|
|
||||||
|
export function track(eventName: string, params?: Record<string, unknown>) {
|
||||||
|
tracker.track(eventName, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
|
||||||
|
tracker.trackPageView(eventName, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initUserAnalytics() {
|
||||||
|
tracker.trackSessionStart();
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { reportApiError } from '@dukang/client-logging';
|
||||||
|
|
||||||
|
export const BRAND = {
|
||||||
|
red: '#A02D30',
|
||||||
|
yellow: '#FFC107',
|
||||||
|
bg: '#f5f5f5',
|
||||||
|
text: '#333',
|
||||||
|
muted: '#999',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiBase = '/api/v1';
|
||||||
|
const CLIENT_APP = 'USER_H5';
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
id: string;
|
||||||
|
userNo: string;
|
||||||
|
phone: string | null;
|
||||||
|
phoneVerified: boolean;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
hasWechat: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionPayload = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
deviceKey?: string;
|
||||||
|
phoneVerified: boolean;
|
||||||
|
user?: UserProfile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_KEY = 'deviceKey';
|
||||||
|
const ACCESS_TOKEN = 'accessToken';
|
||||||
|
const REFRESH_TOKEN = 'refreshToken';
|
||||||
|
|
||||||
|
export function getDeviceKey() {
|
||||||
|
return localStorage.getItem(DEVICE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(data: SessionPayload) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggedIn() {
|
||||||
|
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
|
||||||
|
|
||||||
|
async function recoverSession(): Promise<SessionPayload> {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed) return refreshed;
|
||||||
|
clearAuth();
|
||||||
|
return bootstrapSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
|
err.status = json.code;
|
||||||
|
if (json.code === 400) {
|
||||||
|
reportApiError(
|
||||||
|
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||||
|
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithAuthRetry<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retried = false,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await rawRequest<T>(path, options);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
const canRecover =
|
||||||
|
err.status === 401 &&
|
||||||
|
!retried &&
|
||||||
|
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||||
|
if (!canRecover) throw e;
|
||||||
|
await recoverSession();
|
||||||
|
return requestWithAuthRetry<T>(path, options, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
_clientApp: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
|
||||||
|
await bootstrapSession();
|
||||||
|
}
|
||||||
|
return requestWithAuthRetry<T>(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapSession(): Promise<SessionPayload> {
|
||||||
|
const deviceKey = getDeviceKey();
|
||||||
|
const data = await rawRequest<SessionPayload>(
|
||||||
|
'/auth/session/bootstrap',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSession(): Promise<SessionPayload | null> {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const data = await rawRequest<SessionPayload>(
|
||||||
|
'/auth/token/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSession(): Promise<SessionPayload> {
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
try {
|
||||||
|
const me = await rawRequest<UserProfile>('/auth/me');
|
||||||
|
return {
|
||||||
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||||
|
deviceKey: getDeviceKey() ?? undefined,
|
||||||
|
phoneVerified: !!me.phoneVerified,
|
||||||
|
user: me,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
if (err.status === 401) {
|
||||||
|
clearAuth();
|
||||||
|
} else {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed) return refreshed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bootstrapSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
await bootstrapSession();
|
||||||
|
}
|
||||||
|
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, code }),
|
||||||
|
});
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getWechatLocation } from '@dukang/weixin-sdk';
|
||||||
|
import { weixinSdk } from './weixin';
|
||||||
|
|
||||||
|
export type ClientGpsLocation = {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
|
||||||
|
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||||
|
const loc = await weixinSdk.getLocation();
|
||||||
|
if (!loc) return null;
|
||||||
|
return {
|
||||||
|
latitude: loc.latitude,
|
||||||
|
longitude: loc.longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 tryGetClientGpsLocation */
|
||||||
|
export { getWechatLocation };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||||
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
import { isWechatEnv } from './weixin';
|
||||||
|
|
||||||
|
let cachedPhone = CUSTOMER_SERVICE_PHONE;
|
||||||
|
|
||||||
|
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||||
|
export function getCustomerServiceWecomUrl(): string {
|
||||||
|
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||||
|
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomerServicePhone(): string {
|
||||||
|
return cachedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从系统设置拉取客服电话(失败则保持默认常量) */
|
||||||
|
export async function loadCustomerServicePhone(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const cfg = await fetchClientConfig();
|
||||||
|
const phone = cfg.customerServicePhone?.trim();
|
||||||
|
if (phone) cachedPhone = phone;
|
||||||
|
} catch {
|
||||||
|
/* keep fallback */
|
||||||
|
}
|
||||||
|
return cachedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||||
|
* @returns true 已跳转;false 非微信环境已提示
|
||||||
|
*/
|
||||||
|
export function openWecomCustomerService(): boolean {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
window.alert('请在微信中打开以联系在线客服');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
window.location.href = getCustomerServiceWecomUrl();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
|
||||||
|
export { CUSTOMER_SERVICE_PHONE };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type CheckoutContext = {
|
||||||
|
productId?: string | null;
|
||||||
|
qty?: string | number | null;
|
||||||
|
addressId?: string | null;
|
||||||
|
cross?: boolean | string | null;
|
||||||
|
select?: boolean | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readCheckoutContext(params: URLSearchParams): CheckoutContext {
|
||||||
|
return {
|
||||||
|
productId: params.get('productId'),
|
||||||
|
qty: params.get('qty'),
|
||||||
|
addressId: params.get('addressId'),
|
||||||
|
cross: params.get('cross'),
|
||||||
|
select: params.get('select'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) {
|
||||||
|
if (ctx.productId) qs.set('productId', ctx.productId);
|
||||||
|
if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty));
|
||||||
|
if (ctx.addressId) qs.set('addressId', ctx.addressId);
|
||||||
|
if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1');
|
||||||
|
if (ctx.select === true || ctx.select === '1') qs.set('select', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderConfirmUrl(search: CheckoutContext) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (search.productId) qs.set('productId', search.productId);
|
||||||
|
if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty));
|
||||||
|
if (search.addressId) qs.set('addressId', search.addressId);
|
||||||
|
if (search.cross === true || search.cross === '1') qs.set('cross', '1');
|
||||||
|
const query = qs.toString();
|
||||||
|
return query ? `/order/confirm?${query}` : '/order/confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAddressListUrl(ctx: CheckoutContext = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
appendCheckoutContext(qs, ctx);
|
||||||
|
const query = qs.toString();
|
||||||
|
return query ? `/addresses?${query}` : '/addresses';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) {
|
||||||
|
const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
appendCheckoutContext(qs, ctx);
|
||||||
|
const query = qs.toString();
|
||||||
|
return query ? `${path}?${query}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProductDetailUrl(productId?: string | null) {
|
||||||
|
return productId ? `/product/${productId}` : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderAddressSelectUrl(orderId: string) {
|
||||||
|
return `/addresses?orderId=${orderId}&select=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCheckoutContext(ctx: CheckoutContext) {
|
||||||
|
return Boolean(ctx.productId || ctx.select === true || ctx.select === '1');
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Stitch 确认订单页商品缩略图 */
|
||||||
|
export const STITCH_ORDER_PRODUCT_IMAGE =
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import { toAppPath } from '@dukang/weixin-sdk';
|
||||||
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
import { request, saveSession, type UserProfile } from './api';
|
||||||
|
|
||||||
|
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||||
|
|
||||||
|
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||||
|
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||||
|
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||||
|
return request<UserProfile>('USER_H5', '/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;
|
||||||
|
saveSession({
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken ?? '',
|
||||||
|
deviceKey: result.deviceKey,
|
||||||
|
phoneVerified: !!result.phoneVerified,
|
||||||
|
user: result.user as never,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||||
|
const config = await fetchClientConfig();
|
||||||
|
if (!isWxAuthorizeEnabled(config)) return;
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
throw new Error('请在微信内打开以完成授权');
|
||||||
|
}
|
||||||
|
return weixinSdk.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLoginReturnUrl(pathname: string, search: string) {
|
||||||
|
return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
export function normalizePhoneInput(value: string): string {
|
||||||
|
return value.replace(/\D/g, '').slice(0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, message: '请输入手机号码' };
|
||||||
|
}
|
||||||
|
if (trimmed.length !== 11) {
|
||||||
|
return { ok: false, message: '手机号码须为 11 位' };
|
||||||
|
}
|
||||||
|
if (!MOBILE_PHONE_RE.test(trimmed)) {
|
||||||
|
return { ok: false, message: '请输入正确的手机号码' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** 商品无图时的占位图 */
|
||||||
|
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||||
|
|
||||||
|
export type ProductImageSource = {
|
||||||
|
mainImageUrl?: string | null;
|
||||||
|
carouselUrls?: string[] | null;
|
||||||
|
detailImageUrls?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const url of urls) {
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
result.push(url);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||||
|
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||||
|
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||||
|
if (carousel.length > 0) return carousel;
|
||||||
|
|
||||||
|
const main = source?.mainImageUrl;
|
||||||
|
if (main) return [main];
|
||||||
|
|
||||||
|
return [PRODUCT_IMAGE_FALLBACK];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单张主图:封面优先 */
|
||||||
|
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||||
|
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情页顶部轮播 */
|
||||||
|
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||||
|
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||||
|
if (carousel.length > 0) return carousel;
|
||||||
|
return getProductImages(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情页图文长图 */
|
||||||
|
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||||
|
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { apiBase } from './api';
|
||||||
|
|
||||||
|
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
||||||
|
export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
|
||||||
|
|
||||||
|
function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
|
||||||
|
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||||
|
const code = params.get('promo')?.trim();
|
||||||
|
const pid = params.get('pid')?.trim();
|
||||||
|
return {
|
||||||
|
code: code ? code.toUpperCase() : null,
|
||||||
|
pid: pid || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
|
||||||
|
export function capturePromoFromUrl(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
let parsed = readPromoFromSearch(window.location.search);
|
||||||
|
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
|
||||||
|
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||||
|
parsed = readPromoFromSearch(hashQuery);
|
||||||
|
}
|
||||||
|
if (parsed.code) {
|
||||||
|
sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
|
||||||
|
}
|
||||||
|
if (parsed.pid) {
|
||||||
|
sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
|
||||||
|
}
|
||||||
|
return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredPromoCode(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredPromoPid(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||||
|
export async function touchPromoIfNeeded(): Promise<void> {
|
||||||
|
const promoCode = getStoredPromoCode();
|
||||||
|
const qrcodeId = getStoredPromoPid();
|
||||||
|
if (!promoCode && !qrcodeId) return;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': 'USER_H5',
|
||||||
|
};
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(promoCode ? { promoCode } : {}),
|
||||||
|
...(qrcodeId ? { qrcodeId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) return;
|
||||||
|
} catch {
|
||||||
|
/* 静默失败,不阻断用户流程 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { regionData } from 'element-china-area-data';
|
||||||
|
|
||||||
|
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||||
|
|
||||||
|
/** 由国家标准省市区数据构建三级树 */
|
||||||
|
function buildRegionTree(): RegionTree {
|
||||||
|
const tree: RegionTree = {};
|
||||||
|
for (const province of regionData) {
|
||||||
|
const cities: Record<string, string[]> = {};
|
||||||
|
for (const city of province.children ?? []) {
|
||||||
|
cities[city.label] = (city.children ?? []).map((district) => district.label);
|
||||||
|
}
|
||||||
|
tree[province.label] = cities;
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGION_TREE: RegionTree = buildRegionTree();
|
||||||
|
|
||||||
|
export const PROVINCES = Object.keys(REGION_TREE);
|
||||||
|
|
||||||
|
/** 三级选择「全市」选项(省/市/区列表首项) */
|
||||||
|
export const REGION_ALL = '全市';
|
||||||
|
|
||||||
|
export function getCities(province: string): string[] {
|
||||||
|
if (province === REGION_ALL) return [];
|
||||||
|
return Object.keys(REGION_TREE[province] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDistricts(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||||
|
return REGION_TREE[province]?.[city] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 省份列表(含全市) */
|
||||||
|
export function getProvincesForPicker(): string[] {
|
||||||
|
return [...PROVINCES];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 城市列表(含全市) */
|
||||||
|
export function getCitiesForPicker(province: string): string[] {
|
||||||
|
if (province === REGION_ALL) return [REGION_ALL];
|
||||||
|
return [...getCities(province)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区县列表(含全市) */
|
||||||
|
export function getDistrictsForPicker(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
|
||||||
|
return [REGION_ALL, ...getDistricts(province, city)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRegion(province: string, city: string, district: string): string {
|
||||||
|
if (!province) return '';
|
||||||
|
if (province === REGION_ALL) return REGION_ALL;
|
||||||
|
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||||
|
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
|
||||||
|
if (!city || !district) return '';
|
||||||
|
return `${province} ${city} ${district}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅展示省、市两级(门店列表等场景) */
|
||||||
|
export function formatRegionCity(province: string, city: string): string {
|
||||||
|
if (!province) return '';
|
||||||
|
if (province === REGION_ALL) return REGION_ALL;
|
||||||
|
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||||
|
if (!city) return province;
|
||||||
|
return `${province} ${city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店筛选:固定为市级,不按区县过滤 */
|
||||||
|
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
|
||||||
|
const normalized = normalizeRegionSelection(selection);
|
||||||
|
return {
|
||||||
|
province: normalized.province,
|
||||||
|
city: normalized.city,
|
||||||
|
district: REGION_ALL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegionSelection = {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FALLBACK_CITY_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: REGION_ALL,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||||
|
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||||
|
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||||
|
const cities = getCities(provinceInTree);
|
||||||
|
const matchedCity = cities.includes(cityName)
|
||||||
|
? cityName
|
||||||
|
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||||
|
const districts = getDistricts(provinceInTree, matchedCity);
|
||||||
|
const districtName =
|
||||||
|
district && districts.includes(district)
|
||||||
|
? district
|
||||||
|
: REGION_ALL;
|
||||||
|
return normalizeRegionSelection({
|
||||||
|
province: provinceInTree,
|
||||||
|
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||||
|
district: districtName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验已选地区是否仍存在于数据源中 */
|
||||||
|
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
|
if (selection.province === REGION_ALL) {
|
||||||
|
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const province = PROVINCES.includes(selection.province)
|
||||||
|
? selection.province
|
||||||
|
: DEFAULT_REGION.province;
|
||||||
|
|
||||||
|
if (selection.city === REGION_ALL) {
|
||||||
|
return { province, city: REGION_ALL, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = getCities(province);
|
||||||
|
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
|
||||||
|
|
||||||
|
if (selection.district === REGION_ALL) {
|
||||||
|
return { province, city, district: REGION_ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const districts = getDistricts(province, city);
|
||||||
|
const district = districts.includes(selection.district)
|
||||||
|
? selection.district
|
||||||
|
: (districts[0] ?? DEFAULT_REGION.district);
|
||||||
|
|
||||||
|
return { province, city, district };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Stitch user/22 门店详情页 — 图集与地图占位 */
|
||||||
|
export const STITCH_STORE_GALLERY = [
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg',
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM',
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STITCH_STORE_MAP =
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48';
|
||||||
|
|
||||||
|
export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) {
|
||||||
|
const fromMedia = (media || []).map((m) => m.url).filter(Boolean);
|
||||||
|
if (fromMedia.length > 0) return fromMedia;
|
||||||
|
if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)];
|
||||||
|
return [...STITCH_STORE_GALLERY];
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { apiBase } from './api';
|
||||||
|
import {
|
||||||
|
compressImageFileIfNeeded,
|
||||||
|
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||||
|
formatOssMaxSizeMb,
|
||||||
|
} from '@dukang/shared-ui/compressImage';
|
||||||
|
|
||||||
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
|
export type UploadFileResult = {
|
||||||
|
url: string;
|
||||||
|
ossKey: string;
|
||||||
|
bucket: string;
|
||||||
|
mock: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 经 API 服务端转存 OSS */
|
||||||
|
export async function uploadFileToOss(
|
||||||
|
file: File,
|
||||||
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
|
): Promise<UploadFileResult> {
|
||||||
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
|
let prepared = file;
|
||||||
|
if (mediaType === 'IMAGE') {
|
||||||
|
prepared = await compressImageFileIfNeeded(file);
|
||||||
|
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', prepared);
|
||||||
|
formData.append('bizType', options.bizType);
|
||||||
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Client-App': 'USER_H5',
|
||||||
|
};
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||||
|
return json.data as UploadFileResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { SmsScene } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
import { validateMobilePhone } from './phone';
|
||||||
|
|
||||||
|
export function useSmsCode() {
|
||||||
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sentHint, setSentHint] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [mockSms, setMockSms] = useState(true);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClientConfig()
|
||||||
|
.then((cfg) => setMockSms(cfg.mockSms))
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCooldown = useCallback(() => {
|
||||||
|
setCodeCooldown(60);
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setCodeCooldown((c) => {
|
||||||
|
if (c <= 1) {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return c - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendCode = useCallback(
|
||||||
|
async (phone: string, scene: SmsScene) => {
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError('');
|
||||||
|
setSentHint('');
|
||||||
|
try {
|
||||||
|
await request('USER_H5', '/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, scene }),
|
||||||
|
});
|
||||||
|
setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收');
|
||||||
|
startCooldown();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '发送失败');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mockSms, startCooldown],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearMessages = useCallback(() => {
|
||||||
|
setError('');
|
||||||
|
setSentHint('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendCode,
|
||||||
|
sending,
|
||||||
|
codeCooldown,
|
||||||
|
sentHint,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
clearMessages,
|
||||||
|
mockSms,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { trackPageView } from './analytics';
|
||||||
|
|
||||||
|
export function usePageView(eventName: string, params?: Record<string, unknown>) {
|
||||||
|
const fired = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (fired.current) return;
|
||||||
|
fired.current = true;
|
||||||
|
trackPageView(eventName, params);
|
||||||
|
}, [eventName, params]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
import {
|
||||||
|
authorizeWechatForPay,
|
||||||
|
fetchClientConfig,
|
||||||
|
fetchUserProfile,
|
||||||
|
needsWechatAuthForPay,
|
||||||
|
saveWechatLoginResult,
|
||||||
|
} from './pay-wechat';
|
||||||
|
|
||||||
|
export type WechatAuthEnsureResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; redirecting: true }
|
||||||
|
| { ok: false; needBindPhone: true; wxSessionKey: string };
|
||||||
|
|
||||||
|
export async function checkNeedsWechatAuth(): Promise<boolean> {
|
||||||
|
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||||
|
return needsWechatAuthForPay(config, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
|
||||||
|
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
|
||||||
|
if (!isWechatEnv()) return { ok: true };
|
||||||
|
if (!(await checkNeedsWechatAuth())) return { ok: true };
|
||||||
|
|
||||||
|
const result = await authorizeWechatForPay();
|
||||||
|
if (!result) return { ok: false, redirecting: true };
|
||||||
|
|
||||||
|
if (result.needBindPhone && result.wxSessionKey) {
|
||||||
|
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveWechatLoginResult(result)) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, redirecting: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
|
||||||
|
if (!isWechatEnv()) return null;
|
||||||
|
const config = await fetchClientConfig();
|
||||||
|
if (!isWxAuthorizeEnabled(config)) return null;
|
||||||
|
return weixinSdk.handleOAuthCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
|
||||||
|
const config = await fetchClientConfig();
|
||||||
|
if (!isWxAuthorizeEnabled(config)) return;
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
throw new Error('请在微信内打开以使用微信一键授权');
|
||||||
|
}
|
||||||
|
return weixinSdk.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
|
||||||
|
return saveWechatLoginResult(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||||
|
import { apiBase } from './api';
|
||||||
|
import { weixinSdk } from './weixin';
|
||||||
|
import { regionFromGeo, type RegionSelection } from './region-data';
|
||||||
|
|
||||||
|
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||||
|
export const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||||
|
export const FALLBACK_CITY_CODE = '410100';
|
||||||
|
|
||||||
|
export type ResolvedUserCity = {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity: boolean;
|
||||||
|
region: RegionSelection;
|
||||||
|
displayCity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||||
|
|
||||||
|
function readCache(): GpsCityCache | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as GpsCityCache;
|
||||||
|
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(data: ResolvedUserCity) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
GPS_CITY_STORAGE_KEY,
|
||||||
|
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportLocationToServer(payload: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
errMsg?: string;
|
||||||
|
}) {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': 'USER_H5',
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/common/wechat/location`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
throw new Error(json.message || '定位上报失败');
|
||||||
|
}
|
||||||
|
return json.data as {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
|
||||||
|
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
|
||||||
|
if (!force) {
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await getWechatLocationDetailed({
|
||||||
|
apiBase,
|
||||||
|
clientApp: 'USER_H5',
|
||||||
|
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.location) {
|
||||||
|
await reportLocationToServer({
|
||||||
|
sdk: outcome.sdk,
|
||||||
|
status: 'fail',
|
||||||
|
errMsg: outcome.errMsg,
|
||||||
|
}).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await reportLocationToServer({
|
||||||
|
latitude: outcome.location.latitude,
|
||||||
|
longitude: outcome.location.longitude,
|
||||||
|
sdk: outcome.sdk,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
const resolved = toResolved(data);
|
||||||
|
if (resolved) {
|
||||||
|
writeCache(resolved);
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||||
|
import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk';
|
||||||
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
|
||||||
|
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||||
|
export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用';
|
||||||
|
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||||
|
|
||||||
|
export function getDefaultShareImageUrl(): string {
|
||||||
|
if (typeof window === 'undefined') return toAppPath('/logo.png');
|
||||||
|
return new URL(toAppPath('/logo.png'), window.location.origin).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultShareData(
|
||||||
|
overrides?: Partial<WechatShareData>,
|
||||||
|
): WechatShareData {
|
||||||
|
return {
|
||||||
|
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||||
|
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||||
|
link: overrides?.link ?? getWechatShareLink(),
|
||||||
|
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyDefaultWechatShare(
|
||||||
|
overrides?: Partial<WechatShareData>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isWechatEnv()) return;
|
||||||
|
await weixinSdk.setShare(buildDefaultShareData(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleShareButtonClick(onHint: (message: string) => void): void {
|
||||||
|
const showHint = (message: string) => {
|
||||||
|
onHint(message);
|
||||||
|
if (message) {
|
||||||
|
window.setTimeout(() => onHint(''), 2500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
showHint('请在微信内打开后分享');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void applyDefaultWechatShare()
|
||||||
|
.then(() => showHint(WECHAT_SHARE_HINT))
|
||||||
|
.catch(() => showHint('分享配置失败,请刷新页面后重试'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||||
|
|
||||||
|
const CLIENT_APP = 'USER_H5';
|
||||||
|
|
||||||
|
export const weixinSdk = createWeixinSdk({
|
||||||
|
apiBase: '/api/v1',
|
||||||
|
clientApp: CLIENT_APP,
|
||||||
|
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { isWechatEnv };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||||
|
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||||
|
import App from './App';
|
||||||
|
import { initUserAnalytics } from './lib/analytics';
|
||||||
|
import { apiBase } from './lib/api';
|
||||||
|
import './styles.css';
|
||||||
|
import './styles/legal.css';
|
||||||
|
|
||||||
|
installClientErrorReporting({ apiBase, clientApp: 'USER_H5' });
|
||||||
|
initUserAnalytics();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter basename={getRouterBasename()}>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import RegionPicker from '../components/RegionPicker';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
|
||||||
|
import { DEFAULT_REGION, formatRegion } from '../lib/region-data';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type AddressForm = {
|
||||||
|
receiverName: string;
|
||||||
|
phone: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
detail: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddressEditPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState<AddressForm>({
|
||||||
|
receiverName: '',
|
||||||
|
phone: '',
|
||||||
|
province: DEFAULT_REGION.province,
|
||||||
|
city: params.get('city') || DEFAULT_REGION.city,
|
||||||
|
district: DEFAULT_REGION.district,
|
||||||
|
detail: '',
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkoutCtx = readCheckoutContext(params);
|
||||||
|
|
||||||
|
function goBackToList() {
|
||||||
|
navigate(buildAddressListUrl(checkoutCtx));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
request<Array<Record<string, unknown>>>('USER_H5', '/user/addresses').then((list) => {
|
||||||
|
const found = list.find((a) => String(a.id) === id);
|
||||||
|
if (found) {
|
||||||
|
setForm({
|
||||||
|
receiverName: String(found.receiverName),
|
||||||
|
phone: String(found.phone),
|
||||||
|
province: String(found.province),
|
||||||
|
city: String(found.city),
|
||||||
|
district: String(found.district),
|
||||||
|
detail: String(found.detail),
|
||||||
|
isDefault: found.isDefault === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const regionText = formatRegion(form.province, form.city, form.district);
|
||||||
|
|
||||||
|
function validateForm(): string | null {
|
||||||
|
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||||
|
const phoneCheck = validateMobilePhone(form.phone);
|
||||||
|
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||||
|
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
||||||
|
if (!form.detail.trim()) return '请输入详细地址';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (isEdit && id) {
|
||||||
|
await request('USER_H5', `/user/addresses/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await request('USER_H5', '/user/addresses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
navigate(buildAddressListUrl(checkoutCtx));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="address-edit-page">
|
||||||
|
<SubPageHeader
|
||||||
|
title={isEdit ? '编辑收货地址' : '添加收货地址'}
|
||||||
|
onBack={goBackToList}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="address-edit-main sub-page-body">
|
||||||
|
<section className="address-edit-card">
|
||||||
|
<div className="address-edit-field">
|
||||||
|
<label className="address-edit-label">收货人姓名</label>
|
||||||
|
<div className="address-edit-line">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="address-edit-input"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
value={form.receiverName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, receiverName: e.target.value });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="material-symbols-outlined address-edit-field-icon">person</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="address-edit-field">
|
||||||
|
<label className="address-edit-label">手机号码</label>
|
||||||
|
<div className="address-edit-line">
|
||||||
|
<span className="address-edit-prefix">+86</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="address-edit-input"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, phone: normalizePhoneInput(e.target.value) });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="material-symbols-outlined address-edit-field-icon">smartphone</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="address-edit-field address-edit-region" onClick={() => setPickerOpen(true)}>
|
||||||
|
<div className="address-edit-line address-edit-line--picker address-edit-line--region">
|
||||||
|
<span className={regionText ? 'address-edit-region-value' : 'address-edit-region-placeholder'}>
|
||||||
|
{regionText || '省份、城市、区县'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="address-edit-field address-edit-field--last">
|
||||||
|
<label className="address-edit-label">详细地址</label>
|
||||||
|
<div className="address-edit-textarea-wrap">
|
||||||
|
<textarea
|
||||||
|
className="address-edit-textarea"
|
||||||
|
placeholder="街道、门牌号、小区名称等"
|
||||||
|
rows={3}
|
||||||
|
value={form.detail}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, detail: e.target.value });
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="address-edit-card address-edit-default">
|
||||||
|
<div className="address-edit-default-info">
|
||||||
|
<div className="address-edit-default-icon">
|
||||||
|
<span className="material-symbols-outlined fill-icon">stars</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="address-edit-default-title">设为默认地址</h3>
|
||||||
|
<p className="address-edit-default-desc">每次下单时将优先使用此地址</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="address-edit-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isDefault}
|
||||||
|
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="address-edit-toggle-track" />
|
||||||
|
<span className="address-edit-toggle-thumb" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="address-edit-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="address-edit-security">
|
||||||
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
<span>已通过杜康云安全加密处理</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav className="address-edit-footer">
|
||||||
|
<button type="button" className="address-edit-cancel-btn" onClick={goBackToList}>
|
||||||
|
<span className="material-symbols-outlined">close</span>
|
||||||
|
<span>取消</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="address-edit-save-btn"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">publish</span>
|
||||||
|
<span>{saving ? '保存中...' : '保存并发布'}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<RegionPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
value={{ province: form.province, city: form.city, district: form.district }}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onConfirm={(region) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
province: region.province,
|
||||||
|
city: region.city,
|
||||||
|
district: region.district,
|
||||||
|
}));
|
||||||
|
setPickerOpen(false);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type Address = {
|
||||||
|
id: string;
|
||||||
|
receiverName: string;
|
||||||
|
phone: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
detail: string;
|
||||||
|
isDefault?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function maskPhone(phone: string) {
|
||||||
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(a: Address) {
|
||||||
|
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddressListPage() {
|
||||||
|
usePageView('address_list_view');
|
||||||
|
const { profile } = useUserSession();
|
||||||
|
const [list, setList] = useState<Address[]>([]);
|
||||||
|
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||||
|
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const selectMode = params.get('select') === '1';
|
||||||
|
const orderId = params.get('orderId') || '';
|
||||||
|
const productId = params.get('productId') || '';
|
||||||
|
const qty = params.get('qty') || '';
|
||||||
|
const cross = params.get('cross') === '1';
|
||||||
|
const currentAddressId = params.get('addressId') || '';
|
||||||
|
|
||||||
|
const loadList = useCallback(() => {
|
||||||
|
request<Address[]>('USER_H5', '/user/addresses').then(setList);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
}, [loadList, profile?.id]);
|
||||||
|
|
||||||
|
function selectAddress(addr: Address) {
|
||||||
|
if (!selectMode) return;
|
||||||
|
if (orderId) {
|
||||||
|
setPendingAddress(addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (productId) qs.set('productId', productId);
|
||||||
|
if (qty) qs.set('qty', qty);
|
||||||
|
if (cross) qs.set('cross', '1');
|
||||||
|
qs.set('addressId', addr.id);
|
||||||
|
navigate(`/order/confirm?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmOrderAddress() {
|
||||||
|
if (!orderId || !pendingAddress) return;
|
||||||
|
setSavingOrderAddress(true);
|
||||||
|
try {
|
||||||
|
await request('USER_H5', `/trade/orders/${orderId}/address`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
receiverName: pendingAddress.receiverName,
|
||||||
|
receiverPhone: pendingAddress.phone,
|
||||||
|
receiverProvince: pendingAddress.province,
|
||||||
|
receiverCity: pendingAddress.city,
|
||||||
|
receiverDistrict: pendingAddress.district,
|
||||||
|
receiverAddress: `${pendingAddress.province}${pendingAddress.city}${pendingAddress.district}${pendingAddress.detail}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
navigate(`/orders/${orderId}`);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '修改地址失败');
|
||||||
|
} finally {
|
||||||
|
setSavingOrderAddress(false);
|
||||||
|
setPendingAddress(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefault(addr: Address, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (addr.isDefault === 1) return;
|
||||||
|
await request('USER_H5', `/user/addresses/${addr.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
receiverName: addr.receiverName,
|
||||||
|
phone: addr.phone,
|
||||||
|
province: addr.province,
|
||||||
|
city: addr.city,
|
||||||
|
district: addr.district,
|
||||||
|
detail: addr.detail,
|
||||||
|
isDefault: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAddress(id: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!window.confirm('确定删除该收货地址吗?')) return;
|
||||||
|
await request('USER_H5', `/user/addresses/${id}`, { method: 'DELETE' });
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutCtx = readCheckoutContext(params);
|
||||||
|
|
||||||
|
function goEdit(id: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(buildAddressEditUrl(id, checkoutCtx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (orderId) {
|
||||||
|
navigate(`/orders/${orderId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasCheckoutContext(checkoutCtx)) {
|
||||||
|
navigate(buildOrderConfirmUrl(checkoutCtx));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/mine');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="address-list-page">
|
||||||
|
<SubPageHeader title={orderId ? '选择收货地址' : '我的地址'} onBack={goBack} />
|
||||||
|
|
||||||
|
<main className="address-list-main sub-page-body">
|
||||||
|
{list.length === 0 && (
|
||||||
|
<p className="address-list-empty">暂无收货地址,请新增</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="address-list-cards">
|
||||||
|
{list.map((a) => {
|
||||||
|
const isDefault = Number(a.isDefault) === 1;
|
||||||
|
const isSelected = selectMode && currentAddressId === String(a.id);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={a.id}
|
||||||
|
className={`address-list-card${isDefault ? ' is-default' : ''}${isSelected ? ' is-selected' : ''}${selectMode ? ' is-selectable' : ''}`}
|
||||||
|
onClick={() => selectAddress(a)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAddress(a);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role={selectMode ? 'button' : undefined}
|
||||||
|
tabIndex={selectMode ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<div className="address-list-card-head">
|
||||||
|
<div className="address-list-card-contact">
|
||||||
|
<span className="address-list-name">{a.receiverName}</span>
|
||||||
|
<span className="address-list-phone">{maskPhone(a.phone)}</span>
|
||||||
|
</div>
|
||||||
|
{isDefault && <span className="address-list-default-badge">默认</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="address-list-detail">{formatAddress(a)}</p>
|
||||||
|
|
||||||
|
<div className="address-list-divider" />
|
||||||
|
|
||||||
|
<div className="address-list-actions">
|
||||||
|
{isDefault ? (
|
||||||
|
<div className="address-list-default-label">
|
||||||
|
<span className="material-symbols-outlined fill-icon">check_circle</span>
|
||||||
|
<span>默认地址</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="address-list-set-default"
|
||||||
|
onClick={(e) => setDefault(a, e)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">radio_button_unchecked</span>
|
||||||
|
<span>设为默认</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="address-list-action-btns">
|
||||||
|
<button type="button" className="address-list-action-btn" onClick={(e) => goEdit(String(a.id), e)}>
|
||||||
|
<span className="material-symbols-outlined">edit</span>
|
||||||
|
<span>编辑</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="address-list-action-btn"
|
||||||
|
onClick={(e) => removeAddress(String(a.id), e)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">delete</span>
|
||||||
|
<span>删除</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length > 0 && (
|
||||||
|
<div className="address-list-brand" aria-hidden>
|
||||||
|
<div className="address-list-brand-icon">
|
||||||
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
</div>
|
||||||
|
<p>DUKANG HERITAGE SERVICE</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="address-list-footer">
|
||||||
|
<Link
|
||||||
|
to={buildAddressEditUrl('new', checkoutCtx)}
|
||||||
|
className="address-list-add-btn"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">add</span>
|
||||||
|
<span>新增收货地址</span>
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{pendingAddress && (
|
||||||
|
<div className="order-address-modal-overlay" onClick={() => setPendingAddress(null)}>
|
||||||
|
<div className="order-address-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="order-address-modal-head">
|
||||||
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
<h3>修改收货地址</h3>
|
||||||
|
</div>
|
||||||
|
<div className="order-address-modal-body">
|
||||||
|
<p>确认将订单收货地址修改为:</p>
|
||||||
|
<p className="order-address-modal-target">
|
||||||
|
{pendingAddress.receiverName} {maskPhone(pendingAddress.phone)}
|
||||||
|
<br />
|
||||||
|
{formatAddress(pendingAddress)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="order-address-modal-actions">
|
||||||
|
<button type="button" onClick={() => setPendingAddress(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
disabled={savingOrderAddress}
|
||||||
|
onClick={confirmOrderAddress}
|
||||||
|
>
|
||||||
|
{savingOrderAddress ? '保存中...' : '确认修改'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type TicketRow = {
|
||||||
|
id: string;
|
||||||
|
ticketNo: string;
|
||||||
|
ticketType: TicketTypeDto;
|
||||||
|
status: string;
|
||||||
|
orderNo?: string;
|
||||||
|
remark?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
PENDING: '待处理',
|
||||||
|
OPEN: '处理中',
|
||||||
|
RESOLVED: '已完成',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
CLOSED: '已关闭',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AfterSaleListPage() {
|
||||||
|
usePageView('after_sale_list_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<TicketRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
|
||||||
|
.then((res) => setItems(res.items ?? []))
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="after-sale-empty">加载中…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="after-sale-empty-box">
|
||||||
|
<p className="after-sale-empty">暂无售后工单</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||||
|
申请售后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
|
||||||
|
<div className="after-sale-ticket-head">
|
||||||
|
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
|
||||||
|
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="after-sale-order-no">{t.ticketNo}</p>
|
||||||
|
<p className="after-sale-order-meta">订单 {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
|
||||||
|
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||||
|
新建售后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AFTER_SALE_TICKET_TYPES,
|
||||||
|
TICKET_TYPE_LABELS,
|
||||||
|
type AfterSaleTicketType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
|
|
||||||
|
type OrderRow = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
payAmount: number | string;
|
||||||
|
productName?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
|
||||||
|
|
||||||
|
export default function AfterSalePage() {
|
||||||
|
usePageView('after_sale_apply');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const presetOrderId = params.get('orderId') || '';
|
||||||
|
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
|
||||||
|
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
|
||||||
|
);
|
||||||
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
|
const [orderId, setOrderId] = useState(presetOrderId);
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [ticketNo, setTicketNo] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
|
||||||
|
])
|
||||||
|
.then(([paid, completed]) => {
|
||||||
|
const map = new Map<string, OrderRow>();
|
||||||
|
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
|
||||||
|
setOrders([...map.values()]);
|
||||||
|
})
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
|
||||||
|
|
||||||
|
async function onPickFiles(files: FileList | null) {
|
||||||
|
if (!files?.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
|
||||||
|
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
|
||||||
|
uploaded.push(res.url);
|
||||||
|
}
|
||||||
|
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!ticketType || !orderId) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticketType,
|
||||||
|
remark: remark.trim() || undefined,
|
||||||
|
evidenceUrls,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setTicketNo(ticket.ticketNo);
|
||||||
|
setStep(3);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFromType() {
|
||||||
|
if (!ticketType) {
|
||||||
|
window.alert('请选择售后类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFromOrder() {
|
||||||
|
if (!orderId) {
|
||||||
|
window.alert('请选择订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
|
||||||
|
|
||||||
|
<div className="after-sale-steps">
|
||||||
|
{STEPS.map((label, i) => (
|
||||||
|
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="after-sale-type-list">
|
||||||
|
{AFTER_SALE_TICKET_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setTicketType(t)}
|
||||||
|
>
|
||||||
|
<span>{TICKET_TYPE_LABELS[t]}</span>
|
||||||
|
<span className="material-symbols-outlined">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={nextFromType}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="after-sale-empty">暂无可售后订单</p>
|
||||||
|
) : (
|
||||||
|
orders.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setOrderId(o.id)}
|
||||||
|
>
|
||||||
|
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div className="after-sale-actions">
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="after-sale-form">
|
||||||
|
<p className="after-sale-summary">
|
||||||
|
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
|
||||||
|
</p>
|
||||||
|
<label className="after-sale-label">问题描述</label>
|
||||||
|
<textarea
|
||||||
|
className="after-sale-textarea"
|
||||||
|
rows={4}
|
||||||
|
placeholder="请描述问题(选填)"
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="after-sale-label">凭证图片(破损类建议上传)</label>
|
||||||
|
<div className="after-sale-evidence">
|
||||||
|
{evidenceUrls.map((url) => (
|
||||||
|
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
|
||||||
|
))}
|
||||||
|
{evidenceUrls.length < 6 && (
|
||||||
|
<label className="after-sale-evidence-add">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(e) => void onPickFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
{uploading ? '上传中' : '+'}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="after-sale-actions">
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||||
|
{submitting ? '提交中…' : '提交工单'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="after-sale-success">
|
||||||
|
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
|
||||||
|
<p className="after-sale-success-title">售后已提交</p>
|
||||||
|
<p className="after-sale-success-no">工单号 {ticketNo}</p>
|
||||||
|
<p className="after-sale-success-hint">总部将尽快审核,请留意处理进度</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
|
||||||
|
查看我的售后
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
|
||||||
|
返回订单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
export default function BenefitDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
usePageView('benefit_detail_view', { couponId: id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<{ coupon: Record<string, unknown>; ledgers: Array<Record<string, unknown>> } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) request('USER_H5', `/benefit/coupons/${id}`).then(setData);
|
||||||
|
}, [id]);
|
||||||
|
if (!data) return <div className="empty">加载中...</div>;
|
||||||
|
return (
|
||||||
|
<div className="page-no-tab">
|
||||||
|
<PageHeader title="好客权益明细" onBack={() => navigate(-1)} />
|
||||||
|
<div className="card">
|
||||||
|
<p className="body-md">可用余额:<span className="amount-lg">¥{Number(data.coupon.balance)}</span></p>
|
||||||
|
<p className="text-variant body-md">总额:¥{Number(data.coupon.totalAmount)}</p>
|
||||||
|
</div>
|
||||||
|
{data.ledgers.map((l) => (
|
||||||
|
<div key={String(l.id)} className="card">
|
||||||
|
<div className="card-row">
|
||||||
|
<span className="body-md">{String(l.type)}</span>
|
||||||
|
<span className={Number(l.amount) < 0 ? 'text-primary amount-lg' : 'body-md'}>{Number(l.amount) > 0 ? '+' : ''}{Number(l.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="label-md text-muted">{String(l.remark || '')}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import BrandLogo from '@dukang/shared-ui/BrandLogo';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { usePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type BenefitSummary = {
|
||||||
|
totalBalance: number;
|
||||||
|
maxRedeemAmount: number;
|
||||||
|
activeCouponCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CouponItem = {
|
||||||
|
id: string;
|
||||||
|
couponNo: string;
|
||||||
|
totalAmount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
balance: number;
|
||||||
|
status: string;
|
||||||
|
sourceProduct: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMoney(amount: number) {
|
||||||
|
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCouponNo(no: string) {
|
||||||
|
const tail = no.replace(/^BC/i, '').slice(-6);
|
||||||
|
return `NO. DK${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usagePercent(coupon: CouponItem) {
|
||||||
|
const total = Number(coupon.totalAmount);
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRedeemUrl(coupon?: CouponItem) {
|
||||||
|
if (!coupon) return '/redeem';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
couponId: coupon.id,
|
||||||
|
amount: String(coupon.balance),
|
||||||
|
});
|
||||||
|
return `/redeem?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BenefitPage() {
|
||||||
|
usePageView('benefit_page_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const listRef = useRef<HTMLElement>(null);
|
||||||
|
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||||
|
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||||
|
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
request<BenefitSummary>('USER_H5', '/benefit/summary'),
|
||||||
|
request<CouponItem[]>('USER_H5', '/benefit/coupons'),
|
||||||
|
])
|
||||||
|
.then(([s, list]) => {
|
||||||
|
setSummary(s);
|
||||||
|
setCoupons(
|
||||||
|
list.map((c) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
couponNo: String(c.couponNo),
|
||||||
|
totalAmount: Number(c.totalAmount),
|
||||||
|
usedAmount: Number(c.usedAmount),
|
||||||
|
balance: Number(c.balance),
|
||||||
|
status: String(c.status),
|
||||||
|
sourceProduct: String(c.sourceProduct),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||||
|
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||||
|
const visible = tab === 'available' ? available : history;
|
||||||
|
|
||||||
|
function scrollToList() {
|
||||||
|
listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRedeem(coupon?: CouponItem) {
|
||||||
|
navigate(buildRedeemUrl(coupon));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="benefit-page">
|
||||||
|
<header className="benefit-header">
|
||||||
|
<button type="button" className="benefit-header-city">
|
||||||
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
<span>郑州市</span>
|
||||||
|
</button>
|
||||||
|
<h1 className="app-page-title">好客权益</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="benefit-header-btn"
|
||||||
|
aria-label="通知"
|
||||||
|
onClick={() => window.alert('preV1:消息通知即将开放')}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">notifications</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="benefit-main">
|
||||||
|
<section className="benefit-hero">
|
||||||
|
<div className="benefit-hero-top">
|
||||||
|
<div>
|
||||||
|
<p className="benefit-hero-label">当前好客权益余额</p>
|
||||||
|
<div className="benefit-hero-amount">
|
||||||
|
<span className="benefit-hero-symbol">¥</span>
|
||||||
|
<span className="benefit-hero-value">
|
||||||
|
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BrandLogo className="benefit-hero-logo" />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="benefit-hero-link" onClick={scrollToList}>
|
||||||
|
<span>查看权益明细</span>
|
||||||
|
<span className="material-symbols-outlined">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="benefit-action">
|
||||||
|
<button type="button" className="benefit-use-btn" onClick={() => goRedeem()}>
|
||||||
|
<span className="material-symbols-outlined filled">qr_code_2</span>
|
||||||
|
去使用
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="benefit-tabs" ref={listRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`benefit-tab${tab === 'available' ? ' active' : ''}`}
|
||||||
|
onClick={() => setTab('available')}
|
||||||
|
>
|
||||||
|
待使用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`benefit-tab${tab === 'history' ? ' active' : ''}`}
|
||||||
|
onClick={() => setTab('history')}
|
||||||
|
>
|
||||||
|
已用完/已过期
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{visible.length > 0 ? (
|
||||||
|
<div className="benefit-list">
|
||||||
|
{visible.map((c) => (
|
||||||
|
<article
|
||||||
|
key={c.id}
|
||||||
|
className={`benefit-card${tab === 'available' && c.balance > 0 ? ' benefit-card--clickable' : ''}`}
|
||||||
|
role={tab === 'available' && c.balance > 0 ? 'button' : undefined}
|
||||||
|
tabIndex={tab === 'available' && c.balance > 0 ? 0 : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (tab === 'available' && c.balance > 0) goRedeem(c);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (tab === 'available' && c.balance > 0 && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
goRedeem(c);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="benefit-card-inner">
|
||||||
|
<div className="benefit-card-value">
|
||||||
|
<span className="benefit-card-value-label">好客权益</span>
|
||||||
|
<div className="benefit-card-value-amount">
|
||||||
|
<span>¥</span>
|
||||||
|
<span>{Math.round(c.totalAmount)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="benefit-card-notch" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="benefit-card-body">
|
||||||
|
<div className="benefit-card-main">
|
||||||
|
<div className="benefit-card-title-row">
|
||||||
|
<h3 className="benefit-card-title">{c.sourceProduct}</h3>
|
||||||
|
<span className="benefit-card-badge">永久有效</span>
|
||||||
|
</div>
|
||||||
|
<p className="benefit-card-desc">适用于合作酒店餐饮消费,到店核销使用</p>
|
||||||
|
<div className="benefit-card-progress-wrap">
|
||||||
|
<div className="benefit-card-progress-labels">
|
||||||
|
<span>已使用 ¥{formatMoney(c.usedAmount)}</span>
|
||||||
|
<span>未使用 ¥{formatMoney(c.balance)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="benefit-card-progress">
|
||||||
|
<div
|
||||||
|
className="benefit-card-progress-bar"
|
||||||
|
style={{ width: `${usagePercent(c)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="benefit-card-foot">
|
||||||
|
<Link
|
||||||
|
to={`/benefit/${c.id}`}
|
||||||
|
className="benefit-card-no"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{formatCouponNo(c.couponNo)}
|
||||||
|
</Link>
|
||||||
|
{tab === 'available' && c.balance > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="benefit-card-redeem"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goRedeem(c);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即核销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="benefit-empty">
|
||||||
|
{tab === 'available' ? (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined">card_giftcard</span>
|
||||||
|
<p>暂无可用权益,购酒后自动发放</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined">history_edu</span>
|
||||||
|
<p>暂无过期或已使用的权益记录</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import {
|
||||||
|
getCustomerServicePhone,
|
||||||
|
loadCustomerServicePhone,
|
||||||
|
openWecomCustomerService,
|
||||||
|
} from '../lib/customer-service';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
|
|
||||||
|
export default function CustomerServicePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCustomerServicePhone().then(setPhone);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tel = phone.replace(/-/g, '');
|
||||||
|
|
||||||
|
function openOnline() {
|
||||||
|
track('cs_contact', { type: 'wecom_kf' });
|
||||||
|
openWecomCustomerService();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="customer-service-page customer-service-page--oa">
|
||||||
|
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
|
||||||
|
|
||||||
|
<div className="customer-service-oa-body">
|
||||||
|
<p className="customer-service-wecom-title">杜康好客客服</p>
|
||||||
|
<p className="customer-service-wecom-hint">点击下方按钮,在微信内进入在线客服会话</p>
|
||||||
|
|
||||||
|
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}>
|
||||||
|
<span className="material-symbols-outlined">headset_mic</span>
|
||||||
|
联系在线客服
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||||
|
<span className="material-symbols-outlined">call</span>
|
||||||
|
或拨打客服电话 {phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import ProductCarousel from '../components/ProductCarousel';
|
||||||
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
|
import AppToast from '../components/AppToast';
|
||||||
|
import { getProductImages } from '../lib/product-images';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
CITY_STORAGE_KEY,
|
||||||
|
FALLBACK_CITY_CODE,
|
||||||
|
resolveUserCity,
|
||||||
|
} from '../lib/wechat-location';
|
||||||
|
import { formatRegionCity } from '../lib/region-data';
|
||||||
|
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||||
|
|
||||||
|
type Product = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
price: number;
|
||||||
|
benefitDisplay: number;
|
||||||
|
mainImageUrl?: string | null;
|
||||||
|
carouselUrls?: string[] | null;
|
||||||
|
detailImageUrls?: string[] | null;
|
||||||
|
aromaType: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type City = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AROMA_TABS = [
|
||||||
|
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||||
|
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||||
|
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [tab, setTab] = useState('QINGXIANG');
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
|
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
|
||||||
|
const [locationLabel, setLocationLabel] = useState('定位中...');
|
||||||
|
const [toast, setToast] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track('home_view', { pagePath: '/', cityCode });
|
||||||
|
}, [cityCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||||
|
setCities(list);
|
||||||
|
if (!list.some((c) => c.code === cityCode) && list[0]) {
|
||||||
|
setCityCode(list[0].code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resolveUserCity().then((resolved) => {
|
||||||
|
if (!resolved) {
|
||||||
|
setLocationLabel('郑州市');
|
||||||
|
setCityCode(FALLBACK_CITY_CODE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocationLabel(formatRegionCity(resolved.province, resolved.city));
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
setCityCode(resolved.cityCode);
|
||||||
|
} else {
|
||||||
|
setCityCode(FALLBACK_CITY_CODE);
|
||||||
|
setToast('当前城市暂未开城,已展示郑州商品');
|
||||||
|
window.setTimeout(() => setToast(''), 2200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cityCode) return;
|
||||||
|
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||||
|
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
|
||||||
|
}, [cityCode]);
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(''), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAromaTabClick(key: string, open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
showToast('暂未开放');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTab(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
|
const onSale = tab === 'QINGXIANG';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page home-page">
|
||||||
|
<TabMainHeader
|
||||||
|
title="杜康好客"
|
||||||
|
extra={(
|
||||||
|
<div className="tab-main-city tab-main-city--readonly" aria-label={`当前位置 ${locationLabel}`}>
|
||||||
|
<span className="material-symbols-outlined" aria-hidden>location_on</span>
|
||||||
|
<span className="tab-main-city-label">{locationLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<nav className="home-aroma-nav">
|
||||||
|
{AROMA_TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
className={`home-aroma-tab${tab === t.key ? ' active' : ''}${!t.open ? ' muted' : ''}`}
|
||||||
|
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="home-product-list">
|
||||||
|
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||||
|
{onSale &&
|
||||||
|
filtered.map((p) => (
|
||||||
|
<article key={p.id} className="home-product-card">
|
||||||
|
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||||
|
<ProductCarousel images={getProductImages(p)} alt={p.name} />
|
||||||
|
<div className="home-product-body">
|
||||||
|
<div className="home-product-row">
|
||||||
|
<h3 className="home-product-name">{p.name}</h3>
|
||||||
|
<span className="home-product-price">¥{p.price}</span>
|
||||||
|
</div>
|
||||||
|
<p className="home-product-sub">{p.subtitle}</p>
|
||||||
|
<div className="home-product-footer">
|
||||||
|
<CouponBadge amount={p.benefitDisplay} label="好客权益" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="home-product-actions">
|
||||||
|
<Link to={`/product/${p.id}`} className="home-buy-btn">
|
||||||
|
立即购买
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AppToast message={toast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceKind,
|
||||||
|
type InvoiceTitleType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
|
||||||
|
|
||||||
|
export default function InvoiceApplyPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const presetOrderId = params.get('orderId') || '';
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
|
const [orderId, setOrderId] = useState(presetOrderId);
|
||||||
|
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
|
||||||
|
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
||||||
|
const [titleName, setTitleName] = useState('');
|
||||||
|
const [taxNo, setTaxNo] = useState('');
|
||||||
|
const [addressPhone, setAddressPhone] = useState('');
|
||||||
|
const [bankAccount, setBankAccount] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
|
||||||
|
.then((res) => setOrders(res.list ?? []))
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
|
||||||
|
}, [invoiceKind]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!orderId) {
|
||||||
|
window.alert('请选择订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!titleName.trim() || !email.trim() || !phone.trim()) {
|
||||||
|
window.alert('请填写抬头名称、邮箱与手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
titleType,
|
||||||
|
invoiceKind,
|
||||||
|
titleName: titleName.trim(),
|
||||||
|
taxNo: taxNo.trim() || undefined,
|
||||||
|
addressPhone: addressPhone.trim() || undefined,
|
||||||
|
bankAccount: bankAccount.trim() || undefined,
|
||||||
|
email: email.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
|
||||||
|
navigate('/invoices');
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '申请失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body after-sale-form">
|
||||||
|
<label className="after-sale-label">选择已完成订单</label>
|
||||||
|
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="after-sale-empty">暂无已完成订单</p>
|
||||||
|
) : (
|
||||||
|
orders.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setOrderId(o.id)}
|
||||||
|
>
|
||||||
|
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">发票类型</label>
|
||||||
|
<div className="invoice-chip-row">
|
||||||
|
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setInvoiceKind(k)}
|
||||||
|
>
|
||||||
|
{INVOICE_KIND_LABELS[k]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">抬头类型</label>
|
||||||
|
<div className="invoice-chip-row">
|
||||||
|
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
|
||||||
|
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
|
||||||
|
onClick={() => setTitleType(t)}
|
||||||
|
>
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">抬头名称</label>
|
||||||
|
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
|
||||||
|
|
||||||
|
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||||
|
<>
|
||||||
|
<label className="after-sale-label">税号</label>
|
||||||
|
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoiceKind === 'SPECIAL' && (
|
||||||
|
<>
|
||||||
|
<label className="after-sale-label">地址与电话</label>
|
||||||
|
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
|
||||||
|
<label className="after-sale-label">开户行与账号</label>
|
||||||
|
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="after-sale-label">接收邮箱</label>
|
||||||
|
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<label className="after-sale-label">手机号</label>
|
||||||
|
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
|
||||||
|
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||||
|
{submitting ? '提交中…' : '提交申请'}
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
export default function InvoiceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<InvoiceDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
|
||||||
|
.then((res) => setItems(res.items ?? []))
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="after-sale-empty">加载中…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="after-sale-empty-box">
|
||||||
|
<p className="after-sale-empty">暂无发票申请</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||||
|
申请发票
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{items.map((inv) => (
|
||||||
|
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
|
||||||
|
<div className="after-sale-ticket-head">
|
||||||
|
<span>
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
|
||||||
|
</span>
|
||||||
|
<span className="after-sale-ticket-status">
|
||||||
|
{INVOICE_STATUS_LABELS[inv.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="after-sale-order-no">{inv.titleName}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{inv.invoiceNo} · 订单 {inv.orderNo ?? inv.orderId}
|
||||||
|
</p>
|
||||||
|
{inv.status === 'ISSUED' && inv.fileUrl ? (
|
||||||
|
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看/下载发票
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||||
|
申请发票
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type LegalPageProps = {
|
||||||
|
docId: LegalDocument['id'];
|
||||||
|
/** 返回登录页的路径,如 /login */
|
||||||
|
backTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** H5 各端共用的协议/隐私正文页 */
|
||||||
|
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
|
||||||
|
const doc = getLegalDocument(docId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="legal-h5-page">
|
||||||
|
<header className="legal-h5-header">
|
||||||
|
<Link to={backTo} className="legal-h5-back" aria-label="返回">
|
||||||
|
‹
|
||||||
|
</Link>
|
||||||
|
<h1 className="legal-h5-title">{doc.title}</h1>
|
||||||
|
</header>
|
||||||
|
<main className="legal-h5-body">
|
||||||
|
<p className="legal-h5-updated">更新日期:{doc.updatedAt}</p>
|
||||||
|
<p className="legal-h5-intro">{doc.intro}</p>
|
||||||
|
{doc.sections.map((section) => (
|
||||||
|
<section key={section.heading} className="legal-h5-section">
|
||||||
|
<h2>{section.heading}</h2>
|
||||||
|
{section.paragraphs.map((p, i) => (
|
||||||
|
<p key={`${section.heading}-${i}`}>{p}</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { SmsScene } from '@dukang/shared-types';
|
||||||
|
import { request, type SessionPayload } from '../lib/api';
|
||||||
|
import { fetchClientConfig } from '../lib/pay-wechat';
|
||||||
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { useSmsCode } from '../lib/use-sms-code';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
import { loginWithWechatSdk, handleWechatAuthCallback as handleWechatOAuthCallback } from '../lib/wechat-auth';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
import { touchPromoIfNeeded } from '../lib/promo';
|
||||||
|
|
||||||
|
async function finishLogin(navigate: (path: string) => void, returnTo: string) {
|
||||||
|
await touchPromoIfNeeded();
|
||||||
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const returnTo = searchParams.get('return') || '/';
|
||||||
|
const { applySession } = useUserSession();
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||||
|
const [bindMode, setBindMode] = useState(false);
|
||||||
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||||
|
useSmsCode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClientConfig()
|
||||||
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||||
|
.catch(() => setWxAuthorize(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWechatEnv() || !wxAuthorize) return;
|
||||||
|
handleWechatOAuthCallback()
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
handleWechatLoginResult(result);
|
||||||
|
})
|
||||||
|
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
||||||
|
}, [wxAuthorize]);
|
||||||
|
|
||||||
|
function handleWechatLoginResult(result: WechatLoginResult) {
|
||||||
|
if (result.accessToken) {
|
||||||
|
applySession({
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken ?? '',
|
||||||
|
deviceKey: result.deviceKey,
|
||||||
|
phoneVerified: !!result.phoneVerified,
|
||||||
|
user: result.user as SessionPayload['user'],
|
||||||
|
});
|
||||||
|
void finishLogin(navigate, returnTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.needBindPhone && result.wxSessionKey) {
|
||||||
|
setBindMode(true);
|
||||||
|
setWxSessionKey(result.wxSessionKey);
|
||||||
|
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAgreed() {
|
||||||
|
if (!agreed) {
|
||||||
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendCode() {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
|
clearMessages();
|
||||||
|
setMsg('');
|
||||||
|
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setMsg('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
|
setSmsError('');
|
||||||
|
try {
|
||||||
|
if (bindMode && wxSessionKey) {
|
||||||
|
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||||
|
});
|
||||||
|
handleWechatLoginResult(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, code }),
|
||||||
|
});
|
||||||
|
applySession(data);
|
||||||
|
await finishLogin(navigate, returnTo);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wechatLogin() {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const result = await loginWithWechatSdk();
|
||||||
|
if (result) handleWechatLoginResult(result);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMsg = msg || smsError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<header className="login-header">
|
||||||
|
<div className="login-logo-wrap">
|
||||||
|
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="login-logo" fit="contain" />
|
||||||
|
<span className="login-logo-badge">官方</span>
|
||||||
|
</div>
|
||||||
|
<div className="login-welcome">
|
||||||
|
<h1 className="login-welcome-title">欢迎来到杜康好客</h1>
|
||||||
|
<p className="login-welcome-sub">买美酒,享好礼</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="login-main">
|
||||||
|
<div className="login-card">
|
||||||
|
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
|
||||||
|
<div className="login-field">
|
||||||
|
<span className="login-field-prefix">+86</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
|
setMsg('');
|
||||||
|
clearMessages();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||||
|
disabled={codeCooldown > 0 || sending}
|
||||||
|
onClick={onSendCode}
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? '发送中...'
|
||||||
|
: codeCooldown > 0
|
||||||
|
? `${codeCooldown}s 后重新获取`
|
||||||
|
: '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(displayMsg || sentHint) && (
|
||||||
|
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
|
||||||
|
{displayMsg || sentHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="login-agreement" ref={agreementRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
和
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-sms-btn"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={login}
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!bindMode && wxAuthorize && (
|
||||||
|
<>
|
||||||
|
<div className="login-divider">
|
||||||
|
<span className="login-divider-line" />
|
||||||
|
<span className="login-divider-text">或者</span>
|
||||||
|
<span className="login-divider-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||||
|
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||||
|
<span>微信一键授权</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
|
import { request, type UserProfile } from '../lib/api';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||||
|
|
||||||
|
const DEFAULT_AVATAR =
|
||||||
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
||||||
|
|
||||||
|
const ORDER_SHORTCUTS = [
|
||||||
|
{ tab: 'pending_pay', icon: 'payments', label: '待付款' },
|
||||||
|
{ tab: 'paid', icon: 'package_2', label: '已付款' },
|
||||||
|
{ tab: 'completed', icon: 'task_alt', label: '已完成' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
||||||
|
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
|
||||||
|
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
|
||||||
|
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
||||||
|
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
||||||
|
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function formatMoney(amount: number) {
|
||||||
|
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MinePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { profile: sessionProfile, refreshProfile, resetSession } = useUserSession();
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(sessionProfile);
|
||||||
|
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||||
|
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||||
|
const [toast, setToast] = useState('');
|
||||||
|
const [showCs, setShowCs] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
request<UserProfile>('USER_H5', '/auth/me'),
|
||||||
|
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons'),
|
||||||
|
...ORDER_SHORTCUTS.map((s) =>
|
||||||
|
request<{ total: number }>('USER_H5', `/trade/orders?tab=${s.tab}&pageSize=1`),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([me, coupons, ...totals]) => {
|
||||||
|
setProfile(me);
|
||||||
|
void refreshProfile();
|
||||||
|
const balance = coupons.reduce((sum, c) => {
|
||||||
|
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
setBenefitBalance(balance);
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
ORDER_SHORTCUTS.forEach((s, i) => {
|
||||||
|
counts[s.tab] = totals[i]?.total ?? 0;
|
||||||
|
});
|
||||||
|
setOrderCounts(counts);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [refreshProfile]);
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
setToast(msg);
|
||||||
|
window.setTimeout(() => setToast(''), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleService(item: (typeof SERVICES)[number]) {
|
||||||
|
if ('to' in item && item.to) return;
|
||||||
|
if (item.action === 'cs') setShowCs(true);
|
||||||
|
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
void resetSession().then(() => navigate('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = profile?.nickname || '用户';
|
||||||
|
const avatar = profile?.avatarUrl || DEFAULT_AVATAR;
|
||||||
|
const hasWechat = !!profile?.hasWechat;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mine-page">
|
||||||
|
<TabMainHeader title="我的" />
|
||||||
|
<header className="mine-header">
|
||||||
|
<div className="mine-header-texture" aria-hidden />
|
||||||
|
<div className="mine-profile">
|
||||||
|
<div className="mine-avatar-wrap">
|
||||||
|
<AppImage src={avatar} alt="" wrapperClassName="mine-avatar app-image--fill" />
|
||||||
|
{hasWechat && (
|
||||||
|
<span className="mine-wechat-badge" title="已绑定微信">
|
||||||
|
<span className="material-symbols-outlined">chat</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mine-profile-info">
|
||||||
|
<h1 className="mine-profile-name">{nickname}</h1>
|
||||||
|
<div className="mine-profile-meta">
|
||||||
|
<span className="mine-member-tag">{hasWechat ? '微信会员' : '好客会员'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mine-header-glow" aria-hidden />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mine-main">
|
||||||
|
<section className="mine-card">
|
||||||
|
<div className="mine-card-head">
|
||||||
|
<h2 className="mine-card-title">
|
||||||
|
<span className="material-symbols-outlined mine-card-title-icon">account_balance_wallet</span>
|
||||||
|
我的资产
|
||||||
|
</h2>
|
||||||
|
<Link to="/benefit" className="mine-card-link">
|
||||||
|
查看明细
|
||||||
|
<span className="material-symbols-outlined">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mine-asset-panel">
|
||||||
|
<div className="mine-asset-notch" aria-hidden />
|
||||||
|
<div className="mine-asset-notch-line" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="mine-asset-label">好客权益余额</p>
|
||||||
|
<div className="mine-asset-amount">
|
||||||
|
<span className="mine-asset-currency">¥</span>
|
||||||
|
<span className="mine-asset-value">{formatMoney(benefitBalance)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/redeem" className="mine-asset-cta">
|
||||||
|
去使用
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mine-card">
|
||||||
|
<div className="mine-card-head mine-card-head-orders">
|
||||||
|
<h2 className="mine-card-title">我的订单</h2>
|
||||||
|
<Link to="/orders" className="mine-card-link">
|
||||||
|
全部订单
|
||||||
|
<span className="material-symbols-outlined">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mine-order-grid">
|
||||||
|
{ORDER_SHORTCUTS.map((item) => {
|
||||||
|
const count = orderCounts[item.tab] ?? 0;
|
||||||
|
return (
|
||||||
|
<Link key={item.tab} to={`/orders?tab=${item.tab}`} className="mine-order-item">
|
||||||
|
<div className="mine-order-icon-wrap">
|
||||||
|
<span className="material-symbols-outlined mine-order-icon">{item.icon}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="mine-order-badge">{count > 99 ? '99+' : count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mine-order-label">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mine-card mine-services">
|
||||||
|
{SERVICES.map((item, index) => {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div className="mine-service-left">
|
||||||
|
<span className="mine-service-icon-wrap">
|
||||||
|
<span className="material-symbols-outlined">{item.icon}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mine-service-label">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mine-service-right">
|
||||||
|
{'badge' in item && item.badge && (
|
||||||
|
<span className="mine-service-badge">{item.badge}</span>
|
||||||
|
)}
|
||||||
|
<span className="material-symbols-outlined mine-service-chevron">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if ('to' in item && item.to) {
|
||||||
|
return (
|
||||||
|
<Link key={item.label} to={item.to} className="mine-service-item">
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
className="mine-service-item"
|
||||||
|
onClick={() => handleService(item)}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mine-footer">
|
||||||
|
<p className="mine-version">杜康好客 V2.4.0</p>
|
||||||
|
<button type="button" className="mine-logout" onClick={logout}>
|
||||||
|
退出当前账号
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{toast && <div className="mine-toast">{toast}</div>}
|
||||||
|
|
||||||
|
{showCs && <ContactCustomerSheet onClose={() => setShowCs(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user