32 Commits

Author SHA1 Message Date
jacy 1ecc6a11df merge(dev): 企微结算账单明细与成交播报用户明文
CI / verify (push) Waiting to run
2026-08-27 11:46:12 +08:00
jacy cfd42dfc32 merge(dev_jacy): 企微结算账单明细与成交播报用户明文 2026-08-27 11:45:59 +08:00
jacy 737efb89a3 feat(wecom): 结算账单明细与成交播报用户明文
支付/核销补 HQ 备注且手机号不脱敏;结算通知按城市、合伙人、笔数和累计金额分块推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 11:45:08 +08:00
jacy a423fb71e8 merge(dev): v3.5.14 提交订单/支付成功日志端修复
CI / verify (push) Waiting to run
2026-08-26 17:25:25 +08:00
jacy 4111e5c0a6 merge(dev_jacy): v3.5.14 提交订单/支付成功日志端修复 2026-08-26 17:25:15 +08:00
jacy 3dbb73e428 fix(trade): v3.5.14 提交订单/支付成功日志使用真实 clientApp
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:24:56 +08:00
jacy 3a9f27eb38 merge(dev): v3.5.12 大屏BGM、中文展示、分类删除与侧栏
CI / verify (push) Has been cancelled
2026-08-26 10:55:40 +08:00
jacy e2f5aa9dd8 merge(dev_jacy): v3.5.12 大屏BGM、中文展示、分类删除与侧栏 2026-08-26 10:55:14 +08:00
jacy 58a8b47532 feat(admin): v3.5.12 大屏BGM、中文展示、分类删除修复与侧栏顺序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:54:19 +08:00
jacy 6a5c0cb090 Merge pull request 'Dev' (#53) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/53
2026-08-26 09:56:50 +08:00
jacy 448b9983d3 Merge pull request 'Dev jacy v3.5.11 企业微信通知调整' (#52) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/52
2026-08-26 09:56:36 +08:00
jacy 3afbdfe6b9 v3.5.11企业微信通知调整
CI / verify (pull_request) Has been cancelled
2026-08-26 09:55:19 +08:00
jacy 632539e8dc 增加现场提货的配置 2026-08-26 09:22:29 +08:00
jacy c7cd0f6cf2 C端SKU数量小于等于1不展示chip
后端修复规格轴数据错乱显示问题
2026-08-26 07:43:26 +08:00
jacy 00bb6a2227 Merge pull request 'Dev' (#51) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/51
2026-08-26 07:16:04 +08:00
jacy 1f06530894 Merge pull request '小程序版本号' (#50) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/50
2026-08-26 07:15:52 +08:00
jacy 1f4506f60b 小程序版本号
CI / verify (pull_request) Has been cancelled
2026-08-26 07:13:54 +08:00
jacy 93390ccf75 merge(dev): 同城运费路由与配送单信息
CI / verify (push) Has been cancelled
2026-08-25 21:13:05 +08:00
jacy a2976cdc8f merge(dev_jacy): 同城运费路由与配送单信息 2026-08-25 21:12:53 +08:00
jacy cc0c0a6ef8 feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:12:36 +08:00
jacy 8f2ebbfef4 merge(dev): 补承运商删除操作常量
CI / verify (push) Has been cancelled
2026-08-25 14:32:32 +08:00
jacy ee1a3f4b6c merge(dev_jacy): 补承运商删除操作常量 2026-08-25 14:32:27 +08:00
jacy 52a7d3789d fix(ops): 补承运商删除操作常量以便生产构建
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:32:08 +08:00
jacy f2fbf95c53 merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Has been cancelled
2026-08-25 14:30:43 +08:00
jacy 98d52ff173 merge(dev_jacy): v3.5.10 同城配送提示与企微客服 2026-08-25 14:30:10 +08:00
jacy 797b20979d feat(mini-user): v3.5.10 同城配送提示可配置并支持换行
承运商 HTML 按收货市下发;textarea 回车在 C 端转成换行。含门店去掉分享按钮与小程序企微客服。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:29:48 +08:00
jacy 43de361e61 merge(dev): 列设置靠右与订单状态多选
CI / verify (push) Has been cancelled
2026-08-25 14:20:39 +08:00
jacy b1511ecb92 merge(dev_jacy): 列设置靠右与订单状态多选 2026-08-25 14:20:13 +08:00
jacy fed8ff3d3a fix(admin): 列设置靠右并支持订单状态多选
HQ 列表主操作居右、列设置贴最右侧;订单筛选状态可多选,导出同步过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:19:49 +08:00
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/48
2026-08-25 12:39:30 +08:00
jacy f4da71e952 v3.5.9版本迭代列表优化
CI / verify (pull_request) Has been cancelled
2026-08-25 12:38:21 +08:00
174 changed files with 9877 additions and 5091 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -37,7 +37,7 @@ disable-model-invocation: true
``` ```
权益额 = 订单实付 1:1(永久) 权益额 = 订单实付 1:1(永久)
同城起购 2 瓶 / 跨城 1 箱(6瓶) 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
门店结算 = 核销额 × 60% 门店结算 = 核销额 × 60%
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%) 单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
核销码 TTL = 3 分钟;试核销固定 ¥100 核销码 TTL = 3 分钟;试核销固定 ¥100
@@ -12,9 +12,9 @@
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 | | REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 | | REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 | | REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 | | REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 | | REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
| REQ-U-011 | 权益 | 支付成功 1:1 永久 | | REQ-U-011 | 权益 | 支付成功 1:1 永久 |
| REQ-U-012 | 客服 | 已付款起电话/微信客服 | | REQ-U-012 | 客服 | 已付款起电话/微信客服 |
+1
View File
@@ -26,3 +26,4 @@ server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env deploy/auto-release.env
deploy/backups/ deploy/backups/
.playwright-mcp/
+1 -1
View File
@@ -35,7 +35,7 @@
``` ```
权益额 = benefit_amount ?? price 权益额 = benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
C 端门店仅 status=OPEN C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
+1 -1
View File
@@ -92,7 +92,7 @@
```text ```text
权益发放额 = common_product_item.benefit_amount ?? price 权益发放额 = common_product_item.benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
C 端门店列表仅 status=OPEN C 端门店列表仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed 订单 Taball | pending_pay | pending_ship | pending_receive | completed
+123
View File
@@ -46,6 +46,78 @@ 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),
@@ -81,6 +153,23 @@ body,
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;
@@ -219,6 +308,40 @@ body:has(.big-screen-page),
gap: 12px; gap: 12px;
} }
.big-screen-bgm {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 8px;
padding: 6px 14px 6px 12px;
border: 1px solid rgba(105, 192, 255, 0.45);
border-radius: 6px;
background: rgba(8, 28, 56, 0.7);
color: rgba(230, 244, 255, 0.92);
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
cursor: pointer;
line-height: 1;
}
.big-screen-bgm:hover {
border-color: rgba(105, 192, 255, 0.85);
color: #fff;
}
.big-screen-bgm.is-on {
border-color: rgba(64, 169, 255, 0.85);
box-shadow: 0 0 12px rgba(64, 169, 255, 0.28);
color: #fff;
}
.big-screen-bgm-icon {
display: inline-flex;
width: 16px;
height: 16px;
}
.big-screen-live-label { .big-screen-live-label {
font-size: 18px; font-size: 18px;
color: rgba(230, 244, 255, 0.88); color: rgba(230, 244, 255, 0.88);
Binary file not shown.
@@ -0,0 +1,36 @@
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>
);
}
@@ -0,0 +1,25 @@
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,10 +11,23 @@ import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto; type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = { export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */ /** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
}; };
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
return (live ?? []).map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
});
}
function emptyRow(index = 0): PackageRow { function emptyRow(index = 0): PackageRow {
return { return {
name: '', name: '',
@@ -37,6 +50,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null); const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items); const itemsRef = useRef(items);
const loadingRef = useRef(loading); const loadingRef = useRef(loading);
const dirtyRef = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -47,36 +61,33 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
loadingRef.current = loading; loadingRef.current = loading;
}, [loading]); }, [loading]);
function applyServerPackages(data: StorePackagesResponse) {
dirtyRef.current = false;
setPendingRequest(data.pendingRequest ?? null);
setItems(mapLiveRows(data.live));
}
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setPendingRequest(null); setPendingRequest(null);
dirtyRef.current = false;
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => { .then((data) => applyServerPackages(data))
setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [storeId]); }, [storeId]);
// 审核页完成审核后,自动刷新本页「有待审核套餐」提醒 // 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
useEffect(() => { useEffect(() => {
const onChanged = () => { const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => setPendingRequest(data.pendingRequest ?? null)) .then((data) => {
setPendingRequest(data.pendingRequest ?? null);
if (!dirtyRef.current) {
dirtyRef.current = false;
setItems(mapLiveRows(data.live));
}
})
.catch(() => undefined); .catch(() => undefined);
}; };
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
@@ -108,16 +119,19 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
) : null; ) : null;
function updateAt(index: number, patch: Partial<PackageRow>) { function updateAt(index: number, patch: Partial<PackageRow>) {
dirtyRef.current = true;
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
} }
function addRow() { function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return; if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
dirtyRef.current = true;
setItems((prev) => [...prev, emptyRow(prev.length)]); setItems((prev) => [...prev, emptyRow(prev.length)]);
} }
function removeAt(index: number) { function removeAt(index: number) {
const run = () => { const run = () => {
dirtyRef.current = true;
setItems((prev) => { setItems((prev) => {
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
return next.length ? next : []; return next.length ? next : [];
@@ -203,20 +217,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
}), }),
}); });
if (!opts?.quiet) message.success('套餐已保存并生效'); if (!opts?.quiet) message.success('套餐已保存并生效');
setItems( dirtyRef.current = false;
data.live?.length setItems(mapLiveRows(data.live));
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
} catch (e) { } catch (e) {
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e; throw e;
@@ -227,7 +229,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => { saveIfLoaded: async (opts) => {
if (loadingRef.current) return { skipped: true }; if (loadingRef.current || !dirtyRef.current) return { skipped: true };
await save(opts); await save(opts);
return { skipped: false }; return { skipped: false };
}, },
@@ -173,7 +173,9 @@ export default function OrderTrackDrawer({
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
track?.manualQueryUrl track?.queryError
? track.queryError
: track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接' ? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新' : '暂无路由信息,请稍后刷新'
} }
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message, Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd'; } from 'antd';
@@ -50,6 +50,38 @@ function keyOf(ids: string[]) {
return [...ids].sort().join('_'); return [...ids].sort().join('_');
} }
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
if (!initialSkus.length) {
return [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
];
}
return initialSkus.map((s) => ({
...s,
specValueIds: s.specValueIds ?? [],
status: s.status || 'ON_SALE',
allowOnlinePurchase: s.allowOnlinePurchase !== false,
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
allowOnSitePickup: !!s.allowOnSitePickup,
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}));
}
type Props = { type Props = {
productId: string; productId: string;
initialAttrs: SpecAttr[]; initialAttrs: SpecAttr[];
@@ -58,44 +90,19 @@ type Props = {
}; };
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) { export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
const [attrs, setAttrs] = useState<SpecAttr[]>( const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
initialAttrs.length const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
? initialAttrs
: [],
);
const [skus, setSkus] = useState<SkuRow[]>(
initialSkus.length
? initialSkus.map((s) => ({
...s,
specValueIds: s.specValueIds ?? [],
status: s.status || 'ON_SALE',
allowOnlinePurchase: s.allowOnlinePurchase !== false,
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
allowOnSitePickup: !!s.allowOnSitePickup,
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}))
: [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
],
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editIndex, setEditIndex] = useState<number | null>(null); const [editIndex, setEditIndex] = useState<number | null>(null);
useEffect(() => {
setAttrs(initialAttrs.length ? initialAttrs : []);
setSkus(normalizeSkuRows(initialSkus));
setEditIndex(null);
// 仅切商品时重载,避免父组件重渲染冲掉编辑中内容
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId]);
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]); const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
function regenerateMatrix() { function regenerateMatrix() {
+33 -23
View File
@@ -20,7 +20,11 @@ import {
AccountBookOutlined, AccountBookOutlined,
ProjectOutlined, ProjectOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types'; import {
hasAnySystemSettingsPermission,
hasAnyStoreMenuPermission,
HQ_ADMIN_ROLES,
} from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
@@ -28,12 +32,15 @@ import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
);
type MenuItem = NonNullable<MenuProps['items']>[number]; type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' }, { key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' }, { key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{ {
key: 'products-group', key: 'products-group',
icon: <ShoppingOutlined />, icon: <ShoppingOutlined />,
@@ -44,18 +51,17 @@ const MENU_ITEMS: MenuProps['items'] = [
], ],
}, },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ {
key: 'wecom-group', key: 'partners-group',
icon: <TeamOutlined />, icon: <TeamOutlined />,
label: '企微机器人', label: '开城',
children: [ children: [
{ key: '/wecom/bots', label: '智能机器人' }, { key: '/cities', label: '城市' },
{ key: '/wecom/pushes', label: '消息推送' }, { key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
], ],
}, },
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
{ {
key: 'stores-group', key: 'stores-group',
icon: <ShopOutlined />, icon: <ShopOutlined />,
@@ -69,17 +75,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/store-media', label: '门店资源' }, { key: '/store-media', label: '门店资源' },
], ],
}, },
{
key: 'partners-group',
icon: <TeamOutlined />,
label: '开城',
children: [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{ {
key: 'finance-group', key: 'finance-group',
icon: <AccountBookOutlined />, icon: <AccountBookOutlined />,
@@ -121,6 +116,20 @@ 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 />,
@@ -131,7 +140,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/dev-plan/settings', label: '开发设置' }, { key: '/dev-plan/settings', label: '开发设置' },
], ],
}, },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' }, { key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{ {
key: 'logs-group', key: 'logs-group',
@@ -148,9 +156,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 {
@@ -405,7 +413,9 @@ export default function AdminLayout() {
</Typography.Title> </Typography.Title>
<Space> <Space>
<span>{profile?.name || '—'}</span> <span>{profile?.name || '—'}</span>
<span style={{ color: '#999' }}>{profile?.adminRole}</span> <span style={{ color: '#999' }}>
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
</span>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}> <Button type="text" icon={<LogoutOutlined />} onClick={logout}>
退 退
</Button> </Button>
@@ -35,7 +35,11 @@ export function ListColumnPrefsProvider({
`/admin/me/list-columns/${listKey}`, `/admin/me/list-columns/${listKey}`,
{ {
method: 'PUT', method: 'PUT',
body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }), body: JSON.stringify(
next
? { order: next.order, hidden: next.hidden, widths: next.widths }
: { reset: true },
),
}, },
); );
setPrefs(res.listColumnPrefs ?? {}); setPrefs(res.listColumnPrefs ?? {});
+3
View File
@@ -202,6 +202,7 @@ 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;
@@ -235,5 +236,7 @@ export type AdminOrderRow = {
providerOrderNo: string | null; providerOrderNo: string | null;
logisticsCompany?: string | null; logisticsCompany?: string | null;
manualQueryUrl?: string | null; manualQueryUrl?: string | null;
/** 当次应付物流费(按瓶当量 × 承运商计价) */
logisticsFee?: number | null;
}; };
}; };
+67
View File
@@ -0,0 +1,67 @@
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}
</>
);
}
+15
View File
@@ -7,6 +7,12 @@ 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: '待发货',
@@ -25,9 +31,18 @@ export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
USER_ON_SITE: '用户·现场取货', USER_ON_SITE: '用户·现场取货',
USER_WECHAT_CONFIRM: '用户·微信确认收货', USER_WECHAT_CONFIRM: '用户·微信确认收货',
WECHAT_TRADE_MANAGE: '微信结算同步', WECHAT_TRADE_MANAGE: '微信结算同步',
WECHAT_PAY: '微信支付',
MOCK: 'Mock', MOCK: 'Mock',
MOCK_PAY: 'Mock 支付', MOCK_PAY: 'Mock 支付',
SYSTEM: '系统', SYSTEM: '系统',
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
PARTNER_PROXY: '合伙人代下单',
HQ_PROXY: 'HQ 代操作',
HQ_LOGISTICS: 'HQ 物流',
WAREHOUSE_MANUAL: '仓库手工',
HQ: '总部',
STORE: '门店',
PARTNER: '合伙人',
}; };
/** 订单状态 Tag 颜色(Ant Design preset */ /** 订单状态 Tag 颜色(Ant Design preset */
+358
View File
@@ -0,0 +1,358 @@
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);
}
+14 -1
View File
@@ -55,7 +55,20 @@ 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 [...orderedKeys.map((k) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => c.col)]; return [
...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 = {
+101 -29
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, 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,9 +9,11 @@ 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;
@@ -27,7 +29,13 @@ 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),
@@ -38,6 +46,7 @@ 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(
() => ({ () => ({
@@ -50,42 +59,105 @@ export function useAdminListColumns<T>(
[page, pageSize], [page, pageSize],
); );
const columns = useMemo<ColumnsType<T>>( const persistPref = useCallback(
() => [ async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
if (!next) {
setLocalWidths({});
await save(listKey, null);
return;
}
await save(listKey, next);
},
[listKey, save],
);
const persist = useCallback(
async (next: ListColumnSettingItem[] | null) => {
setSaving(true);
try {
if (!next) {
await persistPref(null);
} else {
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
await persistPref({
order: next.map((i) => i.key),
hidden: next.filter((i) => !i.visible).map((i) => i.key),
...(Object.keys(widths).length ? { widths } : {}),
});
}
setOpen(false);
message.success(next ? '列设置已保存' : '已恢复默认列');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
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, serialCol,
...configured.map((col) => ...configured.map((col) =>
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions' col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY } ? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
: col, : col,
), ),
], ];
[configured, serialCol], const widths = { ...(pref?.widths ?? {}), ...localWidths };
); return merged.map((col, i) => {
const key = columnKey(col, i);
async function persist(next: ListColumnSettingItem[] | null) { const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
setSaving(true); const prevHeader = col.onHeaderCell;
try { return {
if (!next) { ...col,
await save(listKey, null); key,
} else { width,
await save(listKey, { title: withResizeTitle(col.title, (e) => {
order: next.map((i) => i.key), const th = (e.currentTarget as HTMLElement).closest('th');
hidden: next.filter((i) => !i.visible).map((i) => i.key), const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
}); beginColumnResize(
} e,
setOpen(false); startWidth,
message.success(next ? '列设置已保存' : '已恢复默认列'); (next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
} catch (e) { (next) => void persistWidth(key, next),
message.error(e instanceof Error ? e.message : '保存失败'); );
} finally { }),
setSaving(false); 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 = (
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}> <span className="admin-list-settings-slot">
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
</Button>
</Button>
</span>
); );
const settingsModal = ( const settingsModal = (
+127 -20
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -9,6 +10,7 @@ import {
Modal, Modal,
Popconfirm, Popconfirm,
Select, Select,
Space,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -17,11 +19,23 @@ 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, fmtTime } from '../lib/constants'; import { COUPON_STATUS_LABELS, DELIVERY_TYPE_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;
@@ -32,10 +46,43 @@ type Row = {
status: string; status: string;
sourceProduct: string; sourceProduct: string;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; user?: { id?: string; userNo: string; phone: string | null };
order?: { orderNo: string } | null; order?: CouponOrder | 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;
@@ -60,11 +107,10 @@ 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>>({});
@@ -102,20 +148,61 @@ export default function BenefitCouponsPage() {
} }
const baseColumns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, {
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, title: '券号',
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) => v || '—', render: (v: string | undefined) =>
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: '操作',
@@ -162,17 +249,15 @@ export default function BenefitCouponsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="好客权益券"
settings={settingsButton}
</Typography.Title> actions={
<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="券号">
@@ -276,15 +361,37 @@ 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="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户">
{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="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="关联订单">
{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="来源">{detail.sourceProduct}</Descriptions.Item> <Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}> <Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
@@ -4,6 +4,7 @@ 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 = {
@@ -41,8 +42,7 @@ export default function BenefitLedgersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader title="权益流水" settings={settingsButton} />
{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,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api'; import { request } from '../lib/api';
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3'; import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3'; import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3'; import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
@@ -33,6 +34,50 @@ const CELEBRATE_SFX: Record<AmountTier, string> = {
/** 浏览器自动播放策略:需用户手势后才能出声 */ /** 浏览器自动播放策略:需用户手势后才能出声 */
let audioUnlocked = false; let audioUnlocked = false;
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null; const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
const BGM_VOLUME = 0.32;
const BGM_DUCK_VOLUME = 0.08;
let bgmAudio: HTMLAudioElement | null = null;
let bgmDucked = false;
function getBgmAudio(): HTMLAudioElement | null {
if (typeof Audio === 'undefined') return null;
if (!bgmAudio) {
bgmAudio = new Audio(bgmSrc);
bgmAudio.loop = true;
bgmAudio.preload = 'auto';
bgmAudio.volume = BGM_VOLUME;
}
return bgmAudio;
}
function applyBgmVolume() {
if (!bgmAudio) return;
bgmAudio.volume = bgmDucked ? BGM_DUCK_VOLUME : BGM_VOLUME;
}
function duckBgm() {
bgmDucked = true;
applyBgmVolume();
}
function unduckBgm() {
bgmDucked = false;
applyBgmVolume();
}
function setBgmPlaying(on: boolean): Promise<boolean> {
const audio = getBgmAudio();
if (!audio) return Promise.resolve(false);
applyBgmVolume();
if (!on) {
audio.pause();
return Promise.resolve(false);
}
return audio
.play()
.then(() => true)
.catch(() => false);
}
function unlockCelebrateAudio() { function unlockCelebrateAudio() {
if (audioUnlocked || !sharedAudio) return; if (audioUnlocked || !sharedAudio) return;
@@ -53,6 +98,7 @@ function unlockCelebrateAudio() {
function playCelebrateSfx(tier: AmountTier): () => void { function playCelebrateSfx(tier: AmountTier): () => void {
if (!sharedAudio) return () => undefined; if (!sharedAudio) return () => undefined;
duckBgm();
try { try {
sharedAudio.pause(); sharedAudio.pause();
sharedAudio.currentTime = 0; sharedAudio.currentTime = 0;
@@ -72,6 +118,7 @@ function playCelebrateSfx(tier: AmountTier): () => void {
} catch { } catch {
/* ignore */ /* ignore */
} }
unduckBgm();
}; };
} }
@@ -341,10 +388,40 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
); );
} }
function BgmToggleButton({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button
type="button"
className={`big-screen-bgm${on ? ' is-on' : ''}`}
onClick={onToggle}
aria-pressed={on}
aria-label={on ? '暂停背景音乐' : '播放背景音乐'}
>
<span className="big-screen-bgm-icon" aria-hidden>
{on ? (
<svg viewBox="0 0 24 24" width="16" height="16">
<path
fill="currentColor"
d="M9 18V6h2v12H9zm4 0V6h2v12h-2z"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M8 5.14v13.72L19.06 12 8 5.14z" />
</svg>
)}
</span>
{on ? 'BGM 播放中' : '播放 BGM'}
</button>
);
}
export default function BigScreenPage() { export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]); const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [bgmOn, setBgmOn] = useState(false);
const bgmWantedRef = useRef(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null); const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null); const seenIdsRef = useRef<Set<string> | null>(null);
const celebratedIdsRef = useRef<Set<string>>(new Set()); const celebratedIdsRef = useRef<Set<string>>(new Set());
@@ -416,6 +493,22 @@ export default function BigScreenPage() {
return () => clearInterval(id); return () => clearInterval(id);
}, [fetchData]); }, [fetchData]);
const toggleBgm = useCallback(() => {
unlockCelebrateAudio();
const next = !bgmWantedRef.current;
bgmWantedRef.current = next;
if (!next) {
void setBgmPlaying(false);
setBgmOn(false);
return;
}
setBgmOn(true);
void setBgmPlaying(true).then((playing) => {
if (!bgmWantedRef.current) return;
setBgmOn(playing);
});
}, []);
useEffect(() => { useEffect(() => {
unlockCelebrateAudio(); unlockCelebrateAudio();
const unlock = () => unlockCelebrateAudio(); const unlock = () => unlockCelebrateAudio();
@@ -424,6 +517,8 @@ export default function BigScreenPage() {
return () => { return () => {
window.removeEventListener('pointerdown', unlock); window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock); window.removeEventListener('keydown', unlock);
bgmWantedRef.current = false;
void setBgmPlaying(false);
}; };
}, []); }, []);
@@ -480,6 +575,7 @@ export default function BigScreenPage() {
<span className="big-screen-subtitle">· </span> <span className="big-screen-subtitle">· </span>
</div> </div>
<div className="big-screen-live-wrap"> <div className="big-screen-live-wrap">
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
<span className="big-screen-live-label"></span> <span className="big-screen-live-label"></span>
<span className="big-screen-live"> <span className="big-screen-live">
<span className="big-screen-live-dot" /> <span className="big-screen-live-dot" />
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33 -23
View File
@@ -14,7 +14,6 @@ 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';
@@ -31,6 +30,8 @@ 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 = {
@@ -232,7 +233,14 @@ 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 },
@@ -308,27 +316,29 @@ export default function CityWarehousesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="仓库"
{settingsButton} settings={settingsButton}
<Button actions={
type="primary" <Button
onClick={() => { type="primary"
createForm.resetFields(); onClick={() => {
setCreateManagerType(WarehouseManagerType.HQ); createForm.resetFields();
setCreateCityId(undefined); setCreateManagerType(WarehouseManagerType.HQ);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL); setCreateCityId(undefined);
createForm.setFieldsValue({ setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
managerType: WarehouseManagerType.HQ, createForm.setFieldsValue({
status: WarehouseStatus.ACTIVE, managerType: WarehouseManagerType.HQ,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL, status: WarehouseStatus.ACTIVE,
}); fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
setCreateOpen(true); });
}} setCreateOpen(true);
> }}
>
</Button>
</Space> </Button>
}
/>
<Alert <Alert
type="info" type="info"
+225 -25
View File
@@ -1,14 +1,43 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer'; import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import {
DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { 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;
@@ -17,17 +46,34 @@ type Row = {
trackingNo: string | null; trackingNo: string | null;
providerOrderNo: string | null; providerOrderNo: string | null;
updatedAt: string; updatedAt: string;
order?: { logisticsFee?: number | null;
id: string; order?: DeliveryOrder;
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>>({});
@@ -61,25 +107,126 @@ 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: 'provider', dataIndex: 'provider', width: 90 }, title: '订单号',
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={async () => { <Button type="link" size="small" onClick={() => void openDetail(row)}></Button>
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
</Space> </Space>
), ),
}, },
@@ -87,20 +234,21 @@ export default function DeliveriesPage() {
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
const order = detail?.order;
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}>/</Typography.Title> <AdminListHeader title="快递/配送单" settings={settingsButton} />
{settingsButton}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item> <Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item> <Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }} <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
<Space> <Space>
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}></Button>
@@ -117,11 +265,63 @@ export default function DeliveriesPage() {
{detail && ( {detail && (
<> <>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item> <Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item> <Descriptions.Item label="用户">
{order?.user ? (
<Space size={8} wrap>
{order.user.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
>
{order.user.userNo}
</AdminPrimaryLink>
) : (
order.user.userNo || '—'
)}
<Typography.Text type="secondary">
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
</Typography.Text>
</Space>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="商品">
<Space align="start">
{order?.imageResource?.url ? (
<img
src={order.imageResource.url}
alt=""
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<span>
{order?.productName || '—'}
{order?.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.productSpec}
</Typography.Text>
) : null}
{order?.barcode69 ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.barcode69}
</Typography.Text>
) : null}
</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
<Descriptions.Item label="配送方式">
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
</Descriptions.Item>
<Descriptions.Item label="收货人">
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
</Descriptions.Item>
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
<Descriptions.Item label="运费">
{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="provider" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item> <Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form> </Form>
+44 -35
View File
@@ -31,6 +31,8 @@ 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) => ({
@@ -243,7 +245,14 @@ 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',
@@ -301,40 +310,40 @@ export default function DevPlanTasksPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="开发计划 · 任务列表"
· settings={settingsButton}
</Typography.Title> actions={
{settingsButton} <>
<Space wrap> <Select
<Select value={exportFormat}
value={exportFormat} style={{ width: 120 }}
style={{ width: 120 }} onChange={setExportFormat}
onChange={setExportFormat} options={[
options={[ { value: 'markdown', label: 'Markdown' },
{ value: 'markdown', label: 'Markdown' }, { value: 'docx', label: 'Word' },
{ value: 'docx', label: 'Word' }, { value: 'xlsx', label: 'Excel' },
{ value: 'xlsx', label: 'Excel' }, { value: 'pdf', label: 'PDF' },
{ value: 'pdf', label: 'PDF' }, ]}
]} />
/> <Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
</Button> <Button loading={exporting} onClick={() => void exportTasks('filter')}>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
</Button> <Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
</Button> <Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
</Button>
</Button> <Button type="primary" onClick={openCreate}>
<Button type="primary" onClick={openCreate}>
</Button>
</Button> </>
</Space> }
</div> />
<Form <Form
layout="inline" layout="inline"
@@ -9,7 +9,6 @@ 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';
@@ -23,6 +22,8 @@ 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';
@@ -122,7 +123,9 @@ export default function DevPlanVersionsPage() {
} }
const baseColumns: ColumnsType<DevPlanVersionDto> = [ const baseColumns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 }, { title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
) },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -165,15 +168,15 @@ export default function DevPlanVersionsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="开发计划 · 版本列表"
· settings={settingsButton}
</Typography.Title> actions={
{settingsButton} <Button type="primary" onClick={openCreate}>
<Button type="primary" onClick={openCreate}>
</Button>
</Button> }
</div> />
<Form <Form
layout="inline" layout="inline"
+46 -9
View File
@@ -4,7 +4,16 @@ 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 = {
@@ -14,8 +23,11 @@ type Row = {
refId: string; refId: string;
status?: string | null; status?: string | null;
param1?: string | null; param1?: string | null;
param1Desc?: string | null;
param2?: string | null; param2?: string | null;
param2Desc?: string | null;
param3?: string | null; param3?: string | null;
param3Desc?: string | null;
remark?: string | null; remark?: string | null;
createdAt: string; createdAt: string;
}; };
@@ -59,10 +71,28 @@ 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: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, title: '类型',
{ title: '状态', dataIndex: 'status', width: 100 }, dataIndex: 'eventType',
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, width: 130,
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,
@@ -119,11 +149,18 @@ export default function DomainEventsPage() {
{detail && ( {detail && (
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item> <Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item> <Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item> <Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item> <Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item> {domainParamValue(detail.param1Desc, detail.param1)}
</Descriptions.Item>
<Descriptions.Item label={domainParamFieldLabel(detail.param2Desc, 2)}>
{domainParamValue(detail.param2Desc, detail.param2)}
</Descriptions.Item>
<Descriptions.Item label={domainParamFieldLabel(detail.param3Desc, 3)}>
{domainParamValue(detail.param3Desc, detail.param3)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions> </Descriptions>
@@ -7,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,6 +29,8 @@ 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]) => ({
@@ -67,7 +69,10 @@ export default function FulfillmentProvidersPage() {
setLoading(true); setLoading(true);
try { try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers'); const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res); setRows(Array.isArray(res) ? res : []);
} catch (e) {
setRows([]);
message.error(e instanceof Error ? e.message : '加载承运商失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -93,6 +98,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: '',
}); });
setOpen(true); setOpen(true);
} }
@@ -121,6 +127,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: row.deliveryHintHtml || '',
}); });
setOpen(true); setOpen(true);
} }
@@ -143,6 +150,7 @@ export default function FulfillmentProvidersPage() {
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined, boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined, boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
}, },
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
}; };
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -175,9 +183,25 @@ 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',
@@ -223,11 +247,23 @@ export default function FulfillmentProvidersPage() {
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}> <Space size={0}>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
</Button>
<Popconfirm
title={`确认删除承运商「${row.name}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void remove(row)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
), ),
}, },
]; ];
@@ -240,20 +276,16 @@ export default function FulfillmentProvidersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<div> title="仓配管理"
<Typography.Title level={4} style={{ margin: 0 }}> description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
settings={settingsButton}
</Typography.Title> actions={
{settingsButton} <Button type="primary" onClick={openCreate}>
<Typography.Text type="secondary">
</Button>
</Typography.Text> }
</div> />
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert <Alert
type="info" type="info"
@@ -285,6 +317,17 @@ export default function FulfillmentProvidersPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item
name="deliveryHintHtml"
label="配送信息提示"
extra="C 端同城送展示。支持 HTMLspan/p/br/b/strong/i/em/fontstyle 可用 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 }]}>
@@ -306,6 +349,7 @@ export default function FulfillmentProvidersPage() {
<Form.Item <Form.Item
name="baseBottles" name="baseBottles"
label="起送瓶数" label="起送瓶数"
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
rules={[{ required: true }]} rules={[{ required: true }]}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
> >
+45 -17
View File
@@ -8,6 +8,8 @@ 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 = {
@@ -69,7 +71,31 @@ 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 },
{ {
@@ -120,22 +146,24 @@ export default function HqAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title> title="HQ 账户"
{settingsButton} settings={settingsButton}
{isSuperAdmin && ( actions={
<Button isSuperAdmin ? (
type="primary" <Button
onClick={() => { type="primary"
createForm.resetFields(); onClick={() => {
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }); createForm.resetFields();
setCreateOpen(true); createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
}} setCreateOpen(true);
> }}
>
</Button>
)} </Button>
</Space> ) : null
}
/>
<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="角色">
+212 -199
View File
@@ -1,204 +1,217 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
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 { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; import { formatRef, zhCode } from '../lib/display-labels';
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 = {
id: string;
hqAccountId: string | null; type Row = {
hqName: string | null; id: string;
hqPhone: string | null; hqAccountId: string | null;
hqRole: string | null; hqName: string | null;
action: string | null; hqPhone: string | null;
actionLabel: string; hqRole: string | null;
refType: string | null; action: string | null;
refId: string | null; actionLabel: string;
status: string | null; refType: string | null;
remark: string | null; refId: string | null;
detail: Record<string, unknown> | null; status: string | null;
createdAt: string; remark: string | null;
}; detail: Record<string, unknown> | null;
createdAt: string;
export default function HqLogsPage() { };
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm(); export default function HqLogsPage() {
const [filters, setFilters] = useState<Record<string, string>>(() => ({ const [searchParams, setSearchParams] = useSearchParams();
hqAccountId: searchParams.get('hqAccountId') ?? '', const [form] = Form.useForm();
action: searchParams.get('action') ?? '', const [filters, setFilters] = useState<Record<string, string>>(() => ({
refType: searchParams.get('refType') ?? '', hqAccountId: searchParams.get('hqAccountId') ?? '',
})); action: searchParams.get('action') ?? '',
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( refType: searchParams.get('refType') ?? '',
'/admin/logs/hq', }));
() => { const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
const qs = new URLSearchParams(); '/admin/logs/hq',
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId); () => {
if (filters.action) qs.set('action', filters.action); const qs = new URLSearchParams();
if (filters.refType) qs.set('refType', filters.refType); if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
return qs; if (filters.action) qs.set('action', filters.action);
}, if (filters.refType) qs.set('refType', filters.refType);
[filters], return qs;
); },
const [detail, setDetail] = useState<Row | null>(null); [filters],
const [drawerOpen, setDrawerOpen] = useState(false); );
const [detail, setDetail] = useState<Row | null>(null);
useEffect(() => { const [drawerOpen, setDrawerOpen] = useState(false);
form.setFieldsValue(filters);
}, [form, filters]); useEffect(() => {
form.setFieldsValue(filters);
const baseColumns: ColumnsType<Row> = [ }, [form, filters]);
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ const baseColumns: ColumnsType<Row> = [
title: '操作人', { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
width: 200, {
title: '操作人',
render: (_, r) => ( width: 200,
<AdminCellLine render: (_, r) => (
primary={r.hqName} <AdminCellLine
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null} primary={r.hqName}
/> secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
), />
}, ),
{ },
title: '行为', {
dataIndex: 'actionLabel', title: '行为',
width: 160, dataIndex: 'actionLabel',
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>, width: 160,
}, render: (v, r) => (
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' }, <AdminPrimaryLink
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, onClick={async () => {
{ const res = await request<Row>(`/admin/logs/hq/${r.id}`);
title: '操作', setDetail(res);
width: 80, setDrawerOpen(true);
render: (_, row) => ( }}
<Button >
type="link" <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
size="small" </AdminPrimaryLink>
onClick={async () => { ),
const res = await request<Row>(`/admin/logs/hq/${row.id}`); },
setDetail(res); { title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
setDrawerOpen(true); { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
}} {
> title: '操作',
width: 80,
</Button> render: (_, row) => (
), <Button
}, type="link"
]; size="small"
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
return (
<div> <div>
<div> {settingsModal}
{settingsModal} <AdminListHeader
<Typography.Title level={4}>HQ </Typography.Title> title="HQ 操作日志"
{settingsButton} settings={settingsButton}
<Typography.Paragraph type="secondary"> description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
/>
</Typography.Paragraph>
<Form
<Form form={form}
form={form} layout="inline"
layout="inline" style={{ marginBottom: 16 }}
style={{ marginBottom: 16 }} onFinish={(values) => {
onFinish={(values) => { setFilters(values);
setFilters(values); setPage(1);
setPage(1); const qs = new URLSearchParams();
const qs = new URLSearchParams(); if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId); if (values.action) qs.set('action', values.action);
if (values.action) qs.set('action', values.action); if (values.refType) qs.set('refType', values.refType);
if (values.refType) qs.set('refType', values.refType); setSearchParams(qs);
setSearchParams(qs); }}
}} >
> <Form.Item name="hqAccountId" label="HQ 账户 ID">
<Form.Item name="hqAccountId" label="HQ 账户 ID"> <Input allowClear style={{ width: 140 }} />
<Input allowClear style={{ width: 140 }} /> </Form.Item>
</Form.Item> <Form.Item name="action" label="行为">
<Form.Item name="action" label="行为"> <Select
<Select allowClear
allowClear showSearch
showSearch optionFilterProp="label"
optionFilterProp="label" style={{ width: 180 }}
style={{ width: 180 }} 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> <Form.Item name="refType" label="对象类型">
<Form.Item name="refType" label="对象类型"> <Input allowClear placeholder="对象编码" style={{ width: 140 }} />
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} /> </Form.Item>
</Form.Item> <Form.Item>
<Form.Item> <Space>
<Space> <Button type="primary" htmlType="submit"></Button>
<Button type="primary" htmlType="submit"></Button> <Button onClick={() => {
<Button onClick={() => { form.resetFields();
form.resetFields(); setFilters({ hqAccountId: '', action: '', refType: '' });
setFilters({ hqAccountId: '', action: '', refType: '' }); setSearchParams({});
setSearchParams({}); setPage(1);
setPage(1); void reload();
void reload(); }}
}} >
>
</Button>
</Button> </Space>
</Space> </Form.Item>
</Form.Item> </Form>
</Form>
<Table
<Table rowKey="id"
rowKey="id" loading={loading}
loading={loading} columns={columns}
columns={columns} dataSource={data?.items ?? []}
dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
scroll={{ x: 'max-content' }} pagination={{
pagination={{ current: page,
current: page, pageSize,
pageSize, total: data?.total ?? 0,
total: data?.total ?? 0, showSizeChanger: true,
showSizeChanger: true, onChange: (p, ps) => {
onChange: (p, ps) => { setPage(p);
setPage(p); setPageSize(ps);
setPageSize(ps); },
}, }}
}} />
/>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}> {detail && (
{detail && ( <>
<> <Descriptions column={1} bordered size="small">
<Descriptions column={1} bordered size="small"> <Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="操作人">
<Descriptions.Item label="操作人"> <AdminCellLine
<AdminCellLine 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>
</Descriptions.Item> <Descriptions.Item label="角色">{zhCode(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>
</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="状态">{detail.status || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> </Descriptions>
</Descriptions> <Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title> <pre style={{
<pre style={{ margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4, maxHeight: 400, overflow: 'auto', fontSize: 12,
maxHeight: 400, overflow: 'auto', fontSize: 12, }}
}} >
> {JSON.stringify(detail.detail, null, 2)}
{JSON.stringify(detail.detail, null, 2)} </pre>
</pre> </>
</> )}
)} </Drawer>
</Drawer> </div>
</div> );
); }
+28 -28
View File
@@ -11,7 +11,6 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography,
Upload, Upload,
message, message,
} from 'antd'; } from 'antd';
@@ -31,6 +30,8 @@ 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 = {
@@ -204,7 +205,13 @@ 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,
@@ -234,32 +241,25 @@ export default function InvoicesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div <AdminListHeader
style={{ title="发票管理"
display: 'flex', settings={settingsButton}
justifyContent: 'space-between', actions={
alignItems: 'center', <Button
marginBottom: 16, type="primary"
}} onClick={() => {
> createForm.setFieldsValue({
<Typography.Title level={4} style={{ margin: 0 }}> titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
</Typography.Title> invoiceCategory: 'LIQUOR',
{settingsButton} });
<Button setCreateOpen(true);
type="primary" }}
onClick={() => { >
createForm.setFieldsValue({
titleType: 'PERSONAL', </Button>
invoiceKind: 'NORMAL', }
invoiceCategory: 'LIQUOR', />
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form <Form
form={filterForm} form={filterForm}
layout="inline" layout="inline"
+14 -10
View File
@@ -26,6 +26,8 @@ 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 = {
@@ -127,7 +129,13 @@ 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 },
{ {
@@ -257,15 +265,11 @@ export default function KnowledgeBasesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16 }} wrap> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="知识库"
settings={settingsButton}
</Typography.Title> description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
{settingsButton} />
<Typography.Text type="secondary">
.txt/.md AI
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
+14 -10
View File
@@ -26,6 +26,8 @@ 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 = {
@@ -156,7 +158,13 @@ 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',
@@ -264,15 +272,11 @@ export default function LlmConfigsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16 }} wrap> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="语言模型配置"
settings={settingsButton}
</Typography.Title> description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
{settingsButton} />
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
+15 -10
View File
@@ -30,6 +30,8 @@ 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;
@@ -246,7 +248,14 @@ 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,
@@ -397,15 +406,11 @@ export default function LogisticsBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="物流对账"
settings={settingsButton}
</Typography.Title> description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
{settingsButton} />
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs <Tabs
items={[ items={[
+126 -63
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -27,15 +27,19 @@ 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';
@@ -142,7 +146,7 @@ type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = { type OrderExportFilters = {
orderNo?: string; orderNo?: string;
status?: string; status?: string | string[];
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
@@ -217,6 +221,12 @@ 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,
@@ -229,7 +239,8 @@ function buildExportPayload(
return payload; return payload;
} }
if (filters.orderNo) payload.orderNo = filters.orderNo; if (filters.orderNo) payload.orderNo = filters.orderNo;
if (filters.status) payload.status = filters.status; const statuses = selectedStatuses(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;
@@ -273,6 +284,7 @@ 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();
@@ -354,7 +366,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);
if (values.status) qs.set('status', values.status); for (const status of selectedStatuses(values.status)) qs.append('status', status);
if (values.orderType) qs.set('orderType', values.orderType); if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
@@ -593,63 +605,105 @@ export default function OrdersPage() {
const baseColumns: ColumnsType<AdminOrderRow> = [ const baseColumns: ColumnsType<AdminOrderRow> = [
{ {
title: '商品', title: '订单号',
width: 240, dataIndex: 'orderNo',
render: (_, row) => ( width: 180,
<div> render: (v, row) => (
<Space size={4} wrap> <Space size={4}>
<span>{row.productName || '—'}</span> <AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null} </Space>
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</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: '用户',
width: 130, 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: '商品',
key: 'productName',
width: 180,
render: (_, row) => ( render: (_, row) => (
<div> <Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}> <span>{row.productName || '—'}</span>
{ORDER_STATUS_LABELS[row.status] || row.status} {row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
</Tag> {row.orderType === 'PROXY' || row.isProxyOrder ? (
<div>¥{row.payAmount}</div> <Tag color="purple"></Tag>
</div> ) : null}
</Space>
), ),
}, },
{
title: '规格',
dataIndex: 'productSpec',
width: 140,
render: (v: string | undefined) => v || '—',
},
{
title: '数量',
dataIndex: 'quantity',
width: 80,
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: '收货',
width: 240, dataIndex: 'receiverName',
render: (_, row) => { width: 90,
const address = formatReceiverAddress(row); render: (v: string | undefined) => v || '—',
return ( },
<div> {
<div> title: '电话',
{row.receiverName || '—'} {row.receiverPhone || ''} dataIndex: 'receiverPhone',
</div> width: 120,
{address ? ( render: (v: string | undefined) => v || '—',
<Typography.Text type="secondary" style={{ fontSize: 12 }}> },
{address} {
</Typography.Text> title: '地址',
) : null} key: 'receiverAddress',
</div> width: 260,
); render: (_, row) => formatReceiverAddress(row) || '—',
},
}, },
{ {
title: '下单时间', title: '下单时间',
@@ -695,18 +749,20 @@ export default function OrdersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="订单监控"
<Space size={12}> settings={settingsButton}
{settingsButton} actions={
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button> <>
{canProxyOrder ? ( <Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
<Button type="primary" onClick={() => setProxyOpen(true)}> {canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null} </Button>
</Space> ) : null}
</Space> </>
}
/>
{!canDeleteOrders && profile ? ( {!canDeleteOrders && profile ? (
<Alert <Alert
@@ -744,10 +800,12 @@ export default function OrdersPage() {
</Space> </Space>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={12} sm={6} md={5} lg={3}> <Col xs={24} sm={12} md={8} lg={6}>
<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 }))}
/> />
@@ -1108,6 +1166,11 @@ export default function OrdersPage() {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item> <Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item> <Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
<Descriptions.Item label="运费">
{detail.delivery.logisticsFee == null
? '—'
: `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`}
</Descriptions.Item>
{detail.delivery.manualQueryUrl && ( {detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接"> <Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer"> <a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
@@ -1148,7 +1211,7 @@ export default function OrdersPage() {
{ {
title: '备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',
render: (v) => v || '—', render: (v) => zhRemark(v),
}, },
{ {
title: '时间', title: '时间',
@@ -5,9 +5,10 @@ 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 { AdminCellLine } from '../components/AdminCellLine'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
@@ -176,14 +177,13 @@ export default function PartnerAccountsPage() {
title: '姓名 / 类型', title: '姓名 / 类型',
width: 200, width: 200,
render: (_, row) => ( render: (_, row) => (
<AdminCellLine <span className="admin-cell-line">
primary={row.name} <AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
secondary={ <span className="admin-cell-line-secondary">
row.parentAccountId {' · '}
? `子账号 · ${staffRoleLabel(row.staffRole)}` {row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
: '主账号' </span>
} </span>
/>
), ),
}, },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
@@ -262,15 +262,11 @@ export default function PartnerAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<div> title="合伙人子账号"
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> settings={settingsButton}
{settingsButton} description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
<Typography.Text type="secondary"> />
</Typography.Text>
</div>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
File diff suppressed because it is too large Load Diff
+252 -232
View File
@@ -1,237 +1,257 @@
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 { 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, type PartnerLogCategory,
resolvePartnerLogCategory, type PartnerStaffRole,
type PartnerLogCategory, } from '@dukang/shared-types';
type PartnerStaffRole, import { request } from '../lib/api';
} from '@dukang/shared-types'; import { AdminCellLine } from '../components/AdminCellLine';
import { request } from '../lib/api'; import { fmtTime } from '../lib/constants';
import { AdminCellLine } from '../components/AdminCellLine'; import { clientAppLabel, eventNameLabel, formatRef, partnerEventCategoryLabel } from '../lib/display-labels';
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 = {
id: string;
partnerId: string; type Row = {
partnerAccountId: string | null; id: string;
accountName: string | null; partnerId: string;
accountPhone: string | null; partnerAccountId: string | null;
companyName: string | null; accountName: string | null;
isSubAccount?: boolean; accountPhone: string | null;
staffRole?: string | null; companyName: string | null;
category: PartnerLogCategory | null; isSubAccount?: boolean;
eventName: string; staffRole?: string | null;
clientApp: string | null; category: PartnerLogCategory | null;
refType: string | null; eventName: string;
refId: string | null; clientApp: string | null;
extraJson: Record<string, unknown> | null; refType: string | null;
createdAt: string; refId: string | null;
}; extraJson: Record<string, unknown> | null;
createdAt: string;
function summarizeExtra(json: Record<string, unknown> | null) { };
if (!json) return '—';
const text = JSON.stringify(json); function summarizeExtra(json: Record<string, unknown> | null) {
return text.length > 80 ? `${text.slice(0, 80)}` : text; if (!json) return '—';
} const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
export default function PartnerLogsPage() { }
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm(); export default function PartnerLogsPage() {
const [category, setCategory] = useState(searchParams.get('category') ?? ''); const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<Record<string, string>>(() => ({ const [form] = Form.useForm();
partnerId: searchParams.get('partnerId') ?? '', const [category, setCategory] = useState(searchParams.get('category') ?? '');
partnerAccountId: searchParams.get('partnerAccountId') ?? '', const [filters, setFilters] = useState<Record<string, string>>(() => ({
phone: searchParams.get('phone') ?? '', partnerId: searchParams.get('partnerId') ?? '',
companyName: searchParams.get('companyName') ?? '', partnerAccountId: searchParams.get('partnerAccountId') ?? '',
eventName: searchParams.get('eventName') ?? '', phone: searchParams.get('phone') ?? '',
})); companyName: searchParams.get('companyName') ?? '',
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( eventName: searchParams.get('eventName') ?? '',
'/admin/logs/partners', }));
() => { const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
const qs = new URLSearchParams(); '/admin/logs/partners',
if (filters.partnerId) qs.set('partnerId', filters.partnerId); () => {
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId); const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone); if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.companyName) qs.set('companyName', filters.companyName); if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.eventName) qs.set('eventName', filters.eventName); if (filters.phone) qs.set('phone', filters.phone);
if (category) qs.set('category', category); if (filters.companyName) qs.set('companyName', filters.companyName);
return qs; if (filters.eventName) qs.set('eventName', filters.eventName);
}, if (category) qs.set('category', category);
[filters, category], return qs;
); },
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); [filters, category],
const [drawerOpen, setDrawerOpen] = useState(false); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
useEffect(() => { const [drawerOpen, setDrawerOpen] = useState(false);
form.setFieldsValue(filters);
}, [form, filters]); useEffect(() => {
form.setFieldsValue(filters);
const baseColumns: ColumnsType<Row> = [ }, [form, filters]);
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ const baseColumns: ColumnsType<Row> = [
title: '合伙人', { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
width: 180, {
title: '合伙人',
render: (_, r) => ( width: 180,
r.isSubAccount ? ( render: (_, r) => (
<Tag color="blue"></Tag> r.isSubAccount ? (
) : ( <Tag color="blue"></Tag>
<AdminCellLine primary={r.companyName} secondary={r.partnerId} /> ) : (
) <AdminCellLine primary={r.companyName} secondary={r.partnerId} />
), )
}, ),
{ },
title: '账号', {
width: 150, title: '账号',
width: 150,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.accountName} primary={r.accountName}
secondary={ secondary={
r.isSubAccount && r.staffRole r.isSubAccount && r.staffRole
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}` ? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
: r.accountPhone || r.partnerAccountId : r.accountPhone || r.partnerAccountId
} }
/> />
), ),
}, },
{ {
title: '分类', title: '分类',
dataIndex: 'category', dataIndex: 'category',
width: 100, width: 100,
render: (v: PartnerLogCategory | null, r) => ( render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag> <Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 180 }, {
{ title: '事件',
title: '关联', dataIndex: 'eventName',
width: 120, width: 180,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (v, row) => (
}, <AdminPrimaryLink
{ onClick={async () => {
title: '摘要', setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
render: (_, r) => summarizeExtra(r.extraJson), }}
}, >
{ {eventNameLabel(v)}
title: '操作', </AdminPrimaryLink>
width: 80, ),
render: (_, row) => ( },
<Button {
type="link" title: '关联',
size="small" width: 120,
onClick={async () => { render: (_, r) => formatRef(r.refType, r.refId),
setDetail(await request(`/admin/logs/partners/${row.id}`)); },
setDrawerOpen(true); {
}} title: '摘要',
> render: (_, r) => summarizeExtra(r.extraJson),
},
</Button> {
), title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
return (
<div> <div>
{settingsModal}
return ( <AdminListHeader title="合伙人日志" settings={settingsButton} />
<div> <Segmented
{settingsModal} options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
<Typography.Title level={4} style={{ marginBottom: 16 }}> value={category}
onChange={(v) => {
</Typography.Title> setCategory(String(v));
{settingsButton} setPage(1);
<Segmented setSearchParams((prev) => {
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} const next = new URLSearchParams(prev);
value={category} if (v) next.set('category', String(v));
onChange={(v) => { else next.delete('category');
setCategory(String(v)); return next;
setPage(1); });
setSearchParams((prev) => { }}
const next = new URLSearchParams(prev); style={{ marginBottom: 16 }}
if (v) next.set('category', String(v)); />
else next.delete('category'); <Form
return next; form={form}
}); layout="inline"
}} style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
style={{ marginBottom: 16 }} onFinish={(values) => {
/> setFilters(values);
<Form setPage(1);
form={form} }}
layout="inline" >
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }} <Form.Item name="phone" label="手机号">
onFinish={(values) => { <Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
setFilters(values); </Form.Item>
setPage(1); <Form.Item name="companyName" label="公司">
}} <Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
> </Form.Item>
<Form.Item name="phone" label="手机号"> <Form.Item name="partnerId" label="合伙人 ID">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} /> <Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司"> <Form.Item name="eventName" label="事件编码">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} /> <Input placeholder="事件编码" allowClear style={{ width: 160 }} />
</Form.Item> </Form.Item>
<Form.Item name="partnerId" label="合伙人ID"> <Form.Item>
<Input placeholder="partnerId" allowClear style={{ width: 120 }} /> <Space>
</Form.Item> <Button type="primary" htmlType="submit">
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} /> </Button>
</Form.Item> <Button
<Form.Item> onClick={() => {
<Space> form.resetFields();
<Button type="primary" htmlType="submit"> setFilters({
partnerId: '',
</Button> partnerAccountId: '',
<Button phone: '',
onClick={() => { companyName: '',
form.resetFields(); eventName: '',
setFilters({ });
partnerId: '', setPage(1);
partnerAccountId: '', }}
phone: '', >
companyName: '',
eventName: '', </Button>
}); </Space>
setPage(1); </Form.Item>
}} </Form>
> <Table
rowKey="id"
</Button> loading={loading}
</Space> columns={columns}
</Form.Item> dataSource={data?.items ?? []}
</Form> pagination={{
<Table current: page,
rowKey="id" pageSize,
loading={loading} total: data?.total ?? 0,
columns={columns} showSizeChanger: true,
dataSource={data?.items ?? []} onChange: (p, ps) => {
pagination={{ setPage(p);
current: page, setPageSize(ps);
pageSize, },
total: data?.total ?? 0, }}
showSizeChanger: true, scroll={{ x: 'max-content' }}
onChange: (p, ps) => { />
setPage(p); <Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
setPageSize(ps); {detail && (
}, <Descriptions column={1} bordered size="small">
}} <Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
scroll={{ x: 'max-content' }} <Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
/> <Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Descriptions.Item label="分类">{partnerEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
{detail && ( <Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
<Descriptions column={1} bordered size="small"> <Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
{Object.entries(detail).map(([k, v]) => ( <Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
<Descriptions.Item key={k} label={k}> <Descriptions.Item label="时间">{fmtTime(detail.createdAt ? String(detail.createdAt) : null)}</Descriptions.Item>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')} <Descriptions.Item label="参数">
</Descriptions.Item> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
))} {JSON.stringify(detail.extraJson ?? {}, null, 2)}
</Descriptions> </pre>
)} </Descriptions.Item>
</Drawer> </Descriptions>
)}
</Drawer>
</div>
);
}
+415 -405
View File
@@ -1,410 +1,420 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Drawer, Drawer,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
Table, Table,
Tabs, Tabs,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
CITY_PARTNER_SCOPE_LABELS, CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType, CityPartnerScopeType,
PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS, PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
companyName: string; type Row = {
phone: string; id: string;
name: string; companyName: string;
contactPhone?: string | null; phone: string;
cityId?: string | null; name: string;
cityName?: string | null; contactPhone?: string | null;
scopeType?: string; cityId?: string | null;
districtCodes?: string[] | null; cityName?: string | null;
orderCommissionRate?: number; scopeType?: string;
redeemCommissionRate?: number; districtCodes?: string[] | null;
storeCount: number; orderCommissionRate?: number;
accountCount: number; redeemCommissionRate?: number;
createdAt: string; storeCount: number;
}; accountCount: number;
createdAt: string;
type PartnerDetail = Row & { };
address?: string;
districtCodes?: string[] | null; type PartnerDetail = Row & {
bindingStatus?: string; address?: string;
bankAccountName?: string | null; districtCodes?: string[] | null;
bankAccountNo?: string | null; bindingStatus?: string;
bankBranch?: string | null; bankAccountName?: string | null;
managedWarehouseId?: string | null; bankAccountNo?: string | null;
children?: Array<{ bankBranch?: string | null;
id: string; managedWarehouseId?: string | null;
phone: string; children?: Array<{
name: string; id: string;
staffRole?: string; phone: string;
permissions?: string[]; name: string;
status: string; staffRole?: string;
}>; permissions?: string[];
}; status: string;
}>;
type CityOption = { id: string; name: string; code: string }; };
type WarehouseOption = { id: string; name: string };
type CityOption = { id: string; name: string; code: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); type WarehouseOption = { id: string; name: string };
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
if (!values?.length) return [];
if (Array.isArray(values[0])) { function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); if (!values?.length) return [];
} if (Array.isArray(values[0])) {
return values as string[]; return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
} }
return values as string[];
export default function PartnersPage() { }
const [form] = Form.useForm();
const [editForm] = Form.useForm(); export default function PartnersPage() {
const [createForm] = Form.useForm(); const [form] = Form.useForm();
const [subForm] = Form.useForm(); const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [createForm] = Form.useForm();
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const [subForm] = Form.useForm();
'/admin/partners', const [filters, setFilters] = useState<Record<string, string>>({});
() => { const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
const qs = new URLSearchParams(); '/admin/partners',
if (filters.companyName) qs.set('companyName', filters.companyName); () => {
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone); const qs = new URLSearchParams();
return qs; if (filters.companyName) qs.set('companyName', filters.companyName);
}, if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
[filters], return qs;
); },
const [detail, setDetail] = useState<PartnerDetail | null>(null); [filters],
const [drawerOpen, setDrawerOpen] = useState(false); );
const [createOpen, setCreateOpen] = useState(false); const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [subOpen, setSubOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]); const [createOpen, setCreateOpen] = useState(false);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [subOpen, setSubOpen] = useState(false);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [cities, setCities] = useState<CityOption[]>([]);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createCityId, setCreateCityId] = useState<string | undefined>(); const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined; const [createCityId, setCreateCityId] = useState<string | undefined>();
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
function formatApiError(err: unknown): string | null { const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败'; function formatApiError(err: unknown): string | null {
return err.message.replace(/\b(\d{6})\b/g, (code) => { if (err && typeof err === 'object' && 'errorFields' in err) return null;
const label = districtCodeLabel(code); if (!(err instanceof Error)) return '操作失败';
return label !== code ? `${label}(${code})` : code; return err.message.replace(/\b(\d{6})\b/g, (code) => {
}); const label = districtCodeLabel(code);
} return label !== code ? `${label}(${code})` : code;
});
const loadCities = useCallback(async () => { }
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items); const loadCities = useCallback(async () => {
}, []); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
const loadWarehouses = useCallback(async (cityId: string) => { }, []);
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name }))); const loadWarehouses = useCallback(async (cityId: string) => {
}, []); const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
useEffect(() => { }, []);
void loadCities();
}, [loadCities]); useEffect(() => {
void loadCities();
async function openPartner(id: string) { }, [loadCities]);
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); async function openPartner(id: string) {
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
if (d.cityId) await loadWarehouses(d.cityId); setDetail(d);
editForm.setFieldsValue({ setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
name: d.name, if (d.cityId) await loadWarehouses(d.cityId);
phone: d.phone, editForm.setFieldsValue({
companyName: d.companyName, name: d.name,
contactPhone: d.contactPhone ?? d.phone, phone: d.phone,
address: d.address ?? '', companyName: d.companyName,
scopeType: d.scopeType, contactPhone: d.contactPhone ?? d.phone,
districtCodes: d.districtCodes ?? [], address: d.address ?? '',
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, scopeType: d.scopeType,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, districtCodes: d.districtCodes ?? [],
bindingStatus: d.bindingStatus, orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
managedWarehouseId: d.managedWarehouseId, redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bankAccountName: d.bankAccountName ?? '', bindingStatus: d.bindingStatus,
bankAccountNo: d.bankAccountNo ?? '', managedWarehouseId: d.managedWarehouseId,
bankBranch: d.bankBranch ?? '', bankAccountName: d.bankAccountName ?? '',
}); bankAccountNo: d.bankAccountNo ?? '',
setDrawerOpen(true); bankBranch: d.bankBranch ?? '',
} });
setDrawerOpen(true);
async function savePartner() { }
if (!detail) return;
try { async function savePartner() {
const v = await editForm.validateFields(); if (!detail) return;
const body = { try {
...v, const v = await editForm.validateFields();
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, const body = {
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, ...v,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
}; redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) }); districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
message.success('已保存'); };
setDrawerOpen(false); await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
void reload(); message.success('已保存');
} catch (e) { setDrawerOpen(false);
const msg = formatApiError(e); void reload();
if (msg) message.error(msg); } catch (e) {
} const msg = formatApiError(e);
} if (msg) message.error(msg);
}
const baseColumns: ColumnsType<Row> = [ }
{ title: '城市', dataIndex: 'cityName', width: 100 },
{ const baseColumns: ColumnsType<Row> = [
title: '区县', { title: '城市', dataIndex: 'cityName', width: 100 },
dataIndex: 'districtCodes', {
width: 160, title: '区县',
dataIndex: 'districtCodes',
render: (codes: string[] | null | undefined, row) => width: 160,
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), render: (codes: string[] | null | undefined, row) =>
}, row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
{ title: '公司名', dataIndex: 'companyName' }, },
{ title: '主账号', dataIndex: 'phone', width: 130 }, {
{ title: '公司名',
title: '管辖', dataIndex: 'companyName',
dataIndex: 'scopeType', render: (v, row) => (
width: 100, <AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), ),
}, },
{ title: '门店', dataIndex: 'storeCount', width: 70 }, { title: '主账号', dataIndex: 'phone', width: 130 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) }, {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, title: '管辖',
{ dataIndex: 'scopeType',
title: '操作', width: 100,
width: 120, render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
render: (_, row) => ( },
<Button type="link" size="small" onClick={() => void openPartner(row.id)}> { title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
</Button> { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
), {
}, title: '操作',
]; width: 120,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
return (
<div> <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); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}> <Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button> </Button>
</Space> }
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> />
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
</Form> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Table </Form>
rowKey="id" <Table
className="admin-table-nowrap" rowKey="id"
loading={loading} className="admin-table-nowrap"
columns={columns} loading={loading}
dataSource={data?.items ?? []} columns={columns}
pagination={{ dataSource={data?.items ?? []}
current: page, pagination={{
pageSize, current: page,
total: data?.total ?? 0, pageSize,
showSizeChanger: true, total: data?.total ?? 0,
onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showSizeChanger: true,
}} onChange: (p, ps) => { setPage(p); setPageSize(ps); },
/> }}
/>
<Drawer
title="城市合伙人" <Drawer
width={640} title="城市合伙人"
open={drawerOpen} width={640}
onClose={() => setDrawerOpen(false)} open={drawerOpen}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>} onClose={() => setDrawerOpen(false)}
> extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
{detail && ( >
<Tabs {detail && (
items={[ <Tabs
{ items={[
key: 'info', {
label: '基本信息', key: 'info',
children: ( label: '基本信息',
<Form form={editForm} layout="vertical"> children: (
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form form={editForm} layout="vertical">
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
</Form.Item> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
{editScopeType === CityPartnerScopeType.DISTRICT && ( </Form.Item>
<Form.Item {editScopeType === CityPartnerScopeType.DISTRICT && (
name="districtCodes" <Form.Item
label="区县" name="districtCodes"
extra="仅作标识,可多选当前城市下的区县(不做互斥)" label="区县"
> extra="仅作标识,可多选当前城市下的区县(不做互斥)"
<CityDistrictMultiSelect cityCode={editCityCode} /> >
</Form.Item> <CityDistrictMultiSelect cityCode={editCityCode} />
)} </Form.Item>
<Space style={{ width: '100%' }} size="large"> )}
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Space style={{ width: '100%' }} size="large">
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="managedWarehouseId" label="管仓仓库"> </Space>
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} /> <Form.Item name="managedWarehouseId" label="管仓仓库">
</Form.Item> <Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item> </Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item> <Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item> <Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
</Form> <Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
), </Form>
}, ),
{ },
key: 'staff', {
label: '子账号', key: 'staff',
children: ( label: '子账号',
<> children: (
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}> <>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table </Button>
size="small" <Table
rowKey="id" size="small"
pagination={false} rowKey="id"
dataSource={detail.children ?? []} pagination={false}
columns={[ dataSource={detail.children ?? []}
{ title: '姓名', dataIndex: 'name' }, columns={[
{ title: '手机', dataIndex: 'phone' }, { title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> }, { title: '手机', dataIndex: 'phone' },
{ { title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
title: '权限', {
dataIndex: 'permissions', title: '权限',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—', dataIndex: 'permissions',
}, render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
]} },
/> ]}
</> />
), </>
}, ),
]} },
/> ]}
)} />
</Drawer> )}
</Drawer>
<Modal
title="新建城市合伙人" <Modal
open={createOpen} title="新建城市合伙人"
width={560} open={createOpen}
onCancel={() => setCreateOpen(false)} width={560}
onOk={async () => { onCancel={() => setCreateOpen(false)}
try { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/partners', { const v = await createForm.validateFields();
method: 'POST', await request('/admin/partners', {
body: JSON.stringify({ method: 'POST',
...v, body: JSON.stringify({
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
}), districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}); }),
message.success('已创建'); });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); createForm.resetFields();
} catch (e) { void reload();
const msg = formatApiError(e); } catch (e) {
if (msg) message.error(msg); const msg = formatApiError(e);
} if (msg) message.error(msg);
}} }
> }}
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}> >
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}> <Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Select <Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} <Select
onChange={(id) => { options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
setCreateCityId(id); onChange={(id) => {
createForm.setFieldsValue({ districtCodes: undefined }); setCreateCityId(id);
void loadWarehouses(id); createForm.setFieldsValue({ districtCodes: undefined });
}} void loadWarehouses(id);
/> }}
</Form.Item> />
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
</Form.Item> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
{createScopeType === CityPartnerScopeType.DISTRICT && ( </Form.Item>
<Form.Item {createScopeType === CityPartnerScopeType.DISTRICT && (
name="districtCodes" <Form.Item
label="区县" name="districtCodes"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)" label="区县"
> extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
<CityDistrictMultiSelect cityCode={createCityCode} /> >
</Form.Item> <CityDistrictMultiSelect cityCode={createCityCode} />
)} </Form.Item>
<Space style={{ width: '100%' }} size="large"> )}
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Space style={{ width: '100%' }} size="large">
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
{createCityId && ( </Space>
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)"> {createCityId && (
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} /> <Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
</Form.Item> <Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
)} </Form.Item>
</Form> )}
</Modal> </Form>
</Modal>
<Modal
title="添加子账号" <Modal
open={subOpen} title="添加子账号"
onCancel={() => setSubOpen(false)} open={subOpen}
onOk={async () => { onCancel={() => setSubOpen(false)}
if (!detail) return; onOk={async () => {
const v = await subForm.validateFields(); if (!detail) return;
await request('/admin/partner-accounts', { const v = await subForm.validateFields();
method: 'POST', await request('/admin/partner-accounts', {
body: JSON.stringify({ ...v, parentAccountId: detail.id }), method: 'POST',
}); body: JSON.stringify({ ...v, parentAccountId: detail.id }),
message.success('已创建'); });
setSubOpen(false); message.success('已创建');
void openPartner(detail.id); setSubOpen(false);
}} void openPartner(detail.id);
> }}
<Form form={subForm} layout="vertical"> >
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form form={subForm} layout="vertical">
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="permissions" label="权限"> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Select mode="multiple" options={PERM_OPTIONS} /> <Form.Item name="permissions" label="权限">
</Form.Item> <Select mode="multiple" options={PERM_OPTIONS} />
</Form> </Form.Item>
</Modal> </Form>
</div> </Modal>
); </div>
);
}
+274 -266
View File
@@ -1,271 +1,279 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Button,
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Image, Image,
Input, Input,
Modal, Modal,
Select, Select,
Space, Space,
Table, Table,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
REDEEM_PENDING_STATUS_LABELS, REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem, type RedeemPendingItem,
type RedeemPendingStatus, type RedeemPendingStatus,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
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> = {
PENDING: 'orange',
COMPLETED: 'green', const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
REJECTED: 'default', PENDING: 'orange',
}; COMPLETED: 'green',
REJECTED: 'default',
export default function PendingRedeemPage() { };
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); export default function PendingRedeemPage() {
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>( const [form] = Form.useForm();
'/admin/redeem-pending', const [filters, setFilters] = useState<Record<string, string>>({});
() => { const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
const qs = new URLSearchParams(); '/admin/redeem-pending',
if (filters.status) qs.set('status', filters.status); () => {
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo); const qs = new URLSearchParams();
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken); if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
return qs; if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
}, if (filters.storeId) qs.set('storeId', filters.storeId);
[filters], return qs;
); },
const [detail, setDetail] = useState<RedeemPendingItem | null>(null); [filters],
const [drawerOpen, setDrawerOpen] = useState(false); );
const [acting, setActing] = useState(false); const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [rejectOpen, setRejectOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
async function openDetail(id: string) { const [rejectReason, setRejectReason] = useState('');
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res); async function openDetail(id: string) {
setDrawerOpen(true); const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
} setDetail(res);
setDrawerOpen(true);
async function complete() { }
if (!detail) return;
setActing(true); async function complete() {
try { if (!detail) return;
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' }); setActing(true);
message.success('已补核销'); try {
setDrawerOpen(false); await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
void reload(); message.success('已补核销');
} catch (e) { setDrawerOpen(false);
message.error(e instanceof Error ? e.message : '补核销失败'); void reload();
} finally { } catch (e) {
setActing(false); message.error(e instanceof Error ? e.message : '补核销失败');
} } finally {
} setActing(false);
}
async function reject() { }
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因'); async function reject() {
return; if (!detail || !rejectReason.trim()) {
} message.error('请填写驳回原因');
setActing(true); return;
try { }
await request(`/admin/redeem-pending/${detail.id}/reject`, { setActing(true);
method: 'POST', try {
body: JSON.stringify({ reason: rejectReason.trim() }), await request(`/admin/redeem-pending/${detail.id}/reject`, {
}); method: 'POST',
message.success('已驳回'); body: JSON.stringify({ reason: rejectReason.trim() }),
setRejectOpen(false); });
setDrawerOpen(false); message.success('已驳回');
void reload(); setRejectOpen(false);
} catch (e) { setDrawerOpen(false);
message.error(e instanceof Error ? e.message : '驳回失败'); void reload();
} finally { } catch (e) {
setActing(false); message.error(e instanceof Error ? e.message : '驳回失败');
} } finally {
} setActing(false);
}
const baseColumns: ColumnsType<RedeemPendingItem> = [ }
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
{ const baseColumns: ColumnsType<RedeemPendingItem> = [
title: '核销码 ID', {
dataIndex: 'redeemToken', title: '待处理单号',
width: 160, dataIndex: 'pendingNo',
width: 170,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>, render: (v, row) => (
}, <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' }, ),
{ },
title: '用户', {
width: 120, title: '核销码 ID',
render: (_, row) => row.user?.userNo || row.user?.phone || '—', dataIndex: 'redeemToken',
}, width: 160,
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
{ title: '失败次数', dataIndex: 'failCount', width: 90 }, },
{ { title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
title: '状态', {
dataIndex: 'status', title: '用户',
width: 100, width: 120,
render: (s: RedeemPendingStatus) => ( render: (_, row) => row.user?.userNo || row.user?.phone || '—',
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag> },
), { title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
}, { title: '失败次数', dataIndex: 'failCount', width: 90 },
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, {
{ title: '状态',
title: '操作', dataIndex: 'status',
width: 80, width: 100,
fixed: 'right', render: (s: RedeemPendingStatus) => (
render: (_, row) => ( <Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}> ),
},
</Button> { title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
), {
}, title: '操作',
]; width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
return (
<div> <div>
<div> {settingsModal}
{settingsModal} <AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
<Typography.Title level={4}></Typography.Title> <Form
{settingsButton} form={form}
<Form layout="inline"
form={form} style={{ marginBottom: 16 }}
layout="inline" onFinish={(v) => {
style={{ marginBottom: 16 }} setFilters(v);
onFinish={(v) => { setPage(1);
setFilters(v); }}
setPage(1); >
}} <Form.Item name="pendingNo" label="待处理单号">
> <Input allowClear />
<Form.Item name="pendingNo" label="待处理单号"> </Form.Item>
<Input allowClear /> <Form.Item name="redeemToken" label="核销码 ID">
</Form.Item> <Input allowClear />
<Form.Item name="redeemToken" label="核销码 ID"> </Form.Item>
<Input allowClear /> <Form.Item name="storeId" label="门店 ID">
</Form.Item> <Input allowClear />
<Form.Item name="storeId" label="门店 ID"> </Form.Item>
<Input allowClear /> <Form.Item name="status" label="状态">
</Form.Item> <Select
<Form.Item name="status" label="状态"> allowClear
<Select style={{ width: 120 }}
allowClear options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
style={{ width: 120 }} value,
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({ label,
value, }))}
label, />
}))} </Form.Item>
/> <Form.Item>
</Form.Item> <Button type="primary" htmlType="submit">
<Form.Item>
<Button type="primary" htmlType="submit"> </Button>
</Form.Item>
</Button> </Form>
</Form.Item>
</Form> <Table
rowKey="id"
<Table loading={loading}
rowKey="id" columns={columns}
loading={loading} dataSource={data?.items ?? []}
columns={columns} scroll={{ x: 'max-content' }}
dataSource={data?.items ?? []} pagination={{
scroll={{ x: 'max-content' }} current: page,
pagination={{ pageSize,
current: page, total: data?.total ?? 0,
pageSize, showSizeChanger: true,
total: data?.total ?? 0, onChange: (p, ps) => {
showSizeChanger: true, setPage(p);
onChange: (p, ps) => { setPageSize(ps);
setPage(p); },
setPageSize(ps); }}
}, />
}}
/> <Drawer
title="待处理核销详情"
<Drawer width={560}
title="待处理核销详情" open={drawerOpen}
width={560} onClose={() => setDrawerOpen(false)}
open={drawerOpen} extra={
onClose={() => setDrawerOpen(false)} detail?.status === 'PENDING' && (
extra={ <Space>
detail?.status === 'PENDING' && ( <Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}> </Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}> </Button>
</Space>
</Button> )
</Space> }
) >
} {detail && (
> <>
{detail && ( {detail.photoUrl && (
<> <div style={{ textAlign: 'center', marginBottom: 16 }}>
{detail.photoUrl && ( <Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
<div style={{ textAlign: 'center', marginBottom: 16 }}> </div>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} /> )}
</div> <Descriptions column={1} bordered size="small">
)} <Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions column={1} bordered size="small"> <Descriptions.Item label="核销码 ID">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item> <Typography.Text copyable>{detail.redeemToken}</Typography.Text>
<Descriptions.Item label="核销码 ID"> </Descriptions.Item>
<Typography.Text copyable>{detail.redeemToken}</Typography.Text> <Descriptions.Item label="状态">
</Descriptions.Item> <Tag color={STATUS_COLOR[detail.status]}>
<Descriptions.Item label="状态"> {REDEEM_PENDING_STATUS_LABELS[detail.status]}
<Tag color={STATUS_COLOR[detail.status]}> </Tag>
{REDEEM_PENDING_STATUS_LABELS[detail.status]} </Descriptions.Item>
</Tag> <Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item> <Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item> <Descriptions.Item label="门店">
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item> {detail.store?.name || '—'}{detail.store?.id}
<Descriptions.Item label="门店"> </Descriptions.Item>
{detail.store?.name || '—'}{detail.store?.id} <Descriptions.Item label="用户">
</Descriptions.Item> {detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
<Descriptions.Item label="用户"> </Descriptions.Item>
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'} <Descriptions.Item label="关联核销单">
</Descriptions.Item> {detail.redeemRecord?.redeemNo || '—'}
<Descriptions.Item label="关联核销单"> </Descriptions.Item>
{detail.redeemRecord?.redeemNo || '—'} <Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item> <Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> <Descriptions.Item label="处理时间">
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item> {detail.processedAt ? fmtTime(detail.processedAt) : '—'}
<Descriptions.Item label="处理时间"> </Descriptions.Item>
{detail.processedAt ? fmtTime(detail.processedAt) : '—'} </Descriptions>
</Descriptions.Item> </>
</Descriptions> )}
</> </Drawer>
)}
</Drawer> <Modal
title="驳回待处理单"
<Modal open={rejectOpen}
title="驳回待处理单" okText="确认驳回"
open={rejectOpen} okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
okText="确认驳回" onOk={() => void reject()}
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }} onCancel={() => setRejectOpen(false)}
onOk={() => void reject()} >
onCancel={() => setRejectOpen(false)} <Input.TextArea
> rows={3}
<Input.TextArea placeholder="请填写驳回原因"
rows={3} value={rejectReason}
placeholder="请填写驳回原因" onChange={(e) => setRejectReason(e.target.value)}
value={rejectReason} />
onChange={(e) => setRejectReason(e.target.value)} </Modal>
/> </div>
</Modal> );
</div> }
);
@@ -14,6 +14,7 @@ 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;
@@ -140,7 +141,23 @@ 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 },
+16 -6
View File
@@ -15,6 +15,8 @@ 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 = {
@@ -391,7 +393,14 @@ 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 }, { title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
<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',
@@ -502,11 +511,11 @@ export default function ProductsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="商品管理"
{settingsButton} settings={settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button> actions={<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 }} />
@@ -601,6 +610,7 @@ export default function ProductsPage() {
label: '规格与 SKU', label: '规格与 SKU',
children: ( children: (
<ProductSpecsEditor <ProductSpecsEditor
key={String(detail.id)}
productId={String(detail.id)} productId={String(detail.id)}
initialAttrs={(detail.specAttrs as never) ?? []} initialAttrs={(detail.specAttrs as never) ?? []}
initialSkus={(detail.skus as never) ?? []} initialSkus={(detail.skus as never) ?? []}
+15 -6
View File
@@ -15,6 +15,8 @@ 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;
@@ -77,7 +79,14 @@ 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: '场景',
@@ -167,11 +176,11 @@ export default function PromoCodesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title> title="推广码管理"
{settingsButton} settings={settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button> actions={<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>}
</div> />
<Form <Form
form={filterForm} form={filterForm}
@@ -8,6 +8,8 @@ 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 = {
@@ -84,7 +86,7 @@ export default function RedeemRecordsPage() {
width: 200, width: 200,
render: (v, row) => ( render: (v, row) => (
<span> <span>
{v} <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? ( {row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}> <Tag color="orange" style={{ marginLeft: 6 }}>
@@ -140,8 +142,7 @@ export default function RedeemRecordsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader title="核销记录" settings={settingsButton} />
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+6 -9
View File
@@ -13,6 +13,7 @@ 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 = {
@@ -121,15 +122,11 @@ export default function ResourcesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="OSS 资源库"
OSS settings={settingsButton}
</Typography.Title> actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>}
{settingsButton} />
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+26 -19
View File
@@ -7,6 +7,8 @@ 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 };
@@ -83,7 +85,14 @@ export default function StoreAccountsPage() {
width: 120, width: 120,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <AdminPrimaryLink
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>
), ),
@@ -145,24 +154,22 @@ export default function StoreAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Space direction="vertical" size={0}> title="门店账户"
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> settings={settingsButton}
{settingsButton} description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
<Typography.Text type="secondary"> actions={
<Button
</Typography.Text> type="primary"
</Space> onClick={() => {
<Button void loadStores();
type="primary" setCreateOpen(true);
onClick={() => { }}
void loadStores(); >
setCreateOpen(true);
}} </Button>
> }
/>
</Button>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,8 @@ 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 = {
@@ -130,7 +132,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 || ''} / ` : ''}
{name} <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</span> </span>
), ),
}, },
@@ -157,9 +159,20 @@ export default function StoreCategoriesPage() {
<Popconfirm <Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'} title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => { onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); try {
message.success('已处理'); const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
void reload(); `/admin/store-categories/${row.id}`,
{ method: 'DELETE' },
);
if (res.softDisabled) {
message.success('该分类已有门店占用,已改为停用');
} else {
message.success('已删除');
}
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}} }}
> >
<Button type="link" size="small" danger></Button> <Button type="link" size="small" danger></Button>
@@ -178,27 +191,27 @@ export default function StoreCategoriesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Space direction="vertical" size={0}> title="门店分类"
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> settings={settingsButton}
{settingsButton} description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
<Typography.Text type="secondary">/宿/ </Typography.Text> actions={
</Space> <>
<Space> {canDelete ? (
{canDelete ? ( <Button
<Button onClick={async () => {
onClick={async () => { await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
await request('/admin/store-categories/ensure-defaults', { method: 'POST' }); message.success('已同步默认分类');
message.success('已同步默认分类'); void reload();
void reload(); }}
}} >
>
</Button>
</Button> ) : null}
) : null} <Button type="primary" onClick={() => openCreate()}></Button>
<Button type="primary" onClick={() => openCreate()}></Button> </>
</Space> }
</Space> />
<Table <Table
rowKey="id" rowKey="id"
+32 -15
View File
@@ -4,15 +4,16 @@ 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 = {
@@ -100,17 +101,33 @@ export default function StoreLogsPage() {
{ {
title: '分类', dataIndex: 'category', width: 100, title: '分类', dataIndex: 'category', width: 100,
render: (v: StoreLogCategory | null, r) => ( render: (v: StoreLogCategory | null, r) => (
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag> <Tag>{storeEventCategoryLabel(r.eventName, v)}</Tag>
),
},
{
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) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => formatRef(r.refType, r.refId),
}, },
{ {
title: '摘要', title: '摘要',
@@ -134,11 +151,11 @@ export default function StoreLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader
{settingsButton} title="商户日志"
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}> settings={settingsButton}
/ 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 }))}
@@ -166,7 +183,7 @@ export default function StoreLogsPage() {
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item> <Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item> <Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item> <Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="eventName" label="事件"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item> <Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
@@ -180,12 +197,12 @@ export default function StoreLogsPage() {
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item> <Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item> <Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类"> <Descriptions.Item label="分类">
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')} {storeEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item> <Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item> <Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item> <Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item> <Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数"> <Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
+23 -6
View File
@@ -8,6 +8,8 @@ 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 = {
@@ -43,7 +45,22 @@ 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,
@@ -80,11 +97,11 @@ export default function StoreMediaPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="门店资源"
{settingsButton} settings={settingsButton}
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button> actions={<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,6 +33,8 @@ 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: '待审核',
@@ -199,7 +201,15 @@ 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',
@@ -471,7 +481,15 @@ 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',
@@ -528,8 +546,7 @@ export default function StorePackageAuditsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader title="审核通知" settings={settingsButton} />
{settingsButton}
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
@@ -6,6 +6,7 @@ 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 = {
@@ -69,10 +70,7 @@ export default function StoreRatingsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <AdminListHeader title="门店评价" settings={settingsButton} />
</Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -21,6 +21,8 @@ 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 = {
@@ -156,7 +158,7 @@ export default function StoreWithdrawalsPage() {
width: 180, width: 180,
render: (v, row) => ( render: (v, row) => (
<Space> <Space>
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link> <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null} {row.overdue ? <Tag color="magenta"></Tag> : null}
</Space> </Space>
), ),
@@ -236,10 +238,7 @@ export default function StoreWithdrawalsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <AdminListHeader title="门店提现审" settings={settingsButton} />
</Typography.Title>
{settingsButton}
{overdueSummary ? ( {overdueSummary ? (
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
{overdueSummary.pendingCount} {overdueSummary.pendingCount}
+10 -10
View File
@@ -40,6 +40,8 @@ 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';
@@ -739,7 +741,7 @@ export default function StoresPage() {
const name = v || '—'; const name = v || '—';
return ( return (
<Space size={4} wrap={false}> <Space size={4} wrap={false}>
<span>{name}</span> <AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
); );
@@ -872,16 +874,14 @@ export default function StoresPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Space direction="vertical" size={0}> title="门店"
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text> description={`${data?.total ?? 0} 家门店(含合伙人录入)`}
</Space> actions={
<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,6 +47,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
@@ -505,7 +506,13 @@ 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 },
{ {
+19 -5
View File
@@ -21,6 +21,8 @@ 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;
@@ -254,7 +256,22 @@ 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',
@@ -394,10 +411,7 @@ export default function TestWhitelistPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <AdminListHeader title="白名单管理" settings={settingsButton} />
</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 }}>
+45 -21
View File
@@ -3,8 +3,17 @@ 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 = {
@@ -24,14 +33,14 @@ type Row = {
}; };
const PROVIDER_OPTIONS = [ const PROVIDER_OPTIONS = [
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' }, { value: 'WECHAT_AUTH', label: '微信授权' },
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' }, { value: 'WECHAT_PAY', label: '微信支付' },
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' }, { value: 'WECHAT_MAP', label: '微信/腾讯地图' },
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' }, { value: 'ALIYUN_OSS', label: '阿里云 OSS' },
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' }, { value: 'ALIYUN_SMS', label: '阿里云短信' },
{ value: 'MOCK_SMS', label: 'MOCK_SMS' }, { value: 'MOCK_SMS', label: 'Mock 短信' },
{ value: 'XFX', label: '小飞侠 (XFX)' }, { value: 'XFX', label: '小飞侠' },
{ value: 'LOGISTICS', label: 'LOGISTICS' }, { value: 'LOGISTICS', label: '物流快递' },
]; ];
const STATUS_COLOR: Record<string, string> = { const STATUS_COLOR: Record<string, string> = {
@@ -73,18 +82,18 @@ export default function ThirdPartyLogsPage() {
const baseColumns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: 'Provider', dataIndex: 'provider', width: 120 }, { title: '渠道', dataIndex: 'provider', width: 120, render: (v: string) => zhLabel(THIRD_PARTY_PROVIDER_LABELS, v) },
{ title: '场景', dataIndex: 'scene', width: 120 }, { title: '场景', dataIndex: 'scene', width: 140, render: (v: string) => zhLabel(THIRD_PARTY_SCENE_LABELS, v) },
{ {
title: '关联', title: '关联',
width: 120, width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => formatRef(r.refType, r.refId),
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 90,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{v}</Tag>, render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{zhLabel(THIRD_PARTY_STATUS_LABELS, v)}</Tag>,
}, },
{ {
title: '错误信息', title: '错误信息',
@@ -95,7 +104,21 @@ 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,
@@ -122,8 +145,7 @@ export default function ThirdPartyLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader title="第三方日志" settings={settingsButton} />
{settingsButton}
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -132,11 +154,11 @@ export default function ThirdPartyLogsPage() {
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="provider" label="Provider"> <Form.Item name="provider" label="渠道">
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} /> <Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="scene" label="场景"> <Form.Item name="scene" label="场景">
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" style={{ width: 160 }} /> <Input allowClear placeholder="场景编码" style={{ width: 160 }} />
</Form.Item> </Form.Item>
<Form.Item name="refId" label="关联ID"> <Form.Item name="refId" label="关联ID">
<Input allowClear style={{ width: 120 }} /> <Input allowClear style={{ width: 120 }} />
@@ -166,13 +188,15 @@ export default function ThirdPartyLogsPage() {
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item> <Descriptions.Item label="渠道">{zhLabel(THIRD_PARTY_PROVIDER_LABELS, String(detail.provider))}</Descriptions.Item>
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item> <Descriptions.Item label="场景">{zhLabel(THIRD_PARTY_SCENE_LABELS, String(detail.scene))}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>{String(detail.status)}</Tag> <Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>
{zhLabel(THIRD_PARTY_STATUS_LABELS, String(detail.status))}
</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="关联"> <Descriptions.Item label="关联">
{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'} {formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item> <Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item> <Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
+16 -1
View File
@@ -26,6 +26,7 @@ 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 = {
@@ -118,7 +119,21 @@ 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',
+30 -13
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd'; import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log'; import { USER_LOG_CATEGORY_OPTIONS, type UserLogCategory } from '../lib/user-log';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { clientAppLabel, eventNameLabel, formatRef, userEventCategoryLabel } from '../lib/display-labels';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
@@ -25,8 +28,6 @@ type Row = {
createdAt: string; createdAt: string;
}; };
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
function summarizeExtra(json: Record<string, unknown> | null) { function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—'; if (!json) return '—';
const text = JSON.stringify(json); const text = JSON.stringify(json);
@@ -77,13 +78,30 @@ export default function UserLogsPage() {
{ {
title: '分类', dataIndex: 'category', width: 100, title: '分类', dataIndex: 'category', width: 100,
render: (v: UserLogCategory | null, r) => ( render: (v: UserLogCategory | null, r) => (
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag> <Tag>{userEventCategoryLabel(r.eventName, v)}</Tag>
), ),
}, },
{ 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) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => formatRef(r.refType, r.refId),
}, },
{ {
title: '摘要', title: '摘要',
@@ -105,8 +123,7 @@ export default function UserLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader title="用户日志" settings={settingsButton} />
{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 }))}
@@ -133,7 +150,7 @@ export default function UserLogsPage() {
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item> <Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item> <Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item> <Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="eventName" label="事件"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item> <Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
@@ -145,10 +162,10 @@ export default function UserLogsPage() {
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item> <Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item> <Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item> <Descriptions.Item label="分类">{userEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item> <Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item> <Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item> <Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数"> <Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
+25 -20
View File
@@ -17,10 +17,13 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { 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;
@@ -332,7 +335,9 @@ export default function UsersPage() {
title: '昵称', title: '昵称',
dataIndex: 'nickname', dataIndex: 'nickname',
width: 140, width: 140,
render: (v: string | null) => v || '—', render: (v: string | null, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
}, },
{ {
title: '备注', title: '备注',
@@ -478,21 +483,21 @@ export default function UsersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="用户监控"
<Space> settings={settingsButton}
{settingsButton} actions={
{canDeleteUsers ? ( canDeleteUsers ? (
<Button <Button
danger danger
disabled={!selectedRowKeys.length} disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()} onClick={() => void openBatchDeleteModal()}
> >
{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 />
@@ -636,10 +641,10 @@ export default function UsersPage() {
{ {
title: '分类', title: '分类',
width: 100, width: 100,
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—', render: (_, r) => userEventCategoryLabel(r.eventName),
}, },
{ title: '事件', dataIndex: 'eventName' }, { title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
{ title: '端', dataIndex: 'clientApp', width: 90 }, { title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
]} ]}
/> />
+317 -313
View File
@@ -1,318 +1,322 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd'; } 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 { AdminCellLine } from '../components/AdminCellLine'; 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 Identity = { type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
actorType: ActorType;
actorId: string; type Identity = {
phone: string | null; actorType: ActorType;
name: string | null; actorId: string;
wxOpenId: string; phone: string | null;
wxUnionId: string | null; name: string | null;
phoneVerified?: boolean; wxOpenId: string;
refLabel: string | null; wxUnionId: string | null;
refId: string | null; phoneVerified?: boolean;
lastLoginAt: string | null; refLabel: string | null;
status: string | number; refId: string | null;
}; lastLoginAt: string | null;
status: string | number;
type GroupRow = { };
groupKey: string;
unionId: string | null; type GroupRow = {
identityCount: number; groupKey: string;
actorTypes: ActorType[]; unionId: string | null;
multiRole: boolean; identityCount: number;
primaryPhone: string | null; actorTypes: ActorType[];
latestLoginAt: string | null; multiRole: boolean;
identities: Identity[]; primaryPhone: string | null;
}; latestLoginAt: string | null;
identities: Identity[];
const ACTOR_TYPE_LABELS: Record<ActorType, string> = { };
USER: 'C 端用户',
STORE: '门店账号', const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
PARTNER: '合伙人账号', USER: 'C 端用户',
HQ: 'HQ 账号', STORE: '门店账号',
}; PARTNER: '合伙人账号',
HQ: 'HQ 账号',
const ACTOR_TYPE_COLORS: Record<ActorType, string> = { };
USER: 'blue',
STORE: 'green', const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
PARTNER: 'orange', USER: 'blue',
HQ: 'purple', STORE: 'green',
}; PARTNER: 'orange',
HQ: 'purple',
function renderActorTags(types: ActorType[]) { };
return types.map((t) => (
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}> function renderActorTags(types: ActorType[]) {
{ACTOR_TYPE_LABELS[t]} return types.map((t) => (
</Tag> <Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
)); {ACTOR_TYPE_LABELS[t]}
} </Tag>
));
export default function WechatBindingsPage() { }
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({ export default function WechatBindingsPage() {
actorType: '', const [form] = Form.useForm();
phone: '', const [filters, setFilters] = useState<Record<string, string>>({
unionId: '', actorType: '',
openId: '', phone: '',
}); unionId: '',
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>( openId: '',
'/admin/wechat-bindings', });
() => { const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
const qs = new URLSearchParams(); '/admin/wechat-bindings',
if (filters.actorType) qs.set('actorType', filters.actorType); () => {
if (filters.phone) qs.set('phone', filters.phone); const qs = new URLSearchParams();
if (filters.unionId) qs.set('unionId', filters.unionId); if (filters.actorType) qs.set('actorType', filters.actorType);
if (filters.openId) qs.set('openId', filters.openId); if (filters.phone) qs.set('phone', filters.phone);
return qs; if (filters.unionId) qs.set('unionId', filters.unionId);
}, if (filters.openId) qs.set('openId', filters.openId);
[filters], return qs;
); },
const [detail, setDetail] = useState<GroupRow | null>(null); [filters],
const [drawerOpen, setDrawerOpen] = useState(false); );
const [detail, setDetail] = useState<GroupRow | null>(null);
useEffect(() => { const [drawerOpen, setDrawerOpen] = useState(false);
form.setFieldsValue(filters);
}, [form, filters]); useEffect(() => {
form.setFieldsValue(filters);
async function openDetail(row: GroupRow) { }, [form, filters]);
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
setDetail(res); async function openDetail(row: GroupRow) {
setDrawerOpen(true); const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
} setDetail(res);
setDrawerOpen(true);
const baseColumns: ColumnsType<GroupRow> = [ }
{
title: 'unionId', const baseColumns: ColumnsType<GroupRow> = [
dataIndex: 'unionId', {
width: 180, title: 'unionId',
dataIndex: 'unionId',
render: (v) => v || <Tag> unionId</Tag>, width: 180,
}, render: (v) => v || <Tag> unionId</Tag>,
{ },
title: '身份数', {
dataIndex: 'identityCount', title: '身份数',
width: 90, dataIndex: 'identityCount',
render: (v, r) => ( width: 90,
<Space size={4}> render: (v, r) => (
<span>{v}</span> <Space size={4}>
{r.multiRole ? <Tag color="red"></Tag> : null} <span>{v}</span>
</Space> {r.multiRole ? <Tag color="red"></Tag> : null}
), </Space>
}, ),
{ },
title: '端类型', {
dataIndex: 'actorTypes', title: '端类型',
width: 220, dataIndex: 'actorTypes',
render: (types: ActorType[]) => renderActorTags(types), width: 220,
}, render: (types: ActorType[]) => renderActorTags(types),
{ },
title: '手机号', {
dataIndex: 'primaryPhone', title: '手机号',
width: 140, dataIndex: 'primaryPhone',
render: (v) => maskPhone(v), width: 140,
}, render: (v, row) => (
{ <AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
title: '身份摘要', ),
},
render: (_, r) => ( {
<AdminCellLine title: '身份摘要',
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} render: (_, r) => (
secondary={r.identities <AdminCellLine
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : '')) primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
.filter(Boolean) secondary={r.identities
.join(' · ')} .map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
/> .filter(Boolean)
), .join(' · ')}
}, />
{ ),
title: '最近登录', },
dataIndex: 'latestLoginAt', {
width: 160, title: '最近登录',
render: fmtTime, dataIndex: 'latestLoginAt',
}, width: 160,
{ render: fmtTime,
title: '操作', },
width: 80, {
render: (_, row) => ( title: '操作',
<Button type="link" size="small" onClick={() => void openDetail(row)}> width: 80,
render: (_, row) => (
</Button> <Button type="link" size="small" onClick={() => void openDetail(row)}>
),
}, </Button>
]; ),
},
const identityColumns: ColumnsType<Identity> = [ ];
{
title: '端类型', const identityColumns: ColumnsType<Identity> = [
dataIndex: 'actorType', {
width: 120, title: '端类型',
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>, dataIndex: 'actorType',
}, width: 120,
{ render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
title: '账号', },
{
render: (_, r) => ( title: '账号',
<AdminCellLine render: (_, r) => (
primary={r.name || '—'} <AdminCellLine
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')} primary={r.name || ''}
/> secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
), />
}, ),
{ },
title: '归属', {
dataIndex: 'refLabel', title: '归属',
width: 160, dataIndex: 'refLabel',
width: 160,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
}, },
{ {
title: 'wxOpenId', title: 'wxOpenId',
dataIndex: 'wxOpenId', dataIndex: 'wxOpenId',
width: 160, width: 160,
},
}, {
{ title: '手机验证',
title: '手机验证', width: 90,
width: 90, render: (_, r) =>
render: (_, r) => r.actorType === 'USER' ? (
r.actorType === 'USER' ? ( r.phoneVerified ? <Tag color="blue"></Tag> : <Tag></Tag>
r.phoneVerified ? <Tag color="blue"></Tag> : <Tag></Tag> ) : (
) : ( '—'
'—' ),
), },
}, {
{ title: '最近登录',
title: '最近登录', dataIndex: 'lastLoginAt',
dataIndex: 'lastLoginAt', width: 160,
width: 160, render: fmtTime,
render: fmtTime, },
}, {
{ title: '状态',
title: '状态', dataIndex: 'status',
dataIndex: 'status', width: 90,
render: (v) => String(v),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
}, return (
<div> <div>
{settingsModal}
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize }); <AdminListHeader
title="微信绑定总览"
return ( settings={settingsButton}
<div> description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
{settingsModal} />
<Typography.Title level={4}></Typography.Title>
{settingsButton} <Form
<Typography.Paragraph type="secondary"> form={form}
unionId C HQ unionId layout="inline"
</Typography.Paragraph> style={{ marginBottom: 16 }}
onFinish={(values) => {
<Form setPage(1);
form={form} setFilters({
layout="inline" actorType: values.actorType ?? '',
style={{ marginBottom: 16 }} phone: values.phone?.trim() ?? '',
onFinish={(values) => { unionId: values.unionId?.trim() ?? '',
setPage(1); openId: values.openId?.trim() ?? '',
setFilters({ });
actorType: values.actorType ?? '', }}
phone: values.phone?.trim() ?? '', >
unionId: values.unionId?.trim() ?? '', <Form.Item name="actorType" label="端类型">
openId: values.openId?.trim() ?? '', <Select
}); allowClear
}} placeholder="全部"
> style={{ width: 140 }}
<Form.Item name="actorType" label="端类型"> options={[
<Select { value: 'USER', label: 'C 端用户' },
allowClear { value: 'STORE', label: '门店账号' },
placeholder="全部" { value: 'PARTNER', label: '合伙人账号' },
style={{ width: 140 }} { value: 'HQ', label: 'HQ 账号' },
options={[ ]}
{ value: 'USER', label: 'C 端用户' }, />
{ value: 'STORE', label: '门店账号' }, </Form.Item>
{ value: 'PARTNER', label: '合伙人账号' }, <Form.Item name="phone" label="手机号">
{ value: 'HQ', label: 'HQ 账号' }, <Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
]} </Form.Item>
/> <Form.Item name="unionId" label="unionId">
</Form.Item> <Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
<Form.Item name="phone" label="手机号"> </Form.Item>
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} /> <Form.Item name="openId" label="openId">
</Form.Item> <Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
<Form.Item name="unionId" label="unionId"> </Form.Item>
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} /> <Form.Item>
</Form.Item> <Space>
<Form.Item name="openId" label="openId"> <Button type="primary" htmlType="submit">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item> </Button>
<Form.Item> <Button
<Space> onClick={() => {
<Button type="primary" htmlType="submit"> form.resetFields();
setPage(1);
</Button> setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
<Button }}
onClick={() => { >
form.resetFields();
setPage(1); </Button>
setFilters({ actorType: '', phone: '', unionId: '', openId: '' }); <Button onClick={() => void reload()}></Button>
}} </Space>
> </Form.Item>
</Form>
</Button>
<Button onClick={() => void reload()}></Button> <Table<GroupRow>
</Space> rowKey="groupKey"
</Form.Item> loading={loading}
</Form> columns={columns}
dataSource={data?.items ?? []}
<Table<GroupRow> pagination={{
rowKey="groupKey" current: page,
loading={loading} pageSize,
columns={columns} total: data?.total ?? 0,
dataSource={data?.items ?? []} showSizeChanger: true,
pagination={{ onChange: (p, ps) => {
current: page, setPage(p);
pageSize, setPageSize(ps);
total: data?.total ?? 0, },
showSizeChanger: true, }}
onChange: (p, ps) => { />
setPage(p);
setPageSize(ps); <Drawer
}, title="微信绑定详情"
}} width={960}
/> open={drawerOpen}
onClose={() => setDrawerOpen(false)}
<Drawer >
title="微信绑定详情" {detail ? (
width={960} <>
open={drawerOpen} <Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
onClose={() => setDrawerOpen(false)} <Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
> <Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
{detail ? ( <Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
<> <Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions.Item label="一人多角色">
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item> {detail.multiRole ? <Tag color="red"></Tag> : <Tag></Tag>}
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item> <Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item> </Descriptions>
<Descriptions.Item label="一人多角色"> <Table<Identity>
{detail.multiRole ? <Tag color="red"></Tag> : <Tag></Tag>} rowKey={(r) => `${r.actorType}-${r.actorId}`}
</Descriptions.Item> size="small"
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item> columns={identityColumns}
</Descriptions> dataSource={detail.identities}
<Table<Identity> pagination={false}
rowKey={(r) => `${r.actorType}-${r.actorId}`} />
size="small" </>
columns={identityColumns} ) : null}
dataSource={detail.identities} </Drawer>
pagination={false} </div>
/> );
</> }
+24 -14
View File
@@ -4,8 +4,11 @@ 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() {
@@ -28,10 +31,17 @@ 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: 160 }, { title: '动作', dataIndex: 'action', width: 180, render: (v: string) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' }, { title: '权限', dataIndex: 'permission', width: 160, render: (v) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
{ {
title: '结果', title: '结果',
dataIndex: 'success', dataIndex: 'success',
@@ -56,11 +66,11 @@ export default function WecomBotLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<Typography.Title level={4}></Typography.Title> <AdminListHeader
{settingsButton} title="企微机器人日志"
<Typography.Paragraph type="secondary"> settings={settingsButton}
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
</Typography.Paragraph> />
<Form <Form
form={form} form={form}
@@ -72,13 +82,13 @@ export default function WecomBotLogsPage() {
}} }}
> >
<Form.Item name="botId" label="机器人 ID"> <Form.Item name="botId" label="机器人 ID">
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} /> <Input allowClear placeholder="机器人 ID" style={{ width: 140 }} />
</Form.Item> </Form.Item>
<Form.Item name="wecomUserId" label="企微 userid"> <Form.Item name="wecomUserId" label="企微用户">
<Input allowClear placeholder="userid" style={{ width: 140 }} /> <Input allowClear placeholder="企微用户 ID" style={{ width: 140 }} />
</Form.Item> </Form.Item>
<Form.Item name="action" label="动作"> <Form.Item name="action" label="动作">
<Input allowClear placeholder="order.read" style={{ width: 140 }} /> <Input allowClear placeholder="动作编码" style={{ width: 140 }} />
</Form.Item> </Form.Item>
<Form.Item name="success" label="结果"> <Form.Item name="success" label="结果">
<Select <Select
@@ -121,8 +131,8 @@ export default function WecomBotLogsPage() {
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item> <Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item> <Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item> <Descriptions.Item label="动作">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.action)}</Descriptions.Item>
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item> <Descriptions.Item label="权限">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.permission)}</Descriptions.Item>
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item> <Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item> <Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item> <Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
+9 -1
View File
@@ -37,6 +37,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
@@ -293,7 +294,14 @@ 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,6 +35,8 @@ 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 = {
@@ -202,7 +204,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>
<span>{name}</span> <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</Space> </Space>
), ),
}, },
@@ -271,9 +273,15 @@ function PushRoutesTab() {
return ( return (
<> <>
{settingsModal} {settingsModal}
<Typography.Paragraph type="secondary"> <AdminListHeader
Webhook / .env Webhook URL settings={settingsButton}
</Typography.Paragraph> description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
actions={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<Form <Form
form={filterForm} form={filterForm}
@@ -308,10 +316,6 @@ function PushRoutesTab() {
> >
</Button> </Button>
<Button type="primary" onClick={openCreate}>
</Button>
{settingsButton}
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
+22 -26
View File
@@ -27,6 +27,8 @@ 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 = {
@@ -238,7 +240,14 @@ 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',
@@ -297,31 +306,18 @@ export default function WineryBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div <AdminListHeader
style={{ title="酒厂对账单"
display: 'flex', settings={settingsButton}
justifyContent: 'space-between', description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
alignItems: 'flex-start', actions={
marginBottom: 16, canEditWineryBank ? (
gap: 16, <Button type="default" onClick={() => void openBankModal()}>
}}
> </Button>
<Space direction="vertical" size={0}> ) : null
<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>
) : null}
</div>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -10,6 +10,8 @@ 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() {
@@ -25,7 +27,16 @@ 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: '验手机',
@@ -74,20 +85,24 @@ 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 (
<Table <>
rowKey="id" {settingsModal}
className="admin-table-nowrap" <AdminListHeader settings={settingsButton} />
loading={loading} <Table
columns={columns} rowKey="id"
dataSource={data?.items ?? []} className="admin-table-nowrap"
scroll={{ x: 'max-content' }} loading={loading}
pagination={{ columns={columns}
current: page, dataSource={data?.items ?? []}
pageSize, scroll={{ x: 'max-content' }}
total: data?.total ?? 0, pagination={{
showSizeChanger: true, current: page,
onChange: (p, ps) => { setPage(p); setPageSize(ps); }, pageSize,
}} total: data?.total ?? 0,
/> showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</>
); );
} }
+5 -1
View File
@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
const SELECT_STORE_PATH = '/select-store'; const SELECT_STORE_PATH = '/select-store';
@@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (authenticated && location.pathname === '/login') { if (authenticated && location.pathname === '/login') {
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />; const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
return <Navigate to={next} replace />;
} }
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) { if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
rememberShopReturnPath(location);
return <Navigate to={SELECT_STORE_PATH} replace />; return <Navigate to={SELECT_STORE_PATH} replace />;
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
rememberShopReturnPath(location);
const profile = getStoreProfile(); const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') { if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />; return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
+1 -22
View File
@@ -1,22 +1 @@
/** 从微信扫码结果解析核销 token32 位 hex 或带 token 参数的 URL */ export { parseRedeemTokenFromScan } from '@dukang/shared-types';
export function parseRedeemTokenFromScan(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
const fromQuery = url.searchParams.get('token');
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
return fromQuery.toLowerCase();
}
} catch {
/* not a URL */
}
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
return hexMatch ? hexMatch[0].toLowerCase() : null;
}
+14
View File
@@ -0,0 +1,14 @@
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */
export function goShopPath(
path: string,
navigate: (path: string, opts?: { replace?: boolean }) => void,
opts?: { replace?: boolean },
): void {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return;
}
navigate(path, { replace: opts?.replace ?? true });
}
@@ -0,0 +1,45 @@
/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state */
export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return';
function isRedeemReturnPath(path: string): boolean {
if (!path.startsWith('/redeem')) return false;
if (path.startsWith('/redeem/')) return false;
try {
const url = new URL(path, 'https://local.invalid');
return !!url.searchParams.get('token')?.trim();
} catch {
return false;
}
}
export function rememberShopReturnTo(path: string): void {
if (!isRedeemReturnPath(path)) return;
try {
sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path);
} catch {
/* ignore */
}
}
export function rememberShopReturnPath(location: { pathname: string; search: string }): void {
rememberShopReturnTo(`${location.pathname}${location.search}`);
}
export function peekShopReturnTo(): string | null {
try {
const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY);
return path && isRedeemReturnPath(path) ? path : null;
} catch {
return null;
}
}
export function consumeShopReturnTo(): string | null {
const path = peekShopReturnTo();
try {
sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY);
} catch {
/* ignore */
}
return path;
}
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel'; import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure'; import { reportRedeemFailure } from '../lib/redeem-failure';
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
import { toastError } from '../lib/toast'; import { toastError } from '../lib/toast';
import { useStorePageView } from '../lib/usePageView'; import { useStorePageView } from '../lib/usePageView';
@@ -47,6 +48,7 @@ export default function RedeemConfirmPage() {
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;
} }
consumeShopReturnTo();
setToken(scanned); setToken(scanned);
}, [searchParams, navigate]); }, [searchParams, navigate]);
+6 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { goShopPath } from '../lib/shop-nav';
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus'; import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
import { formatMoney, toMoneyNumber } from '../lib/money'; import { formatMoney, toMoneyNumber } from '../lib/money';
@@ -35,10 +36,12 @@ export default function RedeemSuccessPage() {
}); });
}, [redeemNo, amount]); }, [redeemNo, amount]);
const goHome = () => goShopPath('/', navigate, { replace: true });
return ( return (
<div className="shop-success-page"> <div className="shop-success-page">
<header className="shop-success-header"> <header className="shop-success-header">
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回"> <button type="button" className="shop-redeem-back" onClick={goHome} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span> <span className="material-symbols-outlined">arrow_back</span>
</button> </button>
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
@@ -85,11 +88,11 @@ export default function RedeemSuccessPage() {
</div> </div>
<div className="shop-success-actions"> <div className="shop-success-actions">
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}> <button type="button" className="shop-success-primary-btn" onClick={goHome}>
<span></span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
</button> </button>
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}> <button type="button" className="shop-success-outline-btn" onClick={goHome}>
<span></span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
</button> </button>
+10 -15
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -10,13 +9,11 @@ import {
type ShopSessionPayload, type ShopSessionPayload,
type ShopStoreOption, type ShopStoreOption,
} from '../lib/api'; } from '../lib/api';
import { goShopPath } from '../lib/shop-nav';
import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) { function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) { goShopPath(consumeShopReturnTo() || '/', navigate);
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
} }
export default function SelectStorePage() { export default function SelectStorePage() {
@@ -46,7 +43,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
if (storeId === currentStoreId) { if (storeId === currentStoreId) {
goShopHome(navigate); goAfterSelectStore(navigate);
return; return;
} }
setLoadingId(storeId); setLoadingId(storeId);
@@ -54,7 +51,7 @@ export default function SelectStorePage() {
try { try {
const session = await selectStore(storeId); const session = await selectStore(storeId);
applySession(session); applySession(session);
goShopHome(navigate); goAfterSelectStore(navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败'); setMsg(e instanceof Error ? e.message : '选店失败');
} finally { } finally {
@@ -170,16 +167,14 @@ export default function SelectStorePage() {
); );
} }
/** After login/wechat: route to select-store or home */ /** After login/wechat: route to select-store, redeem return, or home */
export function routeAfterShopLogin( export function routeAfterShopLogin(
session: ShopSessionPayload, session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void, navigate: (path: string, opts?: { replace?: boolean }) => void,
) { ) {
const path = needsStoreSelection(session) ? '/select-store' : '/'; if (needsStoreSelection(session)) {
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂 goShopPath('/select-store', navigate);
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return; return;
} }
navigate(path, { replace: true }); goShopPath(peekShopReturnTo() || '/', navigate);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "3.5.4", "version": "3.5.10",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react';
import { Button, Text } from '@tarojs/components'; import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { toast } from '../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
import { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs';
export type ContactCsSessionContext = { export type ContactCsSessionContext = {
orderId?: string; orderId?: string;
@@ -16,7 +20,7 @@ type ContactCsButtonProps = {
const isWeapp = process.env.TARO_ENV === 'weapp'; const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */ /** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用 */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string { export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user'; if (!session) return 'dukang|from=mini-user';
const parts = ['dukang']; const parts = ['dukang'];
@@ -26,16 +30,61 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
return parts.join('|'); return parts.join('|');
} }
function canOpenWecom(url: string, corpId: string) {
return !!url.trim() && !!corpId.trim();
}
/** /**
* open-type=contact * C 线
* weapp * - weapp CorpID + kfid
* - weapp CorpID退 open-type=contact
* - H5 kfid
*/ */
export default function ContactCsButton({ export default function ContactCsButton({
className = '', className = '',
children = '联系在线客服', children = '联系在线客服',
session, session,
}: ContactCsButtonProps) { }: ContactCsButtonProps) {
if (!isWeapp) return null; const [cs, setCs] = useState(() => getBrandAssetsSync());
useEffect(() => {
void loadBrandAssets().then(setCs);
}, []);
const wecomChatReady = canOpenWecom(cs.customerServiceWecomUrl, cs.wecomCorpId);
const wecomWebReady = !!cs.customerServiceWecomUrl.trim();
if (!isWeapp && !wecomWebReady) return null;
async function openWecom() {
try {
if (isWeapp && wecomChatReady) {
await openWecomCustomerServiceChat({
url: cs.customerServiceWecomUrl,
corpId: cs.wecomCorpId,
session,
});
return;
}
if (typeof window !== 'undefined' && cs.customerServiceWecomUrl) {
window.location.href = cs.customerServiceWecomUrl;
return;
}
toast('请在微信内打开后联系客服');
} catch (e) {
console.error('[wecom-cs] open failed', e);
const msg = formatOpenCsError(e);
if (msg) toast(msg);
}
}
if (wecomChatReady || (!isWeapp && wecomWebReady)) {
return (
<Button className={className} hoverClass="none" onClick={() => void openWecom()}>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
return ( return (
<Button <Button
@@ -0,0 +1,22 @@
import { RichText, Text } from '@tarojs/components';
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
type DeliveryHintHtmlProps = {
html?: string | null;
className?: string;
};
/** 承运商配送提示:HTML 用 RichTexttextarea 里的换行转成 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,10 +10,11 @@ import {
const CANVAS_ID = 'redeem-qr-canvas'; const CANVAS_ID = 'redeem-qr-canvas';
type RedeemQrCodeProps = { type RedeemQrCodeProps = {
token: string; /** 二维码内容:门店 H5 落地 URL,缺省回退 token */
payload: string;
}; };
function drawOnWeappCanvas(token: string) { function drawOnWeappCanvas(payload: 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
@@ -33,33 +34,33 @@ function drawOnWeappCanvas(token: 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, token, drawSize); drawRedeemQrOnCanvas(ctx, payload, drawSize);
}); });
} }
export default function RedeemQrCode({ token }: RedeemQrCodeProps) { export default function RedeemQrCode({ payload }: 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 (!token) { if (!payload) {
setImgSrc(''); setImgSrc('');
return; return;
} }
if (isWeapp) { if (isWeapp) {
const timer = setTimeout(() => drawOnWeappCanvas(token), 120); const timer = setTimeout(() => drawOnWeappCanvas(payload), 120);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
let cancelled = false; let cancelled = false;
void buildRedeemQrDataUrl(token).then((url) => { void buildRedeemQrDataUrl(payload).then((url) => {
if (!cancelled) setImgSrc(url); if (!cancelled) setImgSrc(url);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token, isWeapp]); }, [payload, isWeapp]);
return ( return (
<View className="redeem-qr-box"> <View className="redeem-qr-box">
+8
View File
@@ -3,6 +3,7 @@ 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';
@@ -14,6 +15,8 @@ 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 = {
@@ -22,6 +25,8 @@ 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;
@@ -35,6 +40,9 @@ 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,
}; };
} }
+1 -1
View File
@@ -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.4'; export const APP_VERSION = '3.5.10';
export const APP_VERSION_LABEL = `v${APP_VERSION}`; export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+57
View File
@@ -0,0 +1,57 @@
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,3 +1,8 @@
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 = {
+6 -6
View File
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
color: { dark: '#1f1a17', light: '#ffffff' }, color: { dark: '#1f1a17', light: '#ffffff' },
} as const; } as const;
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */ /** 在 2d Canvas 上绘制二维码(小程序 / H5 通用payload 为落地 URL 或纯 token */
export function drawRedeemQrOnCanvas( export function drawRedeemQrOnCanvas(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
token: string, payload: string,
sizePx = QR_SIZE, sizePx = QR_SIZE,
) { ) {
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' }); const qr = QRCode.create(payload, { 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(
} }
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */ /** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
export async function buildRedeemQrDataUrl(token: string): Promise<string> { export async function buildRedeemQrDataUrl(payload: string): Promise<string> {
try { try {
return await QRCode.toDataURL(token, QR_OPTIONS); return await QRCode.toDataURL(payload, QR_OPTIONS);
} catch { } catch {
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`; return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`;
} }
} }
+118
View File
@@ -0,0 +1,118 @@
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,35 +16,43 @@ function dialPhone(phone: string) {
} }
export default function CustomerServicePage() { export default function CustomerServicePage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone); const [brand, setBrand] = useState(() => getBrandAssetsSync());
useEffect(() => { useEffect(() => {
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone)); void loadBrandAssets().then(setBrand);
}, []); }, []);
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"> <Text className="cs-hint">{hint}</Text>
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text> <Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? ( {isWeapp || wecomUrlReady ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} /> <ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null} ) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}> <View className={isWeapp || wecomUrlReady ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
<Text> <Text>
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'} {isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
</Text> </Text>
</View> </View>
{!isWeapp ? ( {!isWeapp && !wecomUrlReady ? (
<Text className="cs-phone-display">{phone}</Text> <Text className="cs-phone-display">{phone}</Text>
) : null} ) : null}
</View> </View>
+1 -1
View File
@@ -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=1`; const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
if (!isLoggedIn()) { if (!isLoggedIn()) {
goLogin(returnPath); goLogin(returnPath);
return; return;
@@ -56,6 +56,11 @@ 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 || '' : '');
} }
@@ -109,7 +114,7 @@ export default function OrderConfirmPickupPage() {
async function submit() { async function submit() {
if (!canSubmit) { if (!canSubmit) {
if (!quantityOk) { if (!quantityOk) {
const tip = `现场提货至少购买 ${minQty}`; const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
setMsg(tip); setMsg(tip);
toast(tip); toast(tip);
return; return;
@@ -170,7 +175,7 @@ export default function OrderConfirmPickupPage() {
const submitLabel = loading const submitLabel = loading
? '提交中…' ? '提交中…'
: !quantityOk : !quantityOk
? `至少购买 ${minQty}` ? `至少购买 ${minQty}${unitLabel}`
: '提交订单'; : '提交订单';
return ( return (
@@ -223,7 +228,7 @@ export default function OrderConfirmPickupPage() {
</View> </View>
{!quantityOk ? ( {!quantityOk ? (
<Text className="order-qty-hint"> <Text className="order-qty-hint">
{`现场提货至少购买 ${minQty},请调整数量`} {`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -11,7 +11,9 @@ 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';
@@ -75,6 +77,7 @@ 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('');
@@ -151,6 +154,18 @@ 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 })
@@ -289,7 +304,7 @@ export default function OrderConfirmPage() {
: !addressOk : !addressOk
? '请更换地址' ? '请更换地址'
: !quantityOk : !quantityOk
? `至少购买 ${minQty}` ? `至少购买 ${minQty}${unitLabel}`
: '提交订单'; : '提交订单';
const displayMsg = msg || addressHint; const displayMsg = msg || addressHint;
@@ -334,11 +349,17 @@ export default function OrderConfirmPage() {
<View className="order-card"> <View className="order-card">
<Text className="u-muted"> <Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''} {quantity < minQty ? `;跨城至少购买 ${minQty}${unitLabel}` : ''}
</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,6 +10,8 @@ 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,
@@ -83,6 +85,22 @@ 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]
@@ -102,6 +120,7 @@ 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)) {
@@ -133,6 +152,7 @@ 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]);
@@ -144,6 +164,7 @@ 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(() => {});
}); });
@@ -361,6 +382,12 @@ 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,7 +140,8 @@ export default function ProductDetailPage() {
const attrs = product?.specAttrs ?? []; const attrs = product?.specAttrs ?? [];
const skus = product?.skus ?? []; const skus = product?.skus ?? [];
const specEnabled = !!(product?.specEnabled && attrs.length > 0); const onSaleSkuCount = skus.filter((s) => s.status === 'ON_SALE').length;
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) {
@@ -231,7 +232,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 : 1; const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
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,6 +39,10 @@ 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);
@@ -148,7 +152,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 token={token} /> <RedeemQrCode payload={qrPayload} />
</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>
+5 -2
View File
@@ -113,12 +113,15 @@ 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 }>('/redeem/tokens', { const data = await request<{ token: string; amount: number; landingUrl?: string }>('/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}`, url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`,
}); });
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '生成失败'); toast(e instanceof Error ? e.message : '生成失败');
@@ -12,7 +12,6 @@ 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';
@@ -356,7 +355,6 @@ 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">
@@ -407,13 +405,11 @@ export default function StoreDetailPage() {
</View> </View>
) : null} ) : null}
<View className="store-detail-tags"> {store.category?.name ? (
{store.category?.name ? ( <View className="store-detail-tags">
<Text className="store-detail-tag">{store.category.name}</Text> <Text className="store-detail-tag">{store.category.name}</Text>
) : null} </View>
<Text className="store-detail-tag"></Text> ) : null}
<Text className="store-detail-tag"></Text>
</View>
</View> </View>
{marqueeLines.length > 0 ? ( {marqueeLines.length > 0 ? (
+9 -3
View File
@@ -28,7 +28,11 @@
| 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) |
--- ---
@@ -82,9 +86,11 @@
`待付款 → 已付款 → 已完成`;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`
@@ -137,7 +143,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)。
+1
View File
@@ -9,6 +9,7 @@
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
+9 -1
View File
@@ -64,10 +64,18 @@
| 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-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 | | 2026-08-26 | v3.5.14`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
| 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.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 | | 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 | | 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
+121
View File
@@ -0,0 +1,121 @@
# 杜康好客 · 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