8 Commits

Author SHA1 Message Date
jacy ee493823bf 增加音效逻辑
CI / verify (pull_request) Has been cancelled
2026-08-20 15:04:54 +08:00
jacy 8b5bb6a981 大屏显示优化
CI / verify (pull_request) Has been cancelled
2026-08-20 13:41:50 +08:00
jacy 0a36188b35 调整优化大屏 2026-08-20 12:55:11 +08:00
jacy 6cf66c722c v3.5.2 大屏调整
CI / verify (pull_request) Has been cancelled
2026-08-20 11:58:15 +08:00
jacy 4db21cf1a1 mockpay功能调试
CI / verify (pull_request) Has been cancelled
2026-08-20 00:51:18 +08:00
jacy 2f633b307c 删除h5user端和hq小程序端 2026-08-19 16:21:06 +08:00
jacy 62353a6800 整理开发文档 2026-08-19 16:20:33 +08:00
jacy 233ed0af3b v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled
2026-08-19 15:54:51 +08:00
242 changed files with 5930 additions and 17630 deletions
@@ -0,0 +1,34 @@
---
description: 小程序子页不要画与原生导航栏重复的二级 title,标题只用 navigationBarTitleText
globs: apps/mini-user/**/*.{tsx,ts}
alwaysApply: false
---
# mini-user · 小程序导航标题
微信小程序已有原生导航栏。页面内再画一层 `SubPageHeader` / 自定义 title,会与原生标题叠成**二级 title**。
## weapp
- 页面标题只走 `index.config.ts` 的 `navigationBarTitleText`
- **不要**设 `navigationStyle: 'custom'`(除非该页需要完全自定义导航,如滚动透明顶栏、商品详情)
- **不要**在页面内再渲染与原生标题重复的 `SubPageHeader`
- H5 没有原生导航栏:可保留 `SubPageHeader`(仅返回键;H5 实现已隐藏 title 文案)
```tsx
// ✅ weapp 用原生标题;H5 保留返回栏
export default definePageConfig({
navigationBarTitleText: '申请发票',
});
{process.env.TARO_ENV === 'h5' ? (
<SubPageHeader title="申请发票" onBack={...} />
) : null}
```
```tsx
// ❌ 未配页标题 + 再画一层 SubPageHeaderweapp 会双标题)
<SubPageHeader title="申请发票" />
```
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
+1
View File
@@ -139,6 +139,7 @@ C 端门店仅 status=OPEN
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
## 环境与发版
+9
View File
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import BigScreenPage from './pages/BigScreenPage';
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage';
@@ -65,6 +66,14 @@ export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/orders/big-screen"
element={
<RequireAuth>
<BigScreenPage />
</RequireAuth>
}
/>
<Route
element={
<RequireAuth>
+635
View File
@@ -104,3 +104,638 @@ body,
overflow: visible;
max-width: none;
}
/* v3.5.1 #1 订单大屏:发布会现场 */
html:has(.big-screen-page),
body:has(.big-screen-page),
#root:has(.big-screen-page) {
overflow: hidden;
height: 100%;
}
.big-screen-page {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
color: #d6e8ff;
padding: 20px 48px 24px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
.big-screen-stars {
pointer-events: none;
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
opacity: 0.7;
}
.big-screen-frame {
pointer-events: none;
position: absolute;
inset: 14px 18px;
}
.big-screen-corner {
position: absolute;
width: 28px;
height: 28px;
border: 1px solid rgba(105, 192, 255, 0.7);
}
.big-screen-corner--tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.big-screen-corner--tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
.big-screen-corner--bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
.big-screen-corner--br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.big-screen-header {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
}
.big-screen-brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.big-screen-title {
margin: 0;
font-size: 28px;
line-height: 1;
font-weight: 700;
letter-spacing: 4px;
color: #fff;
}
.big-screen-subtitle {
font-size: 16px;
color: rgba(200, 220, 245, 0.55);
letter-spacing: 2px;
}
.big-screen-live-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.big-screen-live-label {
font-size: 18px;
color: rgba(230, 244, 255, 0.88);
letter-spacing: 2px;
}
.big-screen-live {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 10px;
border: 1px solid rgba(64, 169, 255, 0.7);
border-radius: 6px;
font-size: 14px;
font-weight: 700;
color: #fff;
letter-spacing: 2px;
background: rgba(8, 28, 56, 0.55);
}
.big-screen-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 8px #ff4d4f;
animation: big-screen-pulse 1.2s ease-in-out infinite;
}
@keyframes big-screen-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.75);
}
}
.big-screen-hero {
position: relative;
z-index: 1;
flex: 0 0 auto;
text-align: center;
padding: 16px 0 12px;
}
.big-screen-clock {
font-size: 84px;
line-height: 1;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 10px;
color: #9fd3ff;
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
}
.big-screen-date {
margin-top: 8px;
font-size: 16px;
letter-spacing: 4px;
color: rgba(180, 210, 240, 0.65);
}
.big-screen-list {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
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;
}
}
@@ -217,6 +217,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
method: 'POST',
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);
startPoll(order.id);
} catch (e) {
+21 -14
View File
@@ -23,7 +23,7 @@ import {
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
const { Header, Sider, Content } = Layout;
@@ -61,7 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-package-audits', label: '套餐审核' },
{ key: '/store-package-audits', label: '审核通知' },
{ key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
@@ -244,14 +244,14 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.filter(Boolean) as MenuProps['items'];
}
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
if (!items) return items;
return items.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
if ('children' in item && Array.isArray(item.children)) {
return {
...item,
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
} as MenuItem;
}
if (String(item.key) === '/store-package-audits') {
@@ -259,7 +259,7 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number
...item,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
</span>
),
@@ -276,11 +276,14 @@ export default function AdminLayout() {
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [packagePendingCount, setPackagePendingCount] = useState(0);
const [auditPendingCount, setAuditPendingCount] = useState(0);
function refreshPackagePendingCount() {
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
function refreshAuditPendingCount() {
Promise.all([
request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })),
])
.then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0)))
.catch(() => {});
}
@@ -289,13 +292,17 @@ export default function AdminLayout() {
}, []);
useEffect(() => {
refreshPackagePendingCount();
refreshAuditPendingCount();
}, [location.pathname]);
useEffect(() => {
const onChanged = () => refreshPackagePendingCount();
const onChanged = () => refreshAuditPendingCount();
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
return () => {
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
};
}, []);
useEffect(() => {
@@ -320,8 +327,8 @@ export default function AdminLayout() {
!profile || profile.adminRole === 'SUPER_ADMIN'
? MENU_ITEMS
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachPackageAuditBadge(base, packagePendingCount);
}, [profile, packagePendingCount]);
return attachAuditBadge(base, auditPendingCount);
}, [profile, auditPendingCount]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
+6
View File
@@ -1,5 +1,11 @@
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
export function notifyPackageAuditChanged() {
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
}
export function notifyAuditNoticeChanged() {
notifyPackageAuditChanged();
}
+525
View File
@@ -0,0 +1,525 @@
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
View File
@@ -542,6 +542,7 @@ export default function OrdersPage() {
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
+5 -1
View File
@@ -63,10 +63,12 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -97,8 +99,9 @@ export default function StoreBillsPage() {
form.setFieldsValue({
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
});
}, [filters.kind, filters.status, form]);
}, [filters.kind, filters.status, filters.storeId, form]);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -359,6 +362,7 @@ export default function StoreBillsPage() {
initialValues={{
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
}}
onFinish={(v: {
kind?: string;
@@ -3,18 +3,23 @@ import { useSearchParams } from 'react-router-dom';
import {
Badge,
Button,
Descriptions,
Drawer,
Image,
Input,
Modal,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type {
StoreInfoChangeFieldDiff,
StoreInfoChangeRequestDto,
StoreInfoChangeStatus,
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
@@ -22,7 +27,10 @@ import type {
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
@@ -297,6 +305,289 @@ function PackageDetailCard({
);
}
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
return String(v);
}
return String(v);
}
function InfoChangeAuditPanel({
initialRequestId,
}: {
initialRequestId?: string | null;
}) {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING');
const [pendingCount, setPendingCount] = useState(0);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' });
if (nextStatus) qs.set('status', nextStatus);
const [data, summary] = await Promise.all([
request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
`/admin/store-info-change-requests?${qs}`,
),
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
]);
setItems(data.items);
setTotal(data.total);
setPage(data.page ?? nextPage);
setPendingCount(summary.pendingCount ?? 0);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload(1, status);
}, [status]);
useEffect(() => {
if (initialRequestId) void openDetail(initialRequestId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function openDetail(id: string) {
setDetailOpen(true);
setDetailLoading(true);
setDetail(null);
try {
const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
`/admin/store-info-change-requests/${id}`,
);
setDetail(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载详情失败');
setDetailOpen(false);
} finally {
setDetailLoading(false);
}
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-info-change-requests/${id}/audit`, {
method: 'PUT',
body: JSON.stringify(
action === 'REJECT' ? { action, rejectReason: reason } : { action },
),
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
}
}
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
dataIndex: 'status',
render: (v: StoreInfoChangeStatus) => (
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '变更字段',
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
))
: '—',
},
{
title: '提交方',
render: (_, row) =>
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
},
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{
title: '操作',
render: (_, row) => (
<Space>
<Button type="link" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'PENDING' ? (
<>
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
</Button>
<Button
type="link"
danger
onClick={() => {
setActiveId(row.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : (
row.rejectReason || null
)}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button
key={s || 'all'}
type={status === s ? 'primary' : 'default'}
onClick={() => setStatus(s)}
>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
STORE_INFO_CHANGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
/>
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
}
>
{detailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
{detail.diffs && detail.diffs.length ? (
<Descriptions column={1} bordered size="small">
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
>
<span>
<Typography.Text delete type="secondary">
{fmtFieldValue(d.field, d.live)}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>
{fmtFieldValue(d.field, d.proposed)}
</Typography.Text>
</span>
</Descriptions.Item>
))}
</Descriptions>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</>
) : null}
</Drawer>
<Modal
title="驳回信息变更"
open={rejectOpen}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!activeId) return;
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit(activeId, 'REJECT', rejectReason.trim());
setRejectOpen(false);
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
@@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() {
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
const [activeTab, setActiveTab] = useState<string>(initialTab);
const infoRequestId = searchParams.get('infoRequestId');
useEffect(() => {
const rid = searchParams.get('requestId');
if (rid) void openDetail(rid);
@@ -438,7 +732,16 @@ export default function StorePackageAuditsPage() {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Title level={4}></Typography.Title>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'package',
label: '套餐审核',
children: (
<>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
@@ -466,6 +769,16 @@ export default function StorePackageAuditsPage() {
onChange: (p) => void reload(p, status),
}}
/>
</>
),
},
{
key: 'info',
label: '信息变更',
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
},
]}
/>
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
+28 -1
View File
@@ -192,6 +192,8 @@ type StoreRow = {
visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -767,7 +769,7 @@ export default function StoresPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
width: 280,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
@@ -791,6 +793,31 @@ export default function StoresPage() {
</Button>
</>
) : null}
{row.pendingInfoChangeId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
</>
) : null}
<Button
type="link"
size="small"
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
>
</Button>
</Space>
),
},
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.mp3' {
const src: string;
export default src;
}
+45 -4
View File
@@ -156,10 +156,19 @@ async function rawRequest<T>(
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })) as {
code: number;
message?: string;
data?: T;
reason?: string;
};
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
const err = new Error(json.message || '请求失败') as Error & {
status?: number;
reason?: string;
};
err.status = json.code === 401 ? 401 : json.code;
err.reason = json.reason;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
@@ -224,8 +233,19 @@ export async function request<T>(
try {
return await requestWithAuthRetry<T>(path, fetchOptions);
} catch (e) {
const err = e as Error & { status?: number };
const err = e as Error & { status?: number; reason?: string };
const message = err.message || '请求失败';
// 账号停用 / 合伙人绑定失效:强制退出登录
if (err.reason === 'ACCOUNT_DISABLED') {
if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true });
if (!silent) showPartnerToast(message, 'error');
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login?disabled=1');
}
}
throw e;
}
if (err.status === 401) {
if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true });
@@ -260,7 +280,14 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
touchPartnerSession();
return { authenticated: true, partner };
} catch (e) {
const err = e as Error & { status?: number };
const err = e as Error & { status?: number; reason?: string };
if (err.reason === 'ACCOUNT_DISABLED') {
clearAuth({ keepProfile: true });
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login?disabled=1');
}
return { authenticated: false, partner: getPartnerProfile() };
}
if (err.status === 401) {
const refreshed = await refreshSession();
if (refreshed?.partner) {
@@ -277,3 +304,17 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
/** @deprecated 使用 PartnerSessionPayload */
export type PartnerAuthPayload = PartnerSessionPayload;
export function submitStoreInfoChangeRequest(storeId: string, fields: Record<string, unknown>) {
return request('PARTNER_H5', `/partner/stores/${storeId}/info-change-request`, {
method: 'POST',
body: JSON.stringify(fields),
});
}
export function listStoreInfoChangeRequests(storeId: string) {
return request<Array<{ status?: string }>>(
'PARTNER_H5',
`/partner/stores/${storeId}/info-change-requests`,
);
}
+13 -1
View File
@@ -99,7 +99,7 @@ function formatWechatError(e: unknown): string {
export default function LoginPage() {
const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone();
@@ -120,6 +120,18 @@ export default function LoginPage() {
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
if (params.get('disabled') === '1') {
const tip = '账号已停用或解绑,请重新登录';
setMsg(tip);
toastError(tip);
const next = new URLSearchParams(params);
next.delete('disabled');
setSearchParams(next, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
+25 -10
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { request, submitStoreInfoChangeRequest, listStoreInfoChangeRequests } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
@@ -60,6 +60,7 @@ export default function StoreDetailPage() {
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const [pendingInfoChange, setPendingInfoChange] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
@@ -97,6 +98,12 @@ export default function StoreDetailPage() {
setStore(null);
setLoadError(e instanceof Error ? e.message : '加载失败');
});
// 拉取该门店的信息变更审核记录,若有 PENDING 则展示「审核中」横幅
listStoreInfoChangeRequests(id)
.then((reqs) =>
setPendingInfoChange(Array.isArray(reqs) && reqs.some((r) => r.status === 'PENDING')),
)
.catch(() => {});
}, [id]);
async function changeStatus(next: StoreStatusValue) {
@@ -162,9 +169,8 @@ export default function StoreDetailPage() {
setSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
method: 'PUT',
body: JSON.stringify({
// v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店
await submitStoreInfoChangeRequest(id, {
name: form.name.trim(),
contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
@@ -176,12 +182,11 @@ export default function StoreDetailPage() {
longitude: Number(form.longitude),
}
: {}),
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
setPendingInfoChange(true);
toastSuccess('变更已提交,等待总部审核');
} catch (e) {
setActionError(e instanceof Error ? e.message : '保存失败');
setActionError(e instanceof Error ? e.message : '提交失败');
} finally {
setSaving(false);
}
@@ -279,6 +284,16 @@ export default function StoreDetailPage() {
{auditStatus === 'APPROVED' && (
<p className="label-md text-muted"></p>
)}
{pendingInfoChange && (
<div style={{ marginTop: 12, background: 'rgba(245,166,35,0.08)', border: '1px solid rgba(245,166,35,0.3)', borderRadius: 8, padding: 12 }}>
<p className="body-md" style={{ fontWeight: 600, marginBottom: 4 }}>
</p>
<p className="label-md text-muted" style={{ margin: 0 }}>
线
</p>
</div>
)}
</section>
{canMutate && !auditPending && status !== 'CLOSED' && (
@@ -481,8 +496,8 @@ export default function StoreDetailPage() {
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
<span className="material-symbols-outlined">send</span>
{saving ? '提交中…' : '提交变更'}
</button>
)}
</footer>
+23 -4
View File
@@ -202,7 +202,7 @@ async function rawRequest<T>(
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
let json: { code: number; message?: string; data?: T };
let json: { code: number; message?: string; data?: T; reason?: string };
try {
json = await res.json();
} catch {
@@ -213,8 +213,12 @@ async function rawRequest<T>(
throw err;
}
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
const err = new Error(json.message || '请求失败') as Error & {
status?: number;
reason?: string;
};
err.status = res.status >= 500 ? res.status : json.code;
err.reason = json.reason;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
@@ -254,7 +258,15 @@ async function requestWithAuthRetry<T>(
try {
return await rawRequest<T>(path, options);
} catch (e) {
const err = e as Error & { status?: number };
const err = e as Error & { status?: number; reason?: string };
// 账号停用 / 门店关闭 / 合伙人绑定失效:强制退出登录
if (err.reason === 'ACCOUNT_DISABLED') {
clearAuth();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.replace('/login?disabled=1');
}
throw e;
}
const canRecover =
err.status === 401 &&
!retried &&
@@ -303,7 +315,14 @@ export async function ensureSession(): Promise<{
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
};
} catch (e) {
const err = e as Error & { status?: number };
const err = e as Error & { status?: number; reason?: string };
if (err.reason === 'ACCOUNT_DISABLED') {
clearAuth();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.replace('/login?disabled=1');
}
return { authenticated: false, store: null, needsSelectStore: false };
}
if (err.status === 401) {
const refreshed = await refreshSession();
if (refreshed) {
@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
/**
* v3.5.1 #2:门店端核销即时刷新。
* 核销成功后,通过浏览器自定义事件通知「门店信息页 / 提现页」即时刷新余额与提现按钮状态,
* 避免用户手动下拉刷新。
*/
const REDEEM_SUCCESS_EVENT = 'shop:redeem-success';
export type RedeemSuccessPayload = {
redeemNo?: string;
amount?: number;
storeId?: string;
};
/** 核销成功页 mount 时调用,广播核销成功事件 */
export function notifyRedeemSuccess(payload: RedeemSuccessPayload = {}) {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(REDEEM_SUCCESS_EVENT, { detail: payload }));
}
/** 订阅核销成功事件,回调在事件触发时执行(通常用于刷新余额/提现状态) */
export function useRedeemSuccessListener(
callback: (payload: RedeemSuccessPayload) => void,
deps: React.DependencyList = [],
) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function handler(e: Event) {
const payload = (e as CustomEvent<RedeemSuccessPayload>).detail ?? {};
savedCallback.current(payload);
}
window.addEventListener(REDEEM_SUCCESS_EVENT, handler);
return () => window.removeEventListener(REDEEM_SUCCESS_EVENT, handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
+10
View File
@@ -86,6 +86,16 @@ export default function LoginPage() {
if (hint) setMsg(hint);
}, []);
useEffect(() => {
if (params.get('disabled') === '1') {
setMsg('账号已被停用或门店已关闭,请重新登录');
const next = new URLSearchParams(params);
next.delete('disabled');
setSearchParams(next, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallbackOnce()
+6
View File
@@ -14,6 +14,7 @@ import {
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { useStorePageView } from '../lib/usePageView';
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
export default function MinePage() {
useStorePageView('store_mine_view');
@@ -43,6 +44,11 @@ export default function MinePage() {
void loadMine();
}, [loadMine]);
// v3.5.1 #2:核销成功后即时刷新门店信息(余额等)
useRedeemSuccessListener(() => {
void loadMine();
}, [loadMine]);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallbackOnce()
+10 -1
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -29,6 +30,14 @@ export default function RedeemSuccessPage() {
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
// v3.5.1 #2:核销成功后广播事件,通知门店信息页 / 提现页即时刷新
useEffect(() => {
notifyRedeemSuccess({
redeemNo: redeemNo !== '—' ? redeemNo : undefined,
amount,
});
}, [redeemNo, amount]);
return (
<div className="shop-success-page">
<header className="shop-success-header">
+6
View File
@@ -9,6 +9,7 @@ import {
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
type StatusFilter = 'all' | StoreWithdrawStatus;
@@ -42,6 +43,11 @@ export default function WithdrawPage() {
void load();
}, [load]);
// v3.5.1 #2:核销成功后即时刷新可提余额与提现按钮状态
useRedeemSuccessListener(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
if (statusFilter === 'all') return items;
return items.filter((r) => r.status === statusFilter);
-2
View File
@@ -1,2 +0,0 @@
# 企业微信客服链接(覆盖 shared-types 默认值)
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
-15
View File
@@ -1,15 +0,0 @@
<!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>
-28
View File
@@ -1,28 +0,0 @@
{
"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"
}
}
@@ -1 +0,0 @@
ayPJ4CQqbUcec3jX
Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

-75
View File
@@ -1,75 +0,0 @@
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>
);
}
-8
View File
@@ -1,8 +0,0 @@
type AppToastProps = {
message: string;
};
export default function AppToast({ message }: AppToastProps) {
if (!message) return null;
return <div className="app-toast">{message}</div>;
}
@@ -1,77 +0,0 @@
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>
);
}
@@ -1,168 +0,0 @@
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>
);
}
@@ -1,74 +0,0 @@
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>
);
}
@@ -1,226 +0,0 @@
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>
);
}
@@ -1,14 +0,0 @@
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>
);
}
@@ -1,20 +0,0 @@
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>
);
}
@@ -1,14 +0,0 @@
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;
}
@@ -1,120 +0,0 @@
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 };
-42
View File
@@ -1,42 +0,0 @@
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>
</>
);
}
-21
View File
@@ -1,21 +0,0 @@
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();
}
-196
View File
@@ -1,196 +0,0 @@
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;
}
-24
View File
@@ -1,24 +0,0 @@
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 };
-43
View File
@@ -1,43 +0,0 @@
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 };
-62
View File
@@ -1,62 +0,0 @@
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');
}
-3
View File
@@ -1,3 +0,0 @@
/** 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';
-53
View File
@@ -1,53 +0,0 @@
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}`)}`;
}
-19
View File
@@ -1,19 +0,0 @@
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 };
}
-47
View File
@@ -1,47 +0,0 @@
/** 商品无图时的占位图 */
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 ?? []);
}
-70
View File
@@ -1,70 +0,0 @@
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 {
/* 静默失败,不阻断用户流程 */
}
}
-144
View File
@@ -1,144 +0,0 @@
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 };
}
-16
View File
@@ -1,16 +0,0 @@
/** 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];
}
-49
View File
@@ -1,49 +0,0 @@
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;
}
-81
View File
@@ -1,81 +0,0 @@
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,
};
}
-11
View File
@@ -1,11 +0,0 @@
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]);
}
-58
View File
@@ -1,58 +0,0 @@
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);
}
-144
View File
@@ -1,144 +0,0 @@
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);
}
}
-47
View File
@@ -1,47 +0,0 @@
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('分享配置失败,请刷新页面后重试'));
}
-11
View File
@@ -1,11 +0,0 @@
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 };
-21
View File
@@ -1,21 +0,0 @@
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>,
);
-237
View File
@@ -1,237 +0,0 @@
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>
);
}
-263
View File
@@ -1,263 +0,0 @@
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>
);
}
@@ -1,73 +0,0 @@
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>
);
}
-235
View File
@@ -1,235 +0,0 @@
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>
);
}
@@ -1,34 +0,0 @@
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>
);
}
-242
View File
@@ -1,242 +0,0 @@
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>
);
}
@@ -1,46 +0,0 @@
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>
);
}
-159
View File
@@ -1,159 +0,0 @@
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>
);
}
-158
View File
@@ -1,158 +0,0 @@
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>
);
}
@@ -1,68 +0,0 @@
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>
);
}
-36
View File
@@ -1,36 +0,0 @@
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>
);
}
-243
View File
@@ -1,243 +0,0 @@
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>
);
}
-213
View File
@@ -1,213 +0,0 @@
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>
);
}
-486
View File
@@ -1,486 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { getProductMainImage } from '../lib/product-images';
import { track } from '../lib/analytics';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext';
import { tryGetClientGpsLocation } from '../lib/client-location';
import {
applyWechatLoginResult,
ensureWechatAuthForPay,
handleWechatAuthCallback,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number;
};
type PreviewProduct = {
id: string;
name: string;
spec: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: 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 OrderConfirmPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { phoneVerified, refreshProfile } = useUserSession();
const [showPhoneVerify, setShowPhoneVerify] = useState(false);
const [showWechatBindPhone, setShowWechatBindPhone] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [pendingSubmit, setPendingSubmit] = useState(false);
const productId = params.get('productId') || '';
const forceCross = params.get('cross') === '1';
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(params.get('addressId') || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
setAddresses(list);
const fromUrl = params.get('addressId');
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1) || list[0];
if (def) setAddressId(String(def.id));
});
}, [params]);
useEffect(() => {
if (productId) {
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
}
}, [productId, quantity]);
useEffect(() => {
if (!isWechatEnv()) return;
handleWechatAuthCallback()
.then((result) => {
if (result && applyWechatLoginResult(result)) {
void refreshProfile();
}
})
.catch(() => {});
}, [refreshProfile]);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
method: 'POST',
body: JSON.stringify(body),
})
.then((data) => {
if (!cancelled) setPreview(data);
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : String(e));
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
function updateQuantity(next: number) {
const delivery = forceCross || preview?.deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL';
const localMin = preview?.city?.localMinQty ?? 2;
const crossMin = preview?.city?.crossMinQty ?? 6;
const min = delivery === 'LOCAL' ? localMin : crossMin;
if (next < min) {
setMsg(
delivery === 'LOCAL'
? `同城配送至少购买 ${min}`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
);
return;
}
setMsg('');
setQuantity(next);
const qs = new URLSearchParams(params);
qs.set('qty', String(next));
navigate({ search: qs.toString() }, { replace: true });
}
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
}),
});
const qs = new URLSearchParams();
qs.set('orderId', order.id);
qs.set('productId', productId);
qs.set('qty', String(quantity));
qs.set('addressId', addressId);
if (forceCross) qs.set('cross', '1');
navigate(`/pay?${qs.toString()}`);
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!phoneVerified && !phonePromptSkipped.current) {
setPendingSubmit(true);
setShowPhoneVerify(true);
return;
}
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
// 兼容旧接口:微信授权后提示可选绑定
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handleWechatPhoneBound() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handlePhoneVerified() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
return (
<div className="order-confirm-page">
<SubPageHeader
title="确认订单"
onBack={() => navigate(buildProductDetailUrl(productId))}
/>
<main className="order-confirm-main sub-page-body">
<button
type="button"
className="order-confirm-card order-confirm-address"
onClick={() => {
const qs = new URLSearchParams();
if (productId) qs.set('productId', productId);
qs.set('qty', String(quantity));
if (forceCross) qs.set('cross', '1');
if (addressId) qs.set('addressId', addressId);
qs.set('select', '1');
navigate(`/addresses?${qs.toString()}`);
}}
>
<span className="material-symbols-outlined order-confirm-pin fill-icon">location_on</span>
{selectedAddress ? (
<div className="order-confirm-address-body">
<div className="order-confirm-address-row">
<span className="order-confirm-address-name">{selectedAddress.receiverName}</span>
<span className="order-confirm-address-phone">{maskPhone(selectedAddress.phone)}</span>
</div>
<p className="order-confirm-address-detail">{formatAddress(selectedAddress)}</p>
</div>
) : (
<span className="order-confirm-address-placeholder"></span>
)}
<span className="material-symbols-outlined order-confirm-chevron">chevron_right</span>
</button>
{isCross && (
<div className="order-confirm-card order-confirm-warning">
<span className="material-symbols-outlined order-confirm-warning-icon">warning</span>
<p className="order-confirm-warning-text">
</p>
</div>
)}
{preview && (
<>
<section className="order-confirm-card order-confirm-product">
<div className="order-confirm-product-thumb">
<AppImage
src={productImage}
alt={preview.product.name}
wrapperClassName="app-image--fill"
/>
</div>
<div className="order-confirm-product-info">
<div>
<h3 className="order-confirm-product-name">{preview.product.name}</h3>
<p className="order-confirm-product-spec">
{preview.product.spec || preview.product.subtitle}
</p>
</div>
<div className="order-confirm-product-meta">
<span className="order-confirm-product-price">¥{preview.product.price}</span>
<div className="order-confirm-qty-stepper">
<button
type="button"
className="order-confirm-qty-btn"
disabled={quantity <= minQty}
aria-label="减少数量"
onClick={() => updateQuantity(quantity - 1)}
>
<span className="material-symbols-outlined">remove</span>
</button>
<span className="order-confirm-qty-value">{quantity}</span>
<button
type="button"
className="order-confirm-qty-btn order-confirm-qty-btn--plus"
aria-label="增加数量"
onClick={() => updateQuantity(quantity + 1)}
>
<span className="material-symbols-outlined">add</span>
</button>
</div>
</div>
</div>
</section>
<section className="order-confirm-card order-confirm-benefit coupon-notch">
<div className="order-confirm-benefit-inner">
<span className="order-confirm-benefit-badge"></span>
<span className="order-confirm-benefit-text">
¥{preview.benefitAmount}
</span>
</div>
</section>
<section className="order-confirm-card order-confirm-row-card">
<span className="order-confirm-row-label"></span>
<div className="order-confirm-delivery-value">
<p className="order-confirm-row-value">
{!addressId
? '选择地址后确认'
: isCross
? '物流配送'
: '小飞侠配送'}
</p>
{addressId && !isCross && (
<p className="order-confirm-delivery-hint">24</p>
)}
</div>
</section>
<section className="order-confirm-card order-confirm-summary">
<div className="order-confirm-summary-line">
<span></span>
<span>¥{preview.productAmount}</span>
</div>
<div className="order-confirm-summary-line">
<span></span>
<span className={isCross ? 'order-confirm-freight-cod' : ''}>
{isCross ? '到付' : '免运费'}
</span>
</div>
<div className="order-confirm-summary-total">
<span></span>
<span className="order-confirm-total-amount">¥{preview.payAmount}</span>
</div>
</section>
</>
)}
{previewLoading && !preview && productId && (
<div className="order-confirm-loading">...</div>
)}
{!previewLoading && !preview && productId && (
<div className="order-confirm-loading"></div>
)}
{msg && <p className="order-confirm-msg">{msg}</p>}
</main>
<footer className="order-confirm-footer">
<div className="order-confirm-footer-inner">
<div className="order-confirm-pay-label">
<span className="order-confirm-pay-prefix">:</span>
<span className="order-confirm-pay-amount">
¥{preview?.payAmount ?? '—'}
</span>
</div>
<button
type="button"
className="order-confirm-pay-btn"
disabled={loading || !preview || !addressId}
onClick={submit}
>
{loading ? '支付中...' : !addressId ? '请选择地址' : '微信支付'}
</button>
</div>
</footer>
<PhoneVerifySheet
open={showPhoneVerify}
defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过,不绑定也能继续下单。"
onClose={() => {
setShowPhoneVerify(false);
if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}}
onSuccess={handlePhoneVerified}
/>
<PhoneVerifySheet
open={showWechatBindPhone}
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过继续下单。"
onClose={() => {
setShowWechatBindPhone(false);
setWxSessionKey(null);
if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}}
onSuccess={handleWechatPhoneBound}
/>
</div>
);
}
-517
View File
@@ -1,517 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import AppToast from '../components/AppToast';
import { request } from '../lib/api';
import { buildOrderAddressSelectUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { handleShareButtonClick } from '../lib/wechat-share';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
import { usePageView } from '../lib/usePageView';
type OrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
providerOrderNo?: string;
};
type TrackNode = {
trackInfo?: string;
createdAt?: string;
};
type OrderPayment = {
status?: string;
};
type Order = {
id: string;
orderNo: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
status: string;
originOrderId?: string | null;
remark?: string | null;
receiverName: string;
receiverPhone: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress: string;
productAmount?: number;
freightAmount?: number;
payAmount: number;
benefitAmount: number;
createdAt: string;
items?: OrderItem[];
delivery?: OrderDelivery | null;
payment?: OrderPayment | null;
};
const PROGRESS_STEPS = [
{ label: '下单成功' },
{ label: '出库中' },
{ label: '配送中' },
{ label: '待签收' },
{ label: '完成' },
] as const;
const STATUS_BANNER: Record<string, { title: string; subtitle: string }> = {
PENDING_PAY: { title: '待付款', subtitle: '请尽快完成支付' },
PENDING_SHIP: { title: '待发货', subtitle: '商家正在备货,请耐心等待' },
OUT_WAREHOUSE: { title: '出库中', subtitle: '商品正在出库打包' },
SHIPPING: { title: '配送中', subtitle: '您的佳酿正在赶往您的餐桌,请保持电话畅通' },
PENDING_RECEIVE: { title: '待签收', subtitle: '包裹已送达,请确认签收' },
COMPLETED: { title: '已完成', subtitle: '感谢您的购买,期待再次光临' },
RESHIP: { title: '补发中', subtitle: '包裹正在揽收,请耐心等待' },
};
const EDITABLE_STATUSES = new Set(['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE']);
function maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatDateTime(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function progressActiveIndex(status: string) {
switch (status) {
case 'PENDING_PAY':
return 0;
case 'PENDING_SHIP':
return 1;
case 'OUT_WAREHOUSE':
return 1;
case 'SHIPPING':
return 2;
case 'PENDING_RECEIVE':
return 3;
case 'COMPLETED':
return 4;
default:
return 0;
}
}
function deliveryProviderLabel(provider?: string, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
if (provider === 'LOGISTICS') return '快递配送';
return provider;
}
function fullReceiverAddress(order: Order) {
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
if (region && order.receiverAddress.startsWith(region)) {
return order.receiverAddress;
}
return `${region}${order.receiverAddress}`;
}
export default function OrderDetailPage() {
const { id } = useParams();
usePageView('order_detail_view', { orderId: id });
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [showCs, setShowCs] = useState(false);
const [copyHint, setCopyHint] = useState('');
const [shareToast, setShareToast] = useState('');
const [confirming, setConfirming] = useState(false);
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() {
if (!id) return;
try {
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
setTrackNodes(data.nodes ?? []);
} catch {
setTrackNodes([]);
}
}
async function loadOrder() {
if (!id) return;
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
setOrder(data);
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
void loadTrack();
}
}
useEffect(() => {
loadOrder().catch(() => {});
}, [id]);
const banner = useMemo(() => {
if (!order) return { title: '', subtitle: '' };
if (isReship) return STATUS_BANNER.RESHIP;
return STATUS_BANNER[order.status] ?? { title: order.status, subtitle: '' };
}, [order, isReship]);
const activeProgress = order ? progressActiveIndex(order.status) : 0;
const item = order?.items?.[0];
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
const canPay = order?.status === 'PENDING_PAY' && !isReship;
const canRefund =
order &&
!canPay &&
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const canInvoice = order?.status === 'COMPLETED';
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0);
async function copyOrderNo() {
if (!order) return;
try {
await navigator.clipboard.writeText(order.orderNo);
setCopyHint('已复制');
window.setTimeout(() => setCopyHint(''), 2000);
} catch {
setCopyHint('复制失败');
}
}
async function confirmReceive() {
if (!id || !canConfirmReceive) return;
setConfirming(true);
try {
const updated = await request<Order>('USER_H5', `/trade/orders/${id}/confirm-receive`, {
method: 'POST',
});
setOrder(updated);
} catch (e) {
window.alert(e instanceof Error ? e.message : '确认收货失败');
} finally {
setConfirming(false);
}
}
if (!order) return <div className="empty">...</div>;
return (
<div className="order-detail-page order-detail-page--stitch">
<header className="order-detail-topbar">
<button type="button" className="order-detail-topbar-btn" aria-label="返回" onClick={() => navigate(-1)}>
<span className="material-symbols-outlined">arrow_back_ios</span>
</button>
<h1 className="order-detail-topbar-title"></h1>
<div className="order-detail-topbar-actions">
<button type="button" className="order-detail-topbar-btn" aria-label="分享" onClick={() => handleShareButtonClick(setShareToast)}>
<span className="material-symbols-outlined">share</span>
</button>
<button type="button" className="order-detail-topbar-btn" aria-label="更多" onClick={() => {}}>
<span className="material-symbols-outlined">more_horiz</span>
</button>
</div>
</header>
<AppToast message={shareToast} />
<main className="order-detail-main order-detail-main--stitch">
<section className="order-detail-status-card">
<div className="order-detail-status-deco" aria-hidden>
<span className="material-symbols-outlined">local_shipping</span>
</div>
<div className="order-detail-status-body">
<div className="order-detail-status-head">
<span className="material-symbols-outlined filled">local_shipping</span>
<h2>{banner.title}</h2>
</div>
<p>{banner.subtitle}</p>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
· {order.proxyPartnerName}
</p>
) : null}
</div>
</section>
{!isReship && (
<section className="order-detail-card order-detail-progress-card">
<div className="order-detail-progress">
{PROGRESS_STEPS.map((step, index) => {
const done = index < activeProgress;
const active = index === activeProgress;
const pending = index > activeProgress;
return (
<div
key={step.label}
className={`order-detail-progress-step${active ? ' active' : ''}${done ? ' done' : ''}${pending ? ' pending' : ''}`}
>
{index < PROGRESS_STEPS.length - 1 && (
<span className={`order-detail-progress-line${done ? ' done' : ''}`} aria-hidden />
)}
<div className="order-detail-progress-dot">
{done && <span className="material-symbols-outlined">check</span>}
{active && <span className="order-detail-progress-pulse" />}
{pending && <span className="order-detail-progress-idle" />}
</div>
<span className="order-detail-progress-label">{step.label}</span>
</div>
);
})}
</div>
</section>
)}
{isReship && (
<section className="order-detail-card order-detail-reship-info">
<div className="order-detail-kv">
<span></span>
<span>{order.remark || '商品破损'}</span>
</div>
<div className="order-detail-kv">
<span></span>
{order.originOrderId ? (
<Link to={`/orders/${order.originOrderId}`} className="order-detail-link">
</Link>
) : (
<span></span>
)}
</div>
</section>
)}
{item && (
<section className="order-detail-card">
<div className="order-detail-product">
<div className="order-detail-product-thumb">
<AppImage src={productImage} alt={item.productName} wrapperClassName="app-image--fill" />
</div>
<div className="order-detail-product-info">
<div>
<h3 className="order-detail-product-name">{item.productName}</h3>
<p className="order-detail-product-spec">{item.productSpec}</p>
</div>
<div className="order-detail-product-meta">
<span className="order-detail-product-price">
<small>¥</small>
{formatMoney(isReship ? 0 : Number(item.unitPrice)).replace('.00', '')}
</span>
<span className="order-detail-product-qty">x{item.quantity}</span>
</div>
</div>
</div>
{Number(order.benefitAmount) > 0 && !isReship && (
<div className="order-detail-benefit-panel">
<div className="order-detail-benefit-panel-left">
<div className="order-detail-benefit-icon">
<span className="material-symbols-outlined filled">confirmation_number</span>
</div>
<div>
<div className="order-detail-benefit-amount">
<span>¥{Math.round(Number(order.benefitAmount))}</span>
<span></span>
</div>
<p className="order-detail-benefit-sub">100+</p>
</div>
</div>
<button type="button" className="order-detail-benefit-use" onClick={() => navigate('/redeem')}>
使
</button>
</div>
)}
</section>
)}
<section className="order-detail-card">
<div className="order-detail-card-head">
<h3>
<span className="material-symbols-outlined">location_on</span>
</h3>
{canEditAddress && id && (
<button
type="button"
className="order-detail-edit-btn"
onClick={() => navigate(buildOrderAddressSelectUrl(id))}
>
<span className="material-symbols-outlined">edit</span>
</button>
)}
</div>
<div className="order-detail-kv-list">
<div className="order-detail-kv">
<span></span>
<span>
{order.receiverName} {maskPhone(order.receiverPhone)}
</span>
</div>
<div className="order-detail-kv order-detail-kv--address">
<span></span>
<span>{fullReceiverAddress(order)}</span>
</div>
<div className="order-detail-kv">
<span></span>
<span>
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
</span>
</div>
{order.delivery?.trackingNo && (
<div className="order-detail-kv">
<span></span>
<span>{order.delivery.trackingNo}</span>
</div>
)}
{order.delivery?.manualQueryUrl && (
<div className="order-detail-kv">
<span></span>
<a
href={order.delivery.manualQueryUrl}
target="_blank"
rel="noreferrer"
className="order-detail-link"
>
</a>
</div>
)}
</div>
</section>
{trackNodes.length > 0 && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
<span className="material-symbols-outlined">timeline</span>
</h3>
<div className="order-detail-kv-list order-detail-kv-list--bordered">
{trackNodes.map((node, index) => (
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
<span>{node.trackInfo || '—'}</span>
</div>
))}
</div>
</section>
)}
{!isReship && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
<span className="material-symbols-outlined">info</span>
</h3>
<div className="order-detail-kv-list order-detail-kv-list--bordered">
<div className="order-detail-kv">
<span></span>
<span className="order-detail-kv-inline">
<span className="order-detail-order-no">{order.orderNo}</span>
<button type="button" className="order-detail-copy-btn" onClick={copyOrderNo}>
{copyHint || '复制'}
</button>
</span>
</div>
<div className="order-detail-kv">
<span></span>
<span>{formatDateTime(order.createdAt)}</span>
</div>
<div className="order-detail-kv">
<span></span>
<span>{order.payment?.status === 'SUCCESS' ? '微信支付' : '—'}</span>
</div>
</div>
<div className="order-detail-summary">
<div className="order-detail-kv order-detail-kv--muted">
<span></span>
<span>¥{formatMoney(productTotal)}</span>
</div>
<div className="order-detail-kv order-detail-kv--muted">
<span></span>
<span>¥{formatMoney(freightTotal)}</span>
</div>
<div className="order-detail-kv order-detail-kv--total">
<span></span>
<span className="order-detail-pay-total">¥{formatMoney(Number(order.payAmount))}</span>
</div>
</div>
</section>
)}
</main>
<footer className="order-detail-actionbar">
<button type="button" className="order-detail-action-outline" onClick={() => setShowCs(true)}>
<span className="material-symbols-outlined">headset_mic</span>
</button>
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
>
</button>
)}
{canInvoice && (
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
>
</button>
)}
{canPay && (
<button
type="button"
className="order-detail-action-primary"
onClick={() => navigate(`/pay?orderId=${order.id}`)}
>
<span className="material-symbols-outlined">payments</span>
¥{formatMoney(Number(order.payAmount))}
</button>
)}
{canConfirmReceive && (
<button
type="button"
className="order-detail-action-primary"
disabled={confirming}
onClick={confirmReceive}
>
<span className="material-symbols-outlined">inventory</span>
{confirming ? '提交中...' : '确认收货'}
</button>
)}
</footer>
{showCs && (
<ContactCustomerSheet
orderId={order.id}
orderNo={order.orderNo}
onClose={() => setShowCs(false)}
/>
)}
</div>
);
}
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import OrderStatusTabs from '@dukang/shared-ui/OrderStatusTabs';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
const TABS = [
{ key: 'all', label: '全部' },
{ key: 'pending_pay', label: '待付款' },
{ key: 'paid', label: '已付款' },
{ key: 'completed', label: '已完成' },
];
const STATUS_LABEL: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
function isReshipOrder(order: Record<string, unknown>) {
return String(order.orderType) === 'RESHIPMENT';
}
function orderStatusLabel(order: Record<string, unknown>) {
if (isReshipOrder(order)) return '补发中';
return STATUS_LABEL[String(order.status)] || String(order.status);
}
export default function OrderListPage() {
const [params, setParams] = useSearchParams();
const tab = params.get('tab') || 'all';
usePageView('order_list_view', { tab });
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
const navigate = useNavigate();
useEffect(() => {
request<{ list: Array<Record<string, unknown>> }>('USER_H5', `/trade/orders?tab=${tab}`).then(setData);
}, [tab]);
return (
<div className="page-no-tab">
<PageHeader title="我的订单" onBack={() => navigate('/mine')} />
<OrderStatusTabs tabs={TABS} active={tab} onChange={(key) => setParams({ tab: key })} />
{data.list.length === 0 && <div className="empty"></div>}
{data.list.map((o) => {
const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0];
const isReship = isReshipOrder(o);
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
const isPendingPay = String(o.status) === 'PENDING_PAY';
return (
<div key={String(o.id)} className="card">
<div className="card-row" style={{ marginBottom: 8 }}>
<span className="label-md text-muted">
: {String(o.orderNo)}
{isReship && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
{isProxy && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
</span>
<span className="status-tag">{orderStatusLabel(o)}</span>
</div>
{isProxy && o.proxyPartnerName ? (
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
{String(o.proxyPartnerName)}
</div>
) : null}
{item && (
<div className="card-row">
<AppImage
src={String(item.productImage)}
alt={String(item.productName || '')}
wrapperClassName="order-list-thumb"
/>
<div style={{ flex: 1 }}>
<div className="headline-md">{String(item.productName)}</div>
<div className="text-muted body-md">{String(item.productSpec)} x{Number(item.quantity)}</div>
</div>
<div className="amount-lg">¥{Number(o.payAmount)}</div>
</div>
)}
<div style={{ textAlign: 'right', marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{isPendingPay && (
<button
type="button"
className="btn btn-primary btn-pill"
onClick={() => navigate(`/pay?orderId=${o.id}`)}
>
</button>
)}
<Link to={`/orders/${o.id}`} className="btn btn-outline btn-pill">
</Link>
</div>
</div>
);
})}
</div>
);
}
-239
View File
@@ -1,239 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatPayOrderResult } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { request } from '../lib/api';
import { buildOrderConfirmUrl } from '../lib/navigation';
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
isWechatAuthRequiredError,
needsWechatAuthForPay,
saveWechatLoginResult,
} from '../lib/pay-wechat';
import { applyWechatLoginResult, handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { usePageView } from '../lib/usePageView';
import { track } from '../lib/analytics';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitOrderPaid(orderId: string, maxAttempts = 15) {
for (let i = 0; i < maxAttempts; i += 1) {
const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`);
if (order.payStatus === 'PAID') return true;
await sleep(2000);
}
return false;
}
export default function PayPage() {
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
usePageView('pay_page_view', { orderId });
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [showBindPhone, setShowBindPhone] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [orderNo, setOrderNo] = useState('');
const [deliveryType, setDeliveryType] = useState('');
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
return null;
}
}, []);
const handleWechatLoginResult = useCallback(
async (result: Parameters<typeof applyWechatLoginResult>[0]) => {
if (result.needBindPhone && result.wxSessionKey) {
setWxSessionKey(result.wxSessionKey);
setShowBindPhone(true);
setMsg('');
return;
}
if (saveWechatLoginResult(result)) {
setMsg('');
await refreshPayReadiness();
}
},
[refreshPayReadiness],
);
useEffect(() => {
refreshPayReadiness();
}, [refreshPayReadiness]);
useEffect(() => {
if (!orderId) {
setOrderNo('');
return;
}
request<{ orderNo?: string; deliveryType?: string }>('USER_H5', `/trade/orders/${orderId}`)
.then((order) => {
setOrderNo(order.orderNo || '');
setDeliveryType(order.deliveryType || '');
})
.catch(() => {
setOrderNo('');
setDeliveryType('');
});
}, [orderId]);
useEffect(() => {
if (!isWechatEnv()) return;
handleWechatAuthCallback()
.then((result) => {
if (result) void handleWechatLoginResult(result);
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信授权失败'));
}, [handleWechatLoginResult]);
function goBackConfirm() {
navigate(
buildOrderConfirmUrl({
productId: params.get('productId'),
qty: params.get('qty'),
addressId: params.get('addressId'),
cross: params.get('cross'),
}),
);
}
async function wechatAuthorize() {
setAuthLoading(true);
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以授权微信支付');
return;
}
const result = await authorizeWechatForPay();
if (result) await handleWechatLoginResult(result);
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthLoading(false);
}
}
async function pay() {
if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
method: 'POST',
});
if (result.mode === 'jsapi' && result.prepay) {
setMockMode(false);
const { weixinSdk } = await import('../lib/weixin');
await weixinSdk.pay(result.prepay);
const paid = await waitOrderPaid(orderId);
if (!paid) {
alert('支付结果确认中,请稍后在订单列表查看');
}
navigate(
deliveryType === 'ON_SITE_PICKUP' ? '/orders?tab=completed' : '/orders?tab=paid',
);
return;
}
navigate(deliveryType === 'ON_SITE_PICKUP' ? '/orders?tab=completed' : '/orders?tab=paid');
} catch (e) {
if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权');
return;
}
setMsg(e instanceof Error ? e.message : '支付失败');
track('pay_fail', {
orderId,
failReason: e instanceof Error ? e.message : '支付失败',
pagePath: '/pay',
});
} finally {
setLoading(false);
}
}
return (
<div className="page-no-tab pay-page">
<SubPageHeader title="微信支付" onBack={goBackConfirm} />
<div className="pay-body sub-page-body">
<div className="pay-icon">
<span className="material-symbols-outlined">account_balance_wallet</span>
</div>
<p className="headline-lg text-primary">
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
</p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>
{needsWechatAuth
? '使用微信支付前,需先授权微信账号以完成付款'
: mockMode
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
: '请在微信内完成支付,支付成功后自动跳转'}
</p>
{needsWechatAuth && (
<div className="pay-wechat-auth-card">
<p className="pay-wechat-auth-title"></p>
<p className="pay-wechat-auth-desc"></p>
<button
type="button"
className="login-wechat-btn pay-wechat-auth-btn"
disabled={authLoading}
onClick={wechatAuthorize}
>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span>{authLoading ? '授权中...' : '微信一键授权'}</span>
</button>
</div>
)}
{msg && <p className="pay-wechat-auth-msg">{msg}</p>}
{orderNo && <p className="label-md text-muted" style={{ marginTop: 24 }}> {orderNo}</p>}
</div>
<div className="page-actions">
<button
type="button"
className="btn btn-primary btn-block"
disabled={loading || !orderId || needsWechatAuth}
onClick={pay}
>
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
</button>
</div>
<PhoneVerifySheet
open={showBindPhone}
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
onClose={() => {
setShowBindPhone(false);
setWxSessionKey(null);
}}
onSuccess={() => {
void refreshPayReadiness();
}}
/>
</div>
);
}
@@ -1,168 +0,0 @@
import type { ProductDetailContentDto } from '@dukang/shared-types';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import AppImage from '@dukang/shared-ui/AppImage';
import ProductCarousel from '../components/ProductCarousel';
import AppToast from '../components/AppToast';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import { handleShareButtonClick } from '../lib/wechat-share';
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
import type { ProductImageSource } from '../lib/product-images';
type Product = ProductImageSource & {
name: string;
subtitle?: string;
price: number;
benefitAmount?: number;
detailContent?: ProductDetailContentDto | null;
};
export default function ProductDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const [toast, setToast] = useState('');
useEffect(() => {
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
}, [id]);
useEffect(() => {
if (id) {
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
}
}, [id]);
useEffect(() => {
function onScroll() {
setHeaderSolid(window.scrollY > 100);
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (!product) return <div className="empty">...</div>;
const benefit = Number(product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
return (
<div className="product-detail-page">
<header className={`product-detail-header${headerSolid ? ' solid' : ''}`}>
<button
type="button"
className="product-detail-header-btn"
aria-label="返回"
onClick={() => navigate('/')}
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className={`app-page-title product-detail-header-title${headerSolid ? ' visible' : ''}`}>
{product.name}
</h1>
<button
type="button"
className="product-detail-header-btn"
aria-label="分享"
onClick={() => handleShareButtonClick(setToast)}
>
<span className="material-symbols-outlined">share</span>
</button>
</header>
<AppToast message={toast} />
<main className="product-detail-main">
<section className="product-detail-hero">
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
</section>
<section className="product-detail-info">
<div className="product-detail-price">
<span className="product-detail-price-symbol">¥</span>
<span className="product-detail-price-value">{product.price}</span>
</div>
<h2 className="product-detail-name">{product.name}</h2>
{product.subtitle && (
<p className="product-detail-subtitle">{product.subtitle}</p>
)}
<div className="product-detail-promo">
<div className="product-detail-promo-glow" aria-hidden />
<div className="product-detail-promo-head">
<div className="product-detail-promo-icon">
<span className="material-symbols-outlined fill-icon">confirmation_number</span>
</div>
<h3 className="product-detail-promo-title">
·
<span className="product-detail-promo-amount">¥{benefit}</span>
</h3>
</div>
<p className="product-detail-promo-desc">
</p>
<span className="material-symbols-outlined product-detail-promo-deco" aria-hidden>
restaurant
</span>
</div>
</section>
<section className="product-detail-content">
<div className="product-detail-section-head">
<span className="product-detail-section-bar" />
<h3></h3>
</div>
{detailImages.map((src, index) => (
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
))}
{(detail.storyTitle || detail.storyText || features.length > 0) && (
<div className="product-detail-copy">
{(detail.storyTitle || detail.storyText) && (
<div className="product-detail-story">
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
{detail.storyText && <p>{detail.storyText}</p>}
</div>
)}
{features.length > 0 && (
<div className="product-detail-features">
{features.map((f) => (
<div key={`${f.title}-${f.icon}`} className="product-detail-feature">
<span className="material-symbols-outlined">{f.icon}</span>
<div className="product-detail-feature-title">{f.title}</div>
<div className="product-detail-feature-desc">{f.desc}</div>
</div>
))}
</div>
)}
</div>
)}
</section>
</main>
<nav className="product-detail-bar">
<Link to="/" className="product-detail-bar-home">
<span className="material-symbols-outlined">home</span>
<span></span>
</Link>
<button
type="button"
className="product-detail-buy-btn"
onClick={() => {
track('product_click', { refType: 'PRODUCT', refId: id, productId: id });
navigate(`/order/confirm?productId=${id}&qty=2`);
}}
>
</button>
</nav>
</div>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { usePageView } from '../lib/usePageView';
export default function RedeemCodePage() {
usePageView('redeem_code_view');
const navigate = useNavigate();
const token = sessionStorage.getItem('redeemToken') || '';
const amount = sessionStorage.getItem('redeemAmount') || '0';
const [secondsLeft, setSecondsLeft] = useState(REDEEM_TOKEN_TTL_SECONDS);
useEffect(() => {
const expireAt = sessionStorage.getItem('redeemExpireAt');
if (!expireAt) return;
const tick = () => {
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
setSecondsLeft(left);
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
return (
<div className="page-no-tab" style={{ textAlign: 'center' }}>
<PageHeader title="核销码展示" onBack={() => navigate(-1)} />
<p className="text-variant body-md page-actions" style={{ marginBottom: 0, paddingBottom: 0 }}></p>
<div className="card" style={{ padding: 32 }}>
<div className="label-md text-muted"></div>
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
<div className="code-box">{token}</div>
<p className="label-md text-muted" style={{ marginTop: 16 }}>
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
</button>
</div>
</div>
);
}
-359
View File
@@ -1,359 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
const POLL_INTERVAL_MS = 2500;
type BenefitSummary = {
totalBalance: number;
maxRedeemAmount: number;
activeCouponCount: number;
};
type RedeemTokenStatus =
| { status: 'PENDING'; expireInSeconds: number; amount: number }
| {
status: 'CONSUMED';
record: {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
}
| { status: 'EXPIRED' };
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatTimer(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
export default function RedeemPage() {
usePageView('benefit_redeem_start');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const couponId = searchParams.get('couponId') ?? undefined;
const presetAmount = searchParams.get('amount');
const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amountInput, setAmountInput] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [token, setToken] = useState('');
const [confirmAmount, setConfirmAmount] = useState(0);
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<number | null>(null);
const presetApplied = useRef(false);
const successHandled = useRef(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
useEffect(() => {
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
return () => stopTimer();
}, []);
useEffect(() => {
if (!couponId) {
setCouponBalance(null);
return;
}
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons')
.then((list) => {
const found = list.find((c) => String(c.id) === couponId);
if (found) {
setCouponBalance(Number(found.balance));
}
})
.catch(() => {});
}, [couponId]);
useEffect(() => {
if (presetApplied.current) return;
if (presetAmount && Number(presetAmount) > 0) {
setAmountInput(formatMoney(Number(presetAmount)));
presetApplied.current = true;
}
}, [presetAmount]);
function stopTimer() {
if (timerRef.current != null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
}
const closeModal = useCallback(() => {
stopTimer();
setModalOpen(false);
setToken('');
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
successHandled.current = false;
}, []);
const handleRedeemExpired = useCallback(() => {
if (successHandled.current) return;
successHandled.current = true;
closeModal();
sessionStorage.removeItem('redeemToken');
sessionStorage.removeItem('redeemAmount');
setMsg('核销码已失效,请重新生成');
}, [closeModal]);
const handleRedeemSuccess = useCallback(
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
if (successHandled.current) return;
successHandled.current = true;
stopTimer();
setModalOpen(false);
setToken('');
sessionStorage.setItem('lastRedeemRecordId', record.id);
sessionStorage.setItem('lastRedeemResult', JSON.stringify(record));
sessionStorage.removeItem('redeemToken');
sessionStorage.removeItem('redeemAmount');
navigate('/redeem/success', {
replace: true,
state: { record },
});
},
[navigate],
);
function startTimer() {
stopTimer();
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
timerRef.current = window.setInterval(() => {
setTimerSec((prev) => {
if (prev <= 1) {
stopTimer();
window.setTimeout(() => handleRedeemExpired(), 0);
return 0;
}
return prev - 1;
});
}, 1000);
}
useEffect(() => {
if (!modalOpen || !token) return undefined;
let cancelled = false;
async function pollStatus() {
try {
const status = await request<RedeemTokenStatus>('USER_H5', `/redeem/tokens/${token}/status`);
if (cancelled || successHandled.current) return;
if (status.status === 'CONSUMED' && status.record) {
handleRedeemSuccess(status.record);
} else if (status.status === 'EXPIRED') {
handleRedeemExpired();
} else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
}
} catch {
/* 轮询失败忽略,下次重试 */
}
}
void pollStatus();
const pollId = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(pollId);
};
}, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]);
function parseAmount() {
const value = Number(amountInput);
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
}
function fillMaxAmount() {
if (redeemableMax <= 0) return;
setAmountInput(formatMoney(redeemableMax));
setMsg('');
}
async function submit() {
const amount = parseAmount();
if (!summary) return;
const maxAllowed = couponId ? (couponBalance ?? 0) : summary.totalBalance;
if (amount <= 0) {
setMsg('请输入核销金额');
return;
}
if (amount > maxAllowed) {
setMsg(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
return;
}
setLoading(true);
setMsg('');
successHandled.current = false;
try {
const body: { amount: number; couponId?: string } = { amount };
if (couponId) body.couponId = couponId;
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
method: 'POST',
body: JSON.stringify(body),
});
setToken(data.token);
setConfirmAmount(data.amount);
setModalOpen(true);
startTimer();
sessionStorage.setItem('redeemToken', data.token);
sessionStorage.setItem('redeemAmount', String(data.amount));
} catch (e) {
setMsg(e instanceof Error ? e.message : '生成失败');
} finally {
setLoading(false);
}
}
const qrUrl = token
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
: '';
return (
<div className="redeem-page">
<header className="redeem-header">
<button type="button" className="redeem-header-btn" aria-label="返回" onClick={() => navigate(-1)}>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
<button
type="button"
className="redeem-header-btn"
aria-label="帮助"
onClick={() => window.alert('核销金额不超过可用余额,核销码 3 分钟内有效。')}
>
<span className="material-symbols-outlined">help_outline</span>
</button>
</header>
<main className="redeem-main">
<section className="redeem-balance-card">
<div className="redeem-balance-pattern" aria-hidden />
<div className="redeem-balance-inner">
<span className="redeem-balance-label">
{couponId ? '当前权益可用余额' : '当前好客权益可用余额'}
</span>
<div className="redeem-balance-amount">
<span className="redeem-balance-symbol">¥</span>
<span className="redeem-balance-value">
{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
</span>
</div>
</div>
</section>
<section className="redeem-form-card">
<label className="redeem-form-label" htmlFor="redeem-amount">
(¥)
</label>
<div className="redeem-amount-row">
<span className="redeem-amount-symbol">¥</span>
<input
id="redeem-amount"
className="redeem-amount-input"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="0.00"
value={amountInput}
onChange={(e) => {
setAmountInput(e.target.value);
setMsg('');
}}
/>
</div>
<div className="redeem-amount-foot">
<span> ¥{formatMoney(redeemableMax)}</span>
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
</button>
</div>
</section>
{msg && <p className="redeem-msg">{msg}</p>}
<button
type="button"
className="redeem-submit-btn"
disabled={loading || redeemableMax <= 0}
onClick={submit}
>
<span className="material-symbols-outlined">qr_code_2</span>
{loading ? '生成中...' : '生成核销码'}
</button>
<div className="redeem-security">
<span className="material-symbols-outlined">shield</span>
<p> 3 </p>
</div>
</main>
<div className={`redeem-modal${modalOpen ? ' open' : ''}`} aria-hidden={!modalOpen}>
<div className="redeem-modal-backdrop" onClick={closeModal} aria-hidden />
<div className={`redeem-modal-panel${modalOpen ? ' open' : ''}`}>
<div className="redeem-modal-head">
<h3></h3>
<p></p>
</div>
<div className="redeem-modal-body">
<div className="redeem-qr-wrap">
<div className="redeem-qr-box">
{qrUrl ? (
<img className="redeem-qr-img" src={qrUrl} alt="核销二维码" />
) : (
<div className="redeem-qr-placeholder" />
)}
<div className="redeem-qr-scanline" aria-hidden />
</div>
</div>
<div className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}>
<span className="redeem-timer-value">{formatTimer(timerSec)}</span>
<span className="redeem-timer-label"></span>
</div>
<div className="redeem-modal-amount">
<p></p>
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
</div>
{token && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
wordBreak: 'break-all',
padding: '0 16px',
}}
>
{token}
</p>
</div>
)}
</div>
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
</button>
</div>
</div>
</div>
);
}
@@ -1,134 +0,0 @@
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
type RedeemRecord = {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function StarRating({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (score: number) => void;
}) {
return (
<div className="redeem-rating-row">
<span className="redeem-rating-label">{label}</span>
<div className="redeem-star-row" role="group" aria-label={label}>
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
type="button"
className={`redeem-star-btn${score <= value ? ' active' : ''}`}
aria-label={`${score}`}
onClick={() => onChange(score)}
>
</button>
))}
</div>
</div>
);
}
export default function RedeemSuccessPage() {
usePageView('redeem_success_view');
const navigate = useNavigate();
const location = useLocation();
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [loading, setLoading] = useState(false);
const record = useMemo<RedeemRecord | null>(() => {
const fromState = (location.state as { record?: RedeemRecord })?.record;
if (fromState) return fromState;
try {
const cached = sessionStorage.getItem('lastRedeemResult');
return cached ? (JSON.parse(cached) as RedeemRecord) : null;
} catch {
return null;
}
}, [location.state]);
const amount = Number(record?.amount ?? 0);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = record?.createdAt
? new Date(record.createdAt).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
async function submit() {
setLoading(true);
try {
const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId');
if (redeemRecordId) {
await request('USER_H5', '/redeem/ratings', {
method: 'POST',
body: JSON.stringify({
redeemRecordId,
serviceScore,
envScore,
}),
});
}
} catch {
/* 评价失败不阻塞返回 */
} finally {
setLoading(false);
sessionStorage.removeItem('lastRedeemRecordId');
sessionStorage.removeItem('lastRedeemResult');
navigate('/benefit', { replace: true });
}
}
return (
<div className="page-no-tab redeem-success-page">
<PageHeader title="核销成功" onBack={() => navigate('/benefit', { replace: true })} />
<div className="redeem-success-hero">
<div className="success-icon"></div>
<h2 className="headline-lg text-primary"></h2>
<p className="redeem-success-amount">¥ {formatMoney(amount)}</p>
<p className="text-variant body-md"> {storeName} </p>
</div>
<div className="card redeem-success-details">
<div className="redeem-success-detail-row">
<span></span>
<span>{storeName}</span>
</div>
<div className="redeem-success-detail-row">
<span></span>
<span>{redeemedAt}</span>
</div>
<div className="redeem-success-detail-row">
<span></span>
<span className="redeem-success-mono">{redeemNo}</span>
</div>
</div>
<div className="card redeem-success-rating">
<p className="redeem-success-rating-title"></p>
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
{loading ? '提交中...' : '完成'}
</button>
</div>
</div>
);
}
-238
View File
@@ -1,238 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import ProductCarousel from '../components/ProductCarousel';
import AppToast from '../components/AppToast';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import { handleShareButtonClick } from '../lib/wechat-share';
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
type StoreDetail = {
id: string;
name: string;
phone: string;
province: string;
cityName?: string;
city?: string;
district: string;
address: string;
intro?: string | null;
coverUrl?: string | null;
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
media?: StoreMedia[];
};
const SERVICES = [
{ icon: 'wifi', label: 'WiFi' },
{ icon: 'local_parking', label: '免费停车' },
{ icon: 'meeting_room', label: '独立包间' },
{ icon: 'table_restaurant', label: '宴会大厅' },
] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '2.4km', '3.5km'];
const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '09:30-22:00';
}
function fullAddress(store: StoreDetail) {
const city = store.cityName || store.city || '';
return `${store.province}${city}${store.district}${store.address}`;
}
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [store, setStore] = useState<StoreDetail | null>(null);
const [benefitBalance, setBenefitBalance] = useState(0);
const [headerSolid, setHeaderSolid] = useState(false);
const [toast, setToast] = useState('');
useEffect(() => {
if (id) {
request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
track('store_detail_view', { refType: 'STORE', refId: id, storeId: id });
}
}, [id]);
useEffect(() => {
request<Array<{ balance: number; status: string }>>('USER_H5', '/benefit/coupons')
.then((list) => {
const balance = list.reduce((sum, c) => {
if (c.status === 'ACTIVE') return sum + Number(c.balance || 0);
return sum;
}, 0);
setBenefitBalance(balance);
})
.catch(() => {});
}, []);
useEffect(() => {
function onScroll() {
setHeaderSolid(window.scrollY > 80);
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const galleryImages = useMemo(() => {
if (!store) return [];
return getStoreGalleryImages(store.coverUrl, store.media);
}, [store]);
const distance = MOCK_DISTANCES[Number(id || 0) % MOCK_DISTANCES.length];
const isOpen = store?.status === 'OPEN';
if (!store) {
return <div className="empty store-detail-page">...</div>;
}
function callStore() {
if (store?.phone) window.location.href = `tel:${store.phone}`;
}
function openMap() {
window.alert('preV1:导航功能即将开放');
}
return (
<div className="store-detail-page">
<header className={`store-detail-header${headerSolid ? ' solid' : ''}`}>
<button
type="button"
className="store-detail-header-btn"
aria-label="返回"
onClick={() => navigate('/stores')}
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className={`app-page-title store-detail-header-title${headerSolid ? ' visible' : ''}`}></h1>
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => handleShareButtonClick(setToast)}>
<span className="material-symbols-outlined">share</span>
</button>
</header>
<AppToast message={toast} />
<main className="store-detail-main">
<section className="store-detail-hero">
<ProductCarousel images={galleryImages} alt={store.name} variant="store" />
</section>
<section className="store-detail-info-wrap">
<div className="store-detail-info-card">
<div className="store-detail-info-head">
<div>
<h2 className="store-detail-name">{store.name}</h2>
<p className="store-detail-hours">{formatHours(store)}</p>
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<p className="store-detail-hours"> ¥{Number(store.avgPrice).toFixed(0)}</p>
) : null}
{store.category?.name && (
<span className="store-detail-category">{store.category.name}</span>
)}
</div>
<div className={`store-detail-status${isOpen ? '' : ' closed'}`}>
{isOpen && <span className="store-detail-status-dot" />}
<span>{isOpen ? '营业中' : '休息中'}</span>
</div>
</div>
</div>
</section>
<section className="store-detail-tip">
<span className="material-symbols-outlined">info</span>
<p>2</p>
</section>
<section className="store-detail-location">
<div className="store-detail-location-card">
<div className="store-detail-map">
<AppImage src={STITCH_STORE_MAP} alt="" wrapperClassName="app-image--fill" />
<div className="store-detail-map-gradient" aria-hidden />
</div>
<div className="store-detail-location-body">
<div className="store-detail-location-text">
<p>{fullAddress(store)}</p>
<p className="store-detail-distance">
<span className="material-symbols-outlined">near_me</span>
{distance}
</p>
</div>
<div className="store-detail-location-actions">
<button type="button" className="store-detail-action-btn" aria-label="电话" onClick={callStore}>
<span className="material-symbols-outlined">call</span>
</button>
<button
type="button"
className="store-detail-action-btn store-detail-action-btn--primary"
aria-label="导航"
onClick={openMap}
>
<span className="material-symbols-outlined">directions</span>
</button>
</div>
</div>
</div>
</section>
<section className="store-detail-services">
<h3></h3>
<div className="store-detail-service-grid">
{SERVICES.map((s) => (
<div key={s.label} className="store-detail-service-item">
<span className="material-symbols-outlined">{s.icon}</span>
<span>{s.label}</span>
</div>
))}
</div>
</section>
<section className="store-detail-intro">
<h3></h3>
<div className="store-detail-intro-card">
<p>{store.intro || DEFAULT_INTRO}</p>
<div className="store-detail-intro-foot">
<div className="store-detail-intro-avatars" aria-hidden>
<span />
<span />
<span />
</div>
<span className="store-detail-intro-stat"> 1.2w </span>
</div>
</div>
</section>
</main>
<footer className="store-detail-footer">
<div className="store-detail-footer-inner">
<div className="store-detail-balance">
<span className="store-detail-balance-label"></span>
<span className="store-detail-balance-value">
¥{benefitBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<button type="button" className="store-detail-redeem-btn" onClick={() => navigate('/redeem')}>
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
</div>
</footer>
</div>
);
}
-288
View File
@@ -1,288 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import TabMainHeader from '../components/TabMainHeader';
import RegionPicker from '../components/RegionPicker';
import {
DEFAULT_REGION,
FALLBACK_CITY_REGION,
formatRegionCity,
REGION_ALL,
toCityLevelRegion,
type RegionSelection,
} from '../lib/region-data';
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
type OpenCity = {
code: string;
name: string;
province: string;
};
type StoreItem = {
id: string;
name: string;
province?: string;
cityName?: string;
district: string;
address: string;
coverUrl?: string | null;
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
};
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
}
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
function storeCover(store: StoreItem, index: number) {
if (store.coverUrl) return String(store.coverUrl);
const imgs = ['/images/1.png', '/images/2.png', '/images/3.png'];
return imgs[index % imgs.length];
}
function formatHours(store: StoreItem) {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
}
export default function StoreListPage() {
const navigate = useNavigate();
const [cities, setCities] = useState<OpenCity[]>([]);
const [stores, setStores] = useState<StoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(() => toCityLevelRegion(DEFAULT_REGION));
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
const [usedFallback, setUsedFallback] = useState(false);
const [geoReady, setGeoReady] = useState(false);
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
useEffect(() => {
track('store_list_view', { pagePath: '/stores' });
}, []);
useEffect(() => {
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
}, []);
useEffect(() => {
if (filterMode !== 'auto' || geoReady) return;
resolveUserCity().then((resolved) => {
if (resolved) {
setRegion(toCityLevelRegion(resolved.region));
}
setGeoReady(true);
});
}, [filterMode, geoReady]);
const fetchStores = useCallback(
async (targetRegion: RegionSelection, allowFallback: boolean) => {
const code = resolveCityCode(targetRegion, cities);
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
if (
allowFallback &&
filterMode === 'auto' &&
data.length === 0 &&
!usedFallback &&
code &&
code !== FALLBACK_CITY_CODE
) {
setUsedFallback(true);
setRegion(toCityLevelRegion(FALLBACK_CITY_REGION));
return null;
}
return data;
},
[cities, filterMode, usedFallback],
);
useEffect(() => {
if (filterMode === 'auto' && !geoReady) return;
let cancelled = false;
setLoading(true);
fetchStores(region, true)
.then((data) => {
if (!cancelled && data) setStores(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
const filtered = useMemo(() => {
let list = stores;
if (categoryTab !== '全部') {
list = list.filter((s) => s.category?.name === categoryTab);
}
if (!cityCode) {
list = list.filter((s) => {
const province = s.province ?? '河南省';
const city = s.cityName ?? '郑州市';
if (region.province !== REGION_ALL && province !== region.province) return false;
if (region.city !== REGION_ALL && city !== region.city) return false;
return true;
});
}
const q = keyword.trim().toLowerCase();
if (q) {
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.address.toLowerCase().includes(q) ||
s.district.toLowerCase().includes(q),
);
}
return list;
}, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegionCity(region.province, region.city);
const emptyMessage =
filterMode === 'manual' && filtered.length === 0
? '未找到匹配门店'
: '暂无门店';
return (
<div className="page store-page">
<header className="store-header">
<TabMainHeader title="门店" />
<div className="store-toolbar">
<button
type="button"
className="store-location"
onClick={() => setRegionPickerOpen(true)}
>
<span className="material-symbols-outlined">location_on</span>
<span>{regionLabel}</span>
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
</button>
<div className="store-search">
<span className="material-symbols-outlined store-search-icon">search</span>
<input
type="search"
placeholder="搜索门店名称或地址"
value={keyword}
onChange={(e) => {
setFilterMode('manual');
setKeyword(e.target.value);
}}
/>
</div>
</div>
<div className="store-category-bar">
<div className="store-category-tabs">
{CATEGORY_TABS.map((tab) => (
<button
key={tab}
type="button"
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
onClick={() => {
setFilterMode('manual');
setCategoryTab(tab);
}}
>
{tab}
</button>
))}
</div>
</div>
</header>
<main className="store-list">
{loading && <div className="store-empty">...</div>}
{!loading && filtered.map((s, index) => (
<article
key={s.id}
className="store-card"
role="button"
tabIndex={0}
onClick={() => navigate(`/stores/${s.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate(`/stores/${s.id}`);
}
}}
>
<div className="store-card-cover">
<AppImage src={storeCover(s, index)} alt={s.name} wrapperClassName="app-image--fill" />
</div>
<div className="store-card-body">
<div className="store-card-top">
<div className="store-card-title-row">
<h3 className="store-card-name">{s.name}</h3>
<span className="store-card-distance">
{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
</span>
</div>
<div className="store-card-meta">
<span className="status-open"></span>
<span className="store-card-hours">{formatHours(s)}</span>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<span className="store-card-hours">¥{Number(s.avgPrice).toFixed(0)}</span>
) : null}
</div>
</div>
<div className="store-card-bottom">
<p className="store-card-address">
{s.district}
{s.address}
</p>
<Link
to="/redeem"
className="store-redeem-btn"
onClick={(e) => e.stopPropagation()}
>
</Link>
</div>
</div>
</article>
))}
{!loading && filtered.length === 0 && (
<div className="store-empty">{emptyMessage}</div>
)}
{!loading && filtered.length > 0 && (
<p className="store-list-end"></p>
)}
</main>
<RegionPicker
open={regionPickerOpen}
value={region}
levels={2}
onClose={() => setRegionPickerOpen(false)}
onConfirm={(next) => {
setFilterMode('manual');
setRegion(toCityLevelRegion(next));
setRegionPickerOpen(false);
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
.legal-h5-page {
min-height: 100vh;
background: #faf9f7;
color: #1f1a17;
}
.legal-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
height: 52px;
padding: 0 16px;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.legal-h5-back {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #1f1a17;
text-decoration: none;
}
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.legal-h5-body {
padding: 16px 20px 40px;
max-width: 720px;
margin: 0 auto;
}
.legal-h5-updated {
margin: 0 0 12px;
font-size: 12px;
color: #8d706e;
}
.legal-h5-intro {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.7;
}
.legal-h5-section {
margin-bottom: 18px;
}
.legal-h5-section h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.legal-h5-section p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"paths": {
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
}
},
"include": ["src"]
}
-20
View File
@@ -1,20 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/user/',
plugins: [react()],
resolve: {
alias: {
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
'@dukang/shared-types': path.resolve(__dirname, '../../packages/shared-types/src'),
},
},
server: {
host: true,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
},
},
});
-6
View File
@@ -1,6 +0,0 @@
// babel-preset-taro 用于 weapp 等平台编译;H5 由 vite + @vitejs/plugin-react 处理
module.exports = {
presets: [
['taro', { framework: 'react', ts: true, compiler: 'vite' }],
],
};
-58
View File
@@ -1,58 +0,0 @@
import { defineConfig } from '@tarojs/cli';
// 总部管理端 Taro 配置:先 H5,后续 weapp(微信小程序)
export default defineConfig(async () => ({
projectName: 'mini-hq',
date: '2026-7-5',
designWidth: 375,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html'],
defineConstants: {
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
},
copy: {
patterns: [],
options: {},
},
framework: 'react',
compiler: {
type: 'vite',
vitePlugins: [],
},
cache: {
enable: false,
},
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
devServer: {
port: 5176,
host: '0.0.0.0',
proxy: {
'/api': {
target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
changeOrigin: true,
},
},
},
postcss: {
autoprefixer: { enable: true, config: {} },
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
}));
-48
View File
@@ -1,48 +0,0 @@
{
"name": "@dukang/mini-hq",
"version": "0.1.0",
"private": true,
"description": "杜康好客 · 总部管理端(Taro,先 H5 后小程序)",
"scripts": {
"dev": "taro build --type h5 --watch",
"dev:weapp": "taro build --type weapp --watch",
"build": "taro build --type h5",
"build:weapp": "taro build --type weapp",
"preview": "node ../../scripts/preview-hq.mjs",
"lint": "echo ok"
},
"dependencies": {
"@babel/runtime": "^7.24.4",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
"@tarojs/plugin-html": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/react": "4.2.0",
"@tarojs/router": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/preset-react": "^7.24.1",
"@tarojs/cli": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"@types/react": "^18.3.3",
"@vitejs/plugin-react": "^4.3.1",
"babel-preset-taro": "4.2.0",
"typescript": "^5.4.5",
"vite": "^5.4.0"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
]
}
-14
View File
@@ -1,14 +0,0 @@
{
"miniprogramRoot": "dist/",
"projectname": "mini-hq",
"description": "杜康好客总部管理端",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}
-41
View File
@@ -1,41 +0,0 @@
export default defineAppConfig({
pages: [
'pages/dashboard/index',
'pages/stores/index',
'pages/settlement/index',
'pages/tickets/index',
'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
'pages/stores/detail',
'pages/orders/index',
'pages/orders/detail',
'pages/cities/index',
'pages/products/index',
'pages/reports/index',
'pages/promo/index',
'pages/promo/generate',
'pages/promo/detail',
'pages/refund/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#A61D24',
navigationBarTitleText: '杜康好客·总部',
navigationBarTextStyle: 'white',
navigationStyle: 'custom',
},
tabBar: {
custom: true,
color: '#8D706E',
selectedColor: '#A61D24',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{ pagePath: 'pages/dashboard/index', text: '管理中心' },
{ pagePath: 'pages/stores/index', text: '门店审核' },
{ pagePath: 'pages/settlement/index', text: '结算中心' },
{ pagePath: 'pages/tickets/index', text: '客服中心' },
],
},
});
-428
View File
@@ -1,428 +0,0 @@
/* 杜康好客 · 总部管理端全局样式(Taro H5 / 小程序通用) */
:root {
--hq-red: #a61d24;
--hq-red-dark: #820012;
--hq-amber: #c9a227;
--hq-bg: #f5f3f0;
--hq-surface: #ffffff;
--hq-text: #1f1a17;
--hq-muted: #8d706e;
--hq-line: #ece7e2;
--hq-green: #2d6a4f;
--hq-blue: #2a6ebb;
--hq-orange: #c26a1b;
--hq-radius: 16px;
--hq-shadow: 0 2px 12px rgba(93, 64, 55, 0.08);
--hq-safe-top: env(safe-area-inset-top, 0px);
--hq-safe-bottom: env(safe-area-inset-bottom, 0px);
}
page,
body {
background: var(--hq-bg);
color: var(--hq-text);
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
margin: 0;
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 隐藏滚动条,保留触摸/滚轮滚动 */
html,
body,
page,
#app,
.taro_page,
.hq-page,
scroll-view,
.taro-scroll,
.taro-scroll-view,
.taro-scroll-view__scroll-x,
.taro-scroll-view__scroll-y {
-ms-overflow-style: none;
scrollbar-width: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
page::-webkit-scrollbar,
#app::-webkit-scrollbar,
.hq-page::-webkit-scrollbar,
scroll-view::-webkit-scrollbar,
.taro-scroll::-webkit-scrollbar,
.taro-scroll-view::-webkit-scrollbar,
.taro-scroll-view__scroll-x::-webkit-scrollbar,
.taro-scroll-view__scroll-y::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
background: transparent;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* 页面骨架 */
.hq-page {
min-height: 100vh;
padding-bottom: calc(24px + var(--hq-safe-bottom));
background: var(--hq-bg);
box-sizing: border-box;
}
.hq-page--tab {
padding-bottom: calc(96px + var(--hq-safe-bottom));
}
/* 顶部安全区头部(刘海 / 状态栏适配) */
.hq-header {
position: sticky;
top: 0;
z-index: 50;
padding-top: var(--hq-safe-top);
background: linear-gradient(135deg, var(--hq-red), var(--hq-red-dark));
color: #fff;
box-shadow: var(--hq-shadow);
}
.hq-header__bar {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0 16px;
}
.hq-header__title {
font-size: 17px;
font-weight: 700;
}
.hq-header__back,
.hq-header__action {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
color: #fff;
font-size: 22px;
}
.hq-header__back {
left: 12px;
}
.hq-header__action {
right: 12px;
font-size: 14px;
gap: 4px;
}
.hq-header--plain {
background: var(--hq-surface);
color: var(--hq-text);
}
.hq-header--plain .hq-header__title {
color: var(--hq-red);
}
.hq-header--plain .hq-header__back,
.hq-header--plain .hq-header__action {
color: var(--hq-red);
}
/* 卡片 */
.hq-card {
background: var(--hq-surface);
border-radius: var(--hq-radius);
margin: 12px 16px;
padding: 16px;
box-shadow: var(--hq-shadow);
box-sizing: border-box;
}
.hq-section-title {
font-size: 15px;
font-weight: 700;
margin: 20px 16px 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.hq-muted {
color: var(--hq-muted);
}
.hq-row {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 统计网格 */
.hq-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 12px 16px;
}
.hq-stat {
background: var(--hq-surface);
border-radius: var(--hq-radius);
padding: 16px;
box-shadow: var(--hq-shadow);
}
.hq-stat__label {
font-size: 12px;
color: var(--hq-muted);
}
.hq-stat__value {
font-size: 24px;
font-weight: 800;
color: var(--hq-red);
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.hq-stat__sub {
font-size: 11px;
color: var(--hq-muted);
margin-top: 4px;
}
/* 列表项 */
.hq-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--hq-surface);
border-bottom: 1px solid var(--hq-line);
}
.hq-list-item:active {
background: #faf8f6;
}
.hq-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
/* 徽标 / 状态 */
.hq-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.hq-badge--warn {
background: rgba(194, 106, 27, 0.12);
color: var(--hq-orange);
}
.hq-badge--ok {
background: rgba(45, 106, 79, 0.12);
color: var(--hq-green);
}
.hq-badge--info {
background: rgba(42, 110, 187, 0.12);
color: var(--hq-blue);
}
.hq-badge--danger {
background: rgba(166, 29, 36, 0.12);
color: var(--hq-red);
}
/* 按钮 */
.hq-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 12px;
padding: 12px 18px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.hq-btn--primary {
background: var(--hq-red);
color: #fff;
}
.hq-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
}
.hq-btn--outline {
background: transparent;
border: 1px solid var(--hq-line);
color: var(--hq-text);
}
.hq-btn--block {
width: 100%;
}
.hq-btn:active {
opacity: 0.85;
}
.hq-btn[disabled] {
opacity: 0.5;
}
/* 快捷入口宫格 */
.hq-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin: 12px 16px;
background: var(--hq-surface);
border-radius: var(--hq-radius);
padding: 16px 8px;
box-shadow: var(--hq-shadow);
}
.hq-grid__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0;
}
.hq-grid__icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.hq-grid__label {
font-size: 12px;
color: var(--hq-text);
}
/* 分段 Tab */
.hq-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 16px;
background: var(--hq-surface);
border-bottom: 1px solid var(--hq-line);
position: sticky;
top: calc(52px + var(--hq-safe-top));
z-index: 40;
-ms-overflow-style: none;
scrollbar-width: none;
}
.hq-tabs::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
}
.hq-tab {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
color: var(--hq-muted);
background: transparent;
}
.hq-tab--active {
background: var(--hq-red);
color: #fff;
font-weight: 600;
}
/* 空态 / 加载 */
.hq-empty {
text-align: center;
padding: 60px 24px;
color: var(--hq-muted);
font-size: 14px;
}
/* 表单 */
.hq-field {
margin: 12px 16px;
}
.hq-field__label {
font-size: 13px;
color: var(--hq-muted);
margin-bottom: 6px;
display: block;
}
.hq-input {
width: 100%;
box-sizing: border-box;
background: var(--hq-surface);
border: 1px solid var(--hq-line);
border-radius: 12px;
padding: 12px 14px;
font-size: 15px;
color: var(--hq-text);
}
.store-cover {
width: 100%;
height: 180px;
display: block;
}
/* 底部固定操作栏 */
.hq-footer-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 16px calc(12px + var(--hq-safe-bottom));
background: var(--hq-surface);
border-top: 1px solid var(--hq-line);
z-index: 60;
}
-8
View File
@@ -1,8 +0,0 @@
import { PropsWithChildren } from 'react';
import './app.css';
function App({ children }: PropsWithChildren) {
return children;
}
export default App;

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