Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e8a12596 | |||
| 3d819dc10b | |||
| 8984465893 | |||
| 3f208d27a9 | |||
| fa69ced448 | |||
| 18c5abea79 | |||
| 5d4ed566de | |||
| 5fdddae41c | |||
| 22c0b03a47 | |||
| f2d03e2595 | |||
| 0c28c204b2 | |||
| e7f49a9639 | |||
| f941dcf072 | |||
| 3382d36a6c | |||
| 9b8e3f1347 | |||
| 1440a59fc8 | |||
| 11659484c6 | |||
| 0fb7ab7abb |
@@ -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 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
|
同城起购 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 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||||
| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
|
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
||||||
| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
|
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
||||||
| 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,4 +26,3 @@ 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 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
同城起购 2 瓶 / 跨城 6 瓶
|
||||||
核销:直接核销 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 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
同城起购 2 瓶 / 跨城 6 瓶
|
||||||
核销:直接核销 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
|
||||||
|
|||||||
@@ -46,78 +46,6 @@ body,
|
|||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 拖表头分割线改列宽 */
|
|
||||||
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-col-resize-handle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 10px;
|
|
||||||
cursor: col-resize;
|
|
||||||
z-index: 3;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-col-resize-handle:hover,
|
|
||||||
.admin-col-resizing .admin-col-resize-handle {
|
|
||||||
background: rgba(22, 119, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.admin-col-resizing,
|
|
||||||
body.admin-col-resizing * {
|
|
||||||
cursor: col-resize !important;
|
|
||||||
user-select: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
|
||||||
.admin-list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-header-left {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-header-desc {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-settings-slot {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-left: auto;
|
|
||||||
order: 99;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-settings-btn.ant-btn {
|
|
||||||
color: rgba(0, 0, 0, 0.55);
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-list-settings-btn.ant-btn:hover {
|
|
||||||
color: rgba(0, 0, 0, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 操作列保持可见 */
|
/* 操作列保持可见 */
|
||||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||||
@@ -153,23 +81,6 @@ body.admin-col-resizing * {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-primary-link {
|
|
||||||
display: inline;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-primary-link:hover {
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table-nowrap .ant-table-cell,
|
.admin-table-nowrap .ant-table-cell,
|
||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -308,40 +219,6 @@ 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.
@@ -1,36 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Space, Typography } from 'antd';
|
|
||||||
|
|
||||||
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
|
|
||||||
export function AdminListHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
settings,
|
|
||||||
}: {
|
|
||||||
title?: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
actions?: ReactNode;
|
|
||||||
settings?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="admin-list-header">
|
|
||||||
<div className="admin-list-header-left">
|
|
||||||
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
{title}
|
|
||||||
</Typography.Title>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
{description ? (
|
|
||||||
<div className="admin-list-header-desc">{description}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="admin-list-header-right">
|
|
||||||
{actions ? <Space wrap>{actions}</Space> : null}
|
|
||||||
{settings}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { MouseEvent, ReactNode } from 'react';
|
|
||||||
|
|
||||||
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
|
|
||||||
export function AdminPrimaryLink({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
onClick: (e?: MouseEvent) => void;
|
|
||||||
}) {
|
|
||||||
const empty = children == null || children === '';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-primary-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClick(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{empty ? '—' : children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,23 +11,10 @@ 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: '',
|
||||||
@@ -50,7 +37,6 @@ 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(() => {
|
||||||
@@ -61,33 +47,36 @@ 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) => applyServerPackages(data))
|
.then((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) => {
|
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
||||||
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);
|
||||||
@@ -119,19 +108,16 @@ 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 : [];
|
||||||
@@ -217,8 +203,20 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
dirtyRef.current = false;
|
setItems(
|
||||||
setItems(mapLiveRows(data.live));
|
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) {
|
} 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;
|
||||||
@@ -229,7 +227,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
saveIfLoaded: async (opts) => {
|
saveIfLoaded: async (opts) => {
|
||||||
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
|
if (loadingRef.current) return { skipped: true };
|
||||||
await save(opts);
|
await save(opts);
|
||||||
return { skipped: false };
|
return { skipped: false };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -173,9 +173,7 @@ export default function OrderTrackDrawer({
|
|||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={
|
description={
|
||||||
track?.queryError
|
track?.manualQueryUrl
|
||||||
? track.queryError
|
|
||||||
: track?.manualQueryUrl
|
|
||||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
: '暂无路由信息,请稍后刷新'
|
: '暂无路由信息,请稍后刷新'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { 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,9 +50,34 @@ function keyOf(ids: string[]) {
|
|||||||
return [...ids].sort().join('_');
|
return [...ids].sort().join('_');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
type Props = {
|
||||||
if (!initialSkus.length) {
|
productId: string;
|
||||||
return [
|
initialAttrs: SpecAttr[];
|
||||||
|
initialSkus: SkuRow[];
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
|
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||||
|
initialAttrs.length
|
||||||
|
? initialAttrs
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const [skus, setSkus] = useState<SkuRow[]>(
|
||||||
|
initialSkus.length
|
||||||
|
? initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}))
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
specValueIds: [],
|
specValueIds: [],
|
||||||
barcode69: '',
|
barcode69: '',
|
||||||
@@ -66,43 +91,11 @@ function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
imageUrl: '',
|
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 = {
|
|
||||||
productId: string;
|
|
||||||
initialAttrs: SpecAttr[];
|
|
||||||
initialSkus: SkuRow[];
|
|
||||||
onSaved: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
|
||||||
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
|
||||||
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
|
||||||
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() {
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import {
|
|||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
||||||
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';
|
||||||
@@ -32,15 +28,12 @@ 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 />,
|
||||||
@@ -51,17 +44,18 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
{
|
{
|
||||||
key: 'partners-group',
|
key: 'wecom-group',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '开城',
|
label: '企微机器人',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/cities', label: '城市' },
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
{ key: '/city-partners', label: '城市合伙人' },
|
{ key: '/wecom/pushes', 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 />,
|
||||||
@@ -75,6 +69,17 @@ 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 />,
|
||||||
@@ -116,20 +121,6 @@ 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: '/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 />,
|
||||||
@@ -140,6 +131,7 @@ 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',
|
||||||
@@ -156,9 +148,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 {
|
||||||
@@ -413,9 +405,7 @@ export default function AdminLayout() {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space>
|
<Space>
|
||||||
<span>{profile?.name || '—'}</span>
|
<span>{profile?.name || '—'}</span>
|
||||||
<span style={{ color: '#999' }}>
|
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
||||||
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
|
||||||
</span>
|
|
||||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ export function ListColumnPrefsProvider({
|
|||||||
`/admin/me/list-columns/${listKey}`,
|
`/admin/me/list-columns/${listKey}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }),
|
||||||
next
|
|
||||||
? { order: next.order, hidden: next.hidden, widths: next.widths }
|
|
||||||
: { reset: true },
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setPrefs(res.listColumnPrefs ?? {});
|
setPrefs(res.listColumnPrefs ?? {});
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ export type AdminOrderRow = {
|
|||||||
productName?: string;
|
productName?: string;
|
||||||
productSpec?: string;
|
productSpec?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
saleUnit?: string;
|
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
receiverProvince?: string;
|
receiverProvince?: string;
|
||||||
@@ -236,7 +235,5 @@ export type AdminOrderRow = {
|
|||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
logisticsCompany?: string | null;
|
logisticsCompany?: string | null;
|
||||||
manualQueryUrl?: string | null;
|
manualQueryUrl?: string | null;
|
||||||
/** 当次应付物流费(按瓶当量 × 承运商计价) */
|
|
||||||
logisticsFee?: number | null;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { MouseEvent, ReactNode } from 'react';
|
|
||||||
import type { ColumnType } from 'antd/es/table';
|
|
||||||
|
|
||||||
export const MIN_COL_WIDTH = 48;
|
|
||||||
export const MAX_COL_WIDTH = 960;
|
|
||||||
|
|
||||||
export function clampColWidth(n: number): number {
|
|
||||||
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function beginColumnResize(
|
|
||||||
e: MouseEvent,
|
|
||||||
startWidth: number,
|
|
||||||
onMove: (width: number) => void,
|
|
||||||
onEnd: (width: number) => void,
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const startX = e.clientX;
|
|
||||||
let latest = clampColWidth(startWidth);
|
|
||||||
let moved = false;
|
|
||||||
document.body.classList.add('admin-col-resizing');
|
|
||||||
|
|
||||||
const onMouseMove = (ev: globalThis.MouseEvent) => {
|
|
||||||
moved = true;
|
|
||||||
latest = clampColWidth(startWidth + ev.clientX - startX);
|
|
||||||
onMove(latest);
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
|
||||||
document.body.classList.remove('admin-col-resizing');
|
|
||||||
if (moved) onEnd(latest);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withResizeTitle<T>(
|
|
||||||
title: ColumnType<T>['title'],
|
|
||||||
onMouseDown: (e: MouseEvent) => void,
|
|
||||||
): ColumnType<T>['title'] {
|
|
||||||
const handle: ReactNode = (
|
|
||||||
<span
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-label="拖动调整列宽"
|
|
||||||
className="admin-col-resize-handle"
|
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (typeof title === 'function') {
|
|
||||||
return (props) => (
|
|
||||||
<>
|
|
||||||
{title(props)}
|
|
||||||
{handle}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{title}
|
|
||||||
{handle}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,6 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
|||||||
MANUAL: '手动',
|
MANUAL: '手动',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
|
||||||
LOCAL: '同城',
|
|
||||||
CROSS_CITY: '跨城',
|
|
||||||
ON_SITE_PICKUP: '现场提货',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
@@ -31,18 +25,9 @@ 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) */
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
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: '推广码',
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -55,20 +55,7 @@ export function applyColumnPrefs<T>(
|
|||||||
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||||
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||||
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||||
return [
|
return [...orderedKeys.map((k) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => c.col)];
|
||||||
...orderedKeys
|
|
||||||
.map((k) => {
|
|
||||||
const col = byKey.get(k);
|
|
||||||
if (!col) return null;
|
|
||||||
const w = pref?.widths?.[k];
|
|
||||||
return w != null ? { ...col, width: w } : col;
|
|
||||||
})
|
|
||||||
.filter((c): c is ColumnType<T> => !!c),
|
|
||||||
...actions.map((c) => {
|
|
||||||
const w = pref?.widths?.[c.key];
|
|
||||||
return w != null ? { ...c.col, width: w } : c.col;
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListColumnSettingItem = {
|
export type ListColumnSettingItem = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button, message } from 'antd';
|
import { Button, message } from 'antd';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
@@ -9,11 +9,9 @@ import {
|
|||||||
ACTIONS_COLUMN_KEY,
|
ACTIONS_COLUMN_KEY,
|
||||||
SERIAL_COLUMN_KEY,
|
SERIAL_COLUMN_KEY,
|
||||||
applyColumnPrefs,
|
applyColumnPrefs,
|
||||||
columnKey,
|
|
||||||
settingItems,
|
settingItems,
|
||||||
type ListColumnSettingItem,
|
type ListColumnSettingItem,
|
||||||
} from './list-column-prefs';
|
} from './list-column-prefs';
|
||||||
import { beginColumnResize, withResizeTitle } from './column-resize';
|
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -29,13 +27,7 @@ export function useAdminListColumns<T>(
|
|||||||
const { prefs, save } = useListColumnPrefs();
|
const { prefs, save } = useListColumnPrefs();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
|
||||||
const pref = prefs[listKey];
|
const pref = prefs[listKey];
|
||||||
const prefRef = useRef(pref);
|
|
||||||
prefRef.current = pref;
|
|
||||||
const localWidthsRef = useRef(localWidths);
|
|
||||||
localWidthsRef.current = localWidths;
|
|
||||||
const itemsRef = useRef<ListColumnSettingItem[]>([]);
|
|
||||||
|
|
||||||
const configured = useMemo(
|
const configured = useMemo(
|
||||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||||
@@ -46,7 +38,6 @@ export function useAdminListColumns<T>(
|
|||||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||||
[allColumns, pref],
|
[allColumns, pref],
|
||||||
);
|
);
|
||||||
itemsRef.current = items;
|
|
||||||
|
|
||||||
const serialCol: ColumnType<T> = useMemo(
|
const serialCol: ColumnType<T> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -59,30 +50,27 @@ export function useAdminListColumns<T>(
|
|||||||
[page, pageSize],
|
[page, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistPref = useCallback(
|
const columns = useMemo<ColumnsType<T>>(
|
||||||
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
() => [
|
||||||
if (!next) {
|
serialCol,
|
||||||
setLocalWidths({});
|
...configured.map((col) =>
|
||||||
await save(listKey, null);
|
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||||
return;
|
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||||
}
|
: col,
|
||||||
await save(listKey, next);
|
),
|
||||||
},
|
],
|
||||||
[listKey, save],
|
[configured, serialCol],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persist = useCallback(
|
async function persist(next: ListColumnSettingItem[] | null) {
|
||||||
async (next: ListColumnSettingItem[] | null) => {
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
await persistPref(null);
|
await save(listKey, null);
|
||||||
} else {
|
} else {
|
||||||
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
await save(listKey, {
|
||||||
await persistPref({
|
|
||||||
order: next.map((i) => i.key),
|
order: next.map((i) => i.key),
|
||||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||||
...(Object.keys(widths).length ? { widths } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -92,72 +80,12 @@ export function useAdminListColumns<T>(
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[persistPref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const persistWidth = useCallback(
|
|
||||||
async (key: string, width: number) => {
|
|
||||||
const current = prefRef.current;
|
|
||||||
const prevW = current?.widths?.[key];
|
|
||||||
if (prevW === width) return;
|
|
||||||
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
|
|
||||||
const hidden = current?.hidden ?? [];
|
|
||||||
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
|
|
||||||
try {
|
|
||||||
await persistPref({ order, hidden, widths });
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '列宽保存失败');
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[persistPref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<T>>(() => {
|
|
||||||
const merged: ColumnType<T>[] = [
|
|
||||||
serialCol,
|
|
||||||
...configured.map((col) =>
|
|
||||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
|
||||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
|
||||||
: col,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const widths = { ...(pref?.widths ?? {}), ...localWidths };
|
|
||||||
return merged.map((col, i) => {
|
|
||||||
const key = columnKey(col, i);
|
|
||||||
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
|
|
||||||
const prevHeader = col.onHeaderCell;
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
key,
|
|
||||||
width,
|
|
||||||
title: withResizeTitle(col.title, (e) => {
|
|
||||||
const th = (e.currentTarget as HTMLElement).closest('th');
|
|
||||||
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
|
|
||||||
beginColumnResize(
|
|
||||||
e,
|
|
||||||
startWidth,
|
|
||||||
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
|
|
||||||
(next) => void persistWidth(key, next),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
onHeaderCell: (column) => {
|
|
||||||
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
|
|
||||||
return {
|
|
||||||
...extra,
|
|
||||||
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
|
|
||||||
|
|
||||||
const settingsButton = (
|
const settingsButton = (
|
||||||
<span className="admin-list-settings-slot">
|
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
|
||||||
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
|
||||||
列设置
|
列设置
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const settingsModal = (
|
const settingsModal = (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -19,23 +17,11 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
|
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
type CouponOrder = {
|
|
||||||
id?: string;
|
|
||||||
orderNo?: string;
|
|
||||||
productName?: string | null;
|
|
||||||
productSpec?: string | null;
|
|
||||||
quantity?: number | null;
|
|
||||||
saleUnit?: string | null;
|
|
||||||
deliveryType?: string | null;
|
|
||||||
payAmount?: number | string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,43 +32,10 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceProduct: string;
|
sourceProduct: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { id?: string; userNo: string; phone: string | null };
|
user?: { userNo: string; phone: string | null };
|
||||||
order?: CouponOrder | null;
|
order?: { orderNo: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function saleUnitLabel(unit?: string | null) {
|
|
||||||
if (unit === 'BOX') return '箱';
|
|
||||||
if (unit === 'BOTTLE') return '瓶';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPayAmount(v?: number | string | null) {
|
|
||||||
if (v == null || v === '') return '';
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
|
|
||||||
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
|
|
||||||
const order = row.order;
|
|
||||||
if (!order?.orderNo && !order?.productName) {
|
|
||||||
return row.sourceProduct || '—';
|
|
||||||
}
|
|
||||||
const unit = saleUnitLabel(order.saleUnit);
|
|
||||||
const qty =
|
|
||||||
order.quantity != null ? `${order.quantity}${unit}` : '';
|
|
||||||
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
|
|
||||||
const amount = formatPayAmount(order.payAmount);
|
|
||||||
const parts = [
|
|
||||||
order.productName || row.sourceProduct,
|
|
||||||
order.productSpec,
|
|
||||||
qty,
|
|
||||||
delivery,
|
|
||||||
amount,
|
|
||||||
].filter((p) => p != null && String(p).trim() !== '');
|
|
||||||
return parts.join(' / ') || row.sourceProduct || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
type CouponRedeemRecord = {
|
type CouponRedeemRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
@@ -107,10 +60,11 @@ type CouponRedeemSummary = {
|
|||||||
type CouponDetail = Row & {
|
type CouponDetail = Row & {
|
||||||
redeemSummary?: CouponRedeemSummary | null;
|
redeemSummary?: CouponRedeemSummary | null;
|
||||||
redeemRecords?: CouponRedeemRecord[];
|
redeemRecords?: CouponRedeemRecord[];
|
||||||
|
user?: { userNo?: string; phone?: string | null };
|
||||||
|
order?: { orderNo?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
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>>({});
|
||||||
@@ -148,61 +102,20 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||||
title: '券号',
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||||
dataIndex: 'couponNo',
|
|
||||||
width: 200,
|
|
||||||
ellipsis: false,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '用户',
|
|
||||||
dataIndex: ['user', 'userNo'],
|
|
||||||
width: 120,
|
|
||||||
ellipsis: false,
|
|
||||||
render: (v: string | undefined, row) =>
|
|
||||||
row.user?.id ? (
|
|
||||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
v || '—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '订单',
|
title: '订单',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v: string | undefined) =>
|
render: (v) => v || '—',
|
||||||
v ? (
|
|
||||||
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{
|
{ title: '来源', dataIndex: 'sourceProduct' },
|
||||||
title: '来源',
|
|
||||||
dataIndex: 'sourceProduct',
|
|
||||||
width: 360,
|
|
||||||
ellipsis: false,
|
|
||||||
render: (_: string, row) => formatCouponSource(row),
|
|
||||||
},
|
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -249,15 +162,17 @@ export default function BenefitCouponsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
title="好客权益券"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
好客权益券
|
||||||
actions={
|
</Typography.Title>
|
||||||
|
<Space>
|
||||||
|
{settingsButton}
|
||||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||||
手动发放
|
手动发放
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Space>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<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="couponNo" label="券号">
|
<Form.Item name="couponNo" label="券号">
|
||||||
@@ -361,37 +276,15 @@ export default function BenefitCouponsPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">
|
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
||||||
{detail.user?.id ? (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
|
|
||||||
>
|
|
||||||
{detail.user?.userNo}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
(detail.user?.userNo ?? '—')
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联订单">
|
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||||
{detail.order?.orderNo ? (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{detail.order.orderNo}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
|
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
import { LEDGER_TYPE_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';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -42,7 +41,8 @@ export default function BenefitLedgersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="权益流水" settings={settingsButton} />
|
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||||
|
{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="userId" label="用户ID"><Input allowClear /></Form.Item>
|
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
@@ -34,50 +33,6 @@ 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;
|
||||||
@@ -98,7 +53,6 @@ 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;
|
||||||
@@ -118,7 +72,6 @@ function playCelebrateSfx(tier: AmountTier): () => void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
unduckBgm();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,40 +341,10 @@ 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());
|
||||||
@@ -493,22 +416,6 @@ 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();
|
||||||
@@ -517,8 +424,6 @@ 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);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -575,7 +480,6 @@ 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,7 +3,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Divider,
|
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -26,8 +25,6 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type WarehouseRow = {
|
type WarehouseRow = {
|
||||||
@@ -230,14 +227,7 @@ export default function CitiesPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{
|
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||||
title: '城市',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 100,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||||
@@ -328,11 +318,11 @@ export default function CitiesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="城市"
|
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={<Button type="primary" onClick={openCreateModal}>新建城市</Button>}
|
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
||||||
/>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||||
@@ -363,28 +353,8 @@ 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>
|
||||||
<Divider orientation="left">履约起购</Divider>
|
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></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="合伙人佣金合计上限 %"
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ 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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type SubRow = PartnerSubAccountRow;
|
type SubRow = PartnerSubAccountRow;
|
||||||
@@ -274,14 +272,7 @@ export default function CityPartnersPage() {
|
|||||||
|
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
{
|
},
|
||||||
title: '公司名',
|
|
||||||
dataIndex: 'companyName',
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '公司名', dataIndex: 'companyName', width: 140 },
|
{ title: '公司名', dataIndex: 'companyName', width: 140 },
|
||||||
{
|
{
|
||||||
title: '主账号姓名',
|
title: '主账号姓名',
|
||||||
@@ -351,10 +342,11 @@ export default function CityPartnersPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
<AdminListHeader
|
return (
|
||||||
title="城市合伙人"
|
<div>
|
||||||
settings={settingsButton}
|
{settingsModal}
|
||||||
actions={
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
城市合伙人
|
城市合伙人
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
@@ -372,8 +364,7 @@ export default function CityPartnersPage() {
|
|||||||
setCreateCityCode(undefined);
|
setCreateCityCode(undefined);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
}
|
>
|
||||||
/>
|
|
||||||
新建合伙人
|
新建合伙人
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -30,8 +31,6 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -233,14 +232,7 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ title: '仓库', dataIndex: 'name', width: 140 },
|
||||||
title: '仓库',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '地址', dataIndex: 'address', width: 180 },
|
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
@@ -316,10 +308,9 @@ export default function CityWarehousesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="仓库"
|
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -337,8 +328,7 @@ export default function CityWarehousesPage() {
|
|||||||
>
|
>
|
||||||
新增仓库
|
新增仓库
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
@@ -1,43 +1,14 @@
|
|||||||
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 {
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
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 { 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;
|
||||||
@@ -46,34 +17,17 @@ type Row = {
|
|||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
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>>({});
|
||||||
@@ -107,126 +61,25 @@ 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: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||||
title: '订单号',
|
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||||
dataIndex: ['order', 'orderNo'],
|
|
||||||
width: 170,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: '运费',
|
|
||||||
dataIndex: 'logisticsFee',
|
|
||||||
width: 90,
|
|
||||||
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={() => void openDetail(row)}>编辑</Button>
|
<Button type="link" size="small" 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);
|
||||||
|
}}>编辑</Button>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -234,21 +87,20 @@ 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} />
|
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||||
|
{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="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
|
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></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={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
@@ -265,63 +117,11 @@ 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="订单">{order?.orderNo || '—'}</Descriptions.Item>
|
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">
|
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||||
{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="运费">
|
|
||||||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="provider" label="provider" 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>
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import { downloadBase64File } from '../lib/exportExcel';
|
|||||||
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';
|
|
||||||
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||||
@@ -245,14 +243,7 @@ export default function DevPlanTasksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||||
{
|
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
||||||
title: '任务号',
|
|
||||||
dataIndex: 'taskNo',
|
|
||||||
width: 160,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -310,11 +301,12 @@ export default function DevPlanTasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
title="开发计划 · 任务列表"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
开发计划 · 任务列表
|
||||||
actions={
|
</Typography.Title>
|
||||||
<>
|
{settingsButton}
|
||||||
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
value={exportFormat}
|
value={exportFormat}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
@@ -341,9 +333,8 @@ export default function DevPlanTasksPage() {
|
|||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
新建任务
|
新建任务
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Space>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -22,8 +23,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -123,9 +122,7 @@ export default function DevPlanVersionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||||
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
|
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
||||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
) },
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -168,15 +165,15 @@ export default function DevPlanVersionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
title="开发计划 · 版本列表"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
开发计划 · 版本列表
|
||||||
actions={
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
新建版本
|
新建版本
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -4,16 +4,7 @@ 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';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -23,11 +14,8 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -71,28 +59,10 @@ export default function DomainEventsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||||
{
|
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
||||||
title: '类型',
|
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||||
dataIndex: 'eventType',
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
width: 130,
|
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||||
render: (v, r) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '关联', render: (_, r) => formatRef(r.refType, r.refId) },
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => zhCode(v) },
|
|
||||||
{
|
|
||||||
title: '摘要',
|
|
||||||
render: (_, r) => domainParamValue(r.param1Desc, r.param1) !== '—'
|
|
||||||
? domainParamValue(r.param1Desc, r.param1)
|
|
||||||
: (r.remark || '—'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -149,18 +119,11 @@ 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="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
|
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
||||||
<Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
|
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
||||||
{domainParamValue(detail.param1Desc, detail.param1)}
|
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
||||||
</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,11 +7,11 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -29,8 +29,6 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||||
@@ -69,10 +67,7 @@ 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(Array.isArray(res) ? res : []);
|
setRows(res);
|
||||||
} catch (e) {
|
|
||||||
setRows([]);
|
|
||||||
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -98,7 +93,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -127,7 +121,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -150,7 +143,6 @@ 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) {
|
||||||
@@ -183,25 +175,9 @@ 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 },
|
||||||
{
|
{ title: '名称', dataIndex: 'name' },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -247,23 +223,11 @@ export default function FulfillmentProvidersPage() {
|
|||||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 140,
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
|
||||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
<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>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -276,16 +240,20 @@ export default function FulfillmentProvidersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="仓配管理"
|
<div>
|
||||||
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
仓配管理
|
||||||
actions={
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
注册承运商
|
注册承运商
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -317,17 +285,6 @@ 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 }]}>
|
||||||
@@ -349,7 +306,6 @@ 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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { request, type HqProfile, type Paginated } from '../lib/api';
|
|||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -71,31 +69,7 @@ export default function HqAccountsPage() {
|
|||||||
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
title: '姓名',
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (v, row) =>
|
|
||||||
isSuperAdmin ? (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => {
|
|
||||||
setDetail(row);
|
|
||||||
editForm.setFieldsValue({
|
|
||||||
name: row.name,
|
|
||||||
phone: row.phone,
|
|
||||||
loginName: row.loginName,
|
|
||||||
adminRole: row.adminRole,
|
|
||||||
status: row.status,
|
|
||||||
cityIds: row.cityIds ?? [],
|
|
||||||
});
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
v
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
@@ -146,11 +120,10 @@ export default function HqAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="HQ 账户"
|
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={
|
{isSuperAdmin && (
|
||||||
isSuperAdmin ? (
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -161,9 +134,8 @@ export default function HqAccountsPage() {
|
|||||||
>
|
>
|
||||||
新建账户
|
新建账户
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
)}
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ 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';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -73,19 +70,9 @@ export default function HqLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '行为',
|
title: '行为',
|
||||||
dataIndex: 'actionLabel',
|
dataIndex: 'actionLabel',
|
||||||
render: (v, r) => (
|
width: 160,
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
|
|
||||||
setDetail(res);
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
|
},
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
@@ -111,11 +98,11 @@ export default function HqLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminListHeader
|
{settingsModal}
|
||||||
title="HQ 操作日志"
|
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
<Typography.Paragraph type="secondary">
|
||||||
/>
|
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -144,7 +131,7 @@ export default function HqLogsPage() {
|
|||||||
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Input allowClear placeholder="对象编码" style={{ width: 140 }} />
|
<Form.Item name="refType" label="对象类型">
|
||||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
@@ -193,12 +180,12 @@ export default function HqLogsPage() {
|
|||||||
primary={detail.hqName}
|
primary={detail.hqName}
|
||||||
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 label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="行为">
|
<Descriptions.Item label="行为">
|
||||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||||
<Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
Upload,
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -30,8 +31,6 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -205,13 +204,7 @@ export default function InvoicesPage() {
|
|||||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||||
: '—',
|
: '—',
|
||||||
},
|
},
|
||||||
{
|
{ title: '名称', dataIndex: 'titleName' },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'titleName',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -241,10 +234,18 @@ export default function InvoicesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div
|
||||||
title="发票管理"
|
style={{
|
||||||
settings={settingsButton}
|
display: 'flex',
|
||||||
actions={
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
发票管理
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -258,8 +259,7 @@ export default function InvoicesPage() {
|
|||||||
>
|
>
|
||||||
创建发票申请
|
创建发票申请
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -129,13 +127,7 @@ export default function KnowledgeBasesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||||
{
|
{ title: '名称', dataIndex: 'name' },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '说明', dataIndex: 'description' },
|
{ title: '说明', dataIndex: 'description' },
|
||||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||||
{
|
{
|
||||||
@@ -265,11 +257,15 @@ export default function KnowledgeBasesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
title="知识库"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
知识库
|
||||||
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
</Typography.Title>
|
||||||
/>
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -158,13 +156,7 @@ export default function LlmConfigsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||||
{
|
{ title: '名称', dataIndex: 'name' },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '提供商',
|
title: '提供商',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
@@ -272,11 +264,15 @@ export default function LlmConfigsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
title="语言模型配置"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
语言模型配置
|
||||||
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
</Typography.Title>
|
||||||
/>
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
type BillRow = {
|
type BillRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -248,14 +246,7 @@ export default function LogisticsBillsPage() {
|
|||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||||
|
|
||||||
const billColumns: ColumnsType<BillRow> = [
|
const billColumns: ColumnsType<BillRow> = [
|
||||||
{
|
{ title: '账单号', dataIndex: 'billNo', width: 170 },
|
||||||
title: '账单号',
|
|
||||||
dataIndex: 'billNo',
|
|
||||||
width: 170,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '承运商',
|
title: '承运商',
|
||||||
width: 140,
|
width: 140,
|
||||||
@@ -406,11 +397,15 @@ export default function LogisticsBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
title="物流对账"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
物流对账
|
||||||
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
</Typography.Title>
|
||||||
/>
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,19 +27,15 @@ import dayjs, { type Dayjs } from 'dayjs';
|
|||||||
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';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
import { downloadBase64File } from '../lib/exportExcel';
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
DELIVERY_TYPE_LABELS,
|
|
||||||
ORDER_STATUS_COLORS,
|
ORDER_STATUS_COLORS,
|
||||||
ORDER_STATUS_LABELS,
|
ORDER_STATUS_LABELS,
|
||||||
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';
|
||||||
@@ -146,7 +142,7 @@ type OrderExportFormat = 'xlsx' | 'pdf';
|
|||||||
|
|
||||||
type OrderExportFilters = {
|
type OrderExportFilters = {
|
||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
status?: string | string[];
|
status?: string;
|
||||||
orderType?: string;
|
orderType?: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
receiverPhone?: string;
|
receiverPhone?: string;
|
||||||
@@ -221,12 +217,6 @@ function formatBenefitBrief(row: AdminOrderRow) {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedStatuses(status: unknown): string[] {
|
|
||||||
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
|
|
||||||
if (typeof status === 'string' && status) return [status];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExportPayload(
|
function buildExportPayload(
|
||||||
scope: OrderExportScope,
|
scope: OrderExportScope,
|
||||||
format: OrderExportFormat,
|
format: OrderExportFormat,
|
||||||
@@ -239,8 +229,7 @@ function buildExportPayload(
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
||||||
const statuses = selectedStatuses(filters.status);
|
if (filters.status) payload.status = filters.status;
|
||||||
if (statuses.length) payload.status = statuses;
|
|
||||||
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;
|
||||||
@@ -284,7 +273,6 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -366,7 +354,7 @@ 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);
|
if (values.status) qs.set('status', values.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);
|
||||||
@@ -604,106 +592,64 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<AdminOrderRow> = [
|
const baseColumns: ColumnsType<AdminOrderRow> = [
|
||||||
{
|
|
||||||
title: '订单号',
|
|
||||||
dataIndex: 'orderNo',
|
|
||||||
width: 180,
|
|
||||||
render: (v, row) => (
|
|
||||||
<Space size={4}>
|
|
||||||
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '用户',
|
|
||||||
key: 'user',
|
|
||||||
width: 120,
|
|
||||||
render: (_, row) =>
|
|
||||||
row.user?.id ? (
|
|
||||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
|
||||||
{row.user.userNo}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
) : (
|
|
||||||
(row.user?.userNo || '—')
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '商品',
|
title: '商品',
|
||||||
key: 'productName',
|
width: 240,
|
||||||
width: 180,
|
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
|
<div>
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<span>{row.productName || '—'}</span>
|
<span>{row.productName || '—'}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
||||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
<Tag color="purple">代下单</Tag>
|
<Tag color="purple">代下单</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '规格',
|
title: '状态 / 实付',
|
||||||
dataIndex: 'productSpec',
|
width: 130,
|
||||||
width: 140,
|
render: (_, row) => (
|
||||||
render: (v: string | undefined) => v || '—',
|
<div>
|
||||||
},
|
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
|
||||||
{
|
{ORDER_STATUS_LABELS[row.status] || row.status}
|
||||||
title: '数量',
|
</Tag>
|
||||||
dataIndex: 'quantity',
|
<div>¥{row.payAmount}</div>
|
||||||
width: 80,
|
</div>
|
||||||
render: (v: number | undefined, row) =>
|
|
||||||
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '配送方式',
|
|
||||||
dataIndex: 'deliveryType',
|
|
||||||
width: 100,
|
|
||||||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (s: string) => (
|
|
||||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '实付',
|
|
||||||
dataIndex: 'payAmount',
|
|
||||||
width: 90,
|
|
||||||
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,
|
||||||
render: (_, row) => formatBenefitBrief(row),
|
render: (_, row) => formatBenefitBrief(row),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收货人',
|
title: '收货信息',
|
||||||
dataIndex: 'receiverName',
|
width: 240,
|
||||||
width: 90,
|
render: (_, row) => {
|
||||||
render: (v: string | undefined) => v || '—',
|
const address = formatReceiverAddress(row);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{row.receiverName || '—'} {row.receiverPhone || ''}
|
||||||
|
</div>
|
||||||
|
{address ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{address}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '电话',
|
|
||||||
dataIndex: 'receiverPhone',
|
|
||||||
width: 120,
|
|
||||||
render: (v: string | undefined) => v || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '地址',
|
|
||||||
key: 'receiverAddress',
|
|
||||||
width: 260,
|
|
||||||
render: (_, row) => formatReceiverAddress(row) || '—',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '下单时间',
|
title: '下单时间',
|
||||||
@@ -749,20 +695,18 @@ export default function OrdersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="订单监控"
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
settings={settingsButton}
|
<Space size={12}>
|
||||||
actions={
|
{settingsButton}
|
||||||
<>
|
|
||||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||||
{canProxyOrder ? (
|
{canProxyOrder ? (
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
代下单
|
代下单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</Space>
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
|
|
||||||
{!canDeleteOrders && profile ? (
|
{!canDeleteOrders && profile ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -800,12 +744,10 @@ export default function OrdersPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={6}>
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
|
||||||
allowClear
|
allowClear
|
||||||
maxTagCount="responsive"
|
|
||||||
placeholder="全部"
|
placeholder="全部"
|
||||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
@@ -1166,11 +1108,6 @@ 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">
|
||||||
@@ -1211,7 +1148,7 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
render: (v) => zhRemark(v),
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
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';
|
|
||||||
|
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
@@ -177,13 +176,14 @@ export default function PartnerAccountsPage() {
|
|||||||
title: '姓名 / 类型',
|
title: '姓名 / 类型',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<span className="admin-cell-line">
|
<AdminCellLine
|
||||||
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
|
primary={row.name}
|
||||||
<span className="admin-cell-line-secondary">
|
secondary={
|
||||||
{' · '}
|
row.parentAccountId
|
||||||
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
|
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||||
</span>
|
: '主账号'
|
||||||
</span>
|
}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
@@ -262,11 +262,15 @@ export default function PartnerAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="合伙人子账号"
|
<div>
|
||||||
settings={settingsButton}
|
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||||
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
|
{settingsButton}
|
||||||
/>
|
<Typography.Text type="secondary">
|
||||||
|
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -249,14 +247,7 @@ export default function PartnerBillsPage() {
|
|||||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||||
title: '账单号',
|
|
||||||
dataIndex: 'billNo',
|
|
||||||
width: 180,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 160,
|
width: 160,
|
||||||
@@ -350,11 +341,15 @@ export default function PartnerBillsPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
<AdminListHeader
|
return (
|
||||||
title="合伙人账单"
|
<div>
|
||||||
settings={settingsButton}
|
{settingsModal}
|
||||||
description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
/>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
合伙人账单
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -106,28 +105,14 @@ export default function PartnerLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
<Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
|
width: 100,
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '事件',
|
|
||||||
dataIndex: 'eventName',
|
|
||||||
width: 180,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{eventNameLabel(v)}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
render: (v: PartnerLogCategory | null, r) => (
|
render: (v: PartnerLogCategory | null, r) => (
|
||||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||||
{
|
{
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
@@ -156,7 +141,10 @@ export default function PartnerLogsPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
<AdminListHeader title="合伙人日志" settings={settingsButton} />
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||||
合伙人日志
|
合伙人日志
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
@@ -187,11 +175,11 @@ export default function PartnerLogsPage() {
|
|||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" 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="companyName" label="公司">
|
||||||
<Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
|
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="eventName" label="事件编码">
|
<Form.Item name="partnerId" label="合伙人ID">
|
||||||
<Input placeholder="事件编码" allowClear style={{ width: 160 }} />
|
<Input placeholder="partnerId" 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="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||||
@@ -236,19 +224,11 @@ export default function PartnerLogsPage() {
|
|||||||
}}
|
}}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
<Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
|
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||||
<Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
|
{detail && (
|
||||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
|
<Descriptions column={1} bordered size="small">
|
||||||
<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>
|
|
||||||
{Object.entries(detail).map(([k, v]) => (
|
{Object.entries(detail).map(([k, v]) => (
|
||||||
|
<Descriptions.Item key={k} label={k}>
|
||||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ 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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -186,13 +184,7 @@ export default function PartnersPage() {
|
|||||||
|
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
{
|
},
|
||||||
title: '公司名',
|
|
||||||
dataIndex: 'companyName',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '公司名', dataIndex: 'companyName' },
|
{ title: '公司名', dataIndex: 'companyName' },
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
@@ -219,15 +211,13 @@ export default function PartnersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminListHeader
|
{settingsModal}
|
||||||
title="开城合伙人"
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
settings={settingsButton}
|
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||||
actions={
|
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||||
新建城市合伙人
|
新建城市合伙人
|
||||||
}
|
</Button>
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||||
@@ -99,14 +97,7 @@ export default function PendingRedeemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||||
{
|
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||||
title: '待处理单号',
|
|
||||||
dataIndex: 'pendingNo',
|
|
||||||
width: 170,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '核销码 ID',
|
title: '核销码 ID',
|
||||||
dataIndex: 'redeemToken',
|
dataIndex: 'redeemToken',
|
||||||
@@ -147,7 +138,8 @@ export default function PendingRedeemPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
|
{settingsModal}
|
||||||
|
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
|
|||||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = ProductDetailTemplateDto;
|
type Row = ProductDetailTemplateDto;
|
||||||
@@ -141,23 +140,7 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||||
{
|
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 120,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue(mapToForm(d));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '说明', dataIndex: 'description', width: 200 },
|
{ title: '说明', dataIndex: 'description', width: 200 },
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
||||||
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePick
|
|||||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
@@ -393,14 +391,7 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||||
{ title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
|
{ title: '品名', dataIndex: 'name', width: 200 },
|
||||||
<AdminPrimaryLink onClick={async () => {
|
|
||||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
|
||||||
setDetail(d);
|
|
||||||
editForm.setFieldsValue(mapDetailToForm(d));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}>{v}</AdminPrimaryLink>
|
|
||||||
) },
|
|
||||||
{
|
{
|
||||||
title: '累计销售',
|
title: '累计销售',
|
||||||
dataIndex: 'soldBottles',
|
dataIndex: 'soldBottles',
|
||||||
@@ -511,11 +502,11 @@ export default function ProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="商品管理"
|
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>}
|
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||||
/>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称">
|
<Form.Item name="name" label="名称">
|
||||||
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||||
@@ -610,7 +601,6 @@ 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) ?? []}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = PromoCodeItem;
|
type Row = PromoCodeItem;
|
||||||
@@ -79,14 +77,7 @@ export default function PromoCodesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 160,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||||
{
|
{
|
||||||
title: '场景',
|
title: '场景',
|
||||||
@@ -176,11 +167,11 @@ export default function PromoCodesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
title="推广码管理"
|
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>}
|
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -86,7 +84,7 @@ export default function RedeemRecordsPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<span>
|
<span>
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
{v}
|
||||||
{row.isTest ? (
|
{row.isTest ? (
|
||||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||||
测试
|
测试
|
||||||
@@ -142,7 +140,8 @@ export default function RedeemRecordsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="核销记录" settings={settingsButton} />
|
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
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';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -122,11 +121,15 @@ export default function ResourcesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="OSS 资源库"
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
settings={settingsButton}
|
OSS 资源库
|
||||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>登记资源</Button>}
|
</Typography.Title>
|
||||||
/>
|
{settingsButton}
|
||||||
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
登记资源
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
@@ -85,14 +83,7 @@ export default function StoreAccountsPage() {
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<AdminPrimaryLink
|
<span>{v}</span>
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -154,11 +145,14 @@ export default function StoreAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="门店账户"
|
<Space direction="vertical" size={0}>
|
||||||
settings={settingsButton}
|
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||||
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
{settingsButton}
|
||||||
actions={
|
<Typography.Text type="secondary">
|
||||||
|
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -168,8 +162,7 @@ export default function StoreAccountsPage() {
|
|||||||
>
|
>
|
||||||
新建账户
|
新建账户
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -288,7 +286,7 @@ export default function StoreBillsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
|
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
<Space>
|
||||||
<span>{v}</span>
|
<span>{v}</span>
|
||||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -382,23 +380,21 @@ export default function StoreBillsPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
<AdminListHeader
|
return (
|
||||||
title="门店账单"
|
<div>
|
||||||
settings={settingsButton}
|
{settingsModal}
|
||||||
description={
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
<>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
门店账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||||
<div>
|
|
||||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
</div>
|
|
||||||
<Typography.Text type="danger">
|
<Typography.Text type="danger">
|
||||||
</>
|
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type CategoryNode = {
|
type CategoryNode = {
|
||||||
@@ -132,7 +130,7 @@ export default function StoreCategoriesPage() {
|
|||||||
render: (name, row) => (
|
render: (name, row) => (
|
||||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
{name}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -159,20 +157,9 @@ export default function StoreCategoriesPage() {
|
|||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||||
const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
|
message.success('已处理');
|
||||||
`/admin/store-categories/${row.id}`,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
);
|
|
||||||
if (res.softDisabled) {
|
|
||||||
message.success('该分类已有门店占用,已改为停用');
|
|
||||||
} else {
|
|
||||||
message.success('已删除');
|
|
||||||
}
|
|
||||||
void reload();
|
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>
|
||||||
@@ -191,12 +178,13 @@ export default function StoreCategoriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="门店分类"
|
<Space direction="vertical" size={0}>
|
||||||
settings={settingsButton}
|
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||||
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
|
{settingsButton}
|
||||||
actions={
|
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||||
<>
|
</Space>
|
||||||
|
<Space>
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -209,9 +197,8 @@ export default function StoreCategoriesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||||
</>
|
</Space>
|
||||||
}
|
</Space>
|
||||||
/>
|
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -101,33 +100,17 @@ 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>{storeEventCategoryLabel(r.eventName, v)}</Tag>
|
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '事件',
|
|
||||||
dataIndex: 'eventName',
|
|
||||||
width: 160,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
const parsed = parseCompositeId(row.id);
|
|
||||||
if (!parsed) return;
|
|
||||||
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{eventNameLabel(v)}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||||
{
|
{
|
||||||
title: '来源', dataIndex: 'source', width: 100,
|
title: '来源', dataIndex: 'source', width: 100,
|
||||||
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -151,11 +134,11 @@ export default function StoreLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||||
title="商户日志"
|
{settingsButton}
|
||||||
settings={settingsButton}
|
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||||
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
|
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||||
/>
|
</Typography.Paragraph>
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
@@ -183,7 +166,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="事件编码" /></Form.Item>
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></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>
|
||||||
@@ -197,12 +180,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="分类">
|
||||||
{storeEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}
|
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
<Descriptions.Item label="事件">{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="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</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' }}>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/cons
|
|||||||
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';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -45,22 +43,7 @@ export default function StoreMediaPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||||
title: '门店',
|
|
||||||
dataIndex: ['store', 'name'],
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(row);
|
|
||||||
editForm.setFieldsValue(row);
|
|
||||||
setEditOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||||
{
|
{
|
||||||
title: '预览', dataIndex: 'url', width: 100,
|
title: '预览', dataIndex: 'url', width: 100,
|
||||||
@@ -97,11 +80,11 @@ export default function StoreMediaPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="门店资源"
|
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
||||||
settings={settingsButton}
|
{settingsButton}
|
||||||
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>}
|
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||||
/>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="mediaType" label="类型">
|
<Form.Item name="mediaType" label="类型">
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import { notifyPackageAuditChanged } from '../lib/admin-events';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
PENDING: '待审核',
|
PENDING: '待审核',
|
||||||
@@ -201,15 +199,7 @@ function InfoChangeAuditPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
||||||
{
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
title: '门店',
|
|
||||||
dataIndex: 'storeName',
|
|
||||||
render: (_, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
|
||||||
{row.storeName || row.storeId}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -481,15 +471,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
title: '门店',
|
|
||||||
dataIndex: 'storeName',
|
|
||||||
render: (_, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
|
||||||
{row.storeName || row.storeId}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -546,7 +528,8 @@ export default function StorePackageAuditsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="审核通知" settings={settingsButton} />
|
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -70,7 +69,10 @@ export default function StoreRatingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="门店评价" settings={settingsButton} />
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
门店评价
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -158,7 +156,7 @@ export default function StoreWithdrawalsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
||||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -238,7 +236,10 @@ export default function StoreWithdrawalsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="门店提现审" settings={settingsButton} />
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
门店提现审
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
{overdueSummary ? (
|
{overdueSummary ? (
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
待审 {overdueSummary.pendingCount} 笔
|
待审 {overdueSummary.pendingCount} 笔
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ import {
|
|||||||
} from '../lib/storeCreate';
|
} from '../lib/storeCreate';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
@@ -741,7 +739,7 @@ export default function StoresPage() {
|
|||||||
const name = v || '—';
|
const name = v || '—';
|
||||||
return (
|
return (
|
||||||
<Space size={4} wrap={false}>
|
<Space size={4} wrap={false}>
|
||||||
<AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
|
<span>{name}</span>
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
@@ -874,14 +872,16 @@ export default function StoresPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="门店"
|
<Space direction="vertical" size={0}>
|
||||||
settings={settingsButton}
|
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||||
description={`共 ${data?.total ?? 0} 家门店(含合伙人录入)`}
|
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||||
actions={
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{settingsButton}
|
||||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||||
}
|
</Space>
|
||||||
/>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||||
@@ -506,13 +505,7 @@ export default function SupportTicketsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ title: '标题', dataIndex: 'title' },
|
||||||
title: '标题',
|
|
||||||
dataIndex: 'title',
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||||
|
|
||||||
@@ -256,22 +254,7 @@ export default function TestWhitelistPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||||
{
|
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||||
title: '手机号',
|
|
||||||
dataIndex: 'phone',
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={() => {
|
|
||||||
setEditRow(row);
|
|
||||||
editForm.setFieldsValue({ note: row.note ?? '' });
|
|
||||||
setEditOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'note',
|
dataIndex: 'note',
|
||||||
@@ -411,7 +394,10 @@ export default function TestWhitelistPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="白名单管理" settings={settingsButton} />
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
白名单管理
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
|
|
||||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -3,17 +3,8 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -33,14 +24,14 @@ type Row = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_OPTIONS = [
|
const PROVIDER_OPTIONS = [
|
||||||
{ value: 'WECHAT_AUTH', label: '微信授权' },
|
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
|
||||||
{ value: 'WECHAT_PAY', label: '微信支付' },
|
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
|
||||||
{ value: 'WECHAT_MAP', label: '微信/腾讯地图' },
|
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
||||||
{ value: 'ALIYUN_OSS', label: '阿里云 OSS' },
|
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
|
||||||
{ value: 'ALIYUN_SMS', label: '阿里云短信' },
|
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
||||||
{ value: 'MOCK_SMS', label: 'Mock 短信' },
|
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
||||||
{ value: 'XFX', label: '小飞侠' },
|
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
||||||
{ value: 'LOGISTICS', label: '物流快递' },
|
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
@@ -82,18 +73,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: '渠道', dataIndex: 'provider', width: 120, render: (v: string) => zhLabel(THIRD_PARTY_PROVIDER_LABELS, v) },
|
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||||
{ title: '场景', dataIndex: 'scene', width: 140, render: (v: string) => zhLabel(THIRD_PARTY_SCENE_LABELS, v) },
|
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{zhLabel(THIRD_PARTY_STATUS_LABELS, v)}</Tag>,
|
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{v}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '错误信息',
|
title: '错误信息',
|
||||||
@@ -104,21 +95,7 @@ export default function ThirdPartyLogsPage() {
|
|||||||
title: '请求摘要',
|
title: '请求摘要',
|
||||||
render: (_, r) => summarizeJson(r.requestBody),
|
render: (_, r) => summarizeJson(r.requestBody),
|
||||||
},
|
},
|
||||||
{
|
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
|
||||||
title: '外部单号',
|
|
||||||
dataIndex: 'externalNo',
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -145,7 +122,8 @@ export default function ThirdPartyLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="第三方日志" settings={settingsButton} />
|
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
@@ -154,11 +132,11 @@ export default function ThirdPartyLogsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="provider" label="渠道">
|
<Form.Item name="provider" label="Provider">
|
||||||
<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="场景编码" style={{ width: 160 }} />
|
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" 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 }} />
|
||||||
@@ -188,15 +166,13 @@ 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="渠道">{zhLabel(THIRD_PARTY_PROVIDER_LABELS, String(detail.provider))}</Descriptions.Item>
|
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="场景">{zhLabel(THIRD_PARTY_SCENE_LABELS, String(detail.scene))}</Descriptions.Item>
|
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>
|
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>{String(detail.status)}</Tag>
|
||||||
{zhLabel(THIRD_PARTY_STATUS_LABELS, String(detail.status))}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">
|
<Descriptions.Item label="关联">
|
||||||
{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}
|
{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}
|
||||||
</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>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -119,21 +118,7 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
title: '工单号',
|
|
||||||
dataIndex: 'ticketNo',
|
|
||||||
width: 180,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'ticketType',
|
dataIndex: 'ticketType',
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
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, type UserLogCategory } from '../lib/user-log';
|
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -28,6 +25,8 @@ 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);
|
||||||
@@ -78,30 +77,13 @@ 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>{userEventCategoryLabel(r.eventName, v)}</Tag>
|
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||||
title: '事件',
|
|
||||||
dataIndex: 'eventName',
|
|
||||||
width: 160,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/logs/users/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{eventNameLabel(v)}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '端', dataIndex: 'clientApp', width: 110, render: (v) => clientAppLabel(v),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => formatRef(r.refType, r.refId),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
@@ -123,7 +105,8 @@ export default function UserLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader title="用户日志" settings={settingsButton} />
|
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
@@ -150,7 +133,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="事件编码" /></Form.Item>
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></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>
|
||||||
@@ -162,10 +145,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="分类">{userEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</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' }}>
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ 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, type UserSourceType } from '@dukang/shared-types';
|
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
|
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -335,9 +332,7 @@ export default function UsersPage() {
|
|||||||
title: '昵称',
|
title: '昵称',
|
||||||
dataIndex: 'nickname',
|
dataIndex: 'nickname',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v: string | null, row) => (
|
render: (v: string | null) => v || '—',
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
@@ -483,11 +478,11 @@ export default function UsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
title="用户监控"
|
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||||
settings={settingsButton}
|
<Space>
|
||||||
actions={
|
{settingsButton}
|
||||||
canDeleteUsers ? (
|
{canDeleteUsers ? (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
disabled={!selectedRowKeys.length}
|
disabled={!selectedRowKeys.length}
|
||||||
@@ -495,9 +490,9 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null}
|
||||||
}
|
</Space>
|
||||||
/>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" label="手机号">
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
@@ -641,10 +636,10 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => userEventCategoryLabel(r.eventName),
|
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
|
{ title: '事件', dataIndex: 'eventName' },
|
||||||
{ title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
|
{ title: '端', dataIndex: 'clientApp', width: 90 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime, maskPhone } from '../lib/constants';
|
import { fmtTime, maskPhone } 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
@@ -122,9 +120,7 @@ export default function WechatBindingsPage() {
|
|||||||
{
|
{
|
||||||
title: '手机号',
|
title: '手机号',
|
||||||
dataIndex: 'primaryPhone',
|
dataIndex: 'primaryPhone',
|
||||||
render: (v, row) => (
|
width: 140,
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
render: (v) => maskPhone(v),
|
render: (v) => maskPhone(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,11 +207,11 @@ export default function WechatBindingsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
<AdminListHeader
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||||
title="微信绑定总览"
|
|
||||||
settings={settingsButton}
|
return (
|
||||||
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
|
<div>
|
||||||
/>
|
{settingsModal}
|
||||||
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
export default function WecomBotLogsPage() {
|
export default function WecomBotLogsPage() {
|
||||||
@@ -31,17 +28,10 @@ export default function WecomBotLogsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{ title: '机器人', dataIndex: 'botName', width: 120, render: (v, r) => v || r.botKey || '—' },
|
||||||
title: '机器人',
|
|
||||||
dataIndex: 'botName',
|
|
||||||
width: 120,
|
|
||||||
render: (v, r) => (
|
|
||||||
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||||
{ title: '动作', dataIndex: 'action', width: 180, render: (v: string) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||||
{ title: '权限', dataIndex: 'permission', width: 160, render: (v) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '结果',
|
title: '结果',
|
||||||
dataIndex: 'success',
|
dataIndex: 'success',
|
||||||
@@ -66,11 +56,11 @@ export default function WecomBotLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
||||||
title="企微机器人日志"
|
{settingsButton}
|
||||||
settings={settingsButton}
|
<Typography.Paragraph type="secondary">
|
||||||
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
|
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
||||||
/>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -82,13 +72,13 @@ export default function WecomBotLogsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="botId" label="机器人 ID">
|
<Form.Item name="botId" label="机器人 ID">
|
||||||
<Input allowClear placeholder="机器人 ID" style={{ width: 140 }} />
|
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="wecomUserId" label="企微用户">
|
<Form.Item name="wecomUserId" label="企微 userid">
|
||||||
<Input allowClear placeholder="企微用户 ID" style={{ width: 140 }} />
|
<Input allowClear placeholder="userid" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="action" label="动作">
|
<Form.Item name="action" label="动作">
|
||||||
<Input allowClear placeholder="动作编码" style={{ width: 140 }} />
|
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="success" label="结果">
|
<Form.Item name="success" label="结果">
|
||||||
<Select
|
<Select
|
||||||
@@ -131,8 +121,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="动作">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.action)}</Descriptions.Item>
|
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
|
||||||
<Descriptions.Item label="权限">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.permission)}</Descriptions.Item>
|
<Descriptions.Item label="权限">{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>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -294,14 +293,7 @@ export default function WecomBotsPage() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ title: '名称', dataIndex: 'name', width: 140 },
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 140,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ 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';
|
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -204,7 +202,7 @@ function PushRoutesTab() {
|
|||||||
render: (name: string, row) => (
|
render: (name: string, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
<span>{name}</span>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -273,15 +271,9 @@ function PushRoutesTab() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<Typography.Paragraph type="secondary">
|
||||||
settings={settingsButton}
|
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||||||
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
|
</Typography.Paragraph>
|
||||||
actions={
|
|
||||||
<Button type="primary" onClick={openCreate}>
|
|
||||||
新建推送
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
@@ -316,6 +308,10 @@ function PushRoutesTab() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建推送
|
||||||
|
</Button>
|
||||||
|
{settingsButton}
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
|||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -240,14 +238,7 @@ export default function WineryBillsPage() {
|
|||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{ title: '账单号', dataIndex: 'billNo', width: 170 },
|
||||||
title: '账单号',
|
|
||||||
dataIndex: 'billNo',
|
|
||||||
width: 170,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '账单日',
|
title: '账单日',
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'billDate',
|
||||||
@@ -306,18 +297,31 @@ export default function WineryBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<div
|
||||||
title="酒厂对账单"
|
style={{
|
||||||
settings={settingsButton}
|
display: 'flex',
|
||||||
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
justifyContent: 'space-between',
|
||||||
actions={
|
alignItems: 'flex-start',
|
||||||
canEditWineryBank ? (
|
marginBottom: 16,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
酒厂对账单
|
||||||
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||||
|
无需打款(灰),可展开订单明细
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{canEditWineryBank ? (
|
||||||
<Button type="default" onClick={() => void openBankModal()}>
|
<Button type="default" onClick={() => void openBankModal()}>
|
||||||
酒厂银行账户信息配置
|
酒厂银行账户信息配置
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null}
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { fmtTime } from '../../lib/constants';
|
|||||||
import { useAdminList } from '../../lib/useAdminList';
|
import { useAdminList } from '../../lib/useAdminList';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
||||||
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
|
||||||
import { AdminListHeader } from '../../components/AdminListHeader';
|
|
||||||
|
|
||||||
|
|
||||||
export default function PromoCodeUsersPage() {
|
export default function PromoCodeUsersPage() {
|
||||||
@@ -27,16 +25,7 @@ export default function PromoCodeUsersPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
{
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||||
title: '昵称',
|
|
||||||
dataIndex: 'nickname',
|
|
||||||
width: 100,
|
|
||||||
render: (v, row) => (
|
|
||||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
|
|
||||||
{v}
|
|
||||||
</AdminPrimaryLink>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '验手机',
|
title: '验手机',
|
||||||
@@ -85,9 +74,6 @@ export default function PromoCodeUsersPage() {
|
|||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{settingsModal}
|
|
||||||
<AdminListHeader settings={settingsButton} />
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
@@ -103,6 +89,5 @@ export default function PromoCodeUsersPage() {
|
|||||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -19,17 +18,14 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && location.pathname === '/login') {
|
if (authenticated && location.pathname === '/login') {
|
||||||
const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
|
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||||
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 +1,22 @@
|
|||||||
export { parseRedeemTokenFromScan } from '@dukang/shared-types';
|
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/** 扫核销码落地 /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,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||||
import { 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';
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ export default function RedeemConfirmPage() {
|
|||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
consumeShopReturnTo();
|
|
||||||
setToken(scanned);
|
setToken(scanned);
|
||||||
}, [searchParams, navigate]);
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -36,12 +35,10 @@ 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={goHome} aria-label="返回">
|
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} 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>
|
||||||
@@ -88,11 +85,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={goHome}>
|
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
|
||||||
<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={goHome}>
|
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||||
<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,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -9,11 +10,13 @@ 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 goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||||
goShopPath(consumeShopReturnTo() || '/', navigate);
|
if (shouldHardNavigateForJssdk()) {
|
||||||
|
hardNavigateInWechat('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SelectStorePage() {
|
export default function SelectStorePage() {
|
||||||
@@ -43,7 +46,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) {
|
||||||
goAfterSelectStore(navigate);
|
goShopHome(navigate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingId(storeId);
|
setLoadingId(storeId);
|
||||||
@@ -51,7 +54,7 @@ export default function SelectStorePage() {
|
|||||||
try {
|
try {
|
||||||
const session = await selectStore(storeId);
|
const session = await selectStore(storeId);
|
||||||
applySession(session);
|
applySession(session);
|
||||||
goAfterSelectStore(navigate);
|
goShopHome(navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -167,14 +170,16 @@ export default function SelectStorePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** After login/wechat: route to select-store, redeem return, or home */
|
/** After login/wechat: route to select-store 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,
|
||||||
) {
|
) {
|
||||||
if (needsStoreSelection(session)) {
|
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||||
goShopPath('/select-store', navigate);
|
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||||
|
if (shouldHardNavigateForJssdk()) {
|
||||||
|
hardNavigateInWechat(path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
goShopPath(peekShopReturnTo() || '/', navigate);
|
navigate(path, { replace: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.5.10",
|
"version": "3.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
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;
|
||||||
@@ -20,7 +16,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'];
|
||||||
@@ -30,61 +26,16 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
|||||||
return parts.join('|');
|
return parts.join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
function canOpenWecom(url: string, corpId: string) {
|
|
||||||
return !!url.trim() && !!corpId.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端在线客服:
|
* 微信小程序客服入口(open-type=contact)。
|
||||||
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」
|
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
||||||
* - weapp 未配 CorpID:回退 open-type=contact
|
|
||||||
* - H5:有 kfid 则打开企微客服链接
|
|
||||||
*/
|
*/
|
||||||
export default function ContactCsButton({
|
export default function ContactCsButton({
|
||||||
className = '',
|
className = '',
|
||||||
children = '联系在线客服',
|
children = '联系在线客服',
|
||||||
session,
|
session,
|
||||||
}: ContactCsButtonProps) {
|
}: ContactCsButtonProps) {
|
||||||
const [cs, setCs] = useState(() => getBrandAssetsSync());
|
if (!isWeapp) return null;
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
const CANVAS_ID = 'redeem-qr-canvas';
|
const CANVAS_ID = 'redeem-qr-canvas';
|
||||||
|
|
||||||
type RedeemQrCodeProps = {
|
type RedeemQrCodeProps = {
|
||||||
/** 二维码内容:门店 H5 落地 URL,缺省回退 token */
|
token: string;
|
||||||
payload: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawOnWeappCanvas(payload: string) {
|
function drawOnWeappCanvas(token: string) {
|
||||||
const page = Taro.getCurrentInstance().page;
|
const page = Taro.getCurrentInstance().page;
|
||||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||||
query
|
query
|
||||||
@@ -34,33 +33,33 @@ function drawOnWeappCanvas(payload: string) {
|
|||||||
canvas.width = layoutW * dpr;
|
canvas.width = layoutW * dpr;
|
||||||
canvas.height = layoutH * dpr;
|
canvas.height = layoutH * dpr;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
drawRedeemQrOnCanvas(ctx, payload, drawSize);
|
drawRedeemQrOnCanvas(ctx, token, drawSize);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemQrCode({ payload }: RedeemQrCodeProps) {
|
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
|
||||||
const [imgSrc, setImgSrc] = useState('');
|
const [imgSrc, setImgSrc] = useState('');
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payload) {
|
if (!token) {
|
||||||
setImgSrc('');
|
setImgSrc('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeapp) {
|
if (isWeapp) {
|
||||||
const timer = setTimeout(() => drawOnWeappCanvas(payload), 120);
|
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void buildRedeemQrDataUrl(payload).then((url) => {
|
void buildRedeemQrDataUrl(token).then((url) => {
|
||||||
if (!cancelled) setImgSrc(url);
|
if (!cancelled) setImgSrc(url);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [payload, isWeapp]);
|
}, [token, isWeapp]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="redeem-qr-box">
|
<View className="redeem-qr-box">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BRAND_LOGO_URL,
|
BRAND_LOGO_URL,
|
||||||
BRAND_LOGO_WIDE_URL,
|
BRAND_LOGO_WIDE_URL,
|
||||||
CUSTOMER_SERVICE_PHONE,
|
CUSTOMER_SERVICE_PHONE,
|
||||||
CUSTOMER_SERVICE_WECOM_URL,
|
|
||||||
QUALIFICATION_DISCLOSURE_URL,
|
QUALIFICATION_DISCLOSURE_URL,
|
||||||
type ClientRuntimeConfig,
|
type ClientRuntimeConfig,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -15,8 +14,6 @@ export type BrandAssets = {
|
|||||||
brandLogoMarkUrl: string;
|
brandLogoMarkUrl: string;
|
||||||
qualificationDisclosureUrl: string;
|
qualificationDisclosureUrl: string;
|
||||||
customerServicePhone: string;
|
customerServicePhone: string;
|
||||||
customerServiceWecomUrl: string;
|
|
||||||
wecomCorpId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK: BrandAssets = {
|
const FALLBACK: BrandAssets = {
|
||||||
@@ -25,8 +22,6 @@ const FALLBACK: BrandAssets = {
|
|||||||
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
||||||
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
||||||
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
||||||
customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL,
|
|
||||||
wecomCorpId: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: BrandAssets | null = null;
|
let cached: BrandAssets | null = null;
|
||||||
@@ -40,9 +35,6 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets
|
|||||||
qualificationDisclosureUrl:
|
qualificationDisclosureUrl:
|
||||||
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
||||||
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
||||||
customerServiceWecomUrl:
|
|
||||||
config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl,
|
|
||||||
wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { fetchClientConfig } from './pay-wechat';
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||||
export const APP_VERSION = '3.5.10';
|
export const APP_VERSION = '3.5.4';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { LocalDeliveryDto } from '@dukang/shared-types';
|
|
||||||
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
|
|
||||||
import { request } from './api';
|
|
||||||
|
|
||||||
export { DEFAULT_LOCAL_DELIVERY_HINT };
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 60_000;
|
|
||||||
let cached: LocalDeliveryDto[] | null = null;
|
|
||||||
let cachedAt = 0;
|
|
||||||
let inflight: Promise<LocalDeliveryDto[]> | null = null;
|
|
||||||
|
|
||||||
function cityAliases(name: string): string[] {
|
|
||||||
const raw = name.trim();
|
|
||||||
if (!raw) return [];
|
|
||||||
const noSuffix = raw.replace(/市$/, '');
|
|
||||||
const withSuffix = raw.endsWith('市') ? raw : `${raw}市`;
|
|
||||||
return [raw, noSuffix, withSuffix];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadLocalDeliveries(force = false): Promise<LocalDeliveryDto[]> {
|
|
||||||
if (!force && cached && Date.now() - cachedAt < CACHE_TTL_MS) return cached;
|
|
||||||
if (!force && inflight) return inflight;
|
|
||||||
inflight = request<LocalDeliveryDto[]>('/catalog/local-deliveries')
|
|
||||||
.then((list) => {
|
|
||||||
cached = Array.isArray(list) ? list : [];
|
|
||||||
cachedAt = Date.now();
|
|
||||||
return cached;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
cached = cached ?? [];
|
|
||||||
return cached;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflight = null;
|
|
||||||
});
|
|
||||||
return inflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchLocalDelivery(
|
|
||||||
list: LocalDeliveryDto[],
|
|
||||||
opts: { cityCode?: string | null; cityName?: string | null },
|
|
||||||
): LocalDeliveryDto | null {
|
|
||||||
const code = String(opts.cityCode || '').trim();
|
|
||||||
if (code) {
|
|
||||||
const byCode = list.find((row) => row.city.code === code);
|
|
||||||
if (byCode) return byCode;
|
|
||||||
}
|
|
||||||
const names = new Set(cityAliases(String(opts.cityName || '')));
|
|
||||||
if (!names.size) return null;
|
|
||||||
return list.find((row) => cityAliases(row.city.name).some((n) => names.has(n))) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveLocalDeliveryHintHtml(row: LocalDeliveryDto | null): string {
|
|
||||||
const html = row?.hintHtml?.trim();
|
|
||||||
if (html) return html;
|
|
||||||
return DEFAULT_LOCAL_DELIVERY_HINT;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
|
|
||||||
|
|
||||||
/** @deprecated 使用 DEFAULT_LOCAL_DELIVERY_HINT;空配置回退 */
|
|
||||||
export const LOCAL_DELIVERY_ETA_HINT = DEFAULT_LOCAL_DELIVERY_HINT;
|
|
||||||
|
|
||||||
/** 商品履约能力(与 HQ / 交易硬闸一致) */
|
/** 商品履约能力(与 HQ / 交易硬闸一致) */
|
||||||
|
|
||||||
export type FulfillmentFlags = {
|
export type FulfillmentFlags = {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
|
|||||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用;payload 为落地 URL 或纯 token) */
|
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
|
||||||
export function drawRedeemQrOnCanvas(
|
export function drawRedeemQrOnCanvas(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
payload: string,
|
token: string,
|
||||||
sizePx = QR_SIZE,
|
sizePx = QR_SIZE,
|
||||||
) {
|
) {
|
||||||
const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' });
|
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
|
||||||
const count = qr.modules.size;
|
const count = qr.modules.size;
|
||||||
const cell = sizePx / count;
|
const cell = sizePx / count;
|
||||||
|
|
||||||
@@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
||||||
export async function buildRedeemQrDataUrl(payload: string): Promise<string> {
|
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toDataURL(payload, QR_OPTIONS);
|
return await QRCode.toDataURL(token, QR_OPTIONS);
|
||||||
} catch {
|
} catch {
|
||||||
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`;
|
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
export type WecomCsSessionContext = {
|
|
||||||
orderId?: string;
|
|
||||||
orderNo?: string;
|
|
||||||
from?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OpenCsChatOption = {
|
|
||||||
extInfo: { url: string };
|
|
||||||
corpId: string;
|
|
||||||
showMessageCard?: boolean;
|
|
||||||
sendMessageTitle?: string;
|
|
||||||
sendMessagePath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WxCsFail = { errMsg?: string; errCode?: number };
|
|
||||||
|
|
||||||
type WxCsChat = {
|
|
||||||
openCustomerServiceChat?: (
|
|
||||||
option: OpenCsChatOption & {
|
|
||||||
success?: () => void;
|
|
||||||
fail?: (err: WxCsFail) => void;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取小程序原生 wx.openCustomerServiceChat。
|
|
||||||
* 官方不支持 Promise 风格;Taro 模块作用域下也可能读不到全局 wx。
|
|
||||||
*/
|
|
||||||
function getWxOpenCsChat(): WxCsChat['openCustomerServiceChat'] | null {
|
|
||||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
|
||||||
|
|
||||||
const candidates: Array<WxCsChat | null | undefined> = [];
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
if (typeof wx !== 'undefined') candidates.push(wx as WxCsChat);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
candidates.push((globalThis as typeof globalThis & { wx?: WxCsChat }).wx);
|
|
||||||
try {
|
|
||||||
const fromRuntime = new Function(
|
|
||||||
'return typeof wx !== "undefined" ? wx : null',
|
|
||||||
)() as WxCsChat | null;
|
|
||||||
candidates.push(fromRuntime);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const api of candidates) {
|
|
||||||
if (api && typeof api.openCustomerServiceChat === 'function') {
|
|
||||||
return api.openCustomerServiceChat.bind(api);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawCsError(err: unknown): string {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
if (err && typeof err === 'object' && 'errMsg' in err) {
|
|
||||||
return String((err as WxCsFail).errMsg || '');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把微信 fail 转成可读提示;用户取消返回空串,调用方不 toast */
|
|
||||||
export function formatOpenCsError(err: unknown): string {
|
|
||||||
const raw = rawCsError(err);
|
|
||||||
if (/cancel/i.test(raw)) return '';
|
|
||||||
if (/not bind|not bound/i.test(raw)) {
|
|
||||||
return '企业ID未绑定当前小程序。请到微信公众平台「功能 → 客服 → 微信客服」填写同一企业ID';
|
|
||||||
}
|
|
||||||
if (!raw || /openCustomerServiceChat:fail$/i.test(raw.trim())) {
|
|
||||||
return '无法打开微信客服。开发者工具模拟器通常不可用,请用真机预览';
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 小程序调起企业微信「微信客服」会话 */
|
|
||||||
export async function openWecomCustomerServiceChat(params: {
|
|
||||||
url: string;
|
|
||||||
corpId: string;
|
|
||||||
session?: WecomCsSessionContext;
|
|
||||||
}): Promise<void> {
|
|
||||||
const url = params.url.trim();
|
|
||||||
const corpId = params.corpId.trim();
|
|
||||||
if (!url || !corpId) {
|
|
||||||
throw new Error('企微客服未配置');
|
|
||||||
}
|
|
||||||
|
|
||||||
const wxApi = getWxOpenCsChat();
|
|
||||||
if (!wxApi) {
|
|
||||||
throw new Error('当前微信版本不支持企业微信客服');
|
|
||||||
}
|
|
||||||
|
|
||||||
const option: OpenCsChatOption = {
|
|
||||||
extInfo: { url },
|
|
||||||
corpId,
|
|
||||||
};
|
|
||||||
if (params.session?.orderNo || params.session?.orderId) {
|
|
||||||
option.showMessageCard = true;
|
|
||||||
option.sendMessageTitle = params.session.orderNo
|
|
||||||
? `订单 ${params.session.orderNo}`
|
|
||||||
: '订单咨询';
|
|
||||||
if (params.session.orderId) {
|
|
||||||
option.sendMessagePath = `pages/order-detail/index?id=${params.session.orderId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
wxApi({
|
|
||||||
...option,
|
|
||||||
success: () => resolve(),
|
|
||||||
fail: (err) => reject(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -16,43 +16,35 @@ function dialPhone(phone: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomerServicePage() {
|
export default function CustomerServicePage() {
|
||||||
const [brand, setBrand] = useState(() => getBrandAssetsSync());
|
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadBrandAssets().then(setBrand);
|
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const phone = brand.customerServicePhone;
|
|
||||||
const wecomReady = !!brand.customerServiceWecomUrl.trim() && !!brand.wecomCorpId.trim();
|
|
||||||
const wecomUrlReady = !!brand.customerServiceWecomUrl.trim();
|
|
||||||
|
|
||||||
const hint = isWeapp
|
|
||||||
? wecomReady
|
|
||||||
? '点击下方按钮,进入企业微信客服会话'
|
|
||||||
: '点击下方按钮,进入在线客服会话'
|
|
||||||
: wecomUrlReady
|
|
||||||
? '点击下方按钮进入企业微信客服,或拨打客服电话'
|
|
||||||
: '请在微信小程序内打开以使用在线客服,或拨打客服电话';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="cs-page">
|
<PageShell variant="sub" className="cs-page">
|
||||||
<SubPageHeader title="联系客服" />
|
<SubPageHeader title="联系客服" />
|
||||||
<View className="sub-page-body inset-page cs-body">
|
<View className="sub-page-body inset-page cs-body">
|
||||||
<Text className="cs-brand">杜康好客客服</Text>
|
<Text className="cs-brand">杜康好客客服</Text>
|
||||||
<Text className="cs-hint">{hint}</Text>
|
<Text className="cs-hint">
|
||||||
|
{isWeapp
|
||||||
|
? '点击下方按钮,进入小程序在线客服会话'
|
||||||
|
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
|
||||||
|
</Text>
|
||||||
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
||||||
|
|
||||||
{isWeapp || wecomUrlReady ? (
|
{isWeapp ? (
|
||||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className={isWeapp || wecomUrlReady ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
||||||
<Text>
|
<Text>
|
||||||
{isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!isWeapp && !wecomUrlReady ? (
|
{!isWeapp ? (
|
||||||
<Text className="cs-phone-display">{phone}</Text>
|
<Text className="cs-phone-display">{phone}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goOnSitePickup(productId: string) {
|
async function goOnSitePickup(productId: string) {
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ export default function OrderConfirmPickupPage() {
|
|||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const floor = data.minQty ?? 2;
|
|
||||||
if (quantity < floor) {
|
|
||||||
setQuantity(floor);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreview(data);
|
setPreview(data);
|
||||||
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
||||||
}
|
}
|
||||||
@@ -114,7 +109,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit) {
|
if (!canSubmit) {
|
||||||
if (!quantityOk) {
|
if (!quantityOk) {
|
||||||
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
|
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
toast(tip);
|
toast(tip);
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +170,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
const submitLabel = loading
|
const submitLabel = loading
|
||||||
? '提交中…'
|
? '提交中…'
|
||||||
: !quantityOk
|
: !quantityOk
|
||||||
? `至少购买 ${minQty}${unitLabel}`
|
? `至少购买 ${minQty} 瓶`
|
||||||
: '提交订单';
|
: '提交订单';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -228,7 +223,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
</View>
|
</View>
|
||||||
{!quantityOk ? (
|
{!quantityOk ? (
|
||||||
<Text className="order-qty-hint">
|
<Text className="order-qty-hint">
|
||||||
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
|
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { maskPhone } from '../../lib/phone';
|
|||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
|
||||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
@@ -77,7 +75,6 @@ export default function OrderConfirmPage() {
|
|||||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
const [localHintHtml, setLocalHintHtml] = useState('');
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -154,18 +151,6 @@ export default function OrderConfirmPage() {
|
|||||||
[addresses, addressId],
|
[addresses, addressId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cityName = selectedAddress?.city;
|
|
||||||
if (!cityName) {
|
|
||||||
setLocalHintHtml('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadLocalDeliveries().then((list) => {
|
|
||||||
const row = matchLocalDelivery(list, { cityName });
|
|
||||||
setLocalHintHtml(row ? resolveLocalDeliveryHintHtml(row) : '');
|
|
||||||
});
|
|
||||||
}, [selectedAddress?.city]);
|
|
||||||
|
|
||||||
const allowCross =
|
const allowCross =
|
||||||
preview?.allowCrossCityDelivery !== undefined
|
preview?.allowCrossCityDelivery !== undefined
|
||||||
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
||||||
@@ -304,7 +289,7 @@ export default function OrderConfirmPage() {
|
|||||||
: !addressOk
|
: !addressOk
|
||||||
? '请更换地址'
|
? '请更换地址'
|
||||||
: !quantityOk
|
: !quantityOk
|
||||||
? `至少购买 ${minQty}${unitLabel}`
|
? `至少购买 ${minQty} 瓶`
|
||||||
: '提交订单';
|
: '提交订单';
|
||||||
const displayMsg = msg || addressHint;
|
const displayMsg = msg || addressHint;
|
||||||
|
|
||||||
@@ -349,17 +334,11 @@ export default function OrderConfirmPage() {
|
|||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
<Text className="u-muted">
|
<Text className="u-muted">
|
||||||
该地址超出同城配送范围,将由总部物流发货,运费到付
|
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||||||
{quantity < minQty ? `;跨城至少购买 ${minQty}${unitLabel}` : ''}。
|
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isCross && addressOk && addressId && localHintHtml ? (
|
|
||||||
<View className="order-card">
|
|
||||||
<DeliveryHintHtml className="u-muted" html={localHintHtml} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<>
|
<>
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import ContactCsButton from '../../components/ContactCsButton';
|
|||||||
import LogisticsRichText from '../../components/LogisticsRichText';
|
import LogisticsRichText from '../../components/LogisticsRichText';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
|
||||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
|
||||||
import {
|
import {
|
||||||
formatEstimatedArrival,
|
formatEstimatedArrival,
|
||||||
isLogisticsNotArrived,
|
isLogisticsNotArrived,
|
||||||
@@ -85,22 +83,6 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyLocalDeliveryHint(
|
|
||||||
data: OrderDetail,
|
|
||||||
setLocalHintHtml: (html: string) => void,
|
|
||||||
) {
|
|
||||||
if (data.deliveryType !== 'LOCAL') {
|
|
||||||
setLocalHintHtml('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadLocalDeliveries().then((list) => {
|
|
||||||
const row = data.receiverCity
|
|
||||||
? matchLocalDelivery(list, { cityName: data.receiverCity })
|
|
||||||
: null;
|
|
||||||
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fullReceiverAddress(order: OrderDetail) {
|
function fullReceiverAddress(order: OrderDetail) {
|
||||||
const detail = (order.receiverAddress || '').trim();
|
const detail = (order.receiverAddress || '').trim();
|
||||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||||
@@ -120,7 +102,6 @@ export default function OrderDetailPage() {
|
|||||||
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
||||||
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
||||||
const [trackLoading, setTrackLoading] = useState(false);
|
const [trackLoading, setTrackLoading] = useState(false);
|
||||||
const [localHintHtml, setLocalHintHtml] = useState('');
|
|
||||||
|
|
||||||
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
||||||
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
||||||
@@ -152,7 +133,6 @@ export default function OrderDetailPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
void loadOrderTrack(data.delivery, data.status);
|
||||||
applyLocalDeliveryHint(data, setLocalHintHtml);
|
|
||||||
})
|
})
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
@@ -164,7 +144,6 @@ export default function OrderDetailPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
void loadOrderTrack(data.delivery, data.status);
|
||||||
applyLocalDeliveryHint(data, setLocalHintHtml);
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -382,12 +361,6 @@ export default function OrderDetailPage() {
|
|||||||
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{order.deliveryType === 'LOCAL' && localHintHtml ? (
|
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">配送时效</Text>
|
|
||||||
<DeliveryHintHtml className="order-row-value" html={localHintHtml} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
const attrs = product?.specAttrs ?? [];
|
const attrs = product?.specAttrs ?? [];
|
||||||
const skus = product?.skus ?? [];
|
const skus = product?.skus ?? [];
|
||||||
const onSaleSkuCount = skus.filter((s) => s.status === 'ON_SALE').length;
|
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
||||||
const specEnabled = onSaleSkuCount > 1 && attrs.length > 0;
|
|
||||||
const activeSku = useMemo(() => {
|
const activeSku = useMemo(() => {
|
||||||
if (!product) return undefined;
|
if (!product) return undefined;
|
||||||
if (!specEnabled) {
|
if (!specEnabled) {
|
||||||
@@ -232,7 +231,7 @@ export default function ProductDetailPage() {
|
|||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
const skuId = ensureSkuSelected();
|
const skuId = ensureSkuSelected();
|
||||||
if (specEnabled && !skuId) return;
|
if (specEnabled && !skuId) return;
|
||||||
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
||||||
const qs = [`productId=${productId}`, `qty=${qty}`];
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||||
if (skuId) qs.push(`skuId=${skuId}`);
|
if (skuId) qs.push(`skuId=${skuId}`);
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ export default function RedeemCodePage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = decodeURIComponent(router.params.token ?? '');
|
const token = decodeURIComponent(router.params.token ?? '');
|
||||||
const amount = Number(router.params.amount ?? 0);
|
const amount = Number(router.params.amount ?? 0);
|
||||||
const landingUrl = router.params.landingUrl
|
|
||||||
? decodeURIComponent(router.params.landingUrl)
|
|
||||||
: '';
|
|
||||||
const qrPayload = landingUrl || token;
|
|
||||||
|
|
||||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -152,7 +148,7 @@ export default function RedeemCodePage() {
|
|||||||
<View className="redeem-code-panel">
|
<View className="redeem-code-panel">
|
||||||
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
||||||
<View className="redeem-qr-wrap">
|
<View className="redeem-qr-wrap">
|
||||||
<RedeemQrCode payload={qrPayload} />
|
<RedeemQrCode token={token} />
|
||||||
</View>
|
</View>
|
||||||
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
||||||
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
||||||
|
|||||||
@@ -113,15 +113,12 @@ export default function RedeemPage() {
|
|||||||
const body: { amount: number; couponId?: string } = { amount: value };
|
const body: { amount: number; couponId?: string } = { amount: value };
|
||||||
if (couponId) body.couponId = couponId;
|
if (couponId) body.couponId = couponId;
|
||||||
|
|
||||||
const data = await request<{ token: string; amount: number; landingUrl?: string }>('/redeem/tokens', {
|
const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: body,
|
data: body,
|
||||||
});
|
});
|
||||||
const landingQs = data.landingUrl
|
|
||||||
? `&landingUrl=${encodeURIComponent(data.landingUrl)}`
|
|
||||||
: '';
|
|
||||||
Taro.navigateTo({
|
Taro.navigateTo({
|
||||||
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`,
|
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '生成失败');
|
toast(e instanceof Error ? e.message : '生成失败');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Taro, {
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
|
import ShareNavButton from '../../components/ShareNavButton';
|
||||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
@@ -355,6 +356,7 @@ export default function StoreDetailPage() {
|
|||||||
solid={headerSolid}
|
solid={headerSolid}
|
||||||
titleVisible={headerSolid}
|
titleVisible={headerSolid}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
|
right={<ShareNavButton payload={sharePayload} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="store-detail-hero full-bleed">
|
<View className="store-detail-hero full-bleed">
|
||||||
@@ -405,11 +407,13 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{store.category?.name ? (
|
|
||||||
<View className="store-detail-tags">
|
<View className="store-detail-tags">
|
||||||
|
{store.category?.name ? (
|
||||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||||
</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<Text className="store-detail-tag">可核销</Text>
|
||||||
|
<Text className="store-detail-tag">好客门店</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{marqueeLines.length > 0 ? (
|
{marqueeLines.length > 0 ? (
|
||||||
|
|||||||
+3
-9
@@ -28,11 +28,7 @@
|
|||||||
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
|
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
|
||||||
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
|
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
|
||||||
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
||||||
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
||||||
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
|
|
||||||
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
|
|
||||||
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
|
||||||
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,11 +82,9 @@
|
|||||||
|
|
||||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||||
|
|
||||||
**履约起购**(城市 `common_city` 配置,按**瓶当量**;箱装 `quantity × bottlesPerUnit`):现场提货默认 **≥2 瓶**;同城配送默认 **≥2 瓶**;跨城配送默认 **≥6 瓶(1 箱)**。起购属销售策略,不放承运商。承运商「起送瓶数」只用于物流计价/对账,不拦下单。
|
|
||||||
|
|
||||||
**HQ 订单导出**(`admin-web` 订单监控):
|
**HQ 订单导出**(`admin-web` 订单监控):
|
||||||
|
|
||||||
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
||||||
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||||
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||||
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||||
@@ -143,7 +137,7 @@
|
|||||||
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
||||||
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
||||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
- HQ 主列表:最左序号;「列设置」控制显示列与顺序,偏好挂当前 HQ 账号。
|
||||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
2. 携带 `sessionId`(`dukang_session_id`)
|
2. 携带 `sessionId`(`dukang_session_id`)
|
||||||
3. `extraJson` 无密码/令牌;手机号脱敏
|
3. `extraJson` 无密码/令牌;手机号脱敏
|
||||||
4. eventName 先登记 taxonomy 再 emit
|
4. eventName 先登记 taxonomy 再 emit
|
||||||
5. C 端后端埋点 `clientApp` 取 JWT / `X-Client-App`(`USER_MINI` 或 `USER_H5`),禁止写死 H5。`order_submit` / `pay_success` 见 v3.5.14。
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -64,18 +64,10 @@
|
|||||||
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
|
|
||||||
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
|
||||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
|
||||||
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
|
||||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
|
||||||
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) |
|
|
||||||
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
|
||||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
|
||||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
||||||
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
# 杜康好客 · v3.5.10 开发文档
|
|
||||||
|
|
||||||
> **2026-08-25** · mini-user / API
|
|
||||||
> **主题**:门店详情去掉分享按钮;同城配送提示 24 小时内送到;小程序客服接通企业微信
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 版本目标
|
|
||||||
|
|
||||||
| # | 任务 | 类型 | 交付 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| 1 | DPT-20260824-896 | BUG | C 端门店详情去掉顶栏「分享」按钮 |
|
|
||||||
| 2 | DPT-20260824-601 | 优化 | 同城送提示改承运商「配送信息提示」(HTML);C 端 `GET /catalog/local-deliveries` |
|
|
||||||
| 3 | DPT-20260822-651 | 需求 | 小程序在线客服调起企业微信「微信客服」 |
|
|
||||||
|
|
||||||
**不做**:改配送规则 / 改 SLA 计算;合伙人/门店端客服;禁用微信右上角「···」分享菜单。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 门店详情去掉分享按钮
|
|
||||||
|
|
||||||
`pages/store-detail` 顶栏不再渲染 `ShareNavButton`。
|
|
||||||
|
|
||||||
右上角微信原生菜单仍可分享(`enableShareAppMessage` / `WechatShareReady` 保留)。只要去掉页面上的分享按钮。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 同城送提示(承运商 HTML)
|
|
||||||
|
|
||||||
不再写死文案。以用户**收货地址城市**为准:该市 `common_city.status=ACTIVE`(已开城)→ 同城,展示对应仓配承运商的「配送信息提示」;未开城 → 跨城,只展示「总部物流、运费到付」。
|
|
||||||
|
|
||||||
```
|
|
||||||
收货市 → 开城仓库(ACTIVE,优先 API_AUTO 且已绑承运商)→ 承运商.delivery_hint_html
|
|
||||||
```
|
|
||||||
|
|
||||||
空字段时 C 端回退纯文本 `同城配送,预计24小时内送到`。不改下单 / 推单 / 起购。
|
|
||||||
|
|
||||||
### 3.1 承运商字段
|
|
||||||
|
|
||||||
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。允许 `span/p/br/b/strong/i/em/font`,style 仅 `color` / `font-weight` / `font-size` / `font-style`。保存与下发前消毒。HQ 文本框里的回车在 C 端转成换行(不必手写 `<br/>`)。
|
|
||||||
|
|
||||||
HQ「仓配管理 → 承运商」多行输入,例如:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 API
|
|
||||||
|
|
||||||
`GET /catalog/local-deliveries`(公开)。可选 `cityCode` / `cityName`。
|
|
||||||
|
|
||||||
每项:`city` · `warehouse` · `provider` · `hintHtml`。未开城 / 无仓 / 无承运商返回空列表或 `hintHtml=null`,不报错。
|
|
||||||
|
|
||||||
### 3.3 C 端
|
|
||||||
|
|
||||||
| 页面 | 何时展示 |
|
|
||||||
|------|----------|
|
|
||||||
| 商品详情 | 可线上购;用当前选城预览 |
|
|
||||||
| 确认订单 | 收货市已开城且地址校验通过;`RichText` |
|
|
||||||
| 订单详情 | `deliveryType === LOCAL`;按收货市匹配 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 小程序客服接通企业微信
|
|
||||||
|
|
||||||
原先 `open-type=contact` 进入**小程序原生客服**。本版在已配置企微参数时改为 `wx.openCustomerServiceChat`,进入企业微信「微信客服」(与 H5 kfid 同一套)。
|
|
||||||
|
|
||||||
### 配置(HQ → 系统设置 → 微信小程序配置)
|
|
||||||
|
|
||||||
| 键 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| `CUSTOMER_SERVICE_WECOM_URL` | 微信客服 kfid 链接;缺省用代码常量 |
|
|
||||||
| `WECOM_CORP_ID` | 企业 ID(`ww` 开头)。企微「我的企业」可查 |
|
|
||||||
|
|
||||||
`GET /common/client-config` 下发 `customerServiceWecomUrl`、`wecomCorpId`。
|
|
||||||
|
|
||||||
### 行为
|
|
||||||
|
|
||||||
| 环境 | 条件 | 行为 |
|
|
||||||
|------|------|------|
|
|
||||||
| 小程序 | 链接 + CorpID 都有 | `openCustomerServiceChat`;订单详情可带订单卡片 |
|
|
||||||
| 小程序 | 未填 CorpID | 回退 `open-type=contact` |
|
|
||||||
| H5 | 有 kfid 链接 | 打开企微客服网页 |
|
|
||||||
|
|
||||||
### 企微侧前置(运营)
|
|
||||||
|
|
||||||
1. 开通企业微信「微信客服」,拿到 kfid 链接。
|
|
||||||
2. 把 C 端小程序关联到该企业的微信客服。
|
|
||||||
3. 把 CorpID 填进系统设置。未填则用户仍走小程序原生客服。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4.1 Prisma
|
|
||||||
|
|
||||||
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。脚本:[`migrate-fulfillment-delivery-hint-v3510.sql`](../server/dukang-api/prisma/migrate-fulfillment-delivery-hint-v3510.sql)。
|
|
||||||
|
|
||||||
## 5. 验收清单
|
|
||||||
|
|
||||||
- [ ] 门店详情顶栏没有分享按钮;返回/导航/拨打不受影响
|
|
||||||
- [ ] HQ 承运商可编辑「配送信息提示」HTML(颜色/粗细/字号);非法标签被去掉
|
|
||||||
- [ ] `GET /catalog/local-deliveries` 按开城列出仓库+承运商+hintHtml;`cityName` 可筛收货市
|
|
||||||
- [ ] 商品详情(可线上购)按当前选城展示承运商提示(空则回退「同城配送,预计24小时内送到」)
|
|
||||||
- [ ] 确认订单:收货市已开城显示 HTML 提示;未开城只显示「总部物流、运费到付」
|
|
||||||
- [ ] 同城订单详情「配送时效」为该市承运商提示
|
|
||||||
- [ ] 系统设置可改客服链接与 CorpID;保存后 `client-config` 立即带出
|
|
||||||
- [ ] 小程序已填 CorpID:联系客服进入企微微信客服(非小程序原生客服后台)
|
|
||||||
- [ ] 未填 CorpID:小程序仍能打开原生客服,不白屏
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 关键路径
|
|
||||||
|
|
||||||
| 域 | 路径 |
|
|
||||||
|----|------|
|
|
||||||
| 门店详情 | `apps/mini-user/src/pages/store-detail/index.tsx` |
|
|
||||||
| 同城提示 | `fulfillment-provider.service.ts` · `GET /catalog/local-deliveries` · `DeliveryHintHtml` |
|
|
||||||
| 客服按钮 | `apps/mini-user/src/components/ContactCsButton.tsx` · `lib/wecom-cs.ts` |
|
|
||||||
| 下发 | `client-config.controller.ts` · `packages/shared-types` `config.ts` / `wechat.ts` |
|
|
||||||
| HQ 配置 | `system-config.registry.ts` |
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user