Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb831f0580 | |||
| 3166467518 | |||
| 0ff61c2cd1 | |||
| fb66258b6a | |||
| e68eb4d38c | |||
| b375fab44a | |||
| 58010feed8 | |||
| 9f14c59f22 | |||
| 9c8d5f2cad | |||
| 870494563d | |||
| 4c9b3f72bc | |||
| 3b669f7e38 | |||
| 14c5c8c3f3 | |||
| a7f3283ae5 | |||
| da19c39965 | |||
| c06251f0a4 | |||
| faa1830bde | |||
| b226af2adc | |||
| 2b5f42acdf | |||
| 71149044ca | |||
| fccfd7abbe | |||
| 1ecc6a11df | |||
| cfd42dfc32 | |||
| 737efb89a3 | |||
| a423fb71e8 | |||
| 4111e5c0a6 | |||
| 3dbb73e428 | |||
| 3a9f27eb38 | |||
| e2f5aa9dd8 | |||
| 58a8b47532 | |||
| 6a5c0cb090 | |||
| 448b9983d3 | |||
| 3afbdfe6b9 | |||
| 632539e8dc | |||
| c7cd0f6cf2 | |||
| 00bb6a2227 | |||
| 1f06530894 | |||
| 1f4506f60b | |||
| 93390ccf75 | |||
| a2976cdc8f | |||
| cc0c0a6ef8 | |||
| 8f2ebbfef4 | |||
| ee1a3f4b6c | |||
| 52a7d3789d | |||
| f2fbf95c53 | |||
| 98d52ff173 | |||
| 797b20979d |
@@ -89,7 +89,7 @@ await this.prisma.order.update({ ... });
|
|||||||
```typescript
|
```typescript
|
||||||
const benefitAmount = product.benefitAmount ?? product.price;
|
const benefitAmount = product.benefitAmount ?? product.price;
|
||||||
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||||
// 同城 min 2 瓶 / 跨城 min 6 瓶
|
// 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商)
|
||||||
|
|
||||||
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
||||||
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ disable-model-invocation: true
|
|||||||
|
|
||||||
```
|
```
|
||||||
权益额 = 订单实付 1:1(永久)
|
权益额 = 订单实付 1:1(永久)
|
||||||
同城起购 2 瓶 / 跨城 1 箱(6瓶)
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
|
||||||
门店结算 = 核销额 × 60%
|
门店结算 = 核销额 × 60%
|
||||||
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
||||||
核销码 TTL = 3 分钟;试核销固定 ¥100
|
核销码 TTL = 3 分钟;试核销固定 ¥100
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
||||||
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
||||||
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
||||||
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
| REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||||
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
|
||||||
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
|
||||||
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
||||||
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
||||||
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ server/dukang-api/assets/fonts/*.ttc
|
|||||||
server/dukang-api/assets/fonts/*.otf
|
server/dukang-api/assets/fonts/*.otf
|
||||||
deploy/auto-release.env
|
deploy/auto-release.env
|
||||||
deploy/backups/
|
deploy/backups/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
权益额 = benefit_amount ?? price
|
权益额 = benefit_amount ?? price
|
||||||
同城起购 2 瓶 / 跨城 6 瓶
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
||||||
C 端门店仅 status=OPEN
|
C 端门店仅 status=OPEN
|
||||||
订单 Tab:待付款 | 已付款 | 已完成
|
订单 Tab:待付款 | 已付款 | 已完成
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
权益发放额 = common_product_item.benefit_amount ?? price
|
权益发放额 = common_product_item.benefit_amount ?? price
|
||||||
同城起购 2 瓶 / 跨城 6 瓶
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||||
C 端门店列表仅 status=OPEN
|
C 端门店列表仅 status=OPEN
|
||||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
<title>杜康好客 · HQ 管理后台</title>
|
<title>杜康好客 · HQ 管理后台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -25,6 +25,7 @@ import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
|||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||||
import PromoCodesPage from './pages/PromoCodesPage';
|
import PromoCodesPage from './pages/PromoCodesPage';
|
||||||
|
import ActivityPostersPage from './pages/ActivityPostersPage';
|
||||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||||
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
|
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
|
||||||
@@ -86,6 +87,7 @@ export default function App() {
|
|||||||
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/promo-codes" element={<PromoCodesPage />} />
|
<Route path="/promo-codes" element={<PromoCodesPage />} />
|
||||||
|
<Route path="/activity-posters" element={<ActivityPostersPage />} />
|
||||||
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
|
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
|
||||||
<Route index element={<PromoCodeDetailPage />} />
|
<Route index element={<PromoCodeDetailPage />} />
|
||||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||||
|
|||||||
@@ -74,6 +74,60 @@ body.admin-col-resizing * {
|
|||||||
user-select: none !important;
|
user-select: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 订单页筛选:第一行常驻,第二行起可收起 */
|
||||||
|
.admin-orders-filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-row--tools {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--sm {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--md {
|
||||||
|
width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--search {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-filter-item--keyword {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--status {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--date {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-date-picker {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||||
.admin-list-header {
|
.admin-list-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -308,6 +362,40 @@ body:has(.big-screen-page),
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 6px 14px 6px 12px;
|
||||||
|
border: 1px solid rgba(105, 192, 255, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(8, 28, 56, 0.7);
|
||||||
|
color: rgba(230, 244, 255, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm:hover {
|
||||||
|
border-color: rgba(105, 192, 255, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm.is-on {
|
||||||
|
border-color: rgba(64, 169, 255, 0.85);
|
||||||
|
box-shadow: 0 0 12px rgba(64, 169, 255, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.big-screen-live-label {
|
.big-screen-live-label {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: rgba(230, 244, 255, 0.88);
|
color: rgba(230, 244, 255, 0.88);
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { DEFAULT_ACTIVITY_POSTER_QR_SLOT, type ActivityPosterQrSlot } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
imageUrl?: string;
|
||||||
|
value?: ActivityPosterQrSlot;
|
||||||
|
onChange?: (slot: ActivityPosterQrSlot) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPosterQrSlotEditor({ imageUrl, value, onChange }: Props) {
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<{
|
||||||
|
mode: 'move' | 'resize';
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
start: ActivityPosterQrSlot;
|
||||||
|
} | null>(null);
|
||||||
|
const slot = value ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT;
|
||||||
|
|
||||||
|
const apply = useCallback((next: ActivityPosterQrSlot) => {
|
||||||
|
const size = Math.min(50, Math.max(5, next.qrSizePct));
|
||||||
|
const box = boxRef.current;
|
||||||
|
const ratio = box && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 1;
|
||||||
|
const heightPct = size * ratio;
|
||||||
|
const x = Math.min(100 - size, Math.max(0, next.qrXPct));
|
||||||
|
const y = Math.min(Math.max(0, 100 - heightPct), Math.max(0, next.qrYPct));
|
||||||
|
onChange?.({
|
||||||
|
qrXPct: Number(x.toFixed(2)),
|
||||||
|
qrYPct: Number(y.toFixed(2)),
|
||||||
|
qrSizePct: Number(size.toFixed(2)),
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
function onPointerDown(mode: 'move' | 'resize', e: React.PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: { ...slot } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: React.PointerEvent) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const box = boxRef.current;
|
||||||
|
if (!drag || !box) return;
|
||||||
|
const dx = ((e.clientX - drag.startX) / box.clientWidth) * 100;
|
||||||
|
const dy = ((e.clientY - drag.startY) / box.clientHeight) * 100;
|
||||||
|
if (drag.mode === 'move') {
|
||||||
|
apply({
|
||||||
|
...drag.start,
|
||||||
|
qrXPct: drag.start.qrXPct + dx,
|
||||||
|
qrYPct: drag.start.qrYPct + dy,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply({ ...drag.start, qrSizePct: drag.start.qrSizePct + dx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return <div style={{ color: 'rgba(0,0,0,0.45)' }}>请先上传活动图,再拖拽定位方形码栏</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={boxRef}
|
||||||
|
style={{ position: 'relative', width: '100%', maxWidth: 420, userSelect: 'none', touchAction: 'none' }}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
<img src={imageUrl} alt="活动图预览" style={{ width: '100%', display: 'block' }} draggable={false} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${slot.qrXPct}%`,
|
||||||
|
top: `${slot.qrYPct}%`,
|
||||||
|
width: `${slot.qrSizePct}%`,
|
||||||
|
aspectRatio: '1',
|
||||||
|
border: '2px dashed #1677ff',
|
||||||
|
background: 'rgba(22,119,255,0.14)',
|
||||||
|
cursor: 'move',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onPointerDown('move', e)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
background: '#1677ff',
|
||||||
|
borderRadius: 2,
|
||||||
|
cursor: 'nwse-resize',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onPointerDown('resize', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||||
|
拖拽移动码栏,拉右下角调整大小(边长相对图宽 {slot.qrSizePct}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,23 @@ import PackageImagesUpload from './PackageImagesUpload';
|
|||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
export type AdminStorePackagesHandle = {
|
export type AdminStorePackagesHandle = {
|
||||||
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
|
||||||
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
|
||||||
|
return (live ?? []).map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -37,6 +50,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
const itemsRef = useRef(items);
|
const itemsRef = useRef(items);
|
||||||
const loadingRef = useRef(loading);
|
const loadingRef = useRef(loading);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,36 +61,33 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
loadingRef.current = loading;
|
loadingRef.current = loading;
|
||||||
}, [loading]);
|
}, [loading]);
|
||||||
|
|
||||||
|
function applyServerPackages(data: StorePackagesResponse) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
setItems(mapLiveRows(data.live));
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPendingRequest(null);
|
setPendingRequest(null);
|
||||||
|
dirtyRef.current = false;
|
||||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
.then((data) => {
|
.then((data) => applyServerPackages(data))
|
||||||
setPendingRequest(data.pendingRequest ?? null);
|
|
||||||
setItems(
|
|
||||||
data.live?.length
|
|
||||||
? data.live.map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [storeId]);
|
}, [storeId]);
|
||||||
|
|
||||||
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
// 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = () => {
|
const onChanged = () => {
|
||||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
.then((data) => {
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
if (!dirtyRef.current) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setItems(mapLiveRows(data.live));
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
@@ -108,16 +119,19 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
|
dirtyRef.current = true;
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
dirtyRef.current = true;
|
||||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
const run = () => {
|
const run = () => {
|
||||||
|
dirtyRef.current = true;
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
return next.length ? next : [];
|
return next.length ? next : [];
|
||||||
@@ -203,20 +217,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
setItems(
|
dirtyRef.current = false;
|
||||||
data.live?.length
|
setItems(mapLiveRows(data.live));
|
||||||
? data.live.map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
throw e;
|
throw e;
|
||||||
@@ -227,7 +229,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
saveIfLoaded: async (opts) => {
|
saveIfLoaded: async (opts) => {
|
||||||
if (loadingRef.current) return { skipped: true };
|
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
|
||||||
await save(opts);
|
await save(opts);
|
||||||
return { skipped: false };
|
return { skipped: false };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,458 +1,465 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
CITY_PARTNER_SCOPE_LABELS,
|
CITY_PARTNER_SCOPE_LABELS,
|
||||||
CITY_PARTNER_STATUS_LABELS,
|
CITY_PARTNER_STATUS_LABELS,
|
||||||
CityPartnerScopeType,
|
CityPartnerScopeType,
|
||||||
CityPartnerStatus,
|
CityPartnerStatus,
|
||||||
PARTNER_PERMISSION_KEYS,
|
PARTNER_PERMISSION_KEYS,
|
||||||
PARTNER_PERMISSION_LABELS,
|
PARTNER_PERMISSION_LABELS,
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
import { omitNullFields } from '../lib/omit-null-fields';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
import { fmtTime } from '../lib/constants';
|
||||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||||
|
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||||
type PartnerRow = {
|
import PartnerAssocPanel from './PartnerAssocPanel';
|
||||||
id: string;
|
|
||||||
companyName: string;
|
type PartnerRow = {
|
||||||
phone: string;
|
id: string;
|
||||||
name: string;
|
companyName: string;
|
||||||
scopeType?: string;
|
phone: string;
|
||||||
districtCodes?: string[] | null;
|
name: string;
|
||||||
orderCommissionRate?: number;
|
scopeType?: string;
|
||||||
redeemCommissionRate?: number;
|
districtCodes?: string[] | null;
|
||||||
accountCount: number;
|
orderCommissionRate?: number;
|
||||||
createdAt: string;
|
redeemCommissionRate?: number;
|
||||||
};
|
accountCount: number;
|
||||||
|
createdAt: string;
|
||||||
type PartnerDetail = PartnerRow & {
|
};
|
||||||
address?: string;
|
|
||||||
districtCodes?: string[] | null;
|
type PartnerDetail = PartnerRow & {
|
||||||
bindingStatus?: string;
|
address?: string;
|
||||||
bankAccountName?: string | null;
|
districtCodes?: string[] | null;
|
||||||
bankAccountNo?: string | null;
|
bindingStatus?: string;
|
||||||
bankBranch?: string | null;
|
bankAccountName?: string | null;
|
||||||
managedWarehouseId?: string | null;
|
bankAccountNo?: string | null;
|
||||||
managedWarehouseName?: string | null;
|
bankBranch?: string | null;
|
||||||
contactPhone?: string | null;
|
managedWarehouseId?: string | null;
|
||||||
children?: Array<{
|
managedWarehouseName?: string | null;
|
||||||
id: string;
|
contactPhone?: string | null;
|
||||||
phone: string;
|
children?: Array<{
|
||||||
name: string;
|
id: string;
|
||||||
staffRole?: string;
|
phone: string;
|
||||||
permissions?: string[];
|
name: string;
|
||||||
status: string;
|
staffRole?: string;
|
||||||
}>;
|
permissions?: string[];
|
||||||
};
|
status: string;
|
||||||
|
}>;
|
||||||
type Props = {
|
};
|
||||||
cityId: string;
|
|
||||||
cityCode?: string;
|
type Props = {
|
||||||
maxPartnerCommissionRate?: number;
|
cityId: string;
|
||||||
onChanged?: () => void;
|
cityCode?: string;
|
||||||
};
|
maxPartnerCommissionRate?: number;
|
||||||
|
onChanged?: () => void;
|
||||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
};
|
||||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
|
||||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
value: k,
|
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
label: PARTNER_PERMISSION_LABELS[k],
|
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||||
}));
|
value: k,
|
||||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
label: PARTNER_PERMISSION_LABELS[k],
|
||||||
.filter(([value]) => value !== 'PARTNER')
|
}));
|
||||||
.map(([value, label]) => ({ value, label }));
|
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||||
|
.filter(([value]) => value !== 'PARTNER')
|
||||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
.map(([value, label]) => ({ value, label }));
|
||||||
if (!values?.length) return [];
|
|
||||||
if (Array.isArray(values[0])) {
|
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
if (!values?.length) return [];
|
||||||
}
|
if (Array.isArray(values[0])) {
|
||||||
return values as string[];
|
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
return values as string[];
|
||||||
function commissionSumError(
|
}
|
||||||
orderPercent: number,
|
|
||||||
redeemPercent: number,
|
function commissionSumError(
|
||||||
maxRate: number,
|
orderPercent: number,
|
||||||
): string | null {
|
redeemPercent: number,
|
||||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
maxRate: number,
|
||||||
const max = Number(maxRate);
|
): string | null {
|
||||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
const max = Number(maxRate);
|
||||||
if (sum > max + 1e-9) {
|
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||||
}
|
if (sum > max + 1e-9) {
|
||||||
return null;
|
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
function formatApiError(err: unknown): string | null {
|
}
|
||||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
|
||||||
if (!(err instanceof Error)) return '操作失败';
|
function formatApiError(err: unknown): string | null {
|
||||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||||
const label = districtCodeLabel(code);
|
if (!(err instanceof Error)) return '操作失败';
|
||||||
return label !== code ? `${label}(${code})` : code;
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||||
});
|
const label = districtCodeLabel(code);
|
||||||
}
|
return label !== code ? `${label}(${code})` : code;
|
||||||
|
});
|
||||||
export default function CityPartnersPanel({
|
}
|
||||||
cityId,
|
|
||||||
cityCode,
|
export default function CityPartnersPanel({
|
||||||
maxPartnerCommissionRate = 0.05,
|
cityId,
|
||||||
onChanged,
|
cityCode,
|
||||||
}: Props) {
|
maxPartnerCommissionRate = 0.05,
|
||||||
const [editForm] = Form.useForm();
|
onChanged,
|
||||||
const [createForm] = Form.useForm();
|
}: Props) {
|
||||||
const [subForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
const [subForm] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [subEditForm] = Form.useForm();
|
||||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||||
const [subOpen, setSubOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
const [subOpen, setSubOpen] = useState(false);
|
||||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||||
|
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
const loadPartners = useCallback(async () => {
|
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
setLoading(true);
|
|
||||||
try {
|
const loadPartners = useCallback(async () => {
|
||||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
setLoading(true);
|
||||||
setPartners(res.items);
|
try {
|
||||||
} finally {
|
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||||
setLoading(false);
|
setPartners(res.items);
|
||||||
}
|
} finally {
|
||||||
}, [cityId]);
|
setLoading(false);
|
||||||
|
}
|
||||||
useEffect(() => {
|
}, [cityId]);
|
||||||
void loadPartners();
|
|
||||||
}, [loadPartners]);
|
useEffect(() => {
|
||||||
|
void loadPartners();
|
||||||
async function openPartner(id: string) {
|
}, [loadPartners]);
|
||||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
|
||||||
setDetail(d);
|
async function openPartner(id: string) {
|
||||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||||
editForm.setFieldsValue({
|
setDetail(d);
|
||||||
name: d.name,
|
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||||
phone: d.phone,
|
editForm.setFieldsValue({
|
||||||
companyName: d.companyName,
|
name: d.name,
|
||||||
contactPhone: d.contactPhone ?? d.phone,
|
phone: d.phone,
|
||||||
address: d.address ?? '',
|
companyName: d.companyName,
|
||||||
scopeType: d.scopeType,
|
contactPhone: d.contactPhone ?? d.phone,
|
||||||
districtCodes: d.districtCodes ?? [],
|
address: d.address ?? '',
|
||||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
scopeType: d.scopeType,
|
||||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
districtCodes: d.districtCodes ?? [],
|
||||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||||
bankAccountName: d.bankAccountName ?? '',
|
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||||
bankAccountNo: d.bankAccountNo ?? '',
|
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||||
bankBranch: d.bankBranch ?? '',
|
bankAccountName: d.bankAccountName ?? '',
|
||||||
});
|
bankAccountNo: d.bankAccountNo ?? '',
|
||||||
setDrawerOpen(true);
|
bankBranch: d.bankBranch ?? '',
|
||||||
}
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
async function savePartner() {
|
}
|
||||||
if (!detail) return;
|
|
||||||
try {
|
async function savePartner() {
|
||||||
const v = await editForm.validateFields();
|
if (!detail) return;
|
||||||
const err = commissionSumError(
|
try {
|
||||||
Number(v.orderCommissionRate ?? 0),
|
const v = await editForm.validateFields();
|
||||||
Number(v.redeemCommissionRate ?? 0),
|
const err = commissionSumError(
|
||||||
maxPartnerCommissionRate,
|
Number(v.orderCommissionRate ?? 0),
|
||||||
);
|
Number(v.redeemCommissionRate ?? 0),
|
||||||
if (err) {
|
maxPartnerCommissionRate,
|
||||||
message.error(err);
|
);
|
||||||
return;
|
if (err) {
|
||||||
}
|
message.error(err);
|
||||||
await request(`/admin/partners/${detail.id}`, {
|
return;
|
||||||
method: 'PUT',
|
}
|
||||||
body: JSON.stringify({
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
...v,
|
method: 'PUT',
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
body: JSON.stringify({
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
...omitNullFields(v),
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
}),
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
});
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
message.success('已保存');
|
}),
|
||||||
setDrawerOpen(false);
|
});
|
||||||
void loadPartners();
|
message.success('已保存');
|
||||||
onChanged?.();
|
setDrawerOpen(false);
|
||||||
} catch (e) {
|
void loadPartners();
|
||||||
const msg = formatApiError(e);
|
onChanged?.();
|
||||||
if (msg) message.error(msg);
|
} catch (e) {
|
||||||
}
|
const msg = formatApiError(e);
|
||||||
}
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
async function deleteSubAccount(subId: string) {
|
}
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
|
||||||
message.success('子账号已删除');
|
async function deleteSubAccount(subId: string) {
|
||||||
if (detail) void openPartner(detail.id);
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
void loadPartners();
|
message.success('子账号已删除');
|
||||||
onChanged?.();
|
if (detail) void openPartner(detail.id);
|
||||||
}
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
const columns: ColumnsType<PartnerRow> = [
|
}
|
||||||
{
|
|
||||||
title: '区县',
|
const columns: ColumnsType<PartnerRow> = [
|
||||||
dataIndex: 'districtCodes',
|
{
|
||||||
width: 160,
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
width: 160,
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
},
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
{ title: '公司名', dataIndex: 'companyName' },
|
},
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
{ title: '公司名', dataIndex: 'companyName' },
|
||||||
{
|
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||||
title: '管辖',
|
{
|
||||||
dataIndex: 'scopeType',
|
title: '管辖',
|
||||||
width: 100,
|
dataIndex: 'scopeType',
|
||||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
width: 100,
|
||||||
},
|
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||||
{
|
},
|
||||||
title: '佣金',
|
{
|
||||||
width: 120,
|
title: '佣金',
|
||||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
width: 120,
|
||||||
},
|
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
},
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
title: '操作',
|
{
|
||||||
width: 80,
|
title: '操作',
|
||||||
render: (_, row) => (
|
width: 80,
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
render: (_, row) => (
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
),
|
</Button>
|
||||||
},
|
),
|
||||||
];
|
},
|
||||||
|
];
|
||||||
return (
|
|
||||||
<>
|
return (
|
||||||
<Button
|
<>
|
||||||
type="primary"
|
<Button
|
||||||
style={{ marginBottom: 12 }}
|
type="primary"
|
||||||
onClick={() => {
|
style={{ marginBottom: 12 }}
|
||||||
createForm.resetFields();
|
onClick={() => {
|
||||||
createForm.setFieldsValue({
|
createForm.resetFields();
|
||||||
cityId,
|
createForm.setFieldsValue({
|
||||||
orderCommissionRate: 0,
|
cityId,
|
||||||
redeemCommissionRate: 3,
|
orderCommissionRate: 0,
|
||||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
redeemCommissionRate: 3,
|
||||||
});
|
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
});
|
||||||
setCreateOpen(true);
|
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||||
}}
|
setCreateOpen(true);
|
||||||
>
|
}}
|
||||||
新建合伙人
|
>
|
||||||
</Button>
|
新建合伙人
|
||||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
</Button>
|
||||||
|
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||||
<Drawer
|
|
||||||
title="城市合伙人"
|
<Drawer
|
||||||
width={640}
|
title="城市合伙人"
|
||||||
open={drawerOpen}
|
width={640}
|
||||||
onClose={() => setDrawerOpen(false)}
|
open={drawerOpen}
|
||||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
onClose={() => setDrawerOpen(false)}
|
||||||
>
|
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||||
{detail && (
|
>
|
||||||
<Tabs
|
{detail && (
|
||||||
items={[
|
<Tabs
|
||||||
{
|
items={[
|
||||||
key: 'info',
|
{
|
||||||
label: '主账号',
|
key: 'info',
|
||||||
children: (
|
label: '主账号',
|
||||||
<Form form={editForm} layout="vertical">
|
children: (
|
||||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Select options={BINDING_OPTIONS} />
|
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Select options={BINDING_OPTIONS} />
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
</Form.Item>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
</Form.Item>
|
||||||
<Form.Item
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
name="districtCodes"
|
<Form.Item
|
||||||
label="区县"
|
name="districtCodes"
|
||||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
label="区县"
|
||||||
>
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
>
|
||||||
</Form.Item>
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
)}
|
</Form.Item>
|
||||||
<Space style={{ width: '100%' }} size="large">
|
)}
|
||||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Space style={{ width: '100%' }} size="large">
|
||||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
</Space>
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
</Space>
|
||||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
</Typography.Text>
|
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||||
<Form.Item label="管仓仓库">
|
</Typography.Text>
|
||||||
<Input
|
<Form.Item label="管仓仓库">
|
||||||
disabled
|
<Input
|
||||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
disabled
|
||||||
/>
|
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||||
</Form.Item>
|
/>
|
||||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||||
</Form>
|
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||||
),
|
</Form>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: 'staff',
|
{
|
||||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
key: 'staff',
|
||||||
children: (
|
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||||
<PartnerSubAccountList
|
children: (
|
||||||
subs={detail.children ?? []}
|
<PartnerSubAccountList
|
||||||
onAdd={() => {
|
subs={detail.children ?? []}
|
||||||
subForm.resetFields();
|
onAdd={() => {
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
subForm.resetFields();
|
||||||
setSubOpen(true);
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
}}
|
setSubOpen(true);
|
||||||
onEdit={(row) => {
|
}}
|
||||||
setSubEditId(row.id);
|
onEdit={(row) => {
|
||||||
subEditForm.setFieldsValue({
|
setSubEditId(row.id);
|
||||||
name: row.name,
|
subEditForm.setFieldsValue({
|
||||||
phone: row.phone,
|
name: row.name,
|
||||||
staffRole: row.staffRole ?? 'INTERNAL',
|
phone: row.phone,
|
||||||
permissions: row.permissions ?? [],
|
staffRole: row.staffRole ?? 'INTERNAL',
|
||||||
status: row.status,
|
permissions: row.permissions ?? [],
|
||||||
});
|
status: row.status,
|
||||||
setSubEditOpen(true);
|
});
|
||||||
}}
|
setSubEditOpen(true);
|
||||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
}}
|
||||||
/>
|
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
]}
|
},
|
||||||
/>
|
{
|
||||||
)}
|
key: 'assoc',
|
||||||
</Drawer>
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
},
|
||||||
try {
|
]}
|
||||||
const v = await createForm.validateFields();
|
/>
|
||||||
const err = commissionSumError(
|
)}
|
||||||
Number(v.orderCommissionRate ?? 0),
|
</Drawer>
|
||||||
Number(v.redeemCommissionRate ?? 3),
|
|
||||||
maxPartnerCommissionRate,
|
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
);
|
try {
|
||||||
if (err) {
|
const v = await createForm.validateFields();
|
||||||
message.error(err);
|
const err = commissionSumError(
|
||||||
return;
|
Number(v.orderCommissionRate ?? 0),
|
||||||
}
|
Number(v.redeemCommissionRate ?? 3),
|
||||||
await request('/admin/partners', {
|
maxPartnerCommissionRate,
|
||||||
method: 'POST',
|
);
|
||||||
body: JSON.stringify({
|
if (err) {
|
||||||
...v,
|
message.error(err);
|
||||||
cityId,
|
return;
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
}
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
await request('/admin/partners', {
|
||||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
method: 'POST',
|
||||||
}),
|
body: JSON.stringify({
|
||||||
});
|
...v,
|
||||||
message.success('已创建');
|
cityId,
|
||||||
setCreateOpen(false);
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
void loadPartners();
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
onChanged?.();
|
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
} catch (e) {
|
}),
|
||||||
const msg = formatApiError(e);
|
});
|
||||||
if (msg) message.error(msg);
|
message.success('已创建');
|
||||||
}
|
setCreateOpen(false);
|
||||||
}}>
|
void loadPartners();
|
||||||
<Form form={createForm} layout="vertical">
|
onChanged?.();
|
||||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
} catch (e) {
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
const msg = formatApiError(e);
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
if (msg) message.error(msg);
|
||||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
}
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
}}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Form form={createForm} layout="vertical">
|
||||||
</Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
name="districtCodes"
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
label="区县"
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
>
|
</Form.Item>
|
||||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
</Form.Item>
|
<Form.Item
|
||||||
)}
|
name="districtCodes"
|
||||||
<Space style={{ width: '100%' }} size="large">
|
label="区县"
|
||||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
>
|
||||||
</Space>
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
</Form.Item>
|
||||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
)}
|
||||||
</Typography.Text>
|
<Space style={{ width: '100%' }} size="large">
|
||||||
</Form>
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
</Modal>
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
|
</Space>
|
||||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
if (!detail) return;
|
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||||
const v = await subForm.validateFields();
|
</Typography.Text>
|
||||||
await request('/admin/partner-accounts', {
|
</Form>
|
||||||
method: 'POST',
|
</Modal>
|
||||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
|
||||||
});
|
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||||
message.success('已创建');
|
if (!detail) return;
|
||||||
setSubOpen(false);
|
const v = await subForm.validateFields();
|
||||||
void openPartner(detail.id);
|
await request('/admin/partner-accounts', {
|
||||||
void loadPartners();
|
method: 'POST',
|
||||||
onChanged?.();
|
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||||
}}>
|
});
|
||||||
<Form form={subForm} layout="vertical">
|
message.success('已创建');
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
setSubOpen(false);
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
void openPartner(detail.id);
|
||||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
void loadPartners();
|
||||||
<Select options={STAFF_ROLE_OPTIONS} />
|
onChanged?.();
|
||||||
</Form.Item>
|
}}>
|
||||||
<Form.Item name="permissions" label="权限">
|
<Form form={subForm} layout="vertical">
|
||||||
<Checkbox.Group options={PERM_OPTIONS} />
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
</Form.Item>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
</Form>
|
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||||
</Modal>
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
<Form.Item name="permissions" label="权限">
|
||||||
if (!subEditId || !detail) return;
|
<Checkbox.Group options={PERM_OPTIONS} />
|
||||||
const v = await subEditForm.validateFields();
|
</Form.Item>
|
||||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
</Form>
|
||||||
message.success('已更新');
|
</Modal>
|
||||||
setSubEditOpen(false);
|
|
||||||
void openPartner(detail.id);
|
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||||
}}>
|
if (!subEditId || !detail) return;
|
||||||
<Form form={subEditForm} layout="vertical">
|
const v = await subEditForm.validateFields();
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
message.success('已更新');
|
||||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
setSubEditOpen(false);
|
||||||
<Form.Item name="status" label="状态">
|
void openPartner(detail.id);
|
||||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
}}>
|
||||||
</Form.Item>
|
<Form form={subEditForm} layout="vertical">
|
||||||
<Form.Item name="permissions" label="权限">
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Checkbox.Group options={PERM_OPTIONS} />
|
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
</Form.Item>
|
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||||
</Form>
|
<Form.Item name="status" label="状态">
|
||||||
</Modal>
|
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||||
</>
|
</Form.Item>
|
||||||
);
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Checkbox.Group options={PERM_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Image, Input, Modal, Space, Typography } from 'antd';
|
||||||
|
import { PAYMENT_PROOF_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
|
|
||||||
|
export function parsePaymentProofUrls(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((u) => String(u ?? '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentProofGallery({ urls }: { urls?: unknown }) {
|
||||||
|
const list = parsePaymentProofUrls(urls);
|
||||||
|
if (!list.length) return <>—</>;
|
||||||
|
return (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{list.map((url, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${url}-${index}`}
|
||||||
|
src={url}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinancePayProofModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
okText: string;
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOk: (payload: { paymentRef?: string; paymentProofUrls?: string[] }) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FinancePayProofModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
okText,
|
||||||
|
confirmLoading,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
}: FinancePayProofModalProps) {
|
||||||
|
const [paymentRef, setPaymentRef] = useState('');
|
||||||
|
const [proofUrls, setProofUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPaymentRef('');
|
||||||
|
setProofUrls([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
okText={okText}
|
||||||
|
cancelText="取消"
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
destroyOnClose
|
||||||
|
width={480}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={async () => {
|
||||||
|
await onOk({
|
||||||
|
paymentRef: paymentRef.trim() || undefined,
|
||||||
|
paymentProofUrls: proofUrls.length ? proofUrls : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 12 }}>{hint}</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
value={paymentRef}
|
||||||
|
onChange={(e) => setPaymentRef(e.target.value)}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">打款凭证照片(可选,银行转账回单等)</Typography.Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="PAYMENT_PROOF"
|
||||||
|
value={proofUrls}
|
||||||
|
onChange={setProofUrls}
|
||||||
|
maxCount={PAYMENT_PROOF_IMAGE_MAX_COUNT}
|
||||||
|
buttonText="上传凭证照片"
|
||||||
|
tip={`最多 ${PAYMENT_PROOF_IMAGE_MAX_COUNT} 张,支持一次选择多张`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -173,7 +173,9 @@ export default function OrderTrackDrawer({
|
|||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={
|
description={
|
||||||
track?.manualQueryUrl
|
track?.queryError
|
||||||
|
? track.queryError
|
||||||
|
: track?.manualQueryUrl
|
||||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
: '暂无路由信息,请稍后刷新'
|
: '暂无路由信息,请稍后刷新'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
|
||||||
|
const name = row.nickname?.trim() || '—';
|
||||||
|
const remark = row.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartnerAssocPanelProps = {
|
||||||
|
partnerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) {
|
||||||
|
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||||
|
const [users, setUsers] = useState<PartnerAssocUserItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [issuing, setIssuing] = useState(false);
|
||||||
|
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
||||||
|
setSummary(data);
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async (p = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<Paginated<PartnerAssocUserItem>>(
|
||||||
|
`/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`,
|
||||||
|
);
|
||||||
|
setUsers(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setPage(p);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<HqProfile>('/admin/auth/me')
|
||||||
|
.then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc')))
|
||||||
|
.catch(() => setCanEditAssoc(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败'));
|
||||||
|
void loadUsers(1).catch(() => undefined);
|
||||||
|
}, [loadSummary, loadUsers]);
|
||||||
|
|
||||||
|
async function reissue() {
|
||||||
|
setIssuing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' });
|
||||||
|
message.success('已重新生成关联码');
|
||||||
|
await loadSummary();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '生成失败');
|
||||||
|
} finally {
|
||||||
|
setIssuing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(userId: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
||||||
|
message.success('已解绑');
|
||||||
|
await Promise.all([loadSummary(), loadUsers(page)]);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '解绑失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<PartnerAssocUserItem> = [
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 140 },
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'nickname',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Link
|
||||||
|
className="admin-primary-link"
|
||||||
|
to={`/users?userId=${encodeURIComponent(row.id)}`}
|
||||||
|
title="在用户列表中查看"
|
||||||
|
>
|
||||||
|
{formatNicknameWithRemark(row)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '手机', dataIndex: 'phone', render: (v) => v || '—' },
|
||||||
|
{ title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
|
{ title: '已付订单', dataIndex: 'orderCount', width: 90 },
|
||||||
|
...(canEditAssoc
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: PartnerAssocUserItem) => (
|
||||||
|
<Popconfirm title="解绑后该用户新单不再计订单佣金" onConfirm={() => void unbind(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
解绑
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
} satisfies ColumnsType<PartnerAssocUserItem>[number],
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space align="start" style={{ marginBottom: 16 }} wrap>
|
||||||
|
{summary?.qrcodeUrl ? (
|
||||||
|
<img src={summary.qrcodeUrl} alt="关联码" style={{ width: 160, height: 160, background: '#fff' }} />
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">尚未生成关联码</Typography.Text>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||||
|
<Link to={`/users?assocPartnerAccountId=${encodeURIComponent(partnerId)}`}>
|
||||||
|
关联用户 {summary?.userCount ?? 0} 人
|
||||||
|
</Link>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button loading={issuing} onClick={() => void reissue()}>
|
||||||
|
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: 20,
|
||||||
|
total,
|
||||||
|
onChange: (p) => void loadUsers(p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -50,6 +50,38 @@ function keyOf(ids: string[]) {
|
|||||||
return [...ids].sort().join('_');
|
return [...ids].sort().join('_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
||||||
|
if (!initialSkus.length) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
specValueIds: [],
|
||||||
|
barcode69: '',
|
||||||
|
price: 0,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
productId: string;
|
productId: string;
|
||||||
initialAttrs: SpecAttr[];
|
initialAttrs: SpecAttr[];
|
||||||
@@ -58,44 +90,19 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
||||||
initialAttrs.length
|
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
||||||
? initialAttrs
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const [skus, setSkus] = useState<SkuRow[]>(
|
|
||||||
initialSkus.length
|
|
||||||
? initialSkus.map((s) => ({
|
|
||||||
...s,
|
|
||||||
specValueIds: s.specValueIds ?? [],
|
|
||||||
status: s.status || 'ON_SALE',
|
|
||||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
|
||||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
|
||||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
|
||||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
|
||||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
|
||||||
isDefault: !!s.isDefault,
|
|
||||||
imageUrl: s.imageUrl ?? '',
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
specValueIds: [],
|
|
||||||
barcode69: '',
|
|
||||||
price: 0,
|
|
||||||
status: 'ON_SALE',
|
|
||||||
allowOnlinePurchase: true,
|
|
||||||
allowCrossCityDelivery: true,
|
|
||||||
allowOnSitePickup: false,
|
|
||||||
saleUnit: 'BOTTLE',
|
|
||||||
bottlesPerUnit: 1,
|
|
||||||
isDefault: true,
|
|
||||||
imageUrl: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAttrs(initialAttrs.length ? initialAttrs : []);
|
||||||
|
setSkus(normalizeSkuRows(initialSkus));
|
||||||
|
setEditIndex(null);
|
||||||
|
// 仅切商品时重载,避免父组件重渲染冲掉编辑中内容
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
function regenerateMatrix() {
|
function regenerateMatrix() {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [skuId, setSkuId] = useState<string>();
|
const [skuId, setSkuId] = useState<string>();
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
|
const [assocPartnerAccountId, setAssocPartnerAccountId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
const [autoReceive, setAutoReceive] = useState(false);
|
const [autoReceive, setAutoReceive] = useState(false);
|
||||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||||
@@ -168,6 +169,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setProductId(undefined);
|
setProductId(undefined);
|
||||||
setSkuId(undefined);
|
setSkuId(undefined);
|
||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
|
setAssocPartnerAccountId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
@@ -235,6 +237,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
skuId: skuId || undefined,
|
skuId: skuId || undefined,
|
||||||
|
assocPartnerAccountId: assocPartnerAccountId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -388,6 +391,21 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="关联合伙人(选填)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="不选则本单不计订单佣金"
|
||||||
|
value={assocPartnerAccountId}
|
||||||
|
onChange={setAssocPartnerAccountId}
|
||||||
|
options={(options?.partners ?? []).map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.companyName || p.name}${p.phone ? ` · ${p.phone}` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
{(options?.promoCodes.length ?? 0) > 0 ? (
|
{(options?.promoCodes.length ?? 0) > 0 ? (
|
||||||
<Form.Item label="绑定推广码(选填)">
|
<Form.Item label="绑定推广码(选填)">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ type Props = {
|
|||||||
|
|
||||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||||
const user = detail.user as
|
const user = detail.user as
|
||||||
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
| {
|
||||||
|
id?: string;
|
||||||
|
userNo?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const store = detail.store as
|
const store = detail.store as
|
||||||
| {
|
| {
|
||||||
@@ -41,7 +47,13 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
|
|||||||
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
<Descriptions.Item label="用户昵称">
|
||||||
|
{(() => {
|
||||||
|
const name = user?.nickname?.trim() || '—';
|
||||||
|
const remark = user?.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
|
})()}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户手机">
|
<Descriptions.Item label="用户手机">
|
||||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -91,6 +103,9 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
|
|||||||
<Descriptions.Item label="评价">
|
<Descriptions.Item label="评价">
|
||||||
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||||
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||||
|
{(detail.rating as { comment?: string | null }).comment
|
||||||
|
? ` · ${(detail.rating as { comment?: string }).comment}`
|
||||||
|
: ''}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
|
PictureOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
import {
|
||||||
|
hasAnySystemSettingsPermission,
|
||||||
|
hasAnyStoreMenuPermission,
|
||||||
|
HQ_ADMIN_ROLES,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
@@ -28,12 +33,15 @@ import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
|||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
||||||
|
);
|
||||||
|
|
||||||
type MenuItem = NonNullable<MenuProps['items']>[number];
|
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||||
|
|
||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
|
||||||
{
|
{
|
||||||
key: 'products-group',
|
key: 'products-group',
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShoppingOutlined />,
|
||||||
@@ -44,18 +52,17 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
|
||||||
{
|
{
|
||||||
key: 'wecom-group',
|
key: 'partners-group',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '企微机器人',
|
label: '开城',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/wecom/bots', label: '智能机器人' },
|
{ key: '/cities', label: '城市' },
|
||||||
{ key: '/wecom/pushes', label: '消息推送' },
|
{ key: '/city-partners', label: '城市合伙人' },
|
||||||
|
{ key: '/city-warehouses', label: '仓库' },
|
||||||
|
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
|
||||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
|
||||||
{
|
{
|
||||||
key: 'stores-group',
|
key: 'stores-group',
|
||||||
icon: <ShopOutlined />,
|
icon: <ShopOutlined />,
|
||||||
@@ -69,17 +76,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/store-media', label: '门店资源' },
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'partners-group',
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
label: '开城',
|
|
||||||
children: [
|
|
||||||
{ key: '/cities', label: '城市' },
|
|
||||||
{ key: '/city-partners', label: '城市合伙人' },
|
|
||||||
{ key: '/city-warehouses', label: '仓库' },
|
|
||||||
{ key: '/fulfillment-providers', label: '仓配管理' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'finance-group',
|
key: 'finance-group',
|
||||||
icon: <AccountBookOutlined />,
|
icon: <AccountBookOutlined />,
|
||||||
@@ -121,6 +117,21 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/tickets/support', label: '技术支持' },
|
{ key: '/tickets/support', label: '技术支持' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票' },
|
||||||
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
|
{ key: '/activity-posters', icon: <PictureOutlined />, label: '活动图' },
|
||||||
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||||
|
{
|
||||||
|
key: 'wecom-group',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '企微机器人',
|
||||||
|
children: [
|
||||||
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
|
{ key: '/wecom/pushes', label: '消息推送' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||||
|
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||||
{
|
{
|
||||||
key: 'dev-plan-group',
|
key: 'dev-plan-group',
|
||||||
icon: <ProjectOutlined />,
|
icon: <ProjectOutlined />,
|
||||||
@@ -131,7 +142,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/dev-plan/settings', label: '开发设置' },
|
{ key: '/dev-plan/settings', label: '开发设置' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
@@ -148,9 +158,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
@@ -172,6 +182,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/product-detail-templates': 'products',
|
'/product-detail-templates': 'products',
|
||||||
'/orders': 'orders',
|
'/orders': 'orders',
|
||||||
'/promo-codes': 'promo_codes',
|
'/promo-codes': 'promo_codes',
|
||||||
|
'/activity-posters': 'activity_posters',
|
||||||
'/wecom/bots': 'wecom_bots',
|
'/wecom/bots': 'wecom_bots',
|
||||||
'/wecom/pushes': 'wecom_bots',
|
'/wecom/pushes': 'wecom_bots',
|
||||||
'wecom-group': 'wecom_bots',
|
'wecom-group': 'wecom_bots',
|
||||||
@@ -405,7 +416,9 @@ export default function AdminLayout() {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space>
|
<Space>
|
||||||
<span>{profile?.name || '—'}</span>
|
<span>{profile?.name || '—'}</span>
|
||||||
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
<span style={{ color: '#999' }}>
|
||||||
|
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
||||||
|
</span>
|
||||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ export type AdminUserRow = {
|
|||||||
benefitUsedAmount?: number;
|
benefitUsedAmount?: number;
|
||||||
/** 好客权益·剩余未使用 */
|
/** 好客权益·剩余未使用 */
|
||||||
benefitBalance?: number;
|
benefitBalance?: number;
|
||||||
|
assocPartner?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
@@ -229,12 +235,14 @@ export type AdminOrderRow = {
|
|||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
proxyPartnerPhone?: string | null;
|
proxyPartnerPhone?: string | null;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
logisticsCompany?: string | null;
|
logisticsCompany?: string | null;
|
||||||
manualQueryUrl?: string | null;
|
manualQueryUrl?: string | null;
|
||||||
|
/** 当次应付物流费(按瓶当量 × 承运商计价) */
|
||||||
|
logisticsFee?: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,9 +31,18 @@ export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
|||||||
USER_ON_SITE: '用户·现场取货',
|
USER_ON_SITE: '用户·现场取货',
|
||||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||||
|
WECHAT_PAY: '微信支付',
|
||||||
MOCK: 'Mock',
|
MOCK: 'Mock',
|
||||||
MOCK_PAY: 'Mock 支付',
|
MOCK_PAY: 'Mock 支付',
|
||||||
SYSTEM: '系统',
|
SYSTEM: '系统',
|
||||||
|
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
|
||||||
|
PARTNER_PROXY: '合伙人代下单',
|
||||||
|
HQ_PROXY: 'HQ 代操作',
|
||||||
|
HQ_LOGISTICS: 'HQ 物流',
|
||||||
|
WAREHOUSE_MANUAL: '仓库手工',
|
||||||
|
HQ: '总部',
|
||||||
|
STORE: '门店',
|
||||||
|
PARTNER: '合伙人',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||||
@@ -111,6 +120,7 @@ export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
|
|||||||
QRCODE: '二维码',
|
QRCODE: '二维码',
|
||||||
SIGN_PHOTO: '签收照',
|
SIGN_PHOTO: '签收照',
|
||||||
VIDEO: '视频',
|
VIDEO: '视频',
|
||||||
|
ACTIVITY_POSTER: '活动图',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
|
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import {
|
||||||
|
LEDGER_TYPE_LABELS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
|
} from './constants';
|
||||||
|
import { HQ_OPERATION_ACTION_LABELS } from './hq-log';
|
||||||
|
import { USER_LOG_CATEGORY_LABELS } from './user-log';
|
||||||
|
import {
|
||||||
|
PARTNER_LOG_CATEGORY_LABELS,
|
||||||
|
HQ_ADMIN_ROLES,
|
||||||
|
resolvePartnerLogCategory,
|
||||||
|
resolveStoreLogCategory,
|
||||||
|
resolveUserLogCategory,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
WECOM_BOT_PERMISSION_LABELS,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export function zhLabel(dict: Record<string, string>, value?: string | null, empty = '—'): string {
|
||||||
|
if (value == null || value === '') return empty;
|
||||||
|
return dict[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLIENT_APP_LABELS: Record<string, string> = {
|
||||||
|
USER_MINI: 'C 端小程序',
|
||||||
|
USER_H5: 'C 端 H5',
|
||||||
|
PARTNER_MINI: '合伙人小程序',
|
||||||
|
PARTNER_H5: '合伙人 H5',
|
||||||
|
HQ_MINI: 'HQ 小程序',
|
||||||
|
HQ_WEB: 'HQ 后台',
|
||||||
|
SHOP_H5: '门店 H5',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REF_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ORDER: '订单',
|
||||||
|
USER: '用户',
|
||||||
|
STORE: '门店',
|
||||||
|
PARTNER: '合伙人',
|
||||||
|
CITY: '城市',
|
||||||
|
PRODUCT: '商品',
|
||||||
|
COUPON: '权益券',
|
||||||
|
TICKET: '工单',
|
||||||
|
SUPPORT_TICKET: '技术支持工单',
|
||||||
|
INVOICE: '发票',
|
||||||
|
PROMO: '推广码',
|
||||||
|
PROMO_CODE: '推广码',
|
||||||
|
ACTIVITY_POSTER: '活动图',
|
||||||
|
HQ: '总部',
|
||||||
|
HQ_ACCOUNT: 'HQ 账号',
|
||||||
|
STORE_ACCOUNT: '门店账号',
|
||||||
|
PARTNER_ACCOUNT: '合伙人账号',
|
||||||
|
PAYOUT: '打款',
|
||||||
|
WITHDRAWAL: '提现',
|
||||||
|
BILL: '账单',
|
||||||
|
DELIVERY: '配送单',
|
||||||
|
REDEEM: '核销',
|
||||||
|
WAREHOUSE: '仓库',
|
||||||
|
PACKAGE: '套餐',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOMAIN_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ORDER_STATUS: '订单状态',
|
||||||
|
BENEFIT_LEDGER: '权益流水',
|
||||||
|
STORE_AUDIT: '门店审核',
|
||||||
|
TICKET_COLLAB: '工单协作',
|
||||||
|
PROMO_TOUCH: '推广触达',
|
||||||
|
HQ_OPERATION: 'HQ 操作',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PARAM_DESC_LABELS: Record<string, string> = {
|
||||||
|
from_status: '原状态',
|
||||||
|
to_status: '目标状态',
|
||||||
|
operator: '操作人',
|
||||||
|
ledger_type: '流水类型',
|
||||||
|
coupon_id: '权益券 ID',
|
||||||
|
action: '行为',
|
||||||
|
target_type: '对象类型',
|
||||||
|
target_id: '对象 ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
login_success: '登录成功',
|
||||||
|
sms_login: '短信登录',
|
||||||
|
sms_send: '发送验证码',
|
||||||
|
sms_verify_fail: '验证码校验失败',
|
||||||
|
session_start: '会话开始',
|
||||||
|
home_view: '浏览首页',
|
||||||
|
product_list_view: '浏览商品列表',
|
||||||
|
product_click: '点击商品',
|
||||||
|
product_detail_view: '浏览商品详情',
|
||||||
|
order_confirm_view: '浏览确认订单',
|
||||||
|
order_submit: '提交订单',
|
||||||
|
order_list_view: '浏览订单列表',
|
||||||
|
order_detail_view: '浏览订单详情',
|
||||||
|
order_cancel: '取消订单',
|
||||||
|
pay_page_view: '进入支付页',
|
||||||
|
pay_success: '支付成功',
|
||||||
|
pay_fail: '支付失败',
|
||||||
|
wechat_login: '微信登录',
|
||||||
|
wechat_phone: '微信授权手机号',
|
||||||
|
wechat_phone_login: '微信手机号登录',
|
||||||
|
wechat_location: '微信定位',
|
||||||
|
wechat_album: '微信选图',
|
||||||
|
wechat_bind: '绑定微信',
|
||||||
|
store_list_view: '浏览门店列表',
|
||||||
|
store_detail_view: '浏览门店详情',
|
||||||
|
benefit_page_view: '浏览权益页',
|
||||||
|
benefit_detail_view: '浏览权益详情',
|
||||||
|
benefit_redeem_start: '开始核销',
|
||||||
|
benefit_redeem_success: '核销成功',
|
||||||
|
redeem_code_view: '查看核销码',
|
||||||
|
redeem_success_view: '核销成功页',
|
||||||
|
profile_update: '修改资料',
|
||||||
|
bind_phone: '绑定手机号',
|
||||||
|
address_list_view: '浏览地址列表',
|
||||||
|
address_edit: '编辑地址',
|
||||||
|
promo_touch: '推广触达',
|
||||||
|
promo_share: '分享推广',
|
||||||
|
cs_contact: '联系客服',
|
||||||
|
after_sale_list_view: '浏览售后列表',
|
||||||
|
after_sale_apply: '申请售后',
|
||||||
|
client_error: '前端异常',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORE_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
store_sms_send: '发送验证码',
|
||||||
|
store_sms_login: '短信登录',
|
||||||
|
store_sms_verify_fail: '验证码校验失败',
|
||||||
|
store_login_success: '登录成功',
|
||||||
|
store_select: '选择门店',
|
||||||
|
store_wechat_login: '微信登录',
|
||||||
|
store_wechat_bind: '绑定微信',
|
||||||
|
store_home_view: '浏览首页',
|
||||||
|
store_redeem_entry_view: '进入核销页',
|
||||||
|
store_redeem_confirm_view: '核销确认页',
|
||||||
|
store_phone_redeem_view: '手机号核销页',
|
||||||
|
store_records_view: '浏览核销记录',
|
||||||
|
store_withdraw_view: '浏览提现页',
|
||||||
|
store_packages_view: '浏览套餐页',
|
||||||
|
store_mine_view: '浏览我的',
|
||||||
|
store_staff_view: '浏览子账号',
|
||||||
|
store_redeem_preview: '核销预览',
|
||||||
|
store_redeem_confirm: '确认核销',
|
||||||
|
store_redeem_confirm_fail: '核销失败',
|
||||||
|
store_redeem_weaknet_threshold: '弱网阈值',
|
||||||
|
store_redeem_pending_submit: '提交挂起核销',
|
||||||
|
store_redeem_pending_complete: '完成挂起核销',
|
||||||
|
store_redeem_pending_reject: '驳回挂起核销',
|
||||||
|
store_redeem_phone_lookup_sms: '手机号核销发码',
|
||||||
|
store_redeem_phone_balance: '查询用户权益余额',
|
||||||
|
store_redeem_phone_prepare: '手机号核销准备',
|
||||||
|
store_redeem_scan_start: '扫码核销开始',
|
||||||
|
store_payout_created: '生成打款单',
|
||||||
|
store_payout_paid: '打款完成',
|
||||||
|
store_withdraw_applied: '申请提现',
|
||||||
|
store_withdraw_paid: '提现已打款',
|
||||||
|
store_status_change: '变更营业状态',
|
||||||
|
store_staff_create: '新增子账号',
|
||||||
|
store_staff_update: '编辑子账号',
|
||||||
|
store_staff_delete: '删除子账号',
|
||||||
|
store_staff_permission_update: '更新子账号权限',
|
||||||
|
store_package_apply: '申请套餐',
|
||||||
|
store_package_audit_view: '查看套餐审核',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PARTNER_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
partner_sms_send: '发送验证码',
|
||||||
|
partner_sms_login: '短信登录',
|
||||||
|
partner_sms_verify_fail: '验证码校验失败',
|
||||||
|
partner_login_success: '登录成功',
|
||||||
|
partner_wechat_login: '微信登录',
|
||||||
|
partner_wechat_bind: '绑定微信',
|
||||||
|
partner_home_view: '浏览首页',
|
||||||
|
partner_store_list_view: '浏览门店列表',
|
||||||
|
partner_store_detail_view: '浏览门店详情',
|
||||||
|
partner_store_create_view: '进入录店页',
|
||||||
|
partner_order_list_view: '浏览订单列表',
|
||||||
|
partner_order_detail_view: '浏览订单详情',
|
||||||
|
partner_proxy_order_view: '进入代下单页',
|
||||||
|
partner_bills_view: '浏览账单列表',
|
||||||
|
partner_bill_detail_view: '浏览账单详情',
|
||||||
|
partner_staff_list_view: '浏览子账号',
|
||||||
|
partner_leaderboard_view: '浏览排行榜',
|
||||||
|
partner_weekly_report_view: '浏览周报',
|
||||||
|
partner_staff_create: '新增子账号',
|
||||||
|
partner_staff_sms_send: '子账号发验证码',
|
||||||
|
partner_staff_sms_verify_fail: '子账号验证码失败',
|
||||||
|
partner_staff_update: '编辑子账号',
|
||||||
|
partner_staff_delete: '删除子账号',
|
||||||
|
partner_staff_permission_update: '更新子账号权限',
|
||||||
|
partner_store_create: '录入门店',
|
||||||
|
partner_store_status_change: '变更门店状态',
|
||||||
|
partner_store_audit_approved: '门店审核通过',
|
||||||
|
partner_store_audit_rejected: '门店审核驳回',
|
||||||
|
partner_order_ship: '订单发货',
|
||||||
|
partner_delivery_advance: '推进配送',
|
||||||
|
partner_proxy_order_create: '代下单',
|
||||||
|
partner_bill_view: '查看账单',
|
||||||
|
partner_bill_confirm: '确认账单',
|
||||||
|
partner_warehouse_view: '浏览仓库',
|
||||||
|
partner_warehouse_update: '更新仓库',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_NAME_LABELS: Record<string, string> = {
|
||||||
|
...USER_LOG_EVENT_LABELS,
|
||||||
|
...STORE_LOG_EVENT_LABELS,
|
||||||
|
...PARTNER_LOG_EVENT_LABELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
WECHAT_AUTH: '微信授权',
|
||||||
|
WECHAT_PAY: '微信支付',
|
||||||
|
WECHAT_REFUND: '微信退款',
|
||||||
|
WECHAT_MAP: '微信/腾讯地图',
|
||||||
|
ALIYUN_OSS: '阿里云 OSS',
|
||||||
|
ALIYUN_SMS: '阿里云短信',
|
||||||
|
MOCK_SMS: 'Mock 短信',
|
||||||
|
SMS: '短信',
|
||||||
|
XFX: '小飞侠',
|
||||||
|
LOGISTICS: '物流快递',
|
||||||
|
MANUAL: '手动',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_STATUS_LABELS: Record<string, string> = {
|
||||||
|
SUCCESS: '成功',
|
||||||
|
FAILED: '失败',
|
||||||
|
PENDING: '处理中',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_SCENE_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
USERINFO: '用户信息',
|
||||||
|
JSSDK_CONFIG: 'JSSDK 配置',
|
||||||
|
BIND_PHONE: '绑定手机号',
|
||||||
|
GET_LOCATION: '获取定位',
|
||||||
|
CHOOSE_IMAGE: '选择图片',
|
||||||
|
ORDER_PAY: '订单支付',
|
||||||
|
ORDER_REFUND: '订单退款',
|
||||||
|
UPLOAD_SHIPPING: '上传发货信息',
|
||||||
|
TRADE_MANAGE_PUSH: '交易结算推送',
|
||||||
|
ORDER_DISPATCH: '配送下单',
|
||||||
|
ORDER_DISPATCH_HOLD: '配送暂不下发',
|
||||||
|
TRACK_CALLBACK: '物流回调',
|
||||||
|
UPLOAD_TOKEN: '上传凭证',
|
||||||
|
UPLOAD_PUT_OBJECT: '上传文件',
|
||||||
|
GEOCODE: '地理编码',
|
||||||
|
REVERSE_GEOCODE: '逆地理编码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WECOM_BOT_ACTION_LABELS: Record<string, string> = {
|
||||||
|
...WECOM_BOT_PERMISSION_LABELS,
|
||||||
|
'user.read_sms.start': '查询用户·发送验证码',
|
||||||
|
'user.read_sms.verify': '查询用户·校验验证码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GENERIC_STATUS_LABELS: Record<string, string> = {
|
||||||
|
...ORDER_STATUS_LABELS,
|
||||||
|
...LEDGER_TYPE_LABELS,
|
||||||
|
...THIRD_PARTY_STATUS_LABELS,
|
||||||
|
PENDING: '待处理',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
ACTIVE: '正常',
|
||||||
|
DISABLED: '停用',
|
||||||
|
SUCCESS: '成功',
|
||||||
|
FAILED: '失败',
|
||||||
|
OPEN: '营业中',
|
||||||
|
PAUSED: '已暂停',
|
||||||
|
CLOSED: '已关闭',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CODE_ZH: Record<string, string> = {
|
||||||
|
...CLIENT_APP_LABELS,
|
||||||
|
...REF_TYPE_LABELS,
|
||||||
|
...DOMAIN_EVENT_TYPE_LABELS,
|
||||||
|
...EVENT_NAME_LABELS,
|
||||||
|
...ORDER_STATUS_LABELS,
|
||||||
|
...ORDER_STATUS_OPERATOR_LABELS,
|
||||||
|
...LEDGER_TYPE_LABELS,
|
||||||
|
...HQ_OPERATION_ACTION_LABELS,
|
||||||
|
...HQ_ROLE_LABELS,
|
||||||
|
...THIRD_PARTY_PROVIDER_LABELS,
|
||||||
|
...THIRD_PARTY_STATUS_LABELS,
|
||||||
|
...THIRD_PARTY_SCENE_LABELS,
|
||||||
|
...WECOM_BOT_ACTION_LABELS,
|
||||||
|
...GENERIC_STATUS_LABELS,
|
||||||
|
...PARAM_DESC_LABELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function zhCode(value?: string | null, empty = '—'): string {
|
||||||
|
if (value == null || value === '') return empty;
|
||||||
|
return CODE_ZH[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventNameLabel(value?: string | null): string {
|
||||||
|
return zhLabel(EVENT_NAME_LABELS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientAppLabel(value?: string | null): string {
|
||||||
|
return zhLabel(CLIENT_APP_LABELS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRef(refType?: string | null, refId?: string | null): string {
|
||||||
|
if (!refType && !refId) return '—';
|
||||||
|
const type = zhLabel(REF_TYPE_LABELS, refType, refType || '');
|
||||||
|
return refId ? `${type} #${refId}` : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolveUserLogCategory(eventName);
|
||||||
|
if (key && USER_LOG_CATEGORY_LABELS[key]) return USER_LOG_CATEGORY_LABELS[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolveStoreLogCategory(eventName);
|
||||||
|
const labels = STORE_LOG_CATEGORY_LABELS as Record<string, string>;
|
||||||
|
if (key && labels[key]) return labels[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partnerEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolvePartnerLogCategory(eventName);
|
||||||
|
const labels = PARTNER_LOG_CATEGORY_LABELS as Record<string, string>;
|
||||||
|
if (key && labels[key]) return labels[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainParamFieldLabel(desc?: string | null, fallbackIndex?: 1 | 2 | 3): string {
|
||||||
|
if (desc && PARAM_DESC_LABELS[desc]) return PARAM_DESC_LABELS[desc];
|
||||||
|
if (fallbackIndex) return `参数${fallbackIndex}`;
|
||||||
|
return '参数';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zhRemark(value?: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
return value
|
||||||
|
.replace(/partnerAccountId=/g, '合伙人账号=')
|
||||||
|
.replace(/hqAccountId=/g, 'HQ账号=')
|
||||||
|
.replace(/deliveryMode=/g, '履约方式=')
|
||||||
|
.replace(/\bmode=/g, '履约方式=')
|
||||||
|
.replace(/customer=/g, '客户=')
|
||||||
|
.replace(/\bON_SITE_PICKUP\b/g, '现场提货')
|
||||||
|
.replace(/\bCROSS_CITY\b/g, '跨城')
|
||||||
|
.replace(/\bLOCAL\b/g, '同城');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainParamValue(desc?: string | null, value?: string | null): string {
|
||||||
|
if (value == null || value === '') return '—';
|
||||||
|
if (desc === 'from_status' || desc === 'to_status') return zhLabel(ORDER_STATUS_LABELS, value);
|
||||||
|
if (desc === 'operator') return zhLabel(ORDER_STATUS_OPERATOR_LABELS, value);
|
||||||
|
if (desc === 'ledger_type') return zhLabel(LEDGER_TYPE_LABELS, value);
|
||||||
|
if (desc === 'action') return zhLabel(HQ_OPERATION_ACTION_LABELS, value);
|
||||||
|
if (desc === 'target_type') return zhLabel(REF_TYPE_LABELS, value);
|
||||||
|
return zhCode(value);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||||
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||||
|
{ value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' },
|
||||||
{ value: 'USER_DELETE', label: '删除用户' },
|
{ value: 'USER_DELETE', label: '删除用户' },
|
||||||
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
||||||
{ value: 'ORDER_SHIP', label: '订单发货' },
|
{ value: 'ORDER_SHIP', label: '订单发货' },
|
||||||
@@ -60,6 +61,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
||||||
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
||||||
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_CREATE', label: '新增活动图' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_UPDATE', label: '编辑活动图' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_UPDATE_STATUS', label: '活动图上下架' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_DELETE', label: '删除活动图' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */
|
||||||
|
export function omitNullFields<T extends Record<string, unknown>>(input: T): Partial<T> {
|
||||||
|
const out: Partial<T> = {};
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
(out as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
ACTIVITY_POSTER_STATUS_LABELS,
|
||||||
|
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
|
type ActivityPosterItem,
|
||||||
|
type ActivityPosterQrSlot,
|
||||||
|
type ActivityPosterStatus,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import ActivityPosterQrSlotEditor from '../components/ActivityPosterQrSlotEditor';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
title: string;
|
||||||
|
copyText?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
qrXPct: number;
|
||||||
|
qrYPct: number;
|
||||||
|
qrSizePct: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
status?: ActivityPosterStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPostersPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm<FormValues>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<ActivityPosterItem>(
|
||||||
|
'/admin/activity-posters',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ActivityPosterItem | null>(null);
|
||||||
|
|
||||||
|
const imageUrl = Form.useWatch('imageUrl', editForm);
|
||||||
|
const qrXPct = Form.useWatch('qrXPct', editForm);
|
||||||
|
const qrYPct = Form.useWatch('qrYPct', editForm);
|
||||||
|
const qrSizePct = Form.useWatch('qrSizePct', editForm);
|
||||||
|
const slot: ActivityPosterQrSlot = {
|
||||||
|
qrXPct: qrXPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct,
|
||||||
|
qrYPct: qrYPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct,
|
||||||
|
qrSizePct: qrSizePct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
|
||||||
|
};
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
title: '',
|
||||||
|
copyText: '',
|
||||||
|
imageUrl: '',
|
||||||
|
...DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
|
sortOrder: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: ActivityPosterItem) {
|
||||||
|
setEditing(row);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
title: row.title,
|
||||||
|
copyText: row.copyText,
|
||||||
|
imageUrl: row.imageUrl,
|
||||||
|
qrXPct: row.qrXPct,
|
||||||
|
qrYPct: row.qrYPct,
|
||||||
|
qrSizePct: row.qrSizePct,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
const body = JSON.stringify(v);
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/activity-posters/${editing.id}`, { method: 'PUT', body });
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/activity-posters', { method: 'POST', body });
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<ActivityPosterItem> = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => <AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '封面',
|
||||||
|
dataIndex: 'imageUrl',
|
||||||
|
width: 90,
|
||||||
|
render: (url: string) => <Image src={url} width={56} height={56} style={{ objectFit: 'cover' }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '文案',
|
||||||
|
dataIndex: 'copyText',
|
||||||
|
width: 240,
|
||||||
|
render: (v: string) => v || '—',
|
||||||
|
},
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (s: ActivityPosterStatus) => (
|
||||||
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{ACTIVITY_POSTER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
|
||||||
|
await request(`/admin/activity-posters/${row.id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
});
|
||||||
|
message.success(next === 'ACTIVE' ? '已上架' : '已下架');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.status === 'ACTIVE' ? '下架' : '上架'}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该活动图?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await request(`/admin/activity-posters/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('activity-posters', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="活动图"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={openCreate}>新建活动图</Button>}
|
||||||
|
/>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑活动图' : '新建活动图'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void save()}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
|
||||||
|
<Input maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
||||||
|
<OssUpload bizType="ACTIVITY_POSTER" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item name="qrSizePct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item label="二维码栏">
|
||||||
|
<ActivityPosterQrSlotEditor
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
value={slot}
|
||||||
|
onChange={(next) => editForm.setFieldsValue(next)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="copyText" label="推广文案">
|
||||||
|
<Input.TextArea rows={4} maxLength={4000} placeholder="合伙人可一键复制" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||||
|
<Select options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -111,9 +111,16 @@ type CouponDetail = Row & {
|
|||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
if (initialCouponNo) init.couponNo = initialCouponNo;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const [grantOpen, setGrantOpen] = useState(false);
|
const [grantOpen, setGrantOpen] = useState(false);
|
||||||
const [granting, setGranting] = useState(false);
|
const [granting, setGranting] = useState(false);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -132,6 +139,35 @@ export default function BenefitCouponsPage() {
|
|||||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
|
|
||||||
|
async function openCouponDetail(id: string) {
|
||||||
|
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
|
||||||
|
}, [form, initialCouponNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
|
||||||
|
if (openCouponId && !deepLinkOpenedRef.current) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openCouponDetail(openCouponId).catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.couponNo) === initialCouponNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openCouponDetail(first.id).catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, initialCouponNo, loading, location.state]);
|
||||||
|
|
||||||
async function openRedeemDetail(redeemId: string) {
|
async function openRedeemDetail(redeemId: string) {
|
||||||
setRedeemDetailLoading(true);
|
setRedeemDetailLoading(true);
|
||||||
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink
|
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
onClick={() => void openCouponDetail(row.id)}
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
|
||||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||||
@@ -33,6 +34,50 @@ const CELEBRATE_SFX: Record<AmountTier, string> = {
|
|||||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||||
let audioUnlocked = false;
|
let audioUnlocked = false;
|
||||||
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||||
|
const BGM_VOLUME = 0.32;
|
||||||
|
const BGM_DUCK_VOLUME = 0.08;
|
||||||
|
let bgmAudio: HTMLAudioElement | null = null;
|
||||||
|
let bgmDucked = false;
|
||||||
|
|
||||||
|
function getBgmAudio(): HTMLAudioElement | null {
|
||||||
|
if (typeof Audio === 'undefined') return null;
|
||||||
|
if (!bgmAudio) {
|
||||||
|
bgmAudio = new Audio(bgmSrc);
|
||||||
|
bgmAudio.loop = true;
|
||||||
|
bgmAudio.preload = 'auto';
|
||||||
|
bgmAudio.volume = BGM_VOLUME;
|
||||||
|
}
|
||||||
|
return bgmAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBgmVolume() {
|
||||||
|
if (!bgmAudio) return;
|
||||||
|
bgmAudio.volume = bgmDucked ? BGM_DUCK_VOLUME : BGM_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duckBgm() {
|
||||||
|
bgmDucked = true;
|
||||||
|
applyBgmVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unduckBgm() {
|
||||||
|
bgmDucked = false;
|
||||||
|
applyBgmVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBgmPlaying(on: boolean): Promise<boolean> {
|
||||||
|
const audio = getBgmAudio();
|
||||||
|
if (!audio) return Promise.resolve(false);
|
||||||
|
applyBgmVolume();
|
||||||
|
if (!on) {
|
||||||
|
audio.pause();
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return audio
|
||||||
|
.play()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
function unlockCelebrateAudio() {
|
function unlockCelebrateAudio() {
|
||||||
if (audioUnlocked || !sharedAudio) return;
|
if (audioUnlocked || !sharedAudio) return;
|
||||||
@@ -53,6 +98,7 @@ function unlockCelebrateAudio() {
|
|||||||
|
|
||||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||||
if (!sharedAudio) return () => undefined;
|
if (!sharedAudio) return () => undefined;
|
||||||
|
duckBgm();
|
||||||
try {
|
try {
|
||||||
sharedAudio.pause();
|
sharedAudio.pause();
|
||||||
sharedAudio.currentTime = 0;
|
sharedAudio.currentTime = 0;
|
||||||
@@ -72,6 +118,7 @@ function playCelebrateSfx(tier: AmountTier): () => void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
unduckBgm();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,10 +388,40 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BgmToggleButton({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`big-screen-bgm${on ? ' is-on' : ''}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
aria-label={on ? '暂停背景音乐' : '播放背景音乐'}
|
||||||
|
>
|
||||||
|
<span className="big-screen-bgm-icon" aria-hidden>
|
||||||
|
{on ? (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9 18V6h2v12H9zm4 0V6h2v12h-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M8 5.14v13.72L19.06 12 8 5.14z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{on ? 'BGM 播放中' : '播放 BGM'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BigScreenPage() {
|
export default function BigScreenPage() {
|
||||||
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [bgmOn, setBgmOn] = useState(false);
|
||||||
|
const bgmWantedRef = useRef(false);
|
||||||
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||||
const seenIdsRef = useRef<Set<string> | null>(null);
|
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||||
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||||
@@ -416,6 +493,22 @@ export default function BigScreenPage() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const toggleBgm = useCallback(() => {
|
||||||
|
unlockCelebrateAudio();
|
||||||
|
const next = !bgmWantedRef.current;
|
||||||
|
bgmWantedRef.current = next;
|
||||||
|
if (!next) {
|
||||||
|
void setBgmPlaying(false);
|
||||||
|
setBgmOn(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBgmOn(true);
|
||||||
|
void setBgmPlaying(true).then((playing) => {
|
||||||
|
if (!bgmWantedRef.current) return;
|
||||||
|
setBgmOn(playing);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
unlockCelebrateAudio();
|
unlockCelebrateAudio();
|
||||||
const unlock = () => unlockCelebrateAudio();
|
const unlock = () => unlockCelebrateAudio();
|
||||||
@@ -424,6 +517,8 @@ export default function BigScreenPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pointerdown', unlock);
|
window.removeEventListener('pointerdown', unlock);
|
||||||
window.removeEventListener('keydown', unlock);
|
window.removeEventListener('keydown', unlock);
|
||||||
|
bgmWantedRef.current = false;
|
||||||
|
void setBgmPlaying(false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -480,6 +575,7 @@ export default function BigScreenPage() {
|
|||||||
<span className="big-screen-subtitle">· 发布会现场</span>
|
<span className="big-screen-subtitle">· 发布会现场</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="big-screen-live-wrap">
|
<div className="big-screen-live-wrap">
|
||||||
|
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
|
||||||
<span className="big-screen-live-label">实时成交</span>
|
<span className="big-screen-live-label">实时成交</span>
|
||||||
<span className="big-screen-live">
|
<span className="big-screen-live">
|
||||||
<span className="big-screen-live-dot" />
|
<span className="big-screen-live-dot" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -362,8 +363,28 @@ export default function CitiesPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
<Divider orientation="left">履约起购</Divider>
|
||||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
<Form.Item
|
||||||
|
name="pickupMinQty"
|
||||||
|
label="现场提货"
|
||||||
|
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="localMinQty"
|
||||||
|
label="同城配送"
|
||||||
|
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="crossMinQty"
|
||||||
|
label="跨城配送"
|
||||||
|
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="maxPartnerCommissionPercent"
|
name="maxPartnerCommissionPercent"
|
||||||
label="合伙人佣金合计上限 %"
|
label="合伙人佣金合计上限 %"
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { omitNullFields } from '../lib/omit-null-fields';
|
||||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||||
|
import PartnerAssocPanel from '../components/PartnerAssocPanel';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
@@ -60,6 +62,7 @@ type Row = {
|
|||||||
managedWarehouseName?: string | null;
|
managedWarehouseName?: string | null;
|
||||||
maxPartnerCommissionRate?: number | null;
|
maxPartnerCommissionRate?: number | null;
|
||||||
storeCount: number;
|
storeCount: number;
|
||||||
|
assocUserCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -209,7 +212,7 @@ export default function CityPartnersPage() {
|
|||||||
await request(`/admin/partners/${detail.id}`, {
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...v,
|
...omitNullFields(v),
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
@@ -332,6 +335,19 @@ export default function CityPartnersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '关联用户',
|
||||||
|
dataIndex: 'assocUserCount',
|
||||||
|
width: 80,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
|
||||||
|
title={`查看「${row.companyName || row.phone}」关联用户`}
|
||||||
|
>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -355,7 +371,9 @@ export default function CityPartnersPage() {
|
|||||||
title="城市合伙人"
|
title="城市合伙人"
|
||||||
settings={settingsButton}
|
settings={settingsButton}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<>
|
||||||
|
<Link to="/users?assocPartnerAccountId=any">全部关联用户</Link>
|
||||||
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@@ -372,6 +390,7 @@ export default function CityPartnersPage() {
|
|||||||
>
|
>
|
||||||
新建合伙人
|
新建合伙人
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -486,6 +505,14 @@ export default function CityPartnersPage() {
|
|||||||
{detail.storeCount ?? 0}
|
{detail.storeCount ?? 0}
|
||||||
</Link>
|
</Link>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联用户">
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
|
||||||
|
title="查看该城市合伙人关联用户"
|
||||||
|
>
|
||||||
|
{detail.assocUserCount ?? 0}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="管仓仓库" span={2}>
|
<Descriptions.Item label="管仓仓库" span={2}>
|
||||||
{detail.managedWarehouseName ?? (
|
{detail.managedWarehouseName ?? (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
@@ -570,6 +597,11 @@ export default function CityPartnersPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,16 +1,43 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import {
|
||||||
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
DELIVERY_TYPE_LABELS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
fmtTime,
|
||||||
|
} from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type DeliveryOrder = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
deliveryType: string;
|
||||||
|
productName: string;
|
||||||
|
productSpec?: string | null;
|
||||||
|
barcode69?: string | null;
|
||||||
|
quantity: number;
|
||||||
|
saleUnit?: string;
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
payAmount?: number;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
receiverAddress?: string;
|
||||||
|
receiverProvince?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
receiverDistrict?: string;
|
||||||
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
|
imageResource?: { url: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,19 +46,34 @@ type Row = {
|
|||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** 当次应付物流费 */
|
|
||||||
logisticsFee?: number | null;
|
logisticsFee?: number | null;
|
||||||
order?: {
|
order?: DeliveryOrder;
|
||||||
id: string;
|
|
||||||
orderNo: string;
|
|
||||||
status: string;
|
|
||||||
receiverName: string;
|
|
||||||
receiverPhone: string;
|
|
||||||
deliveryType: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatQty(order?: DeliveryOrder | null) {
|
||||||
|
if (!order || order.quantity == null) return '—';
|
||||||
|
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
|
const bottles =
|
||||||
|
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
|
||||||
|
? `(${order.quantity * order.bottlesPerUnit}瓶)`
|
||||||
|
: '';
|
||||||
|
return `${order.quantity}${unit}${bottles}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(order?: DeliveryOrder | null) {
|
||||||
|
if (!order) return '—';
|
||||||
|
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
|
||||||
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
const detail = (order.receiverAddress || '').trim();
|
||||||
|
if (!region) return detail || '—';
|
||||||
|
if (!detail || detail.startsWith(region)) return detail || region;
|
||||||
|
return `${region}${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeliveriesPage() {
|
export default function DeliveriesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -65,25 +107,91 @@ export default function DeliveriesPage() {
|
|||||||
setTrackOpen(true);
|
setTrackOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: Row) {
|
||||||
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
provider: d.provider,
|
||||||
|
trackingNo: d.trackingNo,
|
||||||
|
providerOrderNo: d.providerOrderNo,
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '订单号',
|
title: '订单号',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
onClick={async () => {
|
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
{
|
||||||
|
title: '用户',
|
||||||
|
key: 'user',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => {
|
||||||
|
const user = row.order?.user;
|
||||||
|
if (!user) return '—';
|
||||||
|
const label = user.userNo || user.phone || '—';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{user.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{user.phone && user.userNo ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{user.phone}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
key: 'product',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => {
|
||||||
|
const order = row.order;
|
||||||
|
if (!order?.productName) return '—';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>{order.productName}</span>
|
||||||
|
{order.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
key: 'quantity',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) => formatQty(row.order),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送方式',
|
||||||
|
dataIndex: ['order', 'deliveryType'],
|
||||||
|
width: 90,
|
||||||
|
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '承运商',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
|
||||||
|
},
|
||||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '运费',
|
title: '运费',
|
||||||
@@ -92,20 +200,33 @@ export default function DeliveriesPage() {
|
|||||||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||||
},
|
},
|
||||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
{
|
||||||
|
title: '订单状态',
|
||||||
|
dataIndex: ['order', 'status'],
|
||||||
|
width: 100,
|
||||||
|
render: (s) => ORDER_STATUS_LABELS[s] || s,
|
||||||
|
},
|
||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
|
{
|
||||||
|
title: '收货电话',
|
||||||
|
dataIndex: ['order', 'receiverPhone'],
|
||||||
|
width: 120,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送地址',
|
||||||
|
key: 'address',
|
||||||
|
width: 280,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => formatAddress(row.order),
|
||||||
|
},
|
||||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 140,
|
title: '操作', width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>编辑</Button>
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}>编辑</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -113,19 +234,21 @@ export default function DeliveriesPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
const order = detail?.order;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
<Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
@@ -142,14 +265,63 @@ export default function DeliveriesPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
<Descriptions.Item label="用户">
|
||||||
|
{order?.user ? (
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{order.user.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
|
||||||
|
>
|
||||||
|
{order.user.userNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
order.user.userNo || '—'
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
<Space align="start">
|
||||||
|
{order?.imageResource?.url ? (
|
||||||
|
<img
|
||||||
|
src={order.imageResource.url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{order?.productName || '—'}
|
||||||
|
{order?.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{order?.barcode69 ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.barcode69}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="配送方式">
|
||||||
|
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货人">
|
||||||
|
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="运费">
|
<Descriptions.Item label="运费">
|
||||||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
|
|||||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!batchEditOpen) return;
|
if (!modalOpen && !batchEditOpen) return;
|
||||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||||
.then((res) => setVersions(res.items ?? []))
|
.then((res) => setVersions(res.items ?? []))
|
||||||
.catch(() => setVersions([]));
|
.catch(() => setVersions([]));
|
||||||
}, [batchEditOpen]);
|
}, [modalOpen, batchEditOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen || editing) return;
|
if (!modalOpen || editing) return;
|
||||||
@@ -108,6 +108,7 @@ export default function DevPlanTasksPage() {
|
|||||||
content: row.content,
|
content: row.content,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
|
||||||
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
@@ -121,7 +122,11 @@ export default function DevPlanTasksPage() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...values, attachmentUrls }),
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
attachmentUrls,
|
||||||
|
versionIds: values.versionIds ?? [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +290,21 @@ export default function DevPlanTasksPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '关联版本',
|
||||||
|
dataIndex: 'versions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.versions?.length ? (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{row.versions.map((v) => (
|
||||||
|
<Tag key={v.id}>{v.versionNo}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -405,9 +425,21 @@ export default function DevPlanTasksPage() {
|
|||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="versionIds" label="关联版本">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="可选:关联版本"
|
||||||
|
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import {
|
||||||
|
DOMAIN_EVENT_TYPE_LABELS,
|
||||||
|
domainParamFieldLabel,
|
||||||
|
domainParamValue,
|
||||||
|
formatRef,
|
||||||
|
zhCode,
|
||||||
|
zhLabel,
|
||||||
|
} from '../lib/display-labels';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
@@ -15,8 +23,11 @@ type Row = {
|
|||||||
refId: string;
|
refId: string;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
param1?: string | null;
|
param1?: string | null;
|
||||||
|
param1Desc?: string | null;
|
||||||
param2?: string | null;
|
param2?: string | null;
|
||||||
|
param2Desc?: string | null;
|
||||||
param3?: string | null;
|
param3?: string | null;
|
||||||
|
param3Desc?: string | null;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -70,13 +81,18 @@ export default function DomainEventsPage() {
|
|||||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tag>{v}</Tag>
|
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
{ title: '关联', render: (_, r) => formatRef(r.refType, r.refId) },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => zhCode(v) },
|
||||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
{
|
||||||
|
title: '摘要',
|
||||||
|
render: (_, r) => domainParamValue(r.param1Desc, r.param1) !== '—'
|
||||||
|
? domainParamValue(r.param1Desc, r.param1)
|
||||||
|
: (r.remark || '—'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -133,11 +149,18 @@ export default function DomainEventsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} size="small">
|
<Descriptions column={1} size="small">
|
||||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
<Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
<Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
<Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
|
||||||
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
{domainParamValue(detail.param1Desc, detail.param1)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={domainParamFieldLabel(detail.param2Desc, 2)}>
|
||||||
|
{domainParamValue(detail.param2Desc, detail.param2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={domainParamFieldLabel(detail.param3Desc, 3)}>
|
||||||
|
{domainParamValue(detail.param3Desc, detail.param3)}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -68,7 +69,10 @@ export default function FulfillmentProvidersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||||
setRows(res);
|
setRows(Array.isArray(res) ? res : []);
|
||||||
|
} catch (e) {
|
||||||
|
setRows([]);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -122,6 +127,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -144,6 +150,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||||
},
|
},
|
||||||
|
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||||
@@ -176,6 +183,16 @@ export default function FulfillmentProvidersPage() {
|
|||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(row: FulfillmentProviderDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||||
{
|
{
|
||||||
@@ -230,11 +247,23 @@ export default function FulfillmentProvidersPage() {
|
|||||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
<Space size={0}>
|
||||||
编辑
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
</Button>
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除承运商「${row.name}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void remove(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -288,6 +317,17 @@ export default function FulfillmentProvidersPage() {
|
|||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="deliveryHintHtml"
|
||||||
|
label="配送信息提示"
|
||||||
|
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider orientation="left">物流对账</Divider>
|
<Divider orientation="left">物流对账</Divider>
|
||||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||||
@@ -309,6 +349,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="baseBottles"
|
name="baseBottles"
|
||||||
label="起送瓶数"
|
label="起送瓶数"
|
||||||
|
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { formatRef, zhCode } from '../lib/display-labels';
|
||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -84,7 +85,7 @@ export default function HqLogsPage() {
|
|||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -143,7 +144,7 @@ export default function HqLogsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="refType" label="对象类型">
|
<Form.Item name="refType" label="对象类型">
|
||||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
<Input allowClear placeholder="对象编码" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -192,12 +193,12 @@ export default function HqLogsPage() {
|
|||||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
<Descriptions.Item label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="行为">
|
<Descriptions.Item label="行为">
|
||||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
<Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export default function HqPermissionsPage() {
|
|||||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
||||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -274,7 +274,7 @@ export default function HqPermissionsPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Radio,
|
Radio,
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -24,6 +22,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
ORDER_STATUS_OPERATOR_LABELS,
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
fmtTime,
|
fmtTime,
|
||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
|
import { zhRemark } from '../lib/display-labels';
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
@@ -57,6 +57,7 @@ type ShipDefaults = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CityOption = { id: string; name: string; code: string };
|
type CityOption = { id: string; name: string; code: string };
|
||||||
|
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
|
||||||
|
|
||||||
type WarehouseOption = {
|
type WarehouseOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -93,6 +94,20 @@ type OrderRedeemSummary = {
|
|||||||
redeemRecordSum: number;
|
redeemRecordSum: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssocPartnerBrief = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
orderCommissionRate?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAssocPartner(p?: AssocPartnerBrief | null) {
|
||||||
|
if (!p) return '—';
|
||||||
|
const title = p.companyName || p.name;
|
||||||
|
return [title, p.phone].filter(Boolean).join(' / ') || '—';
|
||||||
|
}
|
||||||
|
|
||||||
type OrderDetail = AdminOrderRow & {
|
type OrderDetail = AdminOrderRow & {
|
||||||
receiverAddress?: string;
|
receiverAddress?: string;
|
||||||
receiverProvince?: string;
|
receiverProvince?: string;
|
||||||
@@ -136,6 +151,11 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
lng?: number | null;
|
lng?: number | null;
|
||||||
lat?: number | null;
|
lat?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
assocPartnerAtPay?: AssocPartnerBrief | null;
|
||||||
|
orderCommissionRateAtPay?: number | null;
|
||||||
|
user?: AdminOrderRow['user'] & {
|
||||||
|
assocPartner?: AssocPartnerBrief | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
||||||
@@ -149,9 +169,12 @@ type OrderExportFilters = {
|
|||||||
orderType?: string;
|
orderType?: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
receiverPhone?: string;
|
receiverPhone?: string;
|
||||||
|
userKeyword?: string;
|
||||||
|
productKeyword?: string;
|
||||||
fulfillmentHold?: boolean;
|
fulfillmentHold?: boolean;
|
||||||
excludeTest?: boolean;
|
|
||||||
deliveryType?: string;
|
deliveryType?: string;
|
||||||
|
promoCodeId?: string;
|
||||||
|
assocPartnerAccountId?: string;
|
||||||
dateRange?: [Dayjs, Dayjs];
|
dateRange?: [Dayjs, Dayjs];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,6 +234,17 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|||||||
{ key: 'year', label: '当年' },
|
{ key: 'year', label: '当年' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
|
||||||
|
|
||||||
|
function formatUserWithRemark(user?: {
|
||||||
|
userNo?: string;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
} | null) {
|
||||||
|
const userNo = user?.userNo || '—';
|
||||||
|
const remark = user?.hqRemark?.trim();
|
||||||
|
return remark ? `${userNo}(${remark})` : userNo;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBenefitBrief(row: AdminOrderRow) {
|
function formatBenefitBrief(row: AdminOrderRow) {
|
||||||
const coupon = row.benefitCoupon;
|
const coupon = row.benefitCoupon;
|
||||||
if (coupon) {
|
if (coupon) {
|
||||||
@@ -243,9 +277,12 @@ function buildExportPayload(
|
|||||||
if (filters.orderType) payload.orderType = filters.orderType;
|
if (filters.orderType) payload.orderType = filters.orderType;
|
||||||
if (filters.cityId) payload.cityId = filters.cityId;
|
if (filters.cityId) payload.cityId = filters.cityId;
|
||||||
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||||
|
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
|
||||||
|
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
|
||||||
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
||||||
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
||||||
if (filters.excludeTest) payload.excludeTest = true;
|
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
|
||||||
|
if (filters.assocPartnerAccountId) payload.assocPartnerAccountId = filters.assocPartnerAccountId;
|
||||||
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
||||||
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
||||||
return payload;
|
return payload;
|
||||||
@@ -284,8 +321,15 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
|
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
|
||||||
|
const initialStatusParam = searchParams.getAll('status').join(',');
|
||||||
|
const initialStatuses = useMemo(
|
||||||
|
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
|
||||||
|
[initialStatusParam],
|
||||||
|
);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
const [logisticsForm] = Form.useForm();
|
const [logisticsForm] = Form.useForm();
|
||||||
@@ -308,6 +352,7 @@ export default function OrdersPage() {
|
|||||||
const [shipping, setShipping] = useState(false);
|
const [shipping, setShipping] = useState(false);
|
||||||
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [shipModalOpen, setShipModalOpen] = useState(false);
|
const [shipModalOpen, setShipModalOpen] = useState(false);
|
||||||
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
@@ -316,6 +361,13 @@ export default function OrdersPage() {
|
|||||||
const [trackOpen, setTrackOpen] = useState(false);
|
const [trackOpen, setTrackOpen] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
|
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
|
||||||
|
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
|
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
@@ -335,6 +387,20 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
}, [form, initialOrderNo]);
|
}, [form, initialOrderNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStatuses.length) {
|
||||||
|
form.setFieldsValue({ status: initialStatuses });
|
||||||
|
}
|
||||||
|
}, [form, initialStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId;
|
||||||
|
if (openOrderId) {
|
||||||
|
void openDetail(openOrderId);
|
||||||
|
navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
|
||||||
|
}
|
||||||
|
}, [location.state, location.pathname, location.search, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||||
const first = data?.items?.[0];
|
const first = data?.items?.[0];
|
||||||
@@ -365,13 +431,18 @@ export default function OrdersPage() {
|
|||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
const statuses = selectedStatuses(values.status);
|
||||||
|
const statusList = statuses.length ? statuses : initialStatuses;
|
||||||
|
for (const status of statusList) qs.append('status', status);
|
||||||
if (values.orderType) qs.set('orderType', values.orderType);
|
if (values.orderType) qs.set('orderType', values.orderType);
|
||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
|
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
|
||||||
|
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
|
||||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
||||||
|
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
|
||||||
|
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
|
||||||
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
||||||
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
@@ -379,7 +450,7 @@ export default function OrdersPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize, initialOrderNo]);
|
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -390,6 +461,9 @@ export default function OrdersPage() {
|
|||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.then((res) => setCities(res.items ?? []))
|
.then((res) => setCities(res.items ?? []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
|
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
|
||||||
@@ -588,7 +662,16 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
const payload = buildExportPayload(
|
||||||
|
scope,
|
||||||
|
exportFormat,
|
||||||
|
{
|
||||||
|
...values,
|
||||||
|
promoCodeId: initialPromoCodeId || values.promoCodeId,
|
||||||
|
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
||||||
|
},
|
||||||
|
selectedRowKeys,
|
||||||
|
);
|
||||||
const result = await request<OrderExportResult>('/admin/orders/export', {
|
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -617,14 +700,14 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '用户',
|
title: '用户',
|
||||||
key: 'user',
|
key: 'user',
|
||||||
width: 120,
|
width: 200,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.user?.id ? (
|
row.user?.id ? (
|
||||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
|
||||||
{row.user.userNo}
|
{formatUserWithRemark(row.user)}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
) : (
|
) : (
|
||||||
(row.user?.userNo || '—')
|
formatUserWithRemark(row.user)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -674,6 +757,13 @@ export default function OrdersPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
render: (v: number) => `¥${v}`,
|
render: (v: number) => `¥${v}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '运费',
|
||||||
|
key: 'logisticsFee',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '好客权益',
|
title: '好客权益',
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -770,15 +860,117 @@ export default function OrdersPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{initialPromoCodeId ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={
|
||||||
|
initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
|
||||||
|
? '已按推广码筛选已完成订单'
|
||||||
|
: '已按推广码筛选订单'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={() => navigate('/orders')}>
|
||||||
|
清除筛选
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onClose={() => navigate('/orders')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Row gutter={[16, 8]}>
|
<div className="admin-orders-filter-row">
|
||||||
<Col xs={24} md={14} lg={12}>
|
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
|
<Select
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
allowClear
|
||||||
<Form.Item name="dateRange" noStyle>
|
showSearch
|
||||||
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
|
optionFilterProp="label"
|
||||||
</Form.Item>
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input allowClear placeholder="手机号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||||
|
<Input allowClear placeholder="编号/昵称/备注/手机" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||||
|
<Input allowClear placeholder="名称/规格" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="全部"
|
||||||
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assocPartnerAccountId"
|
||||||
|
label="关联合伙人"
|
||||||
|
className="admin-orders-filter-item admin-orders-filter-item--status"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="全部"
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: '未关联' },
|
||||||
|
...partners.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: formatAssocPartner({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name || p.id,
|
||||||
|
companyName: p.companyName,
|
||||||
|
phone: p.phone,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setFiltersCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtersCollapsed ? '展开' : '收起'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtersCollapsed ? (
|
||||||
|
<>
|
||||||
|
<div className="admin-orders-filter-row">
|
||||||
|
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
|
<Form.Item name="dateRange" noStyle>
|
||||||
|
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
|
||||||
|
</Form.Item>
|
||||||
{DATE_PRESETS.map((preset) => (
|
{DATE_PRESETS.map((preset) => (
|
||||||
<Button
|
<Button
|
||||||
key={preset.key}
|
key={preset.key}
|
||||||
@@ -789,98 +981,60 @@ export default function OrdersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Form.Item>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={6}>
|
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
|
||||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
{canExportOrders ? (
|
||||||
<Select
|
<Space size={12} wrap>
|
||||||
mode="multiple"
|
<Radio.Group
|
||||||
allowClear
|
optionType="button"
|
||||||
maxTagCount="responsive"
|
buttonStyle="solid"
|
||||||
placeholder="全部"
|
value={exportFormat}
|
||||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
onChange={(e) => setExportFormat(e.target.value)}
|
||||||
/>
|
>
|
||||||
</Form.Item>
|
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||||
</Col>
|
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
</Radio.Group>
|
||||||
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
<Button
|
||||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
disabled={!selectedRowKeys.length}
|
||||||
</Form.Item>
|
loading={exporting}
|
||||||
</Col>
|
onClick={() => void submitExport('selected')}
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
>
|
||||||
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
</Button>
|
||||||
</Form.Item>
|
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||||
</Col>
|
导出全部筛选
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
</Button>
|
||||||
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
</Space>
|
||||||
<Select
|
) : <span />}
|
||||||
allowClear
|
<Space size={12} wrap>
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="全部"
|
|
||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6} lg={6}>
|
|
||||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
|
||||||
<Input allowClear placeholder="手机号" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={8} lg={8}>
|
|
||||||
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
|
|
||||||
<Space size={16} wrap>
|
|
||||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
<Checkbox>大单拦截</Checkbox>
|
<Checkbox>大单拦截</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
<Button
|
||||||
<Checkbox>过滤测试</Checkbox>
|
onClick={() => {
|
||||||
</Form.Item>
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
|
||||||
|
else void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
{canDeleteOrders ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Col>
|
</>
|
||||||
</Row>
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
{canExportOrders ? (
|
|
||||||
<Space size={12} wrap>
|
|
||||||
<Radio.Group
|
|
||||||
optionType="button"
|
|
||||||
buttonStyle="solid"
|
|
||||||
value={exportFormat}
|
|
||||||
onChange={(e) => setExportFormat(e.target.value)}
|
|
||||||
>
|
|
||||||
<Radio.Button value="xlsx">Excel</Radio.Button>
|
|
||||||
<Radio.Button value="pdf">PDF</Radio.Button>
|
|
||||||
</Radio.Group>
|
|
||||||
<Button
|
|
||||||
disabled={!selectedRowKeys.length}
|
|
||||||
loading={exporting}
|
|
||||||
onClick={() => void submitExport('selected')}
|
|
||||||
>
|
|
||||||
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
|
||||||
导出全部筛选
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : <span />}
|
|
||||||
<Space size={12} wrap>
|
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
|
||||||
{canDeleteOrders ? (
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
disabled={!selectedRowKeys.length}
|
|
||||||
onClick={() => setBatchDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -979,7 +1133,33 @@ export default function OrdersPage() {
|
|||||||
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
|
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
<Descriptions.Item label="关联合伙人(本单佣金)">
|
||||||
|
{detail.assocPartnerAtPay
|
||||||
|
? `${formatAssocPartner(detail.assocPartnerAtPay)}${
|
||||||
|
detail.assocPartnerAtPay.orderCommissionRate != null
|
||||||
|
? ` · 费率 ${(Number(detail.assocPartnerAtPay.orderCommissionRate) * 100).toFixed(2)}%`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detail.user?.assocPartner &&
|
||||||
|
detail.user.assocPartner.id !== detail.assocPartnerAtPay?.id ? (
|
||||||
|
<Descriptions.Item label="用户当前关联">
|
||||||
|
{formatAssocPartner(detail.user.assocPartner)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{detail.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
|
||||||
|
>
|
||||||
|
{formatUserWithRemark(detail.user)}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
formatUserWithRemark(detail.user)
|
||||||
|
)}
|
||||||
|
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
||||||
@@ -1158,6 +1338,11 @@ export default function OrdersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运费">
|
||||||
|
{detail.delivery.logisticsFee == null
|
||||||
|
? '—'
|
||||||
|
: `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`}
|
||||||
|
</Descriptions.Item>
|
||||||
{detail.delivery.manualQueryUrl && (
|
{detail.delivery.manualQueryUrl && (
|
||||||
<Descriptions.Item label="查询链接">
|
<Descriptions.Item label="查询链接">
|
||||||
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||||
@@ -1198,7 +1383,7 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
render: (v) => v || '—',
|
render: (v) => zhRemark(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import PartnerAssocPanel from '../components/PartnerAssocPanel';
|
||||||
|
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
@@ -413,6 +414,13 @@ export default function PartnerAccountsPage() {
|
|||||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(!detail.parentAccountId
|
||||||
|
? [{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
]} />
|
]} />
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
@@ -27,6 +28,19 @@ import { AdminListHeader } from '../components/AdminListHeader';
|
|||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
type BillItemRow = {
|
||||||
|
id: string;
|
||||||
|
kind: 'ORDER' | 'REDEEM';
|
||||||
|
refId: string;
|
||||||
|
refNo: string;
|
||||||
|
title?: string | null;
|
||||||
|
extra?: string | null;
|
||||||
|
baseAmount: number;
|
||||||
|
rate: number;
|
||||||
|
commission: number;
|
||||||
|
occurredAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
@@ -39,6 +53,8 @@ type Row = {
|
|||||||
rejectReason?: string | null;
|
rejectReason?: string | null;
|
||||||
paymentRef?: string | null;
|
paymentRef?: string | null;
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
|
orderItems?: BillItemRow[];
|
||||||
|
redeemItems?: BillItemRow[];
|
||||||
partner?: {
|
partner?: {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -531,7 +547,7 @@ export default function PartnerBillsPage() {
|
|||||||
title="合伙人账单明细"
|
title="合伙人账单明细"
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
width={560}
|
width={720}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
@@ -562,6 +578,84 @@ export default function PartnerBillsPage() {
|
|||||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
<Tabs
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: `酒订单 (${detail.orderItems?.length ?? 0})`,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.orderItems ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '订单号', dataIndex: 'refNo', width: 160 },
|
||||||
|
{ title: '商品', dataIndex: 'title' },
|
||||||
|
{ title: '数量', dataIndex: 'extra', width: 70 },
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'baseAmount',
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '费率',
|
||||||
|
dataIndex: 'rate',
|
||||||
|
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '佣金',
|
||||||
|
dataIndex: 'commission',
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
|
||||||
|
酒单佣金 ¥{Number(detail.orderCommission).toFixed(2)}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redeems',
|
||||||
|
label: `核销订单 (${detail.redeemItems?.length ?? 0})`,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.redeemItems ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '核销单号', dataIndex: 'refNo', width: 160 },
|
||||||
|
{ title: '门店', dataIndex: 'title' },
|
||||||
|
{
|
||||||
|
title: '核销额',
|
||||||
|
dataIndex: 'baseAmount',
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '费率',
|
||||||
|
dataIndex: 'rate',
|
||||||
|
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '佣金',
|
||||||
|
dataIndex: 'commission',
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
|
||||||
|
核销佣金 ¥{Number(detail.redeemCommission).toFixed(2)}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
收款账户
|
收款账户
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||||
PARTNER_LOG_CATEGORY_LABELS,
|
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
resolvePartnerLogCategory,
|
|
||||||
type PartnerLogCategory,
|
type PartnerLogCategory,
|
||||||
type PartnerStaffRole,
|
type PartnerStaffRole,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { clientAppLabel, eventNameLabel, formatRef, partnerEventCategoryLabel } from '../lib/display-labels';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -107,7 +106,7 @@ export default function PartnerLogsPage() {
|
|||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: PartnerLogCategory | null, r) => (
|
render: (v: PartnerLogCategory | null, r) => (
|
||||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,14 +120,14 @@ export default function PartnerLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v}
|
{eventNameLabel(v)}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => formatRef(r.refType, r.refId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -188,11 +187,11 @@ export default function PartnerLogsPage() {
|
|||||||
<Form.Item name="companyName" label="公司">
|
<Form.Item name="companyName" label="公司">
|
||||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="partnerId" label="合伙人ID">
|
<Form.Item name="partnerId" label="合伙人 ID">
|
||||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
<Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="eventName" label="事件名">
|
<Form.Item name="eventName" label="事件编码">
|
||||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
<Input placeholder="事件编码" allowClear style={{ width: 160 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -237,11 +236,19 @@ export default function PartnerLogsPage() {
|
|||||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
{Object.entries(detail).map(([k, v]) => (
|
<Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item key={k} label={k}>
|
<Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
|
||||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
<Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="分类">{partnerEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
||||||
))}
|
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt ? String(detail.createdAt) : null)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="参数">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { omitNullFields } from '../lib/omit-null-fields';
|
||||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -30,6 +31,7 @@ import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
|||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import PartnerAssocPanel from '../components/PartnerAssocPanel';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -162,7 +164,7 @@ export default function PartnersPage() {
|
|||||||
try {
|
try {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
const body = {
|
const body = {
|
||||||
...v,
|
...omitNullFields(v),
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
@@ -320,6 +322,11 @@ export default function PartnersPage() {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ export default function ProductsPage() {
|
|||||||
label: '规格与 SKU',
|
label: '规格与 SKU',
|
||||||
children: (
|
children: (
|
||||||
<ProductSpecsEditor
|
<ProductSpecsEditor
|
||||||
|
key={String(detail.id)}
|
||||||
productId={String(detail.id)}
|
productId={String(detail.id)}
|
||||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||||
initialSkus={(detail.skus as never) ?? []}
|
initialSkus={(detail.skus as never) ?? []}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
|
|||||||
|
|
||||||
type SceneOption = { value: PromoCodeScene; label: string };
|
type SceneOption = { value: PromoCodeScene; label: string };
|
||||||
|
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
const blob = await res.blob();
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = objectUrl;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
} catch {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PromoCodesPage() {
|
export default function PromoCodesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
{
|
||||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
title: '扫码',
|
||||||
|
dataIndex: 'scanCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Tooltip title="仅统计已完成订单">订单</Tooltip>,
|
||||||
|
dataIndex: 'orderCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '转化率',
|
title: '转化率',
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 220,
|
width: 140,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size="small" wrap>
|
<Space size="small" wrap>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
disabled={!row.qrcodeUrl}
|
|
||||||
onClick={() => {
|
|
||||||
if (!row.qrcodeUrl) return;
|
|
||||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下载小程序码
|
|
||||||
</Button>
|
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||||
关联用户
|
关联用户
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
@@ -20,17 +20,28 @@ type Row = {
|
|||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
userNo: string;
|
||||||
|
phone: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
};
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { id?: string; couponNo: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
function formatNicknameWithRemark(user?: {
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
nickname?: string | null;
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
hqRemark?: string | null;
|
||||||
|
} | null) {
|
||||||
|
const name = user?.nickname?.trim() || '—';
|
||||||
|
const remark = user?.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||||
|
>
|
||||||
|
{v || '—'}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '用户昵称',
|
title: '用户昵称',
|
||||||
dataIndex: ['user', 'nickname'],
|
dataIndex: ['user', 'nickname'],
|
||||||
width: 100,
|
width: 160,
|
||||||
render: (v: string | null | undefined) => v || '—',
|
render: (_: string | null | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||||
|
>
|
||||||
|
{formatNicknameWithRemark(row.user)}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
formatNicknameWithRemark(row.user)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '用户手机',
|
title: '用户手机',
|
||||||
dataIndex: ['user', 'phone'],
|
dataIndex: ['user', 'phone'],
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string | null | undefined) => maskPhone(v),
|
render: (v: string | null | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
{
|
||||||
|
title: '券号',
|
||||||
|
dataIndex: ['coupon', 'couponNo'],
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | undefined, row) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
|
||||||
|
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +77,7 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StoreBillsPage() {
|
export default function StoreBillsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||||
const initialStoreId = searchParams.get('storeId') || '';
|
const initialStoreId = searchParams.get('storeId') || '';
|
||||||
@@ -111,6 +114,13 @@ export default function StoreBillsPage() {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
overdueCount: number;
|
overdueCount: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [payModal, setPayModal] = useState<{
|
||||||
|
ids: string[];
|
||||||
|
amountHint?: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [paySubmitting, setPaySubmitting] = useState(false);
|
||||||
|
const [withdrawApproveId, setWithdrawApproveId] = useState<string | null>(null);
|
||||||
|
const [withdrawSubmitting, setWithdrawSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
@@ -132,77 +142,11 @@ export default function StoreBillsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
let paymentRef = '';
|
setPayModal({ ids, amountHint });
|
||||||
Modal.confirm({
|
|
||||||
title: '确认打款?',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
将确认 {ids.length} 笔 T+1 门店对账单
|
|
||||||
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="打款凭证号(可选)"
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
onChange={(e) => {
|
|
||||||
paymentRef = e.target.value;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
okText: '确认打款',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
|
||||||
if (ids.length === 1) {
|
|
||||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
|
||||||
} else {
|
|
||||||
setBatchLoading(true);
|
|
||||||
try {
|
|
||||||
await request('/admin/store-bills/batch-confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.success('已确认打款');
|
|
||||||
setSelectedKeys([]);
|
|
||||||
reload();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function approveWithdraw(id: string) {
|
function approveWithdraw(id: string) {
|
||||||
let paymentRef = '';
|
setWithdrawApproveId(id);
|
||||||
Modal.confirm({
|
|
||||||
title: '审核通过并标记已结算?',
|
|
||||||
content: (
|
|
||||||
<Input
|
|
||||||
placeholder="打款凭证号(可选)"
|
|
||||||
onChange={(e) => {
|
|
||||||
paymentRef = e.target.value;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
okText: '通过并结算',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
|
||||||
});
|
|
||||||
message.success('已通过并标记已结算');
|
|
||||||
setDrawerOpen(false);
|
|
||||||
reload();
|
|
||||||
void request<{ pendingCount: number; overdueCount: number }>(
|
|
||||||
'/admin/store-withdrawals/overdue-summary',
|
|
||||||
)
|
|
||||||
.then(setOverdueSummary)
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectWithdraw(id: string) {
|
function rejectWithdraw(id: string) {
|
||||||
@@ -294,7 +238,11 @@ export default function StoreBillsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '日期',
|
title: (
|
||||||
|
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
|
||||||
|
出账日
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
dataIndex: 'date',
|
dataIndex: 'date',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||||
@@ -389,6 +337,7 @@ export default function StoreBillsPage() {
|
|||||||
<>
|
<>
|
||||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||||
|
T+1「出账日」是任务跑的那天(昨日 00:00–今日 00:00 窗口中的今天),不是最后一笔核销时间。
|
||||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type="danger">
|
<Typography.Text type="danger">
|
||||||
@@ -469,7 +418,7 @@ export default function StoreBillsPage() {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="range" label="日期">
|
<Form.Item name="range" label="出账日">
|
||||||
<DatePicker.RangePicker />
|
<DatePicker.RangePicker />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
@@ -543,7 +492,7 @@ export default function StoreBillsPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">
|
<Descriptions.Item label="出账日">
|
||||||
{String(detail.billDate || '').slice(0, 10)}
|
{String(detail.billDate || '').slice(0, 10)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="应付">
|
<Descriptions.Item label="应付">
|
||||||
@@ -558,6 +507,9 @@ export default function StoreBillsPage() {
|
|||||||
<Descriptions.Item label="打款凭证">
|
<Descriptions.Item label="打款凭证">
|
||||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="凭证照片">
|
||||||
|
<PaymentProofGallery urls={detail.paymentProofUrls} />
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{(detail.storeAccount as {
|
{(detail.storeAccount as {
|
||||||
bankAccountName?: string;
|
bankAccountName?: string;
|
||||||
@@ -592,8 +544,22 @@ export default function StoreBillsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '核销单号',
|
title: '核销单号',
|
||||||
render: (_, r) =>
|
render: (_, r) => {
|
||||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
const redeemNo = String(
|
||||||
|
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
|
||||||
|
);
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额',
|
title: '金额',
|
||||||
@@ -628,6 +594,9 @@ export default function StoreBillsPage() {
|
|||||||
<Descriptions.Item label="打款凭证">
|
<Descriptions.Item label="打款凭证">
|
||||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="凭证照片">
|
||||||
|
<PaymentProofGallery urls={detail.paymentProofUrls} />
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{storeAccount ? (
|
{storeAccount ? (
|
||||||
<>
|
<>
|
||||||
@@ -662,7 +631,18 @@ export default function StoreBillsPage() {
|
|||||||
const payout = r.storePayout as
|
const payout = r.storePayout as
|
||||||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
const redeemNo = payout?.redeemRecord?.redeemNo || '';
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -687,6 +667,71 @@ export default function StoreBillsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<FinancePayProofModal
|
||||||
|
open={!!payModal}
|
||||||
|
title="确认打款?"
|
||||||
|
hint={`将确认 ${payModal?.ids.length ?? 0} 笔 T+1 门店对账单${
|
||||||
|
payModal?.amountHint != null ? `,合计约 ¥${payModal.amountHint.toFixed(2)}` : ''
|
||||||
|
}。此操作不可撤销。`}
|
||||||
|
okText="确认打款"
|
||||||
|
confirmLoading={paySubmitting}
|
||||||
|
onCancel={() => setPayModal(null)}
|
||||||
|
onOk={async (payload) => {
|
||||||
|
if (!payModal) return;
|
||||||
|
const { ids } = payModal;
|
||||||
|
setPaySubmitting(true);
|
||||||
|
if (ids.length > 1) setBatchLoading(true);
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
...(ids.length > 1 ? { ids } : {}),
|
||||||
|
});
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
||||||
|
} else {
|
||||||
|
await request('/admin/store-bills/batch-confirm', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
message.success('已确认打款');
|
||||||
|
setPayModal(null);
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
} finally {
|
||||||
|
setPaySubmitting(false);
|
||||||
|
setBatchLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FinancePayProofModal
|
||||||
|
open={!!withdrawApproveId}
|
||||||
|
title="审核通过并标记已结算?"
|
||||||
|
hint="通过后将标记该提现为已结算,此操作不可撤销。"
|
||||||
|
okText="通过并结算"
|
||||||
|
confirmLoading={withdrawSubmitting}
|
||||||
|
onCancel={() => setWithdrawApproveId(null)}
|
||||||
|
onOk={async (payload) => {
|
||||||
|
if (!withdrawApproveId) return;
|
||||||
|
setWithdrawSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-withdrawals/${withdrawApproveId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已通过并标记已结算');
|
||||||
|
setWithdrawApproveId(null);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
void request<{ pendingCount: number; overdueCount: number }>(
|
||||||
|
'/admin/store-withdrawals/overdue-summary',
|
||||||
|
)
|
||||||
|
.then(setOverdueSummary)
|
||||||
|
.catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setWithdrawSubmitting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,9 +159,20 @@ export default function StoreCategoriesPage() {
|
|||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
try {
|
||||||
message.success('已处理');
|
const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
|
||||||
void reload();
|
`/admin/store-categories/${row.id}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
if (res.softDisabled) {
|
||||||
|
message.success('该分类已有门店占用,已改为停用');
|
||||||
|
} else {
|
||||||
|
message.success('已删除');
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger>删除</Button>
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
STORE_LOG_CATEGORY_OPTIONS,
|
STORE_LOG_CATEGORY_OPTIONS,
|
||||||
STORE_LOG_CATEGORY_LABELS,
|
|
||||||
resolveStoreLogCategory,
|
|
||||||
type StoreLogCategory,
|
type StoreLogCategory,
|
||||||
} from '../lib/store-log';
|
} from '../lib/store-log';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { clientAppLabel, eventNameLabel, formatRef, storeEventCategoryLabel } from '../lib/display-labels';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -102,7 +101,7 @@ export default function StoreLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '分类', dataIndex: 'category', width: 100,
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
render: (v: StoreLogCategory | null, r) => (
|
render: (v: StoreLogCategory | null, r) => (
|
||||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{storeEventCategoryLabel(r.eventName, v)}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,7 +117,7 @@ export default function StoreLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v}
|
{eventNameLabel(v)}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -128,7 +127,7 @@ export default function StoreLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => formatRef(r.refType, r.refId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -184,7 +183,7 @@ export default function StoreLogsPage() {
|
|||||||
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
||||||
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
|
<Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -198,12 +197,12 @@ export default function StoreLogsPage() {
|
|||||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">
|
<Descriptions.Item label="分类">
|
||||||
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
{storeEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="参数">
|
<Descriptions.Item label="参数">
|
||||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Row = {
|
|||||||
id: string;
|
id: string;
|
||||||
serviceScore: number;
|
serviceScore: number;
|
||||||
envScore: number;
|
envScore: number;
|
||||||
|
comment?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
redeemAmount: number;
|
redeemAmount: number;
|
||||||
@@ -62,6 +63,12 @@ export default function StoreRatingsPage() {
|
|||||||
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
|
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
|
||||||
{ title: '环境分', dataIndex: 'envScore', width: 80 },
|
{ title: '环境分', dataIndex: 'envScore', width: 80 },
|
||||||
|
{
|
||||||
|
title: '评语',
|
||||||
|
dataIndex: 'comment',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | null) => v || '—',
|
||||||
|
},
|
||||||
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -70,6 +71,8 @@ export default function StoreWithdrawalsPage() {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
overdueCount: number;
|
overdueCount: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [approveId, setApproveId] = useState<string | null>(null);
|
||||||
|
const [approveSubmitting, setApproveSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -89,34 +92,7 @@ export default function StoreWithdrawalsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function approve(id: string) {
|
function approve(id: string) {
|
||||||
let paymentRef = '';
|
setApproveId(id);
|
||||||
Modal.confirm({
|
|
||||||
title: '审核通过并标记已结算?',
|
|
||||||
content: (
|
|
||||||
<Input
|
|
||||||
placeholder="打款凭证号(可选)"
|
|
||||||
onChange={(e) => {
|
|
||||||
paymentRef = e.target.value;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
okText: '通过并结算',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
|
||||||
});
|
|
||||||
message.success('已通过并标记已结算');
|
|
||||||
setDrawerOpen(false);
|
|
||||||
reload();
|
|
||||||
void request<{ pendingCount: number; overdueCount: number }>(
|
|
||||||
'/admin/store-withdrawals/overdue-summary',
|
|
||||||
)
|
|
||||||
.then(setOverdueSummary)
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reject(id: string) {
|
function reject(id: string) {
|
||||||
@@ -356,6 +332,9 @@ export default function StoreWithdrawalsPage() {
|
|||||||
{detail.paymentRef ? (
|
{detail.paymentRef ? (
|
||||||
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
|
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Descriptions.Item label="凭证照片">
|
||||||
|
<PaymentProofGallery urls={detail.paymentProofUrls} />
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="收款户名">
|
<Descriptions.Item label="收款户名">
|
||||||
{storeAccount?.bankAccountName || '—'}
|
{storeAccount?.bankAccountName || '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -394,6 +373,36 @@ export default function StoreWithdrawalsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<FinancePayProofModal
|
||||||
|
open={!!approveId}
|
||||||
|
title="审核通过并标记已结算?"
|
||||||
|
hint="通过后将标记该提现为已结算,此操作不可撤销。"
|
||||||
|
okText="通过并结算"
|
||||||
|
confirmLoading={approveSubmitting}
|
||||||
|
onCancel={() => setApproveId(null)}
|
||||||
|
onOk={async (payload) => {
|
||||||
|
if (!approveId) return;
|
||||||
|
setApproveSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-withdrawals/${approveId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已通过并标记已结算');
|
||||||
|
setApproveId(null);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
void request<{ pendingCount: number; overdueCount: number }>(
|
||||||
|
'/admin/store-withdrawals/overdue-summary',
|
||||||
|
)
|
||||||
|
.then(setOverdueSummary)
|
||||||
|
.catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setApproveSubmitting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
@@ -560,50 +561,43 @@ export default function SupportTicketsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="技术支持"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
actions={
|
||||||
alignItems: 'center',
|
<>
|
||||||
marginBottom: 16,
|
{isSuperAdmin ? (
|
||||||
}}
|
<>
|
||||||
>
|
<Button
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
disabled={!selectedRowKeys.length}
|
||||||
技术支持
|
loading={batchCreateSaving}
|
||||||
</Typography.Title>
|
onClick={() => void submitBatchCreateTasks()}
|
||||||
{settingsButton}
|
>
|
||||||
<Space>
|
一键创建任务
|
||||||
{isSuperAdmin ? (
|
</Button>
|
||||||
<>
|
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||||
<Button
|
一键发布
|
||||||
disabled={!selectedRowKeys.length}
|
</Button>
|
||||||
loading={batchCreateSaving}
|
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||||
onClick={() => void submitBatchCreateTasks()}
|
批量审核
|
||||||
>
|
</Button>
|
||||||
一键创建任务
|
<Button
|
||||||
</Button>
|
disabled={!selectedRowKeys.length}
|
||||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
onClick={() => {
|
||||||
一键发布
|
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||||
</Button>
|
setBatchStatusOpen(true);
|
||||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
}}
|
||||||
批量审核
|
>
|
||||||
</Button>
|
批量改状态
|
||||||
<Button
|
</Button>
|
||||||
disabled={!selectedRowKeys.length}
|
</>
|
||||||
onClick={() => {
|
) : null}
|
||||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
setBatchStatusOpen(true);
|
创建工单
|
||||||
}}
|
</Button>
|
||||||
>
|
</>
|
||||||
批量改状态
|
}
|
||||||
</Button>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
|
||||||
创建工单
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typograp
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import {
|
||||||
|
THIRD_PARTY_PROVIDER_LABELS,
|
||||||
|
THIRD_PARTY_SCENE_LABELS,
|
||||||
|
THIRD_PARTY_STATUS_LABELS,
|
||||||
|
formatRef,
|
||||||
|
zhLabel,
|
||||||
|
} from '../lib/display-labels';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -26,14 +33,14 @@ type Row = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_OPTIONS = [
|
const PROVIDER_OPTIONS = [
|
||||||
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
|
{ value: 'WECHAT_AUTH', label: '微信授权' },
|
||||||
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
|
{ value: 'WECHAT_PAY', label: '微信支付' },
|
||||||
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
{ value: 'WECHAT_MAP', label: '微信/腾讯地图' },
|
||||||
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
|
{ value: 'ALIYUN_OSS', label: '阿里云 OSS' },
|
||||||
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
{ value: 'ALIYUN_SMS', label: '阿里云短信' },
|
||||||
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
{ value: 'MOCK_SMS', label: 'Mock 短信' },
|
||||||
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
{ value: 'XFX', label: '小飞侠' },
|
||||||
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
{ value: 'LOGISTICS', label: '物流快递' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
@@ -75,18 +82,18 @@ export default function ThirdPartyLogsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
{ title: '渠道', dataIndex: 'provider', width: 120, render: (v: string) => zhLabel(THIRD_PARTY_PROVIDER_LABELS, v) },
|
||||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
{ title: '场景', dataIndex: 'scene', width: 140, render: (v: string) => zhLabel(THIRD_PARTY_SCENE_LABELS, v) },
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => formatRef(r.refType, r.refId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{v}</Tag>,
|
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{zhLabel(THIRD_PARTY_STATUS_LABELS, v)}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '错误信息',
|
title: '错误信息',
|
||||||
@@ -147,11 +154,11 @@ export default function ThirdPartyLogsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="provider" label="Provider">
|
<Form.Item name="provider" label="渠道">
|
||||||
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="scene" label="场景">
|
<Form.Item name="scene" label="场景">
|
||||||
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" style={{ width: 160 }} />
|
<Input allowClear placeholder="场景编码" style={{ width: 160 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="refId" label="关联ID">
|
<Form.Item name="refId" label="关联ID">
|
||||||
<Input allowClear style={{ width: 120 }} />
|
<Input allowClear style={{ width: 120 }} />
|
||||||
@@ -181,13 +188,15 @@ export default function ThirdPartyLogsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
<Descriptions.Item label="渠道">{zhLabel(THIRD_PARTY_PROVIDER_LABELS, String(detail.provider))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
<Descriptions.Item label="场景">{zhLabel(THIRD_PARTY_SCENE_LABELS, String(detail.scene))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>{String(detail.status)}</Tag>
|
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>
|
||||||
|
{zhLabel(THIRD_PARTY_STATUS_LABELS, String(detail.status))}
|
||||||
|
</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">
|
<Descriptions.Item label="关联">
|
||||||
{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}
|
{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
import { USER_LOG_CATEGORY_OPTIONS, type UserLogCategory } from '../lib/user-log';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { clientAppLabel, eventNameLabel, formatRef, userEventCategoryLabel } from '../lib/display-labels';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -27,8 +28,6 @@ type Row = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
|
|
||||||
|
|
||||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||||
if (!json) return '—';
|
if (!json) return '—';
|
||||||
const text = JSON.stringify(json);
|
const text = JSON.stringify(json);
|
||||||
@@ -79,7 +78,7 @@ export default function UserLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '分类', dataIndex: 'category', width: 100,
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
render: (v: UserLogCategory | null, r) => (
|
render: (v: UserLogCategory | null, r) => (
|
||||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{userEventCategoryLabel(r.eventName, v)}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -93,13 +92,16 @@ export default function UserLogsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v}
|
{eventNameLabel(v)}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '端', dataIndex: 'clientApp', width: 110, render: (v) => clientAppLabel(v),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => formatRef(r.refType, r.refId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -148,7 +150,7 @@ export default function UserLogsPage() {
|
|||||||
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item>
|
<Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -160,10 +162,10 @@ export default function UserLogsPage() {
|
|||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
<Descriptions.Item label="分类">{userEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
<Descriptions.Item label="参数">
|
<Descriptions.Item label="参数">
|
||||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
|
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
||||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
|
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -119,6 +121,24 @@ type UserBehaviorLog = {
|
|||||||
extraJson?: Record<string, unknown> | null;
|
extraJson?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssocPartnerBrief = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
|
||||||
|
|
||||||
|
const FILTERS_COLLAPSED_KEY = 'admin-users-filters-collapsed';
|
||||||
|
const ASSOC_UNBOUND = 'none';
|
||||||
|
|
||||||
|
function formatAssocPartner(p?: AssocPartnerBrief | PartnerOption | null) {
|
||||||
|
if (!p) return '—';
|
||||||
|
const title = p.companyName || p.name || p.id;
|
||||||
|
return [title, p.phone].filter(Boolean).join(' / ') || '—';
|
||||||
|
}
|
||||||
|
|
||||||
type UserDetail = AdminUserRow & {
|
type UserDetail = AdminUserRow & {
|
||||||
wxUnionId?: string | null;
|
wxUnionId?: string | null;
|
||||||
cityPref?: Record<string, unknown> | null;
|
cityPref?: Record<string, unknown> | null;
|
||||||
@@ -127,6 +147,8 @@ type UserDetail = AdminUserRow & {
|
|||||||
orders?: UserOrderRow[];
|
orders?: UserOrderRow[];
|
||||||
mergedFromCount?: number;
|
mergedFromCount?: number;
|
||||||
addressCount?: number;
|
addressCount?: number;
|
||||||
|
assocPartner?: AssocPartnerBrief | null;
|
||||||
|
assocBoundAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BatchDeletePreviewItem = {
|
type BatchDeletePreviewItem = {
|
||||||
@@ -154,6 +176,7 @@ type BatchDeletePreview = {
|
|||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||||
@@ -174,44 +197,88 @@ export default function UsersPage() {
|
|||||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
||||||
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
|
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
|
||||||
|
const canEditAssoc = (profile?.permissionKeys ?? []).includes('users_partner_assoc');
|
||||||
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
|
const [assocPartnerId, setAssocPartnerId] = useState<string | undefined>();
|
||||||
|
const [savingAssoc, setSavingAssoc] = useState(false);
|
||||||
|
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const partnerOptions = useMemo(() => {
|
||||||
|
const map = new Map(partners.map((p) => [p.id, p]));
|
||||||
|
if (detail?.assocPartner && !map.has(detail.assocPartner.id)) {
|
||||||
|
map.set(detail.assocPartner.id, detail.assocPartner);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}, [partners, detail?.assocPartner]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const values = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
const userId = String(values.userId || '').trim();
|
||||||
|
if (userId) qs.set('userId', userId);
|
||||||
if (values.phone) qs.set('phone', values.phone);
|
if (values.phone) qs.set('phone', values.phone);
|
||||||
if (values.userNo) qs.set('userNo', values.userNo);
|
if (values.userNo) qs.set('userNo', values.userNo);
|
||||||
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
|
|
||||||
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
|
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
|
||||||
qs.set('phoneVerified', values.phoneVerified);
|
qs.set('phoneVerified', values.phoneVerified);
|
||||||
}
|
}
|
||||||
if (values.status !== undefined && values.status !== '') {
|
if (values.status !== undefined && values.status !== '') {
|
||||||
qs.set('status', String(values.status));
|
qs.set('status', String(values.status));
|
||||||
}
|
}
|
||||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
const keyword = String(values.keyword || '').trim();
|
||||||
|
if (keyword) qs.set('keyword', keyword);
|
||||||
|
const assocPartnerAccountId = String(
|
||||||
|
values.assocPartnerAccountId || searchParams.get('assocPartnerAccountId') || '',
|
||||||
|
).trim();
|
||||||
|
if (assocPartnerAccountId) qs.set('assocPartnerAccountId', assocPartnerAccountId);
|
||||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize]);
|
}, [form, page, pageSize, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
const assocPartnerAccountId = searchParams.get('assocPartnerAccountId');
|
||||||
|
form.setFieldsValue({
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
|
...(assocPartnerAccountId ? { assocPartnerAccountId } : {}),
|
||||||
|
});
|
||||||
|
}, [searchParams, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
||||||
if (openUserId) {
|
if (!openUserId) return;
|
||||||
void openDetail(openUserId);
|
form.setFieldsValue({ userId: openUserId });
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
setPage(1);
|
||||||
}
|
void openDetail(openUserId);
|
||||||
}, [location.state, location.pathname, navigate]);
|
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
|
||||||
|
replace: true,
|
||||||
|
state: null,
|
||||||
|
});
|
||||||
|
}, [location.state, location.pathname, navigate, form]);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
const [res, logs] = await Promise.all([
|
const [res, logs] = await Promise.all([
|
||||||
@@ -221,10 +288,29 @@ export default function UsersPage() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
|
setAssocPartnerId(res.assocPartner?.id);
|
||||||
setBehaviorLogs(logs.items ?? []);
|
setBehaviorLogs(logs.items ?? []);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAssoc() {
|
||||||
|
if (!detail) return;
|
||||||
|
setSavingAssoc(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/users/${detail.id}/assoc`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ partnerAccountId: assocPartnerId || null }),
|
||||||
|
});
|
||||||
|
message.success(assocPartnerId ? '已更新关联合伙人' : '已解除关联');
|
||||||
|
await openDetail(detail.id);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSavingAssoc(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDeleteModal() {
|
function openDeleteModal() {
|
||||||
setDeleteConfirm('');
|
setDeleteConfirm('');
|
||||||
setDeleteOpen(true);
|
setDeleteOpen(true);
|
||||||
@@ -407,6 +493,12 @@ export default function UsersPage() {
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '关联合伙人',
|
||||||
|
dataIndex: 'assocPartner',
|
||||||
|
width: 180,
|
||||||
|
render: (p: AdminUserRow['assocPartner']) => formatAssocPartner(p),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'deviceKey',
|
title: 'deviceKey',
|
||||||
dataIndex: 'deviceKey',
|
dataIndex: 'deviceKey',
|
||||||
@@ -497,37 +589,108 @@ export default function UsersPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form
|
||||||
<Form.Item name="phone" label="手机号">
|
form={form}
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
layout="vertical"
|
||||||
</Form.Item>
|
onFinish={() => {
|
||||||
<Form.Item name="userNo" label="用户编号">
|
setPage(1);
|
||||||
<Input placeholder="DK..." allowClear />
|
const next = new URLSearchParams();
|
||||||
</Form.Item>
|
const userId = String(form.getFieldValue('userId') || '').trim();
|
||||||
<Form.Item name="deviceKey" label="deviceKey">
|
const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim();
|
||||||
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
if (userId) next.set('userId', userId);
|
||||||
</Form.Item>
|
if (assoc) next.set('assocPartnerAccountId', assoc);
|
||||||
<Form.Item name="phoneVerified" label="验手机">
|
if (next.toString() !== searchParams.toString()) {
|
||||||
<Select allowClear style={{ width: 100 }} options={[
|
setSearchParams(next, { replace: true });
|
||||||
{ value: '1', label: '已验证' },
|
}
|
||||||
{ value: '0', label: '访客' },
|
void load();
|
||||||
]} />
|
}}
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item name="status" label="状态">
|
<div className="admin-orders-filter-row">
|
||||||
<Select allowClear style={{ width: 90 }} options={[
|
<Form.Item name="keyword" label="搜索" className="admin-orders-filter-item admin-users-filter-item--keyword">
|
||||||
{ value: 1, label: '正常' },
|
<Input allowClear placeholder="编号/昵称/备注/手机" />
|
||||||
{ value: 0, label: '停用' },
|
</Form.Item>
|
||||||
]} />
|
<Form.Item
|
||||||
</Form.Item>
|
name="assocPartnerAccountId"
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
label="关联合伙人"
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
className="admin-orders-filter-item admin-orders-filter-item--status"
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item>
|
<Select
|
||||||
<Space>
|
allowClear
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
showSearch
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
optionFilterProp="label"
|
||||||
</Space>
|
placeholder="全部"
|
||||||
</Form.Item>
|
options={[
|
||||||
|
{ value: 'any', label: '已关联(全部)' },
|
||||||
|
{ value: ASSOC_UNBOUND, label: '未关联' },
|
||||||
|
...partners.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: formatAssocPartner(p),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (searchParams.toString()) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
} else {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setFiltersCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtersCollapsed ? '展开' : '收起'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtersCollapsed ? (
|
||||||
|
<div className="admin-orders-filter-row">
|
||||||
|
<Form.Item name="phone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userNo" label="用户编号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input placeholder="DK..." allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userId" label="用户ID" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Input placeholder="精确匹配" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phoneVerified" label="验手机" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={[
|
||||||
|
{ value: '1', label: '已验证' },
|
||||||
|
{ value: '0', label: '访客' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={[
|
||||||
|
{ value: 1, label: '正常' },
|
||||||
|
{ value: 0, label: '停用' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -569,7 +732,7 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="验手机时间">
|
<Descriptions.Item label="验手机时间">
|
||||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -591,6 +754,38 @@ export default function UsersPage() {
|
|||||||
) : '—'}
|
) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
|
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联合伙人">
|
||||||
|
{canEditAssoc ? (
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="未关联"
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
value={assocPartnerId}
|
||||||
|
onChange={setAssocPartnerId}
|
||||||
|
options={partnerOptions.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.companyName || p.name || p.id}${p.phone ? ` · ${p.phone}` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button type="primary" size="small" loading={savingAssoc} onClick={() => void saveAssoc()}>
|
||||||
|
保存关联
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary">清空后保存即解绑;已支付订单佣金快照不变</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : detail.assocPartner ? (
|
||||||
|
`${detail.assocPartner.companyName || detail.assocPartner.name}${
|
||||||
|
detail.assocPartner.phone ? ` / ${detail.assocPartner.phone}` : ''
|
||||||
|
}`
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联时间">
|
||||||
|
{detail.assocBoundAt ? fmtTime(detail.assocBoundAt) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
{detail.sourcePromo && (
|
{detail.sourcePromo && (
|
||||||
<Descriptions.Item label="推广码">
|
<Descriptions.Item label="推广码">
|
||||||
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
|
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
|
||||||
@@ -603,7 +798,7 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="合并至">
|
<Descriptions.Item label="合并至">
|
||||||
{detail.mergedInto
|
{detail.mergedInto
|
||||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
@@ -640,10 +835,10 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
|
render: (_, r) => userEventCategoryLabel(r.eventName),
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName' },
|
{ title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
|
||||||
{ title: '端', dataIndex: 'clientApp', width: 90 },
|
{ title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { WECOM_BOT_ACTION_LABELS, zhLabel } from '../lib/display-labels';
|
||||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -39,8 +40,8 @@ export default function WecomBotLogsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
{ title: '动作', dataIndex: 'action', width: 180, render: (v: string) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
||||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
{ title: '权限', dataIndex: 'permission', width: 160, render: (v) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
||||||
{
|
{
|
||||||
title: '结果',
|
title: '结果',
|
||||||
dataIndex: 'success',
|
dataIndex: 'success',
|
||||||
@@ -81,13 +82,13 @@ export default function WecomBotLogsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="botId" label="机器人 ID">
|
<Form.Item name="botId" label="机器人 ID">
|
||||||
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} />
|
<Input allowClear placeholder="机器人 ID" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="wecomUserId" label="企微 userid">
|
<Form.Item name="wecomUserId" label="企微用户">
|
||||||
<Input allowClear placeholder="userid" style={{ width: 140 }} />
|
<Input allowClear placeholder="企微用户 ID" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="action" label="动作">
|
<Form.Item name="action" label="动作">
|
||||||
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
|
<Input allowClear placeholder="动作编码" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="success" label="结果">
|
<Form.Item name="success" label="结果">
|
||||||
<Select
|
<Select
|
||||||
@@ -130,8 +131,8 @@ export default function WecomBotLogsPage() {
|
|||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
|
<Descriptions.Item label="动作">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.action)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item>
|
<Descriptions.Item label="权限">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.permission)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
|
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
|
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type CSSProperties } from 'react';
|
import { useState, type CSSProperties } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = {
|
|||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATTRIBUTION_HINT =
|
||||||
|
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
||||||
|
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
async function downloadQrcode(url: string, filename: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PromoCodeDetailPage() {
|
export default function PromoCodeDetailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
|
|||||||
|
|
||||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card
|
||||||
|
size="small"
|
||||||
|
hoverable
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||||
|
>
|
||||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="归因用户数"
|
title={
|
||||||
|
<span>
|
||||||
|
归因用户数
|
||||||
|
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
value={stats?.attributionCount ?? 0}
|
value={stats?.attributionCount ?? 0}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card
|
||||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
size="small"
|
||||||
|
hoverable
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
订单数
|
||||||
|
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={stats?.orderCount ?? detail.orderCount}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../../lib/api';
|
import { request, type Paginated } from '../../lib/api';
|
||||||
import { fmtTime } from '../../lib/constants';
|
import { fmtTime } from '../../lib/constants';
|
||||||
|
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
promoId: string;
|
promoId: string;
|
||||||
@@ -57,11 +59,45 @@ function buildEventsQs(
|
|||||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRefId(row: PromoMetricEventItem): string {
|
function parseEventType(raw: string | null): PromoMetricEventType | undefined {
|
||||||
if (row.orderId) return `订单 ${row.orderId}`;
|
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
|
||||||
if (row.userId) return `用户 ${row.userId}`;
|
return undefined;
|
||||||
if (row.sessionId) return `会话 ${row.sessionId}`;
|
}
|
||||||
return '—';
|
|
||||||
|
function EventRefLinks({ row }: { row: PromoMetricEventItem }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const links = [];
|
||||||
|
if (row.orderId) {
|
||||||
|
links.push(
|
||||||
|
<AdminPrimaryLink
|
||||||
|
key="order"
|
||||||
|
onClick={() => {
|
||||||
|
if (row.orderNo) {
|
||||||
|
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
|
||||||
|
} else {
|
||||||
|
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
订单 {row.orderNo || row.orderId}
|
||||||
|
</AdminPrimaryLink>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.userId) {
|
||||||
|
links.push(
|
||||||
|
<AdminPrimaryLink
|
||||||
|
key="user"
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
|
||||||
|
>
|
||||||
|
用户 {row.userNo || row.userId}
|
||||||
|
</AdminPrimaryLink>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (links.length) {
|
||||||
|
return <Space size={12}>{links}</Space>;
|
||||||
|
}
|
||||||
|
if (row.sessionId) return <>会话 {row.sessionId}</>;
|
||||||
|
return <>—</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLocation(row: PromoMetricEventItem): string {
|
function formatLocation(row: PromoMetricEventItem): string {
|
||||||
@@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const urlEventType = parseEventType(searchParams.get('eventType'));
|
||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||||
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
||||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
|
||||||
const [eventsPage, setEventsPage] = useState(1);
|
const [eventsPage, setEventsPage] = useState(1);
|
||||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||||
const [eventsLoading, setEventsLoading] = useState(false);
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEventType(urlEventType);
|
||||||
|
setEventsPage(1);
|
||||||
|
if (urlEventType) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [urlEventType]);
|
||||||
|
|
||||||
const loadTimeline = useCallback(() => {
|
const loadTimeline = useCallback(() => {
|
||||||
setTimelineLoading(true);
|
setTimelineLoading(true);
|
||||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||||
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
{
|
{
|
||||||
title: 'ID',
|
title: 'ID',
|
||||||
key: 'ref',
|
key: 'ref',
|
||||||
render: (_, row) => formatRefId(row),
|
render: (_, row) => <EventRefLinks row={row} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'IP',
|
title: 'IP',
|
||||||
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
notMerge
|
notMerge
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||||
事件日志
|
事件日志
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
@@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setEventType(v);
|
setEventType(v);
|
||||||
setEventsPage(1);
|
setEventsPage(1);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (v) next.set('eventType', v);
|
||||||
|
else next.delete('eventType');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
}}
|
}}
|
||||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||||
import { Button, Space, Table, Tag } from 'antd';
|
import { Button, Space, Table, Tag, Tooltip } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
USER_SOURCE_TYPE_LABELS,
|
USER_SOURCE_TYPE_LABELS,
|
||||||
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
|
|||||||
width: 160,
|
width: 160,
|
||||||
render: (v) => (v ? fmtTime(v) : '—'),
|
render: (v) => (v ? fmtTime(v) : '—'),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: <Tooltip title="仅统计已完成订单">订单数</Tooltip>, dataIndex: 'orderCount', width: 80 },
|
||||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
<title>城市合伙人</title>
|
<title>城市合伙人</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -22,12 +22,17 @@ import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
|
|||||||
import StaffListPage from './pages/StaffListPage';
|
import StaffListPage from './pages/StaffListPage';
|
||||||
import StaffCreatePage from './pages/StaffCreatePage';
|
import StaffCreatePage from './pages/StaffCreatePage';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
|
import UsersManagePage from './pages/UsersManagePage';
|
||||||
|
import AssocOrdersPage from './pages/AssocOrdersPage';
|
||||||
|
import CommissionOrdersPage from './pages/CommissionOrdersPage';
|
||||||
|
import ActivityPostersPage from './pages/ActivityPostersPage';
|
||||||
|
|
||||||
function PrimaryRoutes() {
|
function PrimaryRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/users" element={<UsersManagePage />} />
|
||||||
<Route path="/stores" element={<StoreListPage />} />
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
<Route path="/center" element={<CenterPage />} />
|
<Route path="/center" element={<CenterPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -37,6 +42,12 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||||
<Route path="/center/staff" element={<StaffListPage />} />
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
|
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
|
||||||
|
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
|
||||||
|
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
|
||||||
|
<Route path="/users/orders" element={<AssocOrdersPage />} />
|
||||||
|
<Route path="/users/:userId/orders" element={<AssocOrdersPage />} />
|
||||||
|
<Route path="/center/commissions" element={<CommissionOrdersPage />} />
|
||||||
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
|
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
|
||||||
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
|
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
|
||||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ActivityPosterItem, PartnerAssocSummary } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
copyActivityCopy,
|
||||||
|
downloadBlob,
|
||||||
|
fetchActivityPosterImage,
|
||||||
|
fetchAssocQrcodeImage,
|
||||||
|
getActivityPosterSelection,
|
||||||
|
listActivityPosters,
|
||||||
|
saveActivityPosterSelection,
|
||||||
|
} from '../lib/activity-posters';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
|
||||||
|
const NONE_ID = 'none';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPosterPicker({ initialId }: Props) {
|
||||||
|
const [items, setItems] = useState<ActivityPosterItem[]>([]);
|
||||||
|
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null);
|
||||||
|
const [partnerId, setPartnerId] = useState<string>('');
|
||||||
|
const [selectedId, setSelectedId] = useState<string>(NONE_ID);
|
||||||
|
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const blobsRef = useRef(new Map<string, Blob>());
|
||||||
|
const urlsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
urlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([
|
||||||
|
listActivityPosters(),
|
||||||
|
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc', { silent: true }),
|
||||||
|
getActivityPosterSelection().catch(() => ({ posterId: null })),
|
||||||
|
])
|
||||||
|
.then(([list, assoc, selection]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setItems(list);
|
||||||
|
setQrcodeUrl(assoc.qrcodeUrl);
|
||||||
|
setPartnerId(assoc.partnerId);
|
||||||
|
const saved = selection.posterId && list.some((item) => item.id === selection.posterId)
|
||||||
|
? selection.posterId
|
||||||
|
: null;
|
||||||
|
const fromQuery = initialId && list.some((item) => item.id === initialId) ? initialId : null;
|
||||||
|
const next = saved || fromQuery || NONE_ID;
|
||||||
|
setSelectedId(next);
|
||||||
|
if (!saved && fromQuery) {
|
||||||
|
void saveActivityPosterSelection(fromQuery).catch((err) => {
|
||||||
|
toastError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => toastError(e instanceof Error ? e.message : '加载活动图失败'))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setListLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [initialId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === NONE_ID || previewUrls[selectedId] || blobsRef.current.has(selectedId)) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
fetchActivityPosterImage(selectedId)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
blobsRef.current.set(selectedId, blob);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
urlsRef.current.push(url);
|
||||||
|
setPreviewUrls((prev) => ({ ...prev, [selectedId]: url }));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) toastError(e instanceof Error ? e.message : '预览失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setPreviewLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedId, previewUrls]);
|
||||||
|
|
||||||
|
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||||
|
const previewUrl = selectedId !== NONE_ID ? previewUrls[selectedId] : undefined;
|
||||||
|
|
||||||
|
async function downloadSelected() {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
if (selectedId === NONE_ID) {
|
||||||
|
const blob = await fetchAssocQrcodeImage();
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
urlsRef.current.push(url);
|
||||||
|
setQrcodeUrl(url);
|
||||||
|
toastSuccess('请长按上方图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadBlob(blob, `partner-assoc-${partnerId || 'qr'}.png`);
|
||||||
|
toastSuccess('已开始下载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selected) return;
|
||||||
|
let blob = blobsRef.current.get(selected.id);
|
||||||
|
if (!blob) {
|
||||||
|
blob = await fetchActivityPosterImage(selected.id);
|
||||||
|
blobsRef.current.set(selected.id, blob);
|
||||||
|
}
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
let url = previewUrls[selected.id];
|
||||||
|
if (!url) {
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
urlsRef.current.push(url);
|
||||||
|
setPreviewUrls((prev) => ({ ...prev, [selected.id]: url }));
|
||||||
|
}
|
||||||
|
toastSuccess('请长按上方图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadBlob(blob, `activity-poster-${selected.id}.png`);
|
||||||
|
toastSuccess('已开始下载');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '下载失败');
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPoster(id: string) {
|
||||||
|
const prev = selectedId;
|
||||||
|
setSelectedId(id);
|
||||||
|
try {
|
||||||
|
await saveActivityPosterSelection(id === NONE_ID ? null : id);
|
||||||
|
} catch (e) {
|
||||||
|
setSelectedId(prev);
|
||||||
|
toastError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRadioRow(id: string, label: string) {
|
||||||
|
return (
|
||||||
|
<label className="partner-activity-radio-row">
|
||||||
|
<span className="headline-md">{label}</span>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="activity-poster"
|
||||||
|
checked={selectedId === id}
|
||||||
|
onChange={() => void selectPoster(id)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listLoading) {
|
||||||
|
return <div className="empty">加载活动图…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partner-activity-picker">
|
||||||
|
<section className="partner-bill-card" style={{ overflow: 'hidden' }}>
|
||||||
|
{renderRadioRow(NONE_ID, '无')}
|
||||||
|
{selectedId === NONE_ID && (
|
||||||
|
<div className="partner-activity-panel">
|
||||||
|
{qrcodeUrl ? (
|
||||||
|
<div className="partner-activity-preview partner-activity-preview--qr">
|
||||||
|
<img src={qrcodeUrl} alt="关联码" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="body-md text-muted" style={{ marginBottom: 12 }}>关联码尚未生成</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={downloading || !qrcodeUrl}
|
||||||
|
onClick={() => void downloadSelected()}
|
||||||
|
>
|
||||||
|
{downloading ? '下载中…' : '下载二维码'}
|
||||||
|
</button>
|
||||||
|
{isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
微信内请长按上方图片保存
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{items.map((item) => (
|
||||||
|
<section key={item.id} className="partner-bill-card" style={{ marginTop: 12, overflow: 'hidden' }}>
|
||||||
|
{renderRadioRow(item.id, item.title)}
|
||||||
|
{selectedId === item.id && (
|
||||||
|
<div className="partner-activity-panel">
|
||||||
|
<div className="partner-activity-preview">
|
||||||
|
<img src={previewUrl || item.imageUrl} alt={item.title} />
|
||||||
|
{previewLoading && !previewUrl && (
|
||||||
|
<div className="partner-activity-preview-mask">正在贴入二维码…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.copyText ? (
|
||||||
|
<p className="body-md" style={{ whiteSpace: 'pre-wrap', margin: '12px 0' }}>{item.copyText}</p>
|
||||||
|
) : (
|
||||||
|
<p className="body-md text-muted" style={{ margin: '12px 0' }}>暂无推广文案</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
void copyActivityCopy(item.copyText)
|
||||||
|
.then(() => toastSuccess('文案已复制'))
|
||||||
|
.catch((e) => toastError(e instanceof Error ? e.message : '复制失败'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制文案
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
style={{ flex: 1, width: 'auto', padding: 0, height: 48 }}
|
||||||
|
disabled={downloading || previewLoading}
|
||||||
|
onClick={() => void downloadSelected()}
|
||||||
|
>
|
||||||
|
{downloading || previewLoading ? '生成中…' : '下载活动图'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{previewUrl && isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
微信内请长按上方图片保存
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { isLoggedIn } from '../lib/api';
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||||
|
{ to: '/users', icon: 'group', label: '用户管理' },
|
||||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||||
{ to: '/center', icon: 'account_circle', label: '合伙人中心' },
|
{ to: '/center', icon: 'account_circle', label: '合伙人中心' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ActivityPosterItem, ActivityPosterSelection } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export async function listActivityPosters() {
|
||||||
|
const data = await request<ActivityPosterItem[]>('PARTNER_H5', '/partner/activity-posters', {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAssocQrcodeImage(): Promise<Blob> {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const res = await fetch('/api/v1/partner/assoc/qrcode', {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
|
'X-Client-App': 'PARTNER_H5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('下载二维码失败');
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActivityPosterImage(id: string): Promise<Blob> {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const res = await fetch(`/api/v1/partner/activity-posters/${id}/image`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
|
'X-Client-App': 'PARTNER_H5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null) as { message?: string } | null;
|
||||||
|
throw new Error(json?.message || '生成活动图失败');
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityPosterSelection() {
|
||||||
|
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveActivityPosterSelection(posterId: string | null) {
|
||||||
|
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ posterId }),
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyActivityCopy(text: string) {
|
||||||
|
const value = text.trim();
|
||||||
|
if (!value) throw new Error('暂无文案');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = value;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type ProxyOrderDraft = {
|
|||||||
promoCodeId: string;
|
promoCodeId: string;
|
||||||
deliveryMode: PartnerProxyDeliveryMode;
|
deliveryMode: PartnerProxyDeliveryMode;
|
||||||
autoReceive: boolean;
|
autoReceive: boolean;
|
||||||
|
assocEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
|
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
|
||||||
@@ -40,6 +41,7 @@ export function loadProxyOrderDraft(): ProxyOrderDraft | null {
|
|||||||
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
|
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
|
||||||
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
|
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
|
||||||
autoReceive: parsed.autoReceive === true,
|
autoReceive: parsed.autoReceive === true,
|
||||||
|
assocEnabled: parsed.assocEnabled !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import ActivityPosterPicker from '../components/ActivityPosterPicker';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
export default function ActivityPostersPage() {
|
||||||
|
usePartnerPageView('partner_activity_posters_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialId = searchParams.get('id');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = '活动图';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="活动图" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<ActivityPosterPicker initialId={initialId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerAssocUserOrderItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerAssocUserOrderItem[]; total: number };
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssocOrdersPage() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
usePartnerPageView(userId ? 'partner_assoc_user_orders_view' : 'partner_assoc_orders_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerAssocUserOrderItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const path = userId
|
||||||
|
? `/partner/assoc/users/${userId}/orders?page=1&pageSize=50`
|
||||||
|
: '/partner/assoc/orders?page=1&pageSize=50';
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', path);
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title={userId ? '用户订单' : '关联用户订单'} onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 笔已付购酒单`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无订单</div>}
|
||||||
|
{items.map((o) => (
|
||||||
|
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{o.productName}</p>
|
||||||
|
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||||
|
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.payAmount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import type { PartnerAssocSummary } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
export default function AssocQrcodePage() {
|
||||||
|
usePartnerPageView('partner_assoc_qrcode_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc')
|
||||||
|
.then(setSummary)
|
||||||
|
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
async function downloadQr() {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/partner/assoc/qrcode', {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
|
'X-Client-App': 'PARTNER_H5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('下载失败');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
setPreviewUrl(url);
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toastSuccess('已开始下载');
|
||||||
|
} catch (e) {
|
||||||
|
if (summary?.qrcodeUrl) {
|
||||||
|
setPreviewUrl(summary.qrcodeUrl);
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastError(e instanceof Error ? e.message : '下载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="关联码" onBack={() => navigate(-1)} />
|
||||||
|
{loading && <div className="empty">加载中…</div>}
|
||||||
|
{!loading && (
|
||||||
|
<section className="partner-bill-card" style={{ margin: 16 }}>
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
||||||
|
用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算
|
||||||
|
</p>
|
||||||
|
{summary?.qrcodeUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl || summary.qrcodeUrl}
|
||||||
|
alt="关联码"
|
||||||
|
style={{ width: 220, height: 220, background: '#fff' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="body-md text-muted">关联码尚未生成,请稍后重试或联系总部补发</p>
|
||||||
|
)}
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
已关联 {summary?.userCount ?? 0} 人
|
||||||
|
</p>
|
||||||
|
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
||||||
|
下载二维码
|
||||||
|
</button>
|
||||||
|
{previewUrl && isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
微信内请长按上方图片保存
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerAssocUserItem[]; total: number; page: number; pageSize: number };
|
||||||
|
|
||||||
|
export default function AssocUsersPage() {
|
||||||
|
usePartnerPageView('partner_assoc_users_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', '/partner/assoc/users?page=1&pageSize=50');
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title="关联用户" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 人`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||||
|
{items.map((u) => (
|
||||||
|
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{u.nickname || u.userNo}</p>
|
||||||
|
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||||
|
<p className="label-md text-muted">{u.boundAt ? u.boundAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="label-md">{u.orderCount} 单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
@@ -34,6 +34,7 @@ export default function BillsPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [detail, setDetail] = useState<PartnerBillDetailDto | null>(null);
|
||||||
|
|
||||||
async function loadBills() {
|
async function loadBills() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -67,6 +68,44 @@ export default function BillsPage() {
|
|||||||
const isRejected = status === 'REJECTED';
|
const isRejected = status === 'REJECTED';
|
||||||
const isPaid = status === 'PAID';
|
const isPaid = status === 'PAID';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bill?.id) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<PartnerBillDetailDto>('PARTNER_H5', `/partner/settlement/bills/${bill.id}`)
|
||||||
|
.then(setDetail)
|
||||||
|
.catch(() => setDetail(null));
|
||||||
|
}, [bill?.id]);
|
||||||
|
|
||||||
|
function renderItemList(title: string, items: PartnerBillItemDto[] | undefined, subtotal: number) {
|
||||||
|
const list = items ?? [];
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div className="partner-info-row" style={{ marginBottom: 8 }}>
|
||||||
|
<span className="body-md" style={{ fontWeight: 700 }}>{title}</span>
|
||||||
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="label-md text-muted">本账期无明细</p>
|
||||||
|
) : (
|
||||||
|
list.map((it) => (
|
||||||
|
<div key={it.id} className="partner-info-row" style={{ alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{it.title || it.refNo}</p>
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
{it.refNo}
|
||||||
|
{it.extra ? ` ${it.extra}` : ''} · {((it.rate ?? 0) * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="body-md">¥ {fmtMoney(it.commission)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function askConfirm(ids: string[]) {
|
function askConfirm(ids: string[]) {
|
||||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||||
return;
|
return;
|
||||||
@@ -183,16 +222,8 @@ export default function BillsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
{renderItemList('酒订单', detail?.orderItems, Number(detail?.orderCommission ?? bill.orderCommission ?? 0))}
|
||||||
<div className="partner-info-row">
|
{renderItemList('核销订单', detail?.redeemItems, Number(detail?.redeemCommission ?? bill.redeemCommission ?? 0))}
|
||||||
<span className="text-variant body-md">订单分佣收入</span>
|
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="text-variant body-md">核销权益分佣</span>
|
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -219,6 +219,33 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/center/activity-posters" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">image</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>活动图</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/users" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">group</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>用户管理</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/center/commissions" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">payments</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>订单佣金</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
<Link to="/center/proxy-orders" className="partner-menu-item">
|
<Link to="/center/proxy-orders" className="partner-menu-item">
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon">
|
<div className="partner-menu-icon">
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerCommissionOrderItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerCommissionOrderItem[]; total: number };
|
||||||
|
|
||||||
|
export default function CommissionOrdersPage() {
|
||||||
|
usePartnerPageView('partner_commission_orders_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerCommissionOrderItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', '/partner/commissions/orders?page=1&pageSize=50');
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title="订单佣金" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 笔关联酒单`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无订单佣金</div>}
|
||||||
|
{items.map((o) => (
|
||||||
|
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{o.productName}</p>
|
||||||
|
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||||
|
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.commission)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
实付 ¥{fmtMoney(o.payAmount)} × {((o.rate ?? 0) * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
import type { PartnerAssocStats, PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
@@ -153,6 +153,7 @@ export default function HomePage() {
|
|||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||||
|
const [assocStats, setAssocStats] = useState<PartnerAssocStats | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = '工作台';
|
document.title = '工作台';
|
||||||
@@ -197,6 +198,16 @@ export default function HomePage() {
|
|||||||
setStores([]);
|
setStores([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
tasks.push(
|
||||||
|
request<PartnerAssocStats>('PARTNER_H5', '/partner/assoc/stats', { silent: true })
|
||||||
|
.then(setAssocStats)
|
||||||
|
.catch(() => setAssocStats(null)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setAssocStats(null);
|
||||||
|
}
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
fetchPartnerLeaderboard('month')
|
fetchPartnerLeaderboard('month')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -208,7 +219,7 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Promise.all(tasks).then(() => undefined);
|
return Promise.all(tasks).then(() => undefined);
|
||||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
}, [navigate, account, canOrders, canDashboard, canStores, isPrimary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHome();
|
void loadHome();
|
||||||
@@ -287,6 +298,51 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPrimary && (
|
||||||
|
<>
|
||||||
|
<Link to="/users" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
<div className="partner-bento-header">
|
||||||
|
<h2 className="headline-md">关联用户</h2>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.userTotal ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stats">
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本日</p>
|
||||||
|
<p className="headline-md">{assocStats?.userToday ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本月</p>
|
||||||
|
<p className="headline-md">{assocStats?.userMonth ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link to="/users/orders" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
<div className="partner-bento-header">
|
||||||
|
<h2 className="headline-md">关联用户订单</h2>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.orderTotal ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stats">
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本日</p>
|
||||||
|
<p className="headline-md">{assocStats?.orderToday ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本月</p>
|
||||||
|
<p className="headline-md">{assocStats?.orderMonth ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isWarehouse && (
|
{isWarehouse && (
|
||||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function initialDraft(): ProxyOrderDraft {
|
|||||||
promoCodeId: '',
|
promoCodeId: '',
|
||||||
deliveryMode: 'ADDRESS',
|
deliveryMode: 'ADDRESS',
|
||||||
autoReceive: false,
|
autoReceive: false,
|
||||||
|
assocEnabled: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ export default function ProxyOrderPage() {
|
|||||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||||
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
||||||
|
const [assocEnabled, setAssocEnabled] = useState(draft0.assocEnabled !== false);
|
||||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -108,6 +110,7 @@ export default function ProxyOrderPage() {
|
|||||||
promoCodeId,
|
promoCodeId,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive,
|
autoReceive,
|
||||||
|
assocEnabled,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,6 +157,7 @@ export default function ProxyOrderPage() {
|
|||||||
promoCodeId,
|
promoCodeId,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive,
|
autoReceive,
|
||||||
|
assocEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
@@ -390,6 +394,9 @@ export default function ProxyOrderPage() {
|
|||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
skuId: skuId || undefined,
|
skuId: skuId || undefined,
|
||||||
|
assocPartnerAccountId: assocEnabled
|
||||||
|
? options?.partners?.[0]?.id || undefined
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -595,6 +602,17 @@ export default function ProxyOrderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assocEnabled}
|
||||||
|
onChange={(e) => setAssocEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>关联合伙人(本单计订单佣金;取消则本单不计)</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
{(options?.promoCodes.length ?? 0) > 0 && (
|
{(options?.promoCodes.length ?? 0) > 0 && (
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">绑定推广码(选填)</label>
|
<label className="partner-form-label">绑定推广码(选填)</label>
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type {
|
||||||
|
PartnerAssocSummary,
|
||||||
|
PartnerAssocUserItem,
|
||||||
|
PartnerAssocUserSort,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
downloadBlob,
|
||||||
|
fetchActivityPosterImage,
|
||||||
|
fetchAssocQrcodeImage,
|
||||||
|
} from '../lib/activity-posters';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerAssocUserItem[]; total: number };
|
||||||
|
|
||||||
|
const SORTS: { key: PartnerAssocUserSort; label: string }[] = [
|
||||||
|
{ key: 'boundAt', label: '关联时间' },
|
||||||
|
{ key: 'createdAt', label: '注册时间' },
|
||||||
|
{ key: 'orderCount', label: '订单数' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatUserLabel(u: PartnerAssocUserItem) {
|
||||||
|
const name = u.nickname?.trim() || u.userNo || '—';
|
||||||
|
const remark = u.partnerRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso?: string | null) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return iso.slice(0, 16).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersManagePage() {
|
||||||
|
usePartnerPageView('partner_users_manage_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [heroUrl, setHeroUrl] = useState<string | null>(null);
|
||||||
|
const [heroLoading, setHeroLoading] = useState(false);
|
||||||
|
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||||||
|
const [sort, setSort] = useState<PartnerAssocUserSort>('boundAt');
|
||||||
|
const [editing, setEditing] = useState<PartnerAssocUserItem | null>(null);
|
||||||
|
const [remarkDraft, setRemarkDraft] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = '用户管理';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const posterId = summary?.activityPosterId;
|
||||||
|
if (!posterId) {
|
||||||
|
setHeroUrl(null);
|
||||||
|
setHeroLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const acc = { url: null as string | null };
|
||||||
|
setHeroLoading(true);
|
||||||
|
fetchActivityPosterImage(posterId)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
acc.url = URL.createObjectURL(blob);
|
||||||
|
setHeroUrl(acc.url);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setHeroUrl(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setHeroLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (acc.url) URL.revokeObjectURL(acc.url);
|
||||||
|
};
|
||||||
|
}, [summary?.activityPosterId]);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
|
||||||
|
setSummary(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', pageSize: '50', sort });
|
||||||
|
const q = appliedKeyword.trim();
|
||||||
|
if (q) qs.set('keyword', q);
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', `/partner/assoc/users?${qs}`);
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
}, [appliedKeyword, sort]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([loadSummary(), loadUsers()]);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadSummary, loadUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setAppliedKeyword(keyword.trim()), 400);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const uid = searchParams.get('userId');
|
||||||
|
if (uid) navigate(`/users/${uid}/orders`, { replace: true });
|
||||||
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
|
async function downloadMainImage() {
|
||||||
|
const posterId = summary?.activityPosterId;
|
||||||
|
try {
|
||||||
|
const blob = posterId
|
||||||
|
? await fetchActivityPosterImage(posterId)
|
||||||
|
: await fetchAssocQrcodeImage();
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
setPreviewUrl(URL.createObjectURL(blob));
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadBlob(blob, posterId
|
||||||
|
? `activity-poster-${posterId}.png`
|
||||||
|
: `partner-assoc-${summary?.partnerId || 'qr'}.png`);
|
||||||
|
toastSuccess('已开始下载');
|
||||||
|
} catch (e) {
|
||||||
|
if (!posterId && summary?.qrcodeUrl) {
|
||||||
|
setPreviewUrl(summary.qrcodeUrl);
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastError(e instanceof Error ? e.message : '下载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemark() {
|
||||||
|
if (!editing) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`PARTNER_H5`, `/partner/assoc/users/${editing.id}/remark`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ remark: remarkDraft }),
|
||||||
|
});
|
||||||
|
toastSuccess(remarkDraft.trim() ? '备注已保存' : '已清除备注');
|
||||||
|
setEditing(null);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load} className="page partner-users-page">
|
||||||
|
<div style={{ padding: '16px 20px 24px' }}>
|
||||||
|
<h1 className="headline-lg" style={{ marginBottom: 16 }}>用户管理</h1>
|
||||||
|
|
||||||
|
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
||||||
|
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
||||||
|
用户扫码后首次锁定,后续购酒计入关联订单
|
||||||
|
</p>
|
||||||
|
{summary?.activityPosterId && (previewUrl || heroUrl) ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl || heroUrl || ''}
|
||||||
|
alt="活动图"
|
||||||
|
style={{ width: '100%', maxWidth: 360, background: '#f5f5f5' }}
|
||||||
|
/>
|
||||||
|
) : summary?.activityPosterId && heroLoading ? (
|
||||||
|
<p className="body-md text-muted">正在生成活动图…</p>
|
||||||
|
) : previewUrl || summary?.qrcodeUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl || summary?.qrcodeUrl || ''}
|
||||||
|
alt="关联码"
|
||||||
|
style={{ width: 200, height: 200, background: '#fff' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="body-md text-muted">{loading ? '加载中…' : '关联码尚未生成'}</p>
|
||||||
|
)}
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
已关联 {summary?.userCount ?? total} 人
|
||||||
|
</p>
|
||||||
|
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadMainImage()}>
|
||||||
|
{summary?.activityPosterId ? '下载活动图' : '下载二维码'}
|
||||||
|
</button>
|
||||||
|
{previewUrl && isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>微信内请长按上方图片保存</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Link to="/center/activity-posters" className="partner-menu-card" style={{ display: 'block', marginBottom: 20, textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">image</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>活动图</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h2 className="headline-md" style={{ marginBottom: 12 }}>已关联用户</h2>
|
||||||
|
<div className="partner-search">
|
||||||
|
<span className="material-symbols-outlined">search</span>
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
placeholder="搜索昵称 / 手机 / 备注 / 编号"
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void loadUsers();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="partner-filter-row" style={{ marginBottom: 12, paddingLeft: 0, paddingRight: 0 }}>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
className={`partner-filter-tab${sort === s.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setSort(s.key)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 12 }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 人`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||||
|
{items.map((u) => (
|
||||||
|
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{formatUserLabel(u)}</p>
|
||||||
|
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||||
|
<p className="label-md text-muted">注册 {fmtTime(u.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="label-md text-primary"
|
||||||
|
style={{ background: 'none', border: 0, padding: 0 }}
|
||||||
|
onClick={() => navigate(`/users/${u.id}/orders`)}
|
||||||
|
>
|
||||||
|
{u.orderCount} 单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="label-md text-primary"
|
||||||
|
style={{ background: 'none', border: 0, padding: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(u);
|
||||||
|
setRemarkDraft(u.partnerRemark ?? '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.partnerRemark ? '改备注' : '添加备注'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="partner-remark-mask" onClick={() => setEditing(null)}>
|
||||||
|
<div className="partner-remark-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p className="headline-md" style={{ marginBottom: 8 }}>用户备注</p>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 12 }}>仅自己可见,总部看不到</p>
|
||||||
|
<textarea
|
||||||
|
value={remarkDraft}
|
||||||
|
maxLength={128}
|
||||||
|
rows={3}
|
||||||
|
placeholder="最多 128 字"
|
||||||
|
style={{ width: '100%', padding: 12, borderRadius: 8, border: '1px solid #eee' }}
|
||||||
|
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="partner-ship-actions">
|
||||||
|
<button type="button" className="partner-btn-secondary" onClick={() => setEditing(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveRemark()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -278,6 +278,22 @@ html {
|
|||||||
|
|
||||||
.partner-btn-primary:active { transform: scale(0.98); }
|
.partner-btn-primary:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.partner-btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-container-low);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-btn-ghost {
|
.partner-btn-ghost {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -1344,7 +1360,7 @@ nav.app-tabbar {
|
|||||||
nav.app-tabbar .app-tabbar-item {
|
nav.app-tabbar .app-tabbar-item {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 33.333%;
|
max-width: 25%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 6px 0 4px;
|
padding: 6px 0 4px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -3932,3 +3948,142 @@ header:has(> .app-page-title:only-child),
|
|||||||
margin-left: var(--space-page);
|
margin-left: var(--space-page);
|
||||||
margin-right: var(--space-page);
|
margin-right: var(--space-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-users-page {
|
||||||
|
padding-bottom: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-remark-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-remark-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--color-card, #fff);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-chip {
|
||||||
|
flex: 0 0 92px;
|
||||||
|
border: 1px solid var(--color-outline-variant, #eee);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-chip img {
|
||||||
|
width: 100%;
|
||||||
|
height: 92px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-chip span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-chip.is-active {
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-preview {
|
||||||
|
position: relative;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-preview img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-preview-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.62);
|
||||||
|
color: var(--color-on-surface-variant, #666);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-center-card {
|
||||||
|
flex: 0 0 120px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-center-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: block;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-center-card span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-radio-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-radio-row input[type='radio'] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-panel {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-preview--qr {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-activity-preview--qr img {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
<title>门店管理中心</title>
|
<title>门店管理中心</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||||
const SELECT_STORE_PATH = '/select-store';
|
const SELECT_STORE_PATH = '/select-store';
|
||||||
@@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && location.pathname === '/login') {
|
if (authenticated && location.pathname === '/login') {
|
||||||
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
|
||||||
|
return <Navigate to={next} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||||
|
rememberShopReturnPath(location);
|
||||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
|
rememberShopReturnPath(location);
|
||||||
const profile = getStoreProfile();
|
const profile = getStoreProfile();
|
||||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||||
|
|||||||
@@ -1,22 +1 @@
|
|||||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
export { parseRedeemTokenFromScan } from '@dukang/shared-types';
|
||||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
|
||||||
return trimmed.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
|
||||||
const fromQuery = url.searchParams.get('token');
|
|
||||||
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
|
||||||
return fromQuery.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* not a URL */
|
|
||||||
}
|
|
||||||
|
|
||||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
|
||||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||||
|
|
||||||
|
/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */
|
||||||
|
export function goShopPath(
|
||||||
|
path: string,
|
||||||
|
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||||
|
opts?: { replace?: boolean },
|
||||||
|
): void {
|
||||||
|
if (shouldHardNavigateForJssdk()) {
|
||||||
|
hardNavigateInWechat(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(path, { replace: opts?.replace ?? true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state) */
|
||||||
|
export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return';
|
||||||
|
|
||||||
|
function isRedeemReturnPath(path: string): boolean {
|
||||||
|
if (!path.startsWith('/redeem')) return false;
|
||||||
|
if (path.startsWith('/redeem/')) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(path, 'https://local.invalid');
|
||||||
|
return !!url.searchParams.get('token')?.trim();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberShopReturnTo(path: string): void {
|
||||||
|
if (!isRedeemReturnPath(path)) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberShopReturnPath(location: { pathname: string; search: string }): void {
|
||||||
|
rememberShopReturnTo(`${location.pathname}${location.search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peekShopReturnTo(): string | null {
|
||||||
|
try {
|
||||||
|
const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY);
|
||||||
|
return path && isRedeemReturnPath(path) ? path : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeShopReturnTo(): string | null {
|
||||||
|
const path = peekShopReturnTo();
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
|
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
|
||||||
import { toastError } from '../lib/toast';
|
import { toastError } from '../lib/toast';
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export default function RedeemConfirmPage() {
|
|||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
consumeShopReturnTo();
|
||||||
setToken(scanned);
|
setToken(scanned);
|
||||||
}, [searchParams, navigate]);
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { goShopPath } from '../lib/shop-nav';
|
||||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||||
|
|
||||||
@@ -35,10 +36,12 @@ export default function RedeemSuccessPage() {
|
|||||||
});
|
});
|
||||||
}, [redeemNo, amount]);
|
}, [redeemNo, amount]);
|
||||||
|
|
||||||
|
const goHome = () => goShopPath('/', navigate, { replace: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-success-page">
|
<div className="shop-success-page">
|
||||||
<header className="shop-success-header">
|
<header className="shop-success-header">
|
||||||
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
|
<button type="button" className="shop-redeem-back" onClick={goHome} aria-label="返回">
|
||||||
<span className="material-symbols-outlined">arrow_back</span>
|
<span className="material-symbols-outlined">arrow_back</span>
|
||||||
</button>
|
</button>
|
||||||
<h1 className="app-page-title">杜康好客</h1>
|
<h1 className="app-page-title">杜康好客</h1>
|
||||||
@@ -85,11 +88,11 @@ export default function RedeemSuccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-success-actions">
|
<div className="shop-success-actions">
|
||||||
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
|
<button type="button" className="shop-success-primary-btn" onClick={goHome}>
|
||||||
<span>继续核销</span>
|
<span>继续核销</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
<button type="button" className="shop-success-outline-btn" onClick={goHome}>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import {
|
import {
|
||||||
needsStoreSelection,
|
needsStoreSelection,
|
||||||
@@ -10,13 +9,11 @@ import {
|
|||||||
type ShopSessionPayload,
|
type ShopSessionPayload,
|
||||||
type ShopStoreOption,
|
type ShopStoreOption,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { goShopPath } from '../lib/shop-nav';
|
||||||
|
import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return';
|
||||||
|
|
||||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||||
if (shouldHardNavigateForJssdk()) {
|
goShopPath(consumeShopReturnTo() || '/', navigate);
|
||||||
hardNavigateInWechat('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SelectStorePage() {
|
export default function SelectStorePage() {
|
||||||
@@ -46,7 +43,7 @@ export default function SelectStorePage() {
|
|||||||
async function onSelect(storeId: string) {
|
async function onSelect(storeId: string) {
|
||||||
if (loadingId) return;
|
if (loadingId) return;
|
||||||
if (storeId === currentStoreId) {
|
if (storeId === currentStoreId) {
|
||||||
goShopHome(navigate);
|
goAfterSelectStore(navigate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingId(storeId);
|
setLoadingId(storeId);
|
||||||
@@ -54,7 +51,7 @@ export default function SelectStorePage() {
|
|||||||
try {
|
try {
|
||||||
const session = await selectStore(storeId);
|
const session = await selectStore(storeId);
|
||||||
applySession(session);
|
applySession(session);
|
||||||
goShopHome(navigate);
|
goAfterSelectStore(navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,16 +167,14 @@ export default function SelectStorePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** After login/wechat: route to select-store or home */
|
/** After login/wechat: route to select-store, redeem return, or home */
|
||||||
export function routeAfterShopLogin(
|
export function routeAfterShopLogin(
|
||||||
session: ShopSessionPayload,
|
session: ShopSessionPayload,
|
||||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||||
) {
|
) {
|
||||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
if (needsStoreSelection(session)) {
|
||||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
goShopPath('/select-store', navigate);
|
||||||
if (shouldHardNavigateForJssdk()) {
|
|
||||||
hardNavigateInWechat(path);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(path, { replace: true });
|
goShopPath(peekShopReturnTo() || '/', navigate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.5.4",
|
"version": "3.5.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineAppConfig({
|
|||||||
{ root: 'pages/login', pages: ['index'] },
|
{ root: 'pages/login', pages: ['index'] },
|
||||||
{ root: 'pages/user-agreement', pages: ['index'] },
|
{ root: 'pages/user-agreement', pages: ['index'] },
|
||||||
{ root: 'pages/privacy-policy', pages: ['index'] },
|
{ root: 'pages/privacy-policy', pages: ['index'] },
|
||||||
|
{ root: 'pages/benefit-rules', pages: ['index'] },
|
||||||
{ root: 'pages/invoice-titles', pages: ['index'] },
|
{ root: 'pages/invoice-titles', pages: ['index'] },
|
||||||
{ root: 'pages/invoice-apply', pages: ['index'] },
|
{ root: 'pages/invoice-apply', pages: ['index'] },
|
||||||
],
|
],
|
||||||
@@ -75,7 +76,7 @@ export default defineAppConfig({
|
|||||||
pagePath: 'pages/benefit/index',
|
pagePath: 'pages/benefit/index',
|
||||||
text: '好客权益',
|
text: '好客权益',
|
||||||
iconPath: 'assets/tabbar/benefit.png',
|
iconPath: 'assets/tabbar/benefit.png',
|
||||||
selectedIconPath: 'assets/tabbar/benefit-active.png',
|
selectedIconPath: 'assets/icons/store-benefit-y.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pagePath: 'pages/mine/index',
|
pagePath: 'pages/mine/index',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
@import './styles/benefit.css';
|
@import './styles/benefit.css';
|
||||||
@import './styles/mine.css';
|
@import './styles/mine.css';
|
||||||
@import './styles/address.css';
|
@import './styles/address.css';
|
||||||
|
@import './styles/benefit-promo.css';
|
||||||
|
@import './components/HomeSplash.css';
|
||||||
|
|
||||||
page,
|
page,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import Taro, { useDidShow } from '@tarojs/taro';
|
|||||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||||
import { installClientErrorReporting } from './lib/client-error';
|
import { installClientErrorReporting } from './lib/client-error';
|
||||||
import { prefetchShareBrandAssets } from './lib/wechat-share';
|
import { prefetchShareBrandAssets } from './lib/wechat-share';
|
||||||
|
import { prefetchHomeSplashAssets } from './lib/home-splash';
|
||||||
import { capturePromoSceneAndTouchScan } from './lib/promo';
|
import { capturePromoSceneAndTouchScan } from './lib/promo';
|
||||||
import { initClientVersionChecks } from './lib/client-version';
|
import { initClientVersionChecks } from './lib/client-version';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
installClientErrorReporting();
|
installClientErrorReporting();
|
||||||
prefetchShareBrandAssets();
|
prefetchShareBrandAssets();
|
||||||
|
prefetchHomeSplashAssets();
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
const handlingRef = useRef(false);
|
const handlingRef = useRef(false);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 307 B |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { Image, Text, View } from '@tarojs/components';
|
import { Image, Text, View } from '@tarojs/components';
|
||||||
import iconStoreBenefit from '../assets/icons/store-benefit.png';
|
import iconStoreBenefit from '../assets/icons/store-benefit-y.png';
|
||||||
|
|
||||||
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
|
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import {
|
||||||
|
BENEFIT_INTRO,
|
||||||
|
BENEFIT_INTRO_EMPHASIS,
|
||||||
|
BENEFIT_RULES_PATH,
|
||||||
|
BENEFIT_SLOGAN,
|
||||||
|
} from '../lib/benefit-copy';
|
||||||
|
|
||||||
|
type BenefitIntroCardProps = {
|
||||||
|
showLink?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** 左侧金色粗条(门店详情等强调卡) */
|
||||||
|
accent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BenefitIntroCard({
|
||||||
|
showLink = false,
|
||||||
|
className,
|
||||||
|
accent = false,
|
||||||
|
}: BenefitIntroCardProps) {
|
||||||
|
const prefix = BENEFIT_INTRO.endsWith(BENEFIT_INTRO_EMPHASIS)
|
||||||
|
? BENEFIT_INTRO.slice(0, -BENEFIT_INTRO_EMPHASIS.length)
|
||||||
|
: BENEFIT_INTRO;
|
||||||
|
|
||||||
|
function goRules() {
|
||||||
|
Taro.navigateTo({ url: BENEFIT_RULES_PATH });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<Text className="benefit-intro-card-title">{BENEFIT_SLOGAN}</Text>
|
||||||
|
<Text className="benefit-intro-card-body">
|
||||||
|
{prefix}
|
||||||
|
<Text className="benefit-intro-card-em">{BENEFIT_INTRO_EMPHASIS}</Text>
|
||||||
|
</Text>
|
||||||
|
{showLink ? (
|
||||||
|
<Text className="benefit-intro-card-link" onClick={goRules}>
|
||||||
|
查看完整说明 ›
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`benefit-intro-card${accent ? ' benefit-intro-card--accent' : ''}${className ? ` ${className}` : ''}`}
|
||||||
|
>
|
||||||
|
{accent ? <View className="benefit-intro-card-bar" /> : null}
|
||||||
|
{accent ? <View className="benefit-intro-card-main">{inner}</View> : inner}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import { BENEFIT_SLOGAN } from '../lib/benefit-copy';
|
||||||
|
|
||||||
|
type BenefitSloganBarProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BenefitSloganBar({ className }: BenefitSloganBarProps) {
|
||||||
|
return (
|
||||||
|
<View className={`benefit-slogan-bar${className ? ` ${className}` : ''}`}>
|
||||||
|
<View className="benefit-slogan-bar-accent" />
|
||||||
|
<Text className="benefit-slogan-bar-text">{BENEFIT_SLOGAN}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export const EMPTY_CATEGORY: CategorySelection = {
|
|||||||
export function formatCategoryLabel(sel: CategorySelection): string {
|
export function formatCategoryLabel(sel: CategorySelection): string {
|
||||||
if (sel.childName) return sel.childName;
|
if (sel.childName) return sel.childName;
|
||||||
if (sel.parentName) return sel.parentName;
|
if (sel.parentName) return sel.parentName;
|
||||||
return '全部分类';
|
return '全部菜系';
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryPickerProps = {
|
type CategoryPickerProps = {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Text } from '@tarojs/components';
|
import { Button, Text } from '@tarojs/components';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { toast } from '../lib/api';
|
||||||
|
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
|
||||||
|
import { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs';
|
||||||
|
|
||||||
export type ContactCsSessionContext = {
|
export type ContactCsSessionContext = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
@@ -16,7 +20,7 @@ type ContactCsButtonProps = {
|
|||||||
|
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
/** 组装 session-from(微信限制约 1000 字符) */
|
/** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用) */
|
||||||
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||||
if (!session) return 'dukang|from=mini-user';
|
if (!session) return 'dukang|from=mini-user';
|
||||||
const parts = ['dukang'];
|
const parts = ['dukang'];
|
||||||
@@ -26,16 +30,61 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
|||||||
return parts.join('|');
|
return parts.join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canOpenWecom(url: string, corpId: string) {
|
||||||
|
return !!url.trim() && !!corpId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信小程序客服入口(open-type=contact)。
|
* C 端在线客服:
|
||||||
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」
|
||||||
|
* - weapp 未配 CorpID:回退 open-type=contact
|
||||||
|
* - H5:有 kfid 则打开企微客服链接
|
||||||
*/
|
*/
|
||||||
export default function ContactCsButton({
|
export default function ContactCsButton({
|
||||||
className = '',
|
className = '',
|
||||||
children = '联系在线客服',
|
children = '联系在线客服',
|
||||||
session,
|
session,
|
||||||
}: ContactCsButtonProps) {
|
}: ContactCsButtonProps) {
|
||||||
if (!isWeapp) return null;
|
const [cs, setCs] = useState(() => getBrandAssetsSync());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBrandAssets().then(setCs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wecomChatReady = canOpenWecom(cs.customerServiceWecomUrl, cs.wecomCorpId);
|
||||||
|
const wecomWebReady = !!cs.customerServiceWecomUrl.trim();
|
||||||
|
|
||||||
|
if (!isWeapp && !wecomWebReady) return null;
|
||||||
|
|
||||||
|
async function openWecom() {
|
||||||
|
try {
|
||||||
|
if (isWeapp && wecomChatReady) {
|
||||||
|
await openWecomCustomerServiceChat({
|
||||||
|
url: cs.customerServiceWecomUrl,
|
||||||
|
corpId: cs.wecomCorpId,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && cs.customerServiceWecomUrl) {
|
||||||
|
window.location.href = cs.customerServiceWecomUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('请在微信内打开后联系客服');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wecom-cs] open failed', e);
|
||||||
|
const msg = formatOpenCsError(e);
|
||||||
|
if (msg) toast(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wecomChatReady || (!isWeapp && wecomWebReady)) {
|
||||||
|
return (
|
||||||
|
<Button className={className} hoverClass="none" onClick={() => void openWecom()}>
|
||||||
|
{typeof children === 'string' ? <Text>{children}</Text> : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { RichText, Text } from '@tarojs/components';
|
||||||
|
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
|
||||||
|
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
|
||||||
|
|
||||||
|
type DeliveryHintHtmlProps = {
|
||||||
|
html?: string | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 承运商配送提示:HTML 用 RichText;textarea 里的换行转成 br */
|
||||||
|
export default function DeliveryHintHtml({
|
||||||
|
html,
|
||||||
|
className = '',
|
||||||
|
}: DeliveryHintHtmlProps) {
|
||||||
|
const raw = (html || '').trim() || DEFAULT_LOCAL_DELIVERY_HINT;
|
||||||
|
const nodes = deliveryHintHtmlToRichNodes(raw);
|
||||||
|
const looksHtml = /<[a-z][\s\S]*>/i.test(nodes);
|
||||||
|
if (!looksHtml) {
|
||||||
|
return <Text className={className}>{nodes}</Text>;
|
||||||
|
}
|
||||||
|
return <RichText className={className} nodes={nodes} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/* 首页开场:半透明底 + 酒瓶 + 分流光 + 文字渐显 */
|
||||||
|
|
||||||
|
.home-splash {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10010;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash--out {
|
||||||
|
animation: home-splash-out 0.8s ease-in forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-veil {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: rgba(20, 8, 8, 0.58);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% 48%,
|
||||||
|
rgba(166, 29, 36, 0.42) 0%,
|
||||||
|
rgba(20, 8, 8, 0.62) 62%,
|
||||||
|
rgba(10, 4, 4, 0.72) 100%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-fade 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-title {
|
||||||
|
font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: #f5d76e;
|
||||||
|
text-shadow: 0 0 14px rgba(255, 191, 0, 0.55), 0 2px 10px rgba(20, 8, 8, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-fade 1.1s 0.65s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-sub {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: rgba(255, 248, 210, 0.88);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-fade 1.1s 1.35s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 220px;
|
||||||
|
height: 420px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-fade 1s 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-glow {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
top: 18%;
|
||||||
|
bottom: 8%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(ellipse at 50% 50%, rgba(255, 191, 0, 0.34) 0%, rgba(255, 191, 0, 0) 72%);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-glow 2.6s 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-beam {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 46px;
|
||||||
|
margin-left: -23px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 248, 210, 0) 0%,
|
||||||
|
rgba(255, 248, 210, 0.16) 28%,
|
||||||
|
rgba(255, 191, 0, 0.22) 50%,
|
||||||
|
rgba(255, 248, 210, 0.1) 78%,
|
||||||
|
rgba(255, 248, 210, 0) 100%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-beam 3.2s 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-bottle {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-bottle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-lights {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 220px;
|
||||||
|
height: 420px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-mode: alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-sheen {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
top: -12%;
|
||||||
|
bottom: -12%;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-sheen--a {
|
||||||
|
width: 48px;
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 248, 210, 0.08) 28%,
|
||||||
|
rgba(255, 248, 210, 0.55) 50%,
|
||||||
|
rgba(255, 191, 0, 0.18) 72%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-70px) skewX(-22deg);
|
||||||
|
animation: home-splash-sheen-a 2.8s 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-sheen--b {
|
||||||
|
width: 22px;
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.42) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-50px) skewX(-18deg);
|
||||||
|
animation: home-splash-sheen-b 3.6s 2.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-skip {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(245, 215, 110, 0.45);
|
||||||
|
background: rgba(20, 8, 8, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
animation: home-splash-fade 0.6s 0.35s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-splash-skip-text {
|
||||||
|
color: rgba(255, 248, 210, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-beam {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-sheen-a {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-70px) skewX(-22deg);
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
82% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(250px) skewX(-22deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-splash-sheen-b {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-40px) skewX(-18deg);
|
||||||
|
}
|
||||||
|
22% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(240px) skewX(-18deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Image, Text, View } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import { HOME_SPLASH_BOTTLE_URL } from '@dukang/shared-types';
|
||||||
|
import { markHomeSplashPlayed } from '../lib/home-splash';
|
||||||
|
|
||||||
|
const SPLASH_NAV_BG = '#140808';
|
||||||
|
const HOME_NAV_BG = '#FAF9F7';
|
||||||
|
const FADE_AT_MS = 4800;
|
||||||
|
const FADE_MS = 800;
|
||||||
|
|
||||||
|
type HomeSplashProps = {
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applySplashChrome() {
|
||||||
|
try {
|
||||||
|
void Taro.hideTabBar({ animation: false });
|
||||||
|
} catch {
|
||||||
|
/* H5 无原生 TabBar */
|
||||||
|
}
|
||||||
|
void Taro.setNavigationBarColor({
|
||||||
|
frontColor: '#ffffff',
|
||||||
|
backgroundColor: SPLASH_NAV_BG,
|
||||||
|
animation: { duration: 200, timingFunc: 'easeIn' },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreChrome() {
|
||||||
|
try {
|
||||||
|
void Taro.showTabBar({ animation: false });
|
||||||
|
} catch {
|
||||||
|
/* H5 无原生 TabBar */
|
||||||
|
}
|
||||||
|
void Taro.setNavigationBarColor({
|
||||||
|
frontColor: '#000000',
|
||||||
|
backgroundColor: HOME_NAV_BG,
|
||||||
|
animation: { duration: 200, timingFunc: 'easeOut' },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeSplash({ onDone }: HomeSplashProps) {
|
||||||
|
const finishedRef = useRef(false);
|
||||||
|
const fadingRef = useRef(false);
|
||||||
|
const onDoneRef = useRef(onDone);
|
||||||
|
onDoneRef.current = onDone;
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
const finish = useCallback(() => {
|
||||||
|
if (finishedRef.current) return;
|
||||||
|
finishedRef.current = true;
|
||||||
|
restoreChrome();
|
||||||
|
onDoneRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginExit = useCallback(() => {
|
||||||
|
if (fadingRef.current || finishedRef.current) return;
|
||||||
|
fadingRef.current = true;
|
||||||
|
setLeaving(true);
|
||||||
|
setTimeout(finish, FADE_MS);
|
||||||
|
}, [finish]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markHomeSplashPlayed();
|
||||||
|
applySplashChrome();
|
||||||
|
const timer = setTimeout(beginExit, FADE_AT_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!finishedRef.current) restoreChrome();
|
||||||
|
};
|
||||||
|
}, [beginExit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`home-splash${leaving ? ' home-splash--out' : ''}`}
|
||||||
|
catchMove
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="home-splash-veil" />
|
||||||
|
|
||||||
|
<View className="home-splash-copy">
|
||||||
|
<Text className="home-splash-title">杜康好客</Text>
|
||||||
|
<Text className="home-splash-sub">购杜康好酒,赠好客权益</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="home-splash-stage">
|
||||||
|
<View className="home-splash-glow" />
|
||||||
|
<Image
|
||||||
|
className="home-splash-bottle"
|
||||||
|
src={HOME_SPLASH_BOTTLE_URL}
|
||||||
|
mode="aspectFit"
|
||||||
|
style={{ width: '220px', height: '420px' }}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
className="home-splash-lights"
|
||||||
|
style={{
|
||||||
|
width: '220px',
|
||||||
|
height: '420px',
|
||||||
|
WebkitMaskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
|
||||||
|
maskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
|
||||||
|
WebkitMaskSize: 'contain',
|
||||||
|
maskSize: 'contain',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
maskRepeat: 'no-repeat',
|
||||||
|
WebkitMaskPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="home-splash-beam" />
|
||||||
|
<View className="home-splash-sheen home-splash-sheen--a" />
|
||||||
|
<View className="home-splash-sheen home-splash-sheen--b" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="home-splash-skip" onClick={finish}>
|
||||||
|
<Text className="home-splash-skip-text">跳过</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, Input } from '@tarojs/components';
|
||||||
|
|
||||||
|
type OrderQtyControlsProps = {
|
||||||
|
value: number;
|
||||||
|
unitLabel: string;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_QTY = 999;
|
||||||
|
|
||||||
|
function parseQty(raw: string): number | null {
|
||||||
|
const n = parseInt(String(raw).replace(/\D/g, ''), 10);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.min(MAX_QTY, Math.max(1, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单数量:加减 + 手动输入,旁注单位(瓶/箱) */
|
||||||
|
export default function OrderQtyControls({ value, unitLabel, onChange }: OrderQtyControlsProps) {
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function current(): number {
|
||||||
|
return parseQty(draft) ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const next = parseQty(draft);
|
||||||
|
if (next == null) {
|
||||||
|
setDraft(String(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(String(next));
|
||||||
|
if (next !== value) onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="order-qty-row">
|
||||||
|
<Text>购买数量</Text>
|
||||||
|
<View className="order-qty-controls">
|
||||||
|
<View className="order-qty-btn" onClick={() => onChange(Math.max(1, current() - 1))}>
|
||||||
|
<Text>−</Text>
|
||||||
|
</View>
|
||||||
|
<Input
|
||||||
|
className="order-qty-input"
|
||||||
|
type="number"
|
||||||
|
maxlength={3}
|
||||||
|
value={draft}
|
||||||
|
onInput={(e) => setDraft(String(e.detail.value ?? ''))}
|
||||||
|
onBlur={commit}
|
||||||
|
onConfirm={commit}
|
||||||
|
/>
|
||||||
|
<Text className="order-qty-unit">{unitLabel}</Text>
|
||||||
|
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
||||||
|
<Text>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user