Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c9b3f72bc | |||
| 3b669f7e38 | |||
| a7f3283ae5 | |||
| da19c39965 | |||
| faa1830bde | |||
| b226af2adc | |||
| 71149044ca | |||
| fccfd7abbe | |||
| cfd42dfc32 | |||
| 737efb89a3 | |||
| 4111e5c0a6 | |||
| 3dbb73e428 | |||
| e2f5aa9dd8 | |||
| 58a8b47532 | |||
| 448b9983d3 | |||
| 3afbdfe6b9 | |||
| 632539e8dc | |||
| c7cd0f6cf2 | |||
| 1f06530894 | |||
| 1f4506f60b | |||
| a2976cdc8f | |||
| cc0c0a6ef8 | |||
| ee1a3f4b6c | |||
| 52a7d3789d | |||
| 98d52ff173 | |||
| 797b20979d |
@@ -89,7 +89,7 @@ await this.prisma.order.update({ ... });
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
// 同城 min 2 瓶 / 跨城 min 6 瓶
|
||||
// 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商)
|
||||
|
||||
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
||||
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
||||
|
||||
@@ -37,7 +37,7 @@ disable-model-invocation: true
|
||||
|
||||
```
|
||||
权益额 = 订单实付 1:1(永久)
|
||||
同城起购 2 瓶 / 跨城 1 箱(6瓶)
|
||||
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
|
||||
门店结算 = 核销额 × 60%
|
||||
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
||||
核销码 TTL = 3 分钟;试核销固定 ¥100
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
||||
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
||||
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
||||
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
||||
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
||||
| REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||
| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
|
||||
| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
|
||||
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
||||
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
||||
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
||||
|
||||
@@ -26,3 +26,4 @@ server/dukang-api/assets/fonts/*.ttc
|
||||
server/dukang-api/assets/fonts/*.otf
|
||||
deploy/auto-release.env
|
||||
deploy/backups/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
```
|
||||
权益额 = benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
||||
C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
```text
|
||||
权益发放额 = common_product_item.benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||
C 端门店列表仅 status=OPEN
|
||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||
|
||||
@@ -74,6 +74,56 @@ body.admin-col-resizing * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* 订单页筛选:第一行常驻,第二行起可收起 */
|
||||
.admin-orders-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-row--tools {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item--sm {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item--md {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item--search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item--status {
|
||||
width: 220px;
|
||||
min-width: 180px;
|
||||
flex: 1 1 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.admin-orders-filter-item--date {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.admin-orders-filter-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-orders-date-picker {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||
.admin-list-header {
|
||||
display: flex;
|
||||
@@ -308,6 +358,40 @@ body:has(.big-screen-page),
|
||||
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 {
|
||||
font-size: 18px;
|
||||
color: rgba(230, 244, 255, 0.88);
|
||||
|
||||
Binary file not shown.
@@ -11,10 +11,23 @@ import PackageImagesUpload from './PackageImagesUpload';
|
||||
type PackageRow = StorePackageItemDto;
|
||||
|
||||
export type AdminStorePackagesHandle = {
|
||||
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
|
||||
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 {
|
||||
return {
|
||||
name: '',
|
||||
@@ -37,6 +50,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||
const itemsRef = useRef(items);
|
||||
const loadingRef = useRef(loading);
|
||||
const dirtyRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,36 +61,33 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
loadingRef.current = loading;
|
||||
}, [loading]);
|
||||
|
||||
function applyServerPackages(data: StorePackagesResponse) {
|
||||
dirtyRef.current = false;
|
||||
setPendingRequest(data.pendingRequest ?? null);
|
||||
setItems(mapLiveRows(data.live));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setPendingRequest(null);
|
||||
dirtyRef.current = false;
|
||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => {
|
||||
setPendingRequest(data.pendingRequest ?? null);
|
||||
setItems(
|
||||
data.live?.length
|
||||
? data.live.map((p, i) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||
return {
|
||||
...p,
|
||||
price: String(p.price),
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: i,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
})
|
||||
.then((data) => applyServerPackages(data))
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [storeId]);
|
||||
|
||||
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
||||
// 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
|
||||
useEffect(() => {
|
||||
const onChanged = () => {
|
||||
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);
|
||||
};
|
||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
@@ -108,16 +119,19 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
) : null;
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||
dirtyRef.current = true;
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
dirtyRef.current = true;
|
||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
const run = () => {
|
||||
dirtyRef.current = true;
|
||||
setItems((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||
return next.length ? next : [];
|
||||
@@ -203,20 +217,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
}),
|
||||
});
|
||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||
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,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
dirtyRef.current = false;
|
||||
setItems(mapLiveRows(data.live));
|
||||
} catch (e) {
|
||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||
throw e;
|
||||
@@ -227,7 +229,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
saveIfLoaded: async (opts) => {
|
||||
if (loadingRef.current) return { skipped: true };
|
||||
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
|
||||
await save(opts);
|
||||
return { skipped: false };
|
||||
},
|
||||
|
||||
@@ -173,7 +173,9 @@ export default function OrderTrackDrawer({
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
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 {
|
||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||
} from 'antd';
|
||||
@@ -50,6 +50,38 @@ function keyOf(ids: string[]) {
|
||||
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 = {
|
||||
productId: string;
|
||||
initialAttrs: SpecAttr[];
|
||||
@@ -58,44 +90,19 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||
initialAttrs.length
|
||||
? initialAttrs
|
||||
: [],
|
||||
);
|
||||
const [skus, setSkus] = useState<SkuRow[]>(
|
||||
initialSkus.length
|
||||
? initialSkus.map((s) => ({
|
||||
...s,
|
||||
specValueIds: s.specValueIds ?? [],
|
||||
status: s.status || 'ON_SALE',
|
||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||
isDefault: !!s.isDefault,
|
||||
imageUrl: s.imageUrl ?? '',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
specValueIds: [],
|
||||
barcode69: '',
|
||||
price: 0,
|
||||
status: 'ON_SALE',
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
imageUrl: '',
|
||||
},
|
||||
],
|
||||
);
|
||||
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
||||
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
||||
const [saving, setSaving] = useState(false);
|
||||
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]);
|
||||
|
||||
function regenerateMatrix() {
|
||||
|
||||
@@ -15,7 +15,13 @@ type Props = {
|
||||
|
||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||
const user = detail.user as
|
||||
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
||||
| {
|
||||
id?: string;
|
||||
userNo?: string;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
hqRemark?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const store = detail.store as
|
||||
| {
|
||||
@@ -41,7 +47,13 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
|
||||
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">
|
||||
{(() => {
|
||||
const name = user?.nickname?.trim() || '—';
|
||||
const remark = user?.hqRemark?.trim();
|
||||
return remark ? `${name}(${remark})` : name;
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">
|
||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
AccountBookOutlined,
|
||||
ProjectOutlined,
|
||||
} 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 { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
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 HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
||||
);
|
||||
|
||||
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||
{
|
||||
key: 'products-group',
|
||||
icon: <ShoppingOutlined />,
|
||||
@@ -44,18 +51,17 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{
|
||||
key: 'wecom-group',
|
||||
key: 'partners-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '企微机器人',
|
||||
label: '开城',
|
||||
children: [
|
||||
{ key: '/wecom/bots', label: '智能机器人' },
|
||||
{ key: '/wecom/pushes', label: '消息推送' },
|
||||
{ key: '/cities', 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',
|
||||
icon: <ShopOutlined />,
|
||||
@@ -69,17 +75,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ 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',
|
||||
icon: <AccountBookOutlined />,
|
||||
@@ -121,6 +116,20 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ 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',
|
||||
icon: <ProjectOutlined />,
|
||||
@@ -131,7 +140,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/dev-plan/settings', label: '开发设置' },
|
||||
],
|
||||
},
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
key: 'logs-group',
|
||||
@@ -148,9 +156,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
@@ -405,7 +413,9 @@ export default function AdminLayout() {
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<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>
|
||||
|
||||
@@ -229,12 +229,14 @@ export type AdminOrderRow = {
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
logisticsCompany?: string | null;
|
||||
manualQueryUrl?: string | null;
|
||||
/** 当次应付物流费(按瓶当量 × 承运商计价) */
|
||||
logisticsFee?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,9 +31,18 @@ export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||
USER_ON_SITE: '用户·现场取货',
|
||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||
WECHAT_PAY: '微信支付',
|
||||
MOCK: 'Mock',
|
||||
MOCK_PAY: 'Mock 支付',
|
||||
SYSTEM: '系统',
|
||||
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
|
||||
PARTNER_PROXY: '合伙人代下单',
|
||||
HQ_PROXY: 'HQ 代操作',
|
||||
HQ_LOGISTICS: 'HQ 物流',
|
||||
WAREHOUSE_MANUAL: '仓库手工',
|
||||
HQ: '总部',
|
||||
STORE: '门店',
|
||||
PARTNER: '合伙人',
|
||||
};
|
||||
|
||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -111,9 +111,16 @@ type CouponDetail = Row & {
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
if (initialCouponNo) init.couponNo = initialCouponNo;
|
||||
return init;
|
||||
});
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [granting, setGranting] = useState(false);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -132,6 +139,35 @@ export default function BenefitCouponsPage() {
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
|
||||
async function openCouponDetail(id: string) {
|
||||
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
|
||||
}, [form, initialCouponNo]);
|
||||
|
||||
useEffect(() => {
|
||||
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
|
||||
if (openCouponId && !deepLinkOpenedRef.current) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openCouponDetail(openCouponId).catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.couponNo) === initialCouponNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openCouponDetail(first.id).catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||
});
|
||||
}
|
||||
}, [data, initialCouponNo, loading, location.state]);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
|
||||
width: 200,
|
||||
ellipsis: false,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
onClick={() => void openCouponDetail(row.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
|
||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||
@@ -33,6 +34,50 @@ const CELEBRATE_SFX: Record<AmountTier, string> = {
|
||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||
let audioUnlocked = false;
|
||||
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() {
|
||||
if (audioUnlocked || !sharedAudio) return;
|
||||
@@ -53,6 +98,7 @@ function unlockCelebrateAudio() {
|
||||
|
||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||
if (!sharedAudio) return () => undefined;
|
||||
duckBgm();
|
||||
try {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
@@ -72,6 +118,7 @@ function playCelebrateSfx(tier: AmountTier): () => void {
|
||||
} catch {
|
||||
/* 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() {
|
||||
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [bgmOn, setBgmOn] = useState(false);
|
||||
const bgmWantedRef = useRef(false);
|
||||
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -416,6 +493,22 @@ export default function BigScreenPage() {
|
||||
return () => clearInterval(id);
|
||||
}, [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(() => {
|
||||
unlockCelebrateAudio();
|
||||
const unlock = () => unlockCelebrateAudio();
|
||||
@@ -424,6 +517,8 @@ export default function BigScreenPage() {
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', 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>
|
||||
</div>
|
||||
<div className="big-screen-live-wrap">
|
||||
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
|
||||
<span className="big-screen-live-label">实时成交</span>
|
||||
<span className="big-screen-live">
|
||||
<span className="big-screen-live-dot" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
@@ -362,8 +363,28 @@ export default function CitiesPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Divider orientation="left">履约起购</Divider>
|
||||
<Form.Item
|
||||
name="pickupMinQty"
|
||||
label="现场提货"
|
||||
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="localMinQty"
|
||||
label="同城配送"
|
||||
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="crossMinQty"
|
||||
label="跨城配送"
|
||||
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="maxPartnerCommissionPercent"
|
||||
label="合伙人佣金合计上限 %"
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
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 { 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 = {
|
||||
id: string;
|
||||
@@ -19,19 +46,34 @@ type Row = {
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
/** 当次应付物流费 */
|
||||
logisticsFee?: number | null;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
deliveryType: string;
|
||||
};
|
||||
order?: DeliveryOrder;
|
||||
};
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
@@ -65,25 +107,91 @@ export default function DeliveriesPage() {
|
||||
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> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: ['order', 'orderNo'],
|
||||
width: 170,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 140,
|
||||
render: (_, row) => {
|
||||
const user = row.order?.user;
|
||||
if (!user) return '—';
|
||||
const label = user.userNo || user.phone || '—';
|
||||
return (
|
||||
<div>
|
||||
{user.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
|
||||
>
|
||||
{label}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{user.phone && user.userNo ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
{user.phone}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
key: 'product',
|
||||
width: 200,
|
||||
render: (_, row) => {
|
||||
const order = row.order;
|
||||
if (!order?.productName) return '—';
|
||||
return (
|
||||
<div>
|
||||
<span>{order.productName}</span>
|
||||
{order.productSpec ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
{order.productSpec}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
key: 'quantity',
|
||||
width: 90,
|
||||
render: (_, row) => formatQty(row.order),
|
||||
},
|
||||
{
|
||||
title: '配送方式',
|
||||
dataIndex: ['order', 'deliveryType'],
|
||||
width: 90,
|
||||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '承运商',
|
||||
dataIndex: 'provider',
|
||||
width: 90,
|
||||
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
|
||||
},
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '运费',
|
||||
@@ -92,20 +200,33 @@ export default function DeliveriesPage() {
|
||||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||
},
|
||||
{ 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', '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: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -113,19 +234,21 @@ export default function DeliveriesPage() {
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||
|
||||
const order = detail?.order;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||
<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="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><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<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); } }} />
|
||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
@@ -142,14 +265,63 @@ export default function DeliveriesPage() {
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单">{order?.orderNo || '—'}</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>
|
||||
<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="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
|
||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchEditOpen) return;
|
||||
if (!modalOpen && !batchEditOpen) return;
|
||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||
.then((res) => setVersions(res.items ?? []))
|
||||
.catch(() => setVersions([]));
|
||||
}, [batchEditOpen]);
|
||||
}, [modalOpen, batchEditOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || editing) return;
|
||||
@@ -108,6 +108,7 @@ export default function DevPlanTasksPage() {
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
|
||||
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||
});
|
||||
setModalOpen(true);
|
||||
@@ -121,7 +122,11 @@ export default function DevPlanTasksPage() {
|
||||
if (editing) {
|
||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...values, attachmentUrls }),
|
||||
body: JSON.stringify({
|
||||
...values,
|
||||
attachmentUrls,
|
||||
versionIds: values.versionIds ?? [],
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
@@ -285,6 +290,21 @@ export default function DevPlanTasksPage() {
|
||||
),
|
||||
},
|
||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '关联版本',
|
||||
dataIndex: 'versions',
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
row.versions?.length ? (
|
||||
<Space size={4} wrap>
|
||||
{row.versions.map((v) => (
|
||||
<Tag key={v.id}>{v.versionNo}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -405,9 +425,21 @@ export default function DevPlanTasksPage() {
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
{editing ? (
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="versionIds" label="关联版本">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="可选:关联版本"
|
||||
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||
<Select
|
||||
|
||||
@@ -4,6 +4,14 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
@@ -15,8 +23,11 @@ type Row = {
|
||||
refId: string;
|
||||
status?: string | null;
|
||||
param1?: string | null;
|
||||
param1Desc?: string | null;
|
||||
param2?: string | null;
|
||||
param2Desc?: string | null;
|
||||
param3?: string | null;
|
||||
param3Desc?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -70,13 +81,18 @@ export default function DomainEventsPage() {
|
||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||
}}
|
||||
>
|
||||
<Tag>{v}</Tag>
|
||||
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||
{ 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: '操作',
|
||||
width: 80,
|
||||
@@ -133,11 +149,18 @@ export default function DomainEventsPage() {
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
||||
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
||||
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
|
||||
{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="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -68,7 +69,10 @@ export default function FulfillmentProvidersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -94,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
deliveryHintHtml: '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -122,6 +127,7 @@ export default function FulfillmentProvidersPage() {
|
||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -144,6 +150,7 @@ export default function FulfillmentProvidersPage() {
|
||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||
},
|
||||
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||
};
|
||||
|
||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||
@@ -176,6 +183,16 @@ export default function FulfillmentProvidersPage() {
|
||||
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> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{
|
||||
@@ -230,11 +247,23 @@ export default function FulfillmentProvidersPage() {
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除承运商「${row.name}」?`}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void remove(row)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -288,6 +317,17 @@ export default function FulfillmentProvidersPage() {
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deliveryHintHtml"
|
||||
label="配送信息提示"
|
||||
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">物流对账</Divider>
|
||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||
@@ -309,6 +349,7 @@ export default function FulfillmentProvidersPage() {
|
||||
<Form.Item
|
||||
name="baseBottles"
|
||||
label="起送瓶数"
|
||||
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { formatRef, zhCode } from '../lib/display-labels';
|
||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
@@ -84,7 +85,7 @@ export default function HqLogsPage() {
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
|
||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -143,7 +144,7 @@ export default function HqLogsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="refType" label="对象类型">
|
||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||
<Input allowClear placeholder="对象编码" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
@@ -192,12 +193,12 @@ export default function HqLogsPage() {
|
||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
|
||||
<Descriptions.Item label="行为">
|
||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
ORDER_STATUS_OPERATOR_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { zhRemark } from '../lib/display-labels';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
@@ -149,9 +149,11 @@ type OrderExportFilters = {
|
||||
orderType?: string;
|
||||
cityId?: string;
|
||||
receiverPhone?: string;
|
||||
userKeyword?: string;
|
||||
productKeyword?: string;
|
||||
fulfillmentHold?: boolean;
|
||||
excludeTest?: boolean;
|
||||
deliveryType?: string;
|
||||
promoCodeId?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
};
|
||||
|
||||
@@ -211,6 +213,17 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
{ key: 'year', label: '当年' },
|
||||
];
|
||||
|
||||
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
|
||||
|
||||
function formatUserWithRemark(user?: {
|
||||
userNo?: string;
|
||||
hqRemark?: string | null;
|
||||
} | null) {
|
||||
const userNo = user?.userNo || '—';
|
||||
const remark = user?.hqRemark?.trim();
|
||||
return remark ? `${userNo}(${remark})` : userNo;
|
||||
}
|
||||
|
||||
function formatBenefitBrief(row: AdminOrderRow) {
|
||||
const coupon = row.benefitCoupon;
|
||||
if (coupon) {
|
||||
@@ -243,9 +256,11 @@ function buildExportPayload(
|
||||
if (filters.orderType) payload.orderType = filters.orderType;
|
||||
if (filters.cityId) payload.cityId = filters.cityId;
|
||||
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
|
||||
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
|
||||
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
||||
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
||||
if (filters.excludeTest) payload.excludeTest = true;
|
||||
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
|
||||
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
||||
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
||||
return payload;
|
||||
@@ -284,8 +299,15 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
||||
|
||||
export default function OrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
|
||||
const initialStatusParam = searchParams.getAll('status').join(',');
|
||||
const initialStatuses = useMemo(
|
||||
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
[initialStatusParam],
|
||||
);
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
@@ -316,6 +338,13 @@ export default function OrdersPage() {
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
|
||||
@@ -335,6 +364,20 @@ export default function OrdersPage() {
|
||||
}
|
||||
}, [form, initialOrderNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatuses.length) {
|
||||
form.setFieldsValue({ status: initialStatuses });
|
||||
}
|
||||
}, [form, initialStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId;
|
||||
if (openOrderId) {
|
||||
void openDetail(openOrderId);
|
||||
navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, location.search, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
@@ -365,13 +408,17 @@ export default function OrdersPage() {
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
||||
const statuses = selectedStatuses(values.status);
|
||||
const statusList = statuses.length ? statuses : initialStatuses;
|
||||
for (const status of statusList) qs.append('status', status);
|
||||
if (values.orderType) qs.set('orderType', values.orderType);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
|
||||
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
||||
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
|
||||
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
||||
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
@@ -379,7 +426,7 @@ export default function OrdersPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, page, pageSize, initialOrderNo]);
|
||||
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -588,7 +635,16 @@ export default function OrdersPage() {
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
||||
const payload = buildExportPayload(
|
||||
scope,
|
||||
exportFormat,
|
||||
{
|
||||
...values,
|
||||
promoCodeId: initialPromoCodeId || values.promoCodeId,
|
||||
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
||||
},
|
||||
selectedRowKeys,
|
||||
);
|
||||
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -617,14 +673,14 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 120,
|
||||
width: 200,
|
||||
render: (_, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||
{row.user.userNo}
|
||||
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
|
||||
{formatUserWithRemark(row.user)}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
(row.user?.userNo || '—')
|
||||
formatUserWithRemark(row.user)
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -674,6 +730,13 @@ export default function OrdersPage() {
|
||||
width: 90,
|
||||
render: (v: number) => `¥${v}`,
|
||||
},
|
||||
{
|
||||
title: '运费',
|
||||
key: 'logisticsFee',
|
||||
width: 90,
|
||||
render: (_, row) =>
|
||||
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '好客权益',
|
||||
width: 200,
|
||||
@@ -770,15 +833,93 @@ export default function OrdersPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{initialPromoCodeId ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
|
||||
? '已按推广码筛选已完成订单'
|
||||
: '已按推广码筛选订单'
|
||||
}
|
||||
action={
|
||||
<Button size="small" onClick={() => navigate('/orders')}>
|
||||
清除筛选
|
||||
</Button>
|
||||
}
|
||||
onClose={() => navigate('/orders')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={24} md={14} lg={12}>
|
||||
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Form.Item name="dateRange" noStyle>
|
||||
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div className="admin-orders-filter-row">
|
||||
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||
<Input allowClear placeholder="手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||
<Input allowClear placeholder="编号/昵称/备注/手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||
<Input allowClear placeholder="名称/规格" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="全部"
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => {
|
||||
setFiltersCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{filtersCollapsed ? '展开' : '收起'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{!filtersCollapsed ? (
|
||||
<>
|
||||
<div className="admin-orders-filter-row">
|
||||
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
|
||||
<Space size={8} wrap>
|
||||
<Form.Item name="dateRange" noStyle>
|
||||
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
|
||||
</Form.Item>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
@@ -789,98 +930,60 @@ export default function OrdersPage() {
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="全部"
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={6}>
|
||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
||||
<Input allowClear placeholder="手机号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={8} lg={8}>
|
||||
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
|
||||
<Space size={16} wrap>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
|
||||
{canExportOrders ? (
|
||||
<Space size={12} wrap>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={exporting}
|
||||
onClick={() => void submitExport('selected')}
|
||||
>
|
||||
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||
导出全部筛选
|
||||
</Button>
|
||||
</Space>
|
||||
) : <span />}
|
||||
<Space size={12} wrap>
|
||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||
<Checkbox>大单拦截</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
||||
<Checkbox>过滤测试</Checkbox>
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setPage(1);
|
||||
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
|
||||
else void load();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{canDeleteOrders ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => setBatchDeleteOpen(true)}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
||||
{canExportOrders ? (
|
||||
<Space size={12} wrap>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={exporting}
|
||||
onClick={() => void submitExport('selected')}
|
||||
>
|
||||
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||
导出全部筛选
|
||||
</Button>
|
||||
</Space>
|
||||
) : <span />}
|
||||
<Space size={12} wrap>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
{canDeleteOrders ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => setBatchDeleteOpen(true)}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -979,7 +1082,18 @@ export default function OrdersPage() {
|
||||
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
|
||||
>
|
||||
{formatUserWithRemark(detail.user)}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
formatUserWithRemark(detail.user)
|
||||
)}
|
||||
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
||||
@@ -1158,6 +1272,11 @@ export default function OrdersPage() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</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 && (
|
||||
<Descriptions.Item label="查询链接">
|
||||
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
@@ -1198,7 +1317,7 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
render: (v) => v || '—',
|
||||
render: (v) => zhRemark(v),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
|
||||
@@ -4,15 +4,14 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
type PartnerStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { clientAppLabel, eventNameLabel, formatRef, partnerEventCategoryLabel } from '../lib/display-labels';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
@@ -107,7 +106,7 @@ export default function PartnerLogsPage() {
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
<Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -121,14 +120,14 @@ export default function PartnerLogsPage() {
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
{eventNameLabel(v)}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
render: (_, r) => formatRef(r.refType, r.refId),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
@@ -188,11 +187,11 @@ export default function PartnerLogsPage() {
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
<Form.Item name="partnerId" label="合伙人 ID">
|
||||
<Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
<Form.Item name="eventName" label="事件编码">
|
||||
<Input placeholder="事件编码" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
@@ -237,11 +236,19 @@ export default function PartnerLogsPage() {
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
<Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{partnerEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt ? String(detail.createdAt) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="参数">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -610,6 +610,7 @@ export default function ProductsPage() {
|
||||
label: '规格与 SKU',
|
||||
children: (
|
||||
<ProductSpecsEditor
|
||||
key={String(detail.id)}
|
||||
productId={String(detail.id)}
|
||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||
initialSkus={(detail.skus as never) ?? []}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
|
||||
|
||||
type SceneOption = { value: PromoCodeScene; label: string };
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
export default function PromoCodesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm();
|
||||
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{
|
||||
title: '扫码',
|
||||
dataIndex: 'scanCount',
|
||||
width: 70,
|
||||
render: (v: number, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <Tooltip title="仅统计已完成订单">订单</Tooltip>,
|
||||
dataIndex: 'orderCount',
|
||||
width: 70,
|
||||
render: (v: number, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
width: 90,
|
||||
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!row.qrcodeUrl}
|
||||
onClick={() => {
|
||||
if (!row.qrcodeUrl) return;
|
||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
||||
}}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||
关联用户
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
@@ -20,17 +20,28 @@ type Row = {
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||
user?: {
|
||||
id?: string;
|
||||
userNo: string;
|
||||
phone: string | null;
|
||||
nickname?: string | null;
|
||||
hqRemark?: string | null;
|
||||
};
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
coupon?: { id?: string; couponNo: string };
|
||||
};
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
function formatNicknameWithRemark(user?: {
|
||||
nickname?: string | null;
|
||||
hqRemark?: string | null;
|
||||
} | null) {
|
||||
const name = user?.nickname?.trim() || '—';
|
||||
const remark = user?.hqRemark?.trim();
|
||||
return remark ? `${name}(${remark})` : name;
|
||||
}
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||
>
|
||||
{v || '—'}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
v || '—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户昵称',
|
||||
dataIndex: ['user', 'nickname'],
|
||||
width: 100,
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
width: 160,
|
||||
render: (_: string | null | undefined, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||
>
|
||||
{formatNicknameWithRemark(row.user)}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
formatNicknameWithRemark(row.user)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户手机',
|
||||
dataIndex: ['user', 'phone'],
|
||||
width: 120,
|
||||
render: (v: string | null | undefined) => maskPhone(v),
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||
{
|
||||
title: '券号',
|
||||
dataIndex: ['coupon', 'couponNo'],
|
||||
width: 160,
|
||||
render: (v: string | undefined, row) =>
|
||||
v ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() =>
|
||||
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
|
||||
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -75,6 +76,7 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||
};
|
||||
|
||||
export default function StoreBillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
@@ -294,7 +296,11 @@ export default function StoreBillsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
title: (
|
||||
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
|
||||
出账日
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'date',
|
||||
width: 160,
|
||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||
@@ -389,6 +395,7 @@ export default function StoreBillsPage() {
|
||||
<>
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||
T+1「出账日」是任务跑的那天(昨日 00:00–今日 00:00 窗口中的今天),不是最后一笔核销时间。
|
||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||
<div>
|
||||
<Typography.Text type="danger">
|
||||
@@ -469,7 +476,7 @@ export default function StoreBillsPage() {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="日期">
|
||||
<Form.Item name="range" label="出账日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
@@ -543,7 +550,7 @@ export default function StoreBillsPage() {
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">
|
||||
<Descriptions.Item label="出账日">
|
||||
{String(detail.billDate || '').slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">
|
||||
@@ -592,8 +599,22 @@ export default function StoreBillsPage() {
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) =>
|
||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
render: (_, r) => {
|
||||
const redeemNo = String(
|
||||
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
|
||||
);
|
||||
return redeemNo ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() =>
|
||||
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||
}
|
||||
>
|
||||
{redeemNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
@@ -662,7 +683,18 @@ export default function StoreBillsPage() {
|
||||
const payout = r.storePayout as
|
||||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||||
| undefined;
|
||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
||||
const redeemNo = payout?.redeemRecord?.redeemNo || '';
|
||||
return redeemNo ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() =>
|
||||
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||
}
|
||||
>
|
||||
{redeemNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -159,9 +159,20 @@ export default function StoreCategoriesPage() {
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
try {
|
||||
const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
|
||||
`/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>
|
||||
|
||||
@@ -4,13 +4,12 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
STORE_LOG_CATEGORY_OPTIONS,
|
||||
STORE_LOG_CATEGORY_LABELS,
|
||||
resolveStoreLogCategory,
|
||||
type StoreLogCategory,
|
||||
} from '../lib/store-log';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { clientAppLabel, eventNameLabel, formatRef, storeEventCategoryLabel } from '../lib/display-labels';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
@@ -102,7 +101,7 @@ export default function StoreLogsPage() {
|
||||
{
|
||||
title: '分类', dataIndex: 'category', width: 100,
|
||||
render: (v: StoreLogCategory | null, r) => (
|
||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||
<Tag>{storeEventCategoryLabel(r.eventName, v)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -118,7 +117,7 @@ export default function StoreLogsPage() {
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
{eventNameLabel(v)}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
@@ -128,7 +127,7 @@ export default function StoreLogsPage() {
|
||||
},
|
||||
{
|
||||
title: '关联', width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
render: (_, r) => formatRef(r.refType, r.refId),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
@@ -184,7 +183,7 @@ export default function StoreLogsPage() {
|
||||
<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="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 onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
@@ -198,12 +197,12 @@ export default function StoreLogsPage() {
|
||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||
<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 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="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="参数">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
@@ -560,50 +561,43 @@ export default function SupportTicketsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={batchCreateSaving}
|
||||
onClick={() => void submitBatchCreateTasks()}
|
||||
>
|
||||
一键创建任务
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||
一键发布
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => {
|
||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||
setBatchStatusOpen(true);
|
||||
}}
|
||||
>
|
||||
批量改状态
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="技术支持"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={batchCreateSaving}
|
||||
onClick={() => void submitBatchCreateTasks()}
|
||||
>
|
||||
一键创建任务
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||
一键发布
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => {
|
||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||
setBatchStatusOpen(true);
|
||||
}}
|
||||
>
|
||||
批量改状态
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typograp
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
@@ -26,14 +33,14 @@ type Row = {
|
||||
};
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
|
||||
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
|
||||
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
||||
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
|
||||
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
||||
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
||||
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
||||
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
||||
{ value: 'WECHAT_AUTH', label: '微信授权' },
|
||||
{ value: 'WECHAT_PAY', label: '微信支付' },
|
||||
{ value: 'WECHAT_MAP', label: '微信/腾讯地图' },
|
||||
{ value: 'ALIYUN_OSS', label: '阿里云 OSS' },
|
||||
{ value: 'ALIYUN_SMS', label: '阿里云短信' },
|
||||
{ value: 'MOCK_SMS', label: 'Mock 短信' },
|
||||
{ value: 'XFX', label: '小飞侠' },
|
||||
{ value: 'LOGISTICS', label: '物流快递' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -75,18 +82,18 @@ export default function ThirdPartyLogsPage() {
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
{ title: '渠道', dataIndex: 'provider', width: 120, render: (v: string) => zhLabel(THIRD_PARTY_PROVIDER_LABELS, v) },
|
||||
{ title: '场景', dataIndex: 'scene', width: 140, render: (v: string) => zhLabel(THIRD_PARTY_SCENE_LABELS, v) },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
render: (_, r) => formatRef(r.refType, r.refId),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
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: '错误信息',
|
||||
@@ -147,11 +154,11 @@ export default function ThirdPartyLogsPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="provider" label="Provider">
|
||||
<Form.Item name="provider" label="渠道">
|
||||
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene" label="场景">
|
||||
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" style={{ width: 160 }} />
|
||||
<Input allowClear placeholder="场景编码" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="refId" label="关联ID">
|
||||
<Input allowClear style={{ width: 120 }} />
|
||||
@@ -181,13 +188,15 @@ export default function ThirdPartyLogsPage() {
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道">{zhLabel(THIRD_PARTY_PROVIDER_LABELS, String(detail.provider))}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{zhLabel(THIRD_PARTY_SCENE_LABELS, String(detail.scene))}</Descriptions.Item>
|
||||
<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 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 label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
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 { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { clientAppLabel, eventNameLabel, formatRef, userEventCategoryLabel } from '../lib/display-labels';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
@@ -27,8 +28,6 @@ type Row = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
@@ -79,7 +78,7 @@ export default function UserLogsPage() {
|
||||
{
|
||||
title: '分类', dataIndex: 'category', width: 100,
|
||||
render: (v: UserLogCategory | null, r) => (
|
||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||
<Tag>{userEventCategoryLabel(r.eventName, v)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -93,13 +92,16 @@ export default function UserLogsPage() {
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
{eventNameLabel(v)}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '端', dataIndex: 'clientApp', width: 110, render: (v) => clientAppLabel(v),
|
||||
},
|
||||
{
|
||||
title: '关联', width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
render: (_, r) => formatRef(r.refType, r.refId),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
@@ -148,7 +150,7 @@ export default function UserLogsPage() {
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></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 onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
@@ -160,10 +162,10 @@ export default function UserLogsPage() {
|
||||
<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.phone || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{userEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="参数">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
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 { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
|
||||
|
||||
type UserOrderRow = {
|
||||
id: string;
|
||||
@@ -154,6 +155,7 @@ type BatchDeletePreview = {
|
||||
export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||
@@ -184,6 +186,8 @@ export default function UsersPage() {
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
const userId = String(values.userId || '').trim();
|
||||
if (userId) qs.set('userId', userId);
|
||||
if (values.phone) qs.set('phone', values.phone);
|
||||
if (values.userNo) qs.set('userNo', values.userNo);
|
||||
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
|
||||
@@ -201,17 +205,26 @@ export default function UsersPage() {
|
||||
}
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = searchParams.get('userId');
|
||||
if (userId) form.setFieldsValue({ userId });
|
||||
}, [searchParams, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
}, [load, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
||||
if (openUserId) {
|
||||
void openDetail(openUserId);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
if (!openUserId) return;
|
||||
form.setFieldsValue({ userId: openUserId });
|
||||
setPage(1);
|
||||
void openDetail(openUserId);
|
||||
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
|
||||
replace: true,
|
||||
state: null,
|
||||
});
|
||||
}, [location.state, location.pathname, navigate, form]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const [res, logs] = await Promise.all([
|
||||
@@ -497,13 +510,30 @@ export default function UsersPage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={() => {
|
||||
setPage(1);
|
||||
const userId = String(form.getFieldValue('userId') || '').trim();
|
||||
const current = searchParams.get('userId') ?? '';
|
||||
if (userId !== current) {
|
||||
if (userId) setSearchParams({ userId }, { replace: true });
|
||||
else setSearchParams({}, { replace: true });
|
||||
}
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="userNo" label="用户编号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="userId" label="用户ID">
|
||||
<Input placeholder="精确匹配" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceKey" label="deviceKey">
|
||||
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
@@ -525,7 +555,19 @@ export default function UsersPage() {
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setPage(1);
|
||||
if (searchParams.get('userId')) {
|
||||
setSearchParams({}, { replace: true });
|
||||
} else {
|
||||
void load();
|
||||
}
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -569,7 +611,7 @@ export default function UsersPage() {
|
||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="验手机时间">
|
||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||
</Descriptions.Item>
|
||||
@@ -603,7 +645,7 @@ export default function UsersPage() {
|
||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合并至">
|
||||
{detail.mergedInto
|
||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||
@@ -640,10 +682,10 @@ export default function UsersPage() {
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
|
||||
render: (_, r) => userEventCategoryLabel(r.eventName),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName' },
|
||||
{ title: '端', dataIndex: 'clientApp', width: 90 },
|
||||
{ title: '事件', dataIndex: 'eventName', render: (v: string) => eventNameLabel(v) },
|
||||
{ title: '端', dataIndex: 'clientApp', width: 110, render: (v: string | null) => clientAppLabel(v) },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { WECOM_BOT_ACTION_LABELS, zhLabel } from '../lib/display-labels';
|
||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
@@ -39,8 +40,8 @@ export default function WecomBotLogsPage() {
|
||||
),
|
||||
},
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||
{ title: '动作', dataIndex: 'action', width: 180, render: (v: string) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
||||
{ title: '权限', dataIndex: 'permission', width: 160, render: (v) => zhLabel(WECOM_BOT_ACTION_LABELS, v) },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'success',
|
||||
@@ -81,13 +82,13 @@ export default function WecomBotLogsPage() {
|
||||
}}
|
||||
>
|
||||
<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 name="wecomUserId" label="企微 userid">
|
||||
<Input allowClear placeholder="userid" style={{ width: 140 }} />
|
||||
<Form.Item name="wecomUserId" label="企微用户">
|
||||
<Input allowClear placeholder="企微用户 ID" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="动作">
|
||||
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
|
||||
<Input allowClear placeholder="动作编码" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="success" label="结果">
|
||||
<Select
|
||||
@@ -130,8 +131,8 @@ export default function WecomBotLogsPage() {
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
||||
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
|
||||
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="动作">{zhLabel(WECOM_BOT_ACTION_LABELS, detail.action)}</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.success ? '成功' : '失败'}</Descriptions.Item>
|
||||
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const ATTRIBUTION_HINT =
|
||||
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
@@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) {
|
||||
}
|
||||
|
||||
export default function PromoCodeDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||
const [editForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||
>
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="归因用户数"
|
||||
title={
|
||||
<span>
|
||||
归因用户数
|
||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.attributionCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||
>
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
订单数
|
||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.orderCount ?? detail.orderCount}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
DatePicker,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
||||
|
||||
type Props = {
|
||||
promoId: string;
|
||||
@@ -57,11 +59,45 @@ function buildEventsQs(
|
||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function formatRefId(row: PromoMetricEventItem): string {
|
||||
if (row.orderId) return `订单 ${row.orderId}`;
|
||||
if (row.userId) return `用户 ${row.userId}`;
|
||||
if (row.sessionId) return `会话 ${row.sessionId}`;
|
||||
return '—';
|
||||
function parseEventType(raw: string | null): PromoMetricEventType | undefined {
|
||||
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function EventRefLinks({ row }: { row: PromoMetricEventItem }) {
|
||||
const navigate = useNavigate();
|
||||
const links = [];
|
||||
if (row.orderId) {
|
||||
links.push(
|
||||
<AdminPrimaryLink
|
||||
key="order"
|
||||
onClick={() => {
|
||||
if (row.orderNo) {
|
||||
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
|
||||
} else {
|
||||
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
订单 {row.orderNo || row.orderId}
|
||||
</AdminPrimaryLink>,
|
||||
);
|
||||
}
|
||||
if (row.userId) {
|
||||
links.push(
|
||||
<AdminPrimaryLink
|
||||
key="user"
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
|
||||
>
|
||||
用户 {row.userNo || row.userId}
|
||||
</AdminPrimaryLink>,
|
||||
);
|
||||
}
|
||||
if (links.length) {
|
||||
return <Space size={12}>{links}</Space>;
|
||||
}
|
||||
if (row.sessionId) return <>会话 {row.sessionId}</>;
|
||||
return <>—</>;
|
||||
}
|
||||
|
||||
function formatLocation(row: PromoMetricEventItem): string {
|
||||
@@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string {
|
||||
}
|
||||
|
||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const urlEventType = parseEventType(searchParams.get('eventType'));
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
|
||||
const [eventsPage, setEventsPage] = useState(1);
|
||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEventType(urlEventType);
|
||||
setEventsPage(1);
|
||||
if (urlEventType) {
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
}, [urlEventType]);
|
||||
|
||||
const loadTimeline = useCallback(() => {
|
||||
setTimelineLoading(true);
|
||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'ref',
|
||||
render: (_, row) => formatRefId(row),
|
||||
render: (_, row) => <EventRefLinks row={row} />,
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
notMerge
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
事件日志
|
||||
</Typography.Title>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
@@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
onChange={(v) => {
|
||||
setEventType(v);
|
||||
setEventsPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (v) next.set('eventType', v);
|
||||
else next.delete('eventType');
|
||||
setSearchParams(next, { replace: true });
|
||||
}}
|
||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Button, Space, Table, Tag } from 'antd';
|
||||
import { Button, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
USER_SOURCE_TYPE_LABELS,
|
||||
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
|
||||
width: 160,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: <Tooltip title="仅统计已完成订单">订单数</Tooltip>, dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
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 SELECT_STORE_PATH = '/select-store';
|
||||
@@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
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) {
|
||||
rememberShopReturnPath(location);
|
||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
rememberShopReturnPath(location);
|
||||
const profile = getStoreProfile();
|
||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||
const fromQuery = url.searchParams.get('token');
|
||||
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
export { parseRedeemTokenFromScan } from '@dukang/shared-types';
|
||||
|
||||
@@ -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 { request } from '../lib/api';
|
||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
@@ -47,6 +48,7 @@ export default function RedeemConfirmPage() {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
consumeShopReturnTo();
|
||||
setToken(scanned);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { goShopPath } from '../lib/shop-nav';
|
||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
|
||||
@@ -35,10 +36,12 @@ export default function RedeemSuccessPage() {
|
||||
});
|
||||
}, [redeemNo, amount]);
|
||||
|
||||
const goHome = () => goShopPath('/', navigate, { replace: true });
|
||||
|
||||
return (
|
||||
<div className="shop-success-page">
|
||||
<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>
|
||||
</button>
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
@@ -85,11 +88,11 @@ export default function RedeemSuccessPage() {
|
||||
</div>
|
||||
|
||||
<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 className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||
</button>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={goHome}>
|
||||
<span>返回首页</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -10,13 +9,11 @@ import {
|
||||
type ShopSessionPayload,
|
||||
type ShopStoreOption,
|
||||
} 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) {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat('/');
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
goShopPath(consumeShopReturnTo() || '/', navigate);
|
||||
}
|
||||
|
||||
export default function SelectStorePage() {
|
||||
@@ -46,7 +43,7 @@ export default function SelectStorePage() {
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
goShopHome(navigate);
|
||||
goAfterSelectStore(navigate);
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
@@ -54,7 +51,7 @@ export default function SelectStorePage() {
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
goShopHome(navigate);
|
||||
goAfterSelectStore(navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} 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(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
if (needsStoreSelection(session)) {
|
||||
goShopPath('/select-store', navigate);
|
||||
return;
|
||||
}
|
||||
navigate(path, { replace: true });
|
||||
goShopPath(peekShopReturnTo() || '/', navigate);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "3.5.4",
|
||||
"version": "3.5.10",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Text } from '@tarojs/components';
|
||||
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 = {
|
||||
orderId?: string;
|
||||
@@ -16,7 +20,7 @@ type ContactCsButtonProps = {
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
/** 组装 session-from(微信限制约 1000 字符) */
|
||||
/** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用) */
|
||||
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||
if (!session) return 'dukang|from=mini-user';
|
||||
const parts = ['dukang'];
|
||||
@@ -26,16 +30,61 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
function canOpenWecom(url: string, corpId: string) {
|
||||
return !!url.trim() && !!corpId.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序客服入口(open-type=contact)。
|
||||
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
||||
* C 端在线客服:
|
||||
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」
|
||||
* - weapp 未配 CorpID:回退 open-type=contact
|
||||
* - H5:有 kfid 则打开企微客服链接
|
||||
*/
|
||||
export default function ContactCsButton({
|
||||
className = '',
|
||||
children = '联系在线客服',
|
||||
session,
|
||||
}: 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 (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RichText, Text } from '@tarojs/components';
|
||||
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
|
||||
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
|
||||
|
||||
type DeliveryHintHtmlProps = {
|
||||
html?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 承运商配送提示:HTML 用 RichText;textarea 里的换行转成 br */
|
||||
export default function DeliveryHintHtml({
|
||||
html,
|
||||
className = '',
|
||||
}: DeliveryHintHtmlProps) {
|
||||
const raw = (html || '').trim() || DEFAULT_LOCAL_DELIVERY_HINT;
|
||||
const nodes = deliveryHintHtmlToRichNodes(raw);
|
||||
const looksHtml = /<[a-z][\s\S]*>/i.test(nodes);
|
||||
if (!looksHtml) {
|
||||
return <Text className={className}>{nodes}</Text>;
|
||||
}
|
||||
return <RichText className={className} nodes={nodes} />;
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
const CANVAS_ID = 'redeem-qr-canvas';
|
||||
|
||||
type RedeemQrCodeProps = {
|
||||
token: string;
|
||||
/** 二维码内容:门店 H5 落地 URL,缺省回退 token */
|
||||
payload: string;
|
||||
};
|
||||
|
||||
function drawOnWeappCanvas(token: string) {
|
||||
function drawOnWeappCanvas(payload: string) {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
@@ -33,33 +34,33 @@ function drawOnWeappCanvas(token: string) {
|
||||
canvas.width = layoutW * dpr;
|
||||
canvas.height = layoutH * 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 isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (!payload) {
|
||||
setImgSrc('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeapp) {
|
||||
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
|
||||
const timer = setTimeout(() => drawOnWeappCanvas(payload), 120);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void buildRedeemQrDataUrl(token).then((url) => {
|
||||
void buildRedeemQrDataUrl(payload).then((url) => {
|
||||
if (!cancelled) setImgSrc(url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, isWeapp]);
|
||||
}, [payload, isWeapp]);
|
||||
|
||||
return (
|
||||
<View className="redeem-qr-box">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BRAND_LOGO_URL,
|
||||
BRAND_LOGO_WIDE_URL,
|
||||
CUSTOMER_SERVICE_PHONE,
|
||||
CUSTOMER_SERVICE_WECOM_URL,
|
||||
QUALIFICATION_DISCLOSURE_URL,
|
||||
type ClientRuntimeConfig,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -14,6 +15,8 @@ export type BrandAssets = {
|
||||
brandLogoMarkUrl: string;
|
||||
qualificationDisclosureUrl: string;
|
||||
customerServicePhone: string;
|
||||
customerServiceWecomUrl: string;
|
||||
wecomCorpId: string;
|
||||
};
|
||||
|
||||
const FALLBACK: BrandAssets = {
|
||||
@@ -22,6 +25,8 @@ const FALLBACK: BrandAssets = {
|
||||
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
||||
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
||||
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
||||
customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL,
|
||||
wecomCorpId: '',
|
||||
};
|
||||
|
||||
let cached: BrandAssets | null = null;
|
||||
@@ -35,6 +40,9 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets
|
||||
qualificationDisclosureUrl:
|
||||
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
||||
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
||||
customerServiceWecomUrl:
|
||||
config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl,
|
||||
wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 与 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}`;
|
||||
|
||||
|
||||
@@ -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 / 交易硬闸一致) */
|
||||
|
||||
export type FulfillmentFlags = {
|
||||
|
||||
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
} as const;
|
||||
|
||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
|
||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用;payload 为落地 URL 或纯 token) */
|
||||
export function drawRedeemQrOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
token: string,
|
||||
payload: string,
|
||||
sizePx = QR_SIZE,
|
||||
) {
|
||||
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
|
||||
const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' });
|
||||
const count = qr.modules.size;
|
||||
const cell = sizePx / count;
|
||||
|
||||
@@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas(
|
||||
}
|
||||
|
||||
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
||||
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
|
||||
export async function buildRedeemQrDataUrl(payload: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(token, QR_OPTIONS);
|
||||
return await QRCode.toDataURL(payload, QR_OPTIONS);
|
||||
} 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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
||||
const [brand, setBrand] = useState(() => getBrandAssetsSync());
|
||||
|
||||
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 (
|
||||
<PageShell variant="sub" className="cs-page">
|
||||
<SubPageHeader title="联系客服" />
|
||||
<View className="sub-page-body inset-page cs-body">
|
||||
<Text className="cs-brand">杜康好客客服</Text>
|
||||
<Text className="cs-hint">
|
||||
{isWeapp
|
||||
? '点击下方按钮,进入小程序在线客服会话'
|
||||
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
|
||||
</Text>
|
||||
<Text className="cs-hint">{hint}</Text>
|
||||
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
||||
|
||||
{isWeapp ? (
|
||||
{isWeapp || wecomUrlReady ? (
|
||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||
) : 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>
|
||||
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
||||
{isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeapp ? (
|
||||
{!isWeapp && !wecomUrlReady ? (
|
||||
<Text className="cs-phone-display">{phone}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
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()) {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
|
||||
@@ -56,6 +56,11 @@ export default function OrderConfirmPickupPage() {
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
const floor = data.minQty ?? 2;
|
||||
if (quantity < floor) {
|
||||
setQuantity(floor);
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
||||
}
|
||||
@@ -109,7 +114,7 @@ export default function OrderConfirmPickupPage() {
|
||||
async function submit() {
|
||||
if (!canSubmit) {
|
||||
if (!quantityOk) {
|
||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
|
||||
setMsg(tip);
|
||||
toast(tip);
|
||||
return;
|
||||
@@ -170,7 +175,7 @@ export default function OrderConfirmPickupPage() {
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
? `至少购买 ${minQty}${unitLabel}`
|
||||
: '提交订单';
|
||||
|
||||
return (
|
||||
@@ -223,7 +228,7 @@ export default function OrderConfirmPickupPage() {
|
||||
</View>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
|
||||
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -11,7 +11,9 @@ import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
|
||||
@@ -75,6 +77,7 @@ export default function OrderConfirmPage() {
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
@@ -151,6 +154,18 @@ export default function OrderConfirmPage() {
|
||||
[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 =
|
||||
preview?.allowCrossCityDelivery !== undefined
|
||||
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
||||
@@ -289,7 +304,7 @@ export default function OrderConfirmPage() {
|
||||
: !addressOk
|
||||
? '请更换地址'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
? `至少购买 ${minQty}${unitLabel}`
|
||||
: '提交订单';
|
||||
const displayMsg = msg || addressHint;
|
||||
|
||||
@@ -334,11 +349,17 @@ export default function OrderConfirmPage() {
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||||
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||
{quantity < minQty ? `;跨城至少购买 ${minQty}${unitLabel}` : ''}。
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isCross && addressOk && addressId && localHintHtml ? (
|
||||
<View className="order-card">
|
||||
<DeliveryHintHtml className="u-muted" html={localHintHtml} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
|
||||
@@ -10,6 +10,8 @@ import ContactCsButton from '../../components/ContactCsButton';
|
||||
import LogisticsRichText from '../../components/LogisticsRichText';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||
import {
|
||||
formatEstimatedArrival,
|
||||
isLogisticsNotArrived,
|
||||
@@ -83,6 +85,22 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
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) {
|
||||
const detail = (order.receiverAddress || '').trim();
|
||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||
@@ -102,6 +120,7 @@ export default function OrderDetailPage() {
|
||||
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
||||
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
||||
const [trackLoading, setTrackLoading] = useState(false);
|
||||
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||
|
||||
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
||||
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
||||
@@ -133,6 +152,7 @@ export default function OrderDetailPage() {
|
||||
.then((data) => {
|
||||
setOrder(data);
|
||||
void loadOrderTrack(data.delivery, data.status);
|
||||
applyLocalDeliveryHint(data, setLocalHintHtml);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
@@ -144,6 +164,7 @@ export default function OrderDetailPage() {
|
||||
.then((data) => {
|
||||
setOrder(data);
|
||||
void loadOrderTrack(data.delivery, data.status);
|
||||
applyLocalDeliveryHint(data, setLocalHintHtml);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
@@ -361,6 +382,12 @@ export default function OrderDetailPage() {
|
||||
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -140,7 +140,8 @@ export default function ProductDetailPage() {
|
||||
|
||||
const attrs = product?.specAttrs ?? [];
|
||||
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(() => {
|
||||
if (!product) return undefined;
|
||||
if (!specEnabled) {
|
||||
@@ -231,7 +232,7 @@ export default function ProductDetailPage() {
|
||||
if (!productId) return;
|
||||
const skuId = ensureSkuSelected();
|
||||
if (specEnabled && !skuId) return;
|
||||
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
||||
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
|
||||
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||
if (skuId) qs.push(`skuId=${skuId}`);
|
||||
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
||||
|
||||
@@ -39,6 +39,10 @@ export default function RedeemCodePage() {
|
||||
const router = useRouter();
|
||||
const token = decodeURIComponent(router.params.token ?? '');
|
||||
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 timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -148,7 +152,7 @@ export default function RedeemCodePage() {
|
||||
<View className="redeem-code-panel">
|
||||
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
||||
<View className="redeem-qr-wrap">
|
||||
<RedeemQrCode token={token} />
|
||||
<RedeemQrCode payload={qrPayload} />
|
||||
</View>
|
||||
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
||||
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
||||
|
||||
@@ -113,12 +113,15 @@ export default function RedeemPage() {
|
||||
const body: { amount: number; couponId?: string } = { amount: value };
|
||||
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',
|
||||
data: body,
|
||||
});
|
||||
const landingQs = data.landingUrl
|
||||
? `&landingUrl=${encodeURIComponent(data.landingUrl)}`
|
||||
: '';
|
||||
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) {
|
||||
toast(e instanceof Error ? e.message : '生成失败');
|
||||
|
||||
@@ -12,7 +12,6 @@ import Taro, {
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
@@ -356,7 +355,6 @@ export default function StoreDetailPage() {
|
||||
solid={headerSolid}
|
||||
titleVisible={headerSolid}
|
||||
onBack={goBack}
|
||||
right={<ShareNavButton payload={sharePayload} />}
|
||||
/>
|
||||
|
||||
<View className="store-detail-hero full-bleed">
|
||||
@@ -407,13 +405,11 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : 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>
|
||||
) : null}
|
||||
<Text className="store-detail-tag">可核销</Text>
|
||||
<Text className="store-detail-tag">好客门店</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{marqueeLines.length > 0 ? (
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
| 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.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,6 +86,8 @@
|
||||
|
||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||
|
||||
**履约起购**(城市 `common_city` 配置,按**瓶当量**;箱装 `quantity × bottlesPerUnit`):现场提货默认 **≥2 瓶**;同城配送默认 **≥2 瓶**;跨城配送默认 **≥6 瓶(1 箱)**。起购属销售策略,不放承运商。承运商「起送瓶数」只用于物流计价/对账,不拦下单。
|
||||
|
||||
**HQ 订单导出**(`admin-web` 订单监控):
|
||||
|
||||
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
||||
@@ -106,6 +112,7 @@
|
||||
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
||||
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
||||
- 核销详情展示核销门店主账户收款信息
|
||||
|
||||
### 3.4~3.8 Wave 能力
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
2. 携带 `sessionId`(`dukang_session_id`)
|
||||
3. `extraJson` 无密码/令牌;手机号脱敏
|
||||
4. eventName 先登记 taxonomy 再 emit
|
||||
5. C 端后端埋点 `clientApp` 取 JWT / `X-Client-App`(`USER_MINI` 或 `USER_H5`),禁止写死 H5。`order_submit` / `pay_success` 见 v3.5.14。
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -64,9 +64,17 @@
|
||||
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
||||
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) |
|
||||
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
||||
|
||||
@@ -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` |
|
||||
@@ -0,0 +1,103 @@
|
||||
# 杜康好客 · v3.5.11 开发文档
|
||||
|
||||
> **2026-08-26** · catalog / trade / mini-user / admin-web / API
|
||||
> **主题**:三种履约起购挂城市;企微通知补字段;结算通知(提现通过 + 四账单)
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
| # | 任务 | 类型 | 交付 |
|
||||
|---|------|------|------|
|
||||
| 1 | 履约起购 | 需求 | 城市三字段:提货 / 同城 / 跨城(瓶当量) |
|
||||
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
|
||||
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
|
||||
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
|
||||
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 |
|
||||
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
|
||||
|
||||
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
|
||||
|
||||
---
|
||||
|
||||
## 2. 规则
|
||||
|
||||
起购属销售策略,存在 `common_city`:
|
||||
|
||||
| 履约 | 字段 | 默认 | 口径 |
|
||||
|------|------|------|------|
|
||||
| 现场提货 | `pickup_min_qty` | 2 | 瓶当量 |
|
||||
| 同城配送 | `local_min_qty` | 2 | 瓶当量 |
|
||||
| 跨城配送 | `cross_min_qty` | 6 | 瓶当量;箱规 6 瓶/箱 = 最少 1 箱 |
|
||||
|
||||
箱装:`quantity × bottlesPerUnit`。承运商 `pricingRules.baseBottles` 只用于物流对账。
|
||||
|
||||
---
|
||||
|
||||
## 3. 变更面
|
||||
|
||||
- Domain:`validateMinPurchase` 接收 `{ pickupMinQty, localMinQty, crossMinQty }`
|
||||
- Trade:preview / create / 代下单读 `pickupMinQty`
|
||||
- HQ 城市管理:三字段分组;承运商起送 extra
|
||||
- mini-user:提货入口瓶装默认 2;确认页 `unitLabel`
|
||||
- SQL:[`migrate-city-pickup-min-qty-v3511.sql`](../server/dukang-api/prisma/migrate-city-pickup-min-qty-v3511.sql)
|
||||
|
||||
---
|
||||
|
||||
## 4. 企微通知字段
|
||||
|
||||
### 4.1 提交人
|
||||
|
||||
| 角色 | 文案 |
|
||||
|------|------|
|
||||
| 合伙人 | `{企业} · {姓名}`(企业空则只姓名) |
|
||||
| HQ | `HqAccount.name` |
|
||||
| 门店端 | `门店 · {StoreAccount.name}` |
|
||||
|
||||
覆盖:`store.audit_pending`(含 HQ 新建)、`store.info_change_pending`、`store.package_audit_pending`。
|
||||
|
||||
入库模板若仍是上一版默认 body,启动时升级;HQ 手改过的不覆盖(可「恢复默认」)。
|
||||
|
||||
### 4.2 订单支付成功 `order.paid`
|
||||
|
||||
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
|
||||
- 物流:现场提货 / 同城 / 跨城
|
||||
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
|
||||
- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —)
|
||||
- 测试单不推
|
||||
|
||||
### 4.3 核销成功 `redeem.success`
|
||||
|
||||
补 `userName`、`userPhone`(明文)、`userRemark`(HQ 用户备注,空则 —)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结算通知
|
||||
|
||||
条件 key 仍为 `alert.settlement`,HQ 展示 **「结算任务失败通知」**(原「结算任务告警」),放在 **系统监控** 组。提现通过 / 四账单为独立条件,仍在「结算通知」组。
|
||||
|
||||
| key | 触发 | 收款账户 |
|
||||
|-----|------|----------|
|
||||
| `alert.settlement` | 结算任务失败通知(系统监控组):账单定时任务失败、提现超时未审 | — |
|
||||
| `store.withdraw_approved` | 门店手动提现审核通过 | 门店主账号卡 |
|
||||
| `finance.store_bill` | 门店日账单 `created>0` | 各店主账号卡 |
|
||||
| `finance.partner_bill` | 合伙人月账单生成成功 | 合伙人卡 |
|
||||
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
|
||||
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
|
||||
|
||||
账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。
|
||||
|
||||
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收
|
||||
|
||||
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
|
||||
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
|
||||
- [ ] HQ 新建门店:企微可见 HQ 名称
|
||||
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确;用户昵称、明文手机、备注
|
||||
- [ ] 核销成功:有用户昵称、明文手机、备注
|
||||
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
|
||||
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
||||
- [ ] 提现通过、四类账单生成含城市/合伙人、笔数、累计金额、收款人/账号/开户行;无账单不刷屏
|
||||
@@ -0,0 +1,104 @@
|
||||
# 杜康好客 · v3.5.12 开发文档
|
||||
|
||||
> **2026-08-26** · admin-web / API
|
||||
> **主题**:发布会订单大屏循环 BGM;HQ 日志 / 订单状态流转 / 用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏菜单顺序
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
1. 订单大屏内置杜康 BGM,循环播放。
|
||||
2. 右上角提供「播放 BGM / BGM 播放中」开关(浏览器禁止自动出声,需用户点击)。
|
||||
3. 新成交撒花音效响起时,BGM 自动压低,特效结束后恢复。
|
||||
4. HQ 日志、订单详情「状态流转」、用户详情「行为时间线」中的英文码全部改为中文展示。
|
||||
5. 修复 HQ 删除门店分类:接口成功后列表不再自动补种默认树,分类真正消失(有门店占用则停用并明确提示)。
|
||||
6. HQ 左侧导航按业务优先重排:前 11 项固定,系统设置置底。
|
||||
|
||||
**不做**:C 端 / 门店 / 合伙人音频;OSS 签名直链(会过期);成交音效替换;改库内存储码(筛选仍可用事件编码);改权限 key。
|
||||
|
||||
---
|
||||
|
||||
## 2. 规则
|
||||
|
||||
### 2.1 大屏 BGM
|
||||
|
||||
- 音频文件随 `admin-web` 打包:`apps/admin-web/src/assets/big-screen/dukang-bgm.mp3`(约 2MB)。
|
||||
- BGM 与成交音效分两个 `Audio` 实例,互不打断。
|
||||
- 离开大屏页立即暂停 BGM。
|
||||
|
||||
### 2.2 英文码展示中文
|
||||
|
||||
只改 HQ 展示,不改 API 字段值。对照表在 `apps/admin-web/src/lib/display-labels.ts`。
|
||||
|
||||
| 页面 | 原英文 | 展示 |
|
||||
|------|--------|------|
|
||||
| 日志(用户/门店/合伙人/HQ/第三方/领域事件/企微机器人) | `pay_success`、`USER_MINI`、`ORDER`、`WECHAT_PAY`、`SUCCESS`、`order.read` 等 | 支付成功、C 端小程序、订单、微信支付、成功、查询订单 |
|
||||
| 订单详情 · 状态流转 | `WECHAT_PAY`、`PARTNER_PROXY`、`SYSTEM_AUTO_EXPIRE`;备注里的 `deliveryMode=LOCAL` | 微信支付、合伙人代下单、系统·超时取消;履约方式=同城 |
|
||||
| 用户详情 · 行为时间线 | 分类 `pay`、事件 `pay_success`、端 `USER_H5` | 支付、支付成功、C 端 H5 |
|
||||
|
||||
未知码仍原文回退,避免丢信息。列表筛选项「事件编码 / 对象编码」仍按存储码查询。
|
||||
|
||||
### 2.3 删除门店分类
|
||||
|
||||
根因:`GET /admin/store-categories`(以及 C 端/合伙人分类列表)默认 `ensureDefaults()`。删除无门店占用的默认分类(如「火锅」)后刷新列表会按编码把行再 insert 回来,前端却提示「已处理」。
|
||||
|
||||
规则:
|
||||
|
||||
- 列表接口 **不再自动补种**;缺省分类只通过 seed 或 HQ「同步默认分类」补齐。
|
||||
- 无门店占用:硬删除,提示「已删除」。
|
||||
- 有门店占用:改为停用,提示「该分类已有门店占用,已改为停用」。
|
||||
- 仍有下级:拒绝删除(「请先删除或停用下级分类」)。
|
||||
|
||||
### 2.4 HQ 侧栏顺序
|
||||
|
||||
仅改 `apps/admin-web/src/layouts/AdminLayout.tsx` 的 `MENU_ITEMS` 展示顺序与「发票」文案;路由、权限 key 不变。无权限项仍按原规则隐藏。
|
||||
|
||||
固定前 11 项:概览 → 用户 → 商品 → 订单 → 开城 → 门店 → 财务 → 好客权益 → 配送单 → 工单 → 发票。
|
||||
其余:推广码 → 微信绑定 → 企微机器人 → 语言模型 → 知识库 → 开发计划 → OSS 资源库 → 日志 → 权限分配 → HQ账户 → 白名单管理。
|
||||
最后一项:系统设置。
|
||||
|
||||
侧栏「发票」对应原「发票管理」入口(`/invoices`),页内标题仍为发票管理。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键路径
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| 大屏页 | `apps/admin-web/src/pages/BigScreenPage.tsx` |
|
||||
| 样式 | `apps/admin-web/src/admin.css` |
|
||||
| 音频 | `apps/admin-web/src/assets/big-screen/dukang-bgm.mp3` |
|
||||
| 中文对照 | `apps/admin-web/src/lib/display-labels.ts` |
|
||||
| 日志页 | `UserLogsPage` / `StoreLogsPage` / `PartnerLogsPage` / `HqLogsPage` / `ThirdPartyLogsPage` / `DomainEventsPage` / `WecomBotLogsPage` |
|
||||
| 订单详情 | `OrdersPage.tsx` 状态流转表 |
|
||||
| 用户详情 | `UsersPage.tsx` 行为时间线 |
|
||||
| 门店分类删除 | `store-category.service.ts` · `admin-store-categories.controller.ts` · `StoreCategoriesPage.tsx` |
|
||||
| HQ 侧栏顺序 | `apps/admin-web/src/layouts/AdminLayout.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 发版步骤
|
||||
|
||||
| 步骤 | 动作 |
|
||||
|------|------|
|
||||
| 1 | `pnpm --filter @dukang/admin-web build` |
|
||||
| 2 | 发 `admin-web` 静态资源 + `dukang-api`(分类删除修复) |
|
||||
|
||||
无 Prisma 迁移。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收清单
|
||||
|
||||
- [ ] 打开 `/orders/big-screen`,右上角可见「播放 BGM」
|
||||
- [ ] 点击后循环播放背景音乐,按钮变为「BGM 播放中」;再点暂停
|
||||
- [ ] 新成交撒花时成交音效清晰,BGM 变轻;特效结束后 BGM 恢复
|
||||
- [ ] 离开大屏后背景音乐停止
|
||||
- [ ] 日志列表「事件 / 端 / 关联 / 渠道 / 状态」为中文,详情抽屉字段名不为 `param1` / `eventName`
|
||||
- [ ] 订单详情「状态流转」操作人为中文,备注中的履约方式为中文
|
||||
- [ ] 用户详情「行为时间线」分类、事件、端为中文
|
||||
- [ ] 删除无门店占用的分类后列表不再出现该行,提示「已删除」(再点「同步默认分类」才会补回默认项)
|
||||
- [ ] 删除有门店占用的分类:行仍在但状态为停用,提示「已有门店占用,已改为停用」
|
||||
- [ ] 有下级的一级分类删除被拒绝,提示先处理下级
|
||||
- [ ] HQ 侧栏前 11 项为:概览、用户、商品、订单、开城、门店、财务、好客权益、配送单、工单、发票
|
||||
- [ ] 系统设置在侧栏最后一项
|
||||
@@ -0,0 +1,95 @@
|
||||
# 杜康好客 · v3.5.14 修复文档
|
||||
|
||||
> **2026-08-26** · API / 生产数据
|
||||
> **主题**:C 端「提交订单 / 支付成功」用户日志 `client_app` 误标为 H5,改为真实端(小程序)
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
1. 修复后端埋点:`order_submit`、`pay_success` 不再写死 `USER_H5`。
|
||||
2. 回填线上 `log_user_analytics`:这两类事件里所有 `USER_H5` 改为 `USER_MINI`。
|
||||
|
||||
**不做**:核销埋点(`benefit_redeem_*`);未登录 `POST /analytics/events` 的 H5 回退;H5 预览(`TARO_ENV=h5`)仍报 `USER_H5`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 根因
|
||||
|
||||
`TradeService.createOrder` / `payOrder`(Mock 支付成功)/ `handlePaySuccess`(微信支付回调)调用 `analyticsService.trackOneSafe` 时把 `clientApp` 写死为 `'USER_H5'`。
|
||||
|
||||
当前生产 C 端是 `apps/mini-user` 小程序,请求头为 `X-Client-App: USER_MINI`(写入 JWT)。同链路的 `pay_fail` 已使用 JWT 的 `clientApp`,故 HQ「用户日志」里提交订单显示「C 端 H5」,支付失败却可能显示「C 端小程序」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码规则
|
||||
|
||||
| 写入点 | 事件 | `clientApp` |
|
||||
|--------|------|-------------|
|
||||
| `POST /trade/orders` → `createOrder` | `order_submit` | JWT `user.clientApp`(缺省 `USER_H5`) |
|
||||
| `POST /trade/orders/:id/pay` Mock 成功 | `pay_success` | 入参 `clientApp`(缺省 `USER_H5`) |
|
||||
| 微信支付回调 `handlePaySuccess` | `pay_success` | 该订单已有 `order_submit` / `pay_fail`;没有则默认 `USER_MINI` |
|
||||
|
||||
小程序原生:`USER_MINI`。`mini-user` H5 预览:仍为 `USER_H5`(公众号 JSAPI,见 `apps/mini-user/src/lib/api.ts`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 线上数据回填
|
||||
|
||||
表:`log_user_analytics`
|
||||
范围:`event_name IN ('order_submit', 'pay_success') AND client_app = 'USER_H5'`
|
||||
动作:`client_app` → `USER_MINI`
|
||||
|
||||
```sql
|
||||
SELECT event_name, client_app, COUNT(*) AS cnt
|
||||
FROM log_user_analytics
|
||||
WHERE event_name IN ('order_submit', 'pay_success')
|
||||
GROUP BY event_name, client_app;
|
||||
|
||||
UPDATE log_user_analytics
|
||||
SET client_app = 'USER_MINI'
|
||||
WHERE event_name IN ('order_submit', 'pay_success')
|
||||
AND client_app = 'USER_H5';
|
||||
```
|
||||
|
||||
回填结果(生产,2026-08-26):
|
||||
|
||||
| 事件 | 回填前 USER_H5 | 回填后 USER_MINI |
|
||||
|------|----------------|------------------|
|
||||
| `order_submit` | 55 | 55 |
|
||||
| `pay_success` | 28 | 28 |
|
||||
|
||||
共更新 **83** 行。回填后这两类事件已无 `USER_H5`。
|
||||
|
||||
不改其它事件名,不改 `common_event`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| 建单 / 支付埋点 | `server/dukang-api/src/modules/trade/trade.service.ts` |
|
||||
| 建单入参 | `server/dukang-api/src/modules/trade/trade.controller.ts` |
|
||||
| HQ 展示 | `order_submit` → 提交订单;`USER_MINI` → C 端小程序 |
|
||||
|
||||
无 Prisma 迁移。
|
||||
|
||||
---
|
||||
|
||||
## 6. 发版步骤
|
||||
|
||||
| 步骤 | 动作 |
|
||||
|------|------|
|
||||
| 1 | 生产已执行上述 `UPDATE`(历史行) |
|
||||
| 2 | 发 `dukang-api`(新单按 JWT 写端);`--skip-db` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收清单
|
||||
|
||||
- [x] 历史 `order_submit` / `pay_success` 在 HQ 不再显示「C 端 H5」(已回填 83 行)
|
||||
- [ ] 小程序提交订单后,HQ 用户日志「提交订单」端为「C 端小程序」(需发 `dukang-api`)
|
||||
- [ ] 小程序支付成功后,「支付成功」端为「C 端小程序」(需发 `dukang-api`)
|
||||
- [ ] `mini-user` H5 预览下单仍为「C 端 H5」
|
||||
- [ ] 浏览类埋点(`home_view` 等)端字段不变
|
||||
+9
-1
@@ -36,7 +36,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
||||
|
||||
| 链路 | 关键节点 |
|
||||
|------|----------|
|
||||
| C 购酒 | 登录→开城商品→起购(同城2/跨城6)→支付→权益1:1→出码/核销→评价 |
|
||||
| C 购酒 | 登录→开城商品→起购(提货2/同城2/跨城6=1箱,城市配置)→支付→权益1:1→出码/核销→评价 |
|
||||
| 门店 | 登录→扫码确认→记录→store_payout T+1 |
|
||||
| 合伙人 | 拓店三步→HQ审核→辖区订单/账单 |
|
||||
| HQ | 开城/商品/审核/订单/权益/核销/结算/工单 |
|
||||
@@ -45,6 +45,14 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
||||
|
||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||
|
||||
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
||||
|
||||
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
||||
|
||||
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
|
||||
|
||||
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
||||
|
||||
## 5. 验收用例(必过)
|
||||
|
||||
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
||||
|
||||
+6
-3
@@ -22,10 +22,11 @@
|
||||
|
||||
- 四 Tab:首页/权益/门店/我的;微信登录+7天会话
|
||||
- 下单:选城→商品→地址→起购校验→微信支付→权益1:1
|
||||
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟
|
||||
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟。二维码内容为门店 H5 URL(`{SHOP_H5_URL}/redeem?token=`),微信扫一扫直达核销确认页
|
||||
- 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间
|
||||
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
|
||||
- 售后:客服入口;发票/四类型工单按 PRD Wave 进度
|
||||
- 售后:客服入口(小程序优先企微微信客服,未配 CorpID 回退原生客服);发票/四类型工单按 PRD Wave 进度
|
||||
- 同城送:收货市已开城则展示承运商「配送信息提示」(HTML);未开城走跨城到付;门店详情无顶栏分享按钮
|
||||
- 版本:`minClientVersion` 过低强制更新或退出
|
||||
- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」)
|
||||
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
## 3. 门店端(h5-shop)
|
||||
|
||||
- 登录绑定门店;首页扫码核销(微信 JSSDK)
|
||||
- 登录绑定门店;首页扫码核销(微信 JSSDK);也可微信扫一扫用户核销码直达确认页(仍需点「确认核销」)
|
||||
- 核销记录;今日汇总;到账金额×60%展示
|
||||
- 营业状态开关;Mine 门店信息
|
||||
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
|
||||
@@ -68,6 +69,7 @@
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| iOS 登录/选店后 | 用 `location.replace(path)`(`hardNavigateInWechat`),禁止仅 React Router navigate |
|
||||
| 微信扫一扫落地核销页 | 成功后须 `hardNavigateInWechat('/')` 回首页,否则入场 URL 仍是 `/redeem?token=`,下次首页扫码验签失败 |
|
||||
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL,**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
|
||||
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
|
||||
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
|
||||
@@ -150,6 +152,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|
||||
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
||||
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
||||
| 日志 | `/logs/wecom-bots` |
|
||||
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
||||
|
||||
eventKey:`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
validateMinPurchase,
|
||||
minBottleQtyForDelivery,
|
||||
toBottleQuantity,
|
||||
toMinSaleQuantity,
|
||||
validateBusinessHours,
|
||||
@@ -34,36 +35,54 @@ describe('calcBenefitAmount', () => {
|
||||
});
|
||||
|
||||
describe('validateMinPurchase', () => {
|
||||
const mins = { pickupMinQty: 2, localMinQty: 2, crossMinQty: 6 };
|
||||
|
||||
it('local requires 2 bottles', () => {
|
||||
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true);
|
||||
expect(validateMinPurchase('LOCAL', 1, mins).ok).toBe(false);
|
||||
expect(validateMinPurchase('LOCAL', 2, mins).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('cross city requires 6 bottles', () => {
|
||||
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||
expect(validateMinPurchase('CROSS_CITY', 5, mins).ok).toBe(false);
|
||||
expect(validateMinPurchase('CROSS_CITY', 6, mins).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||
it('on-site pickup requires pickup min (default 2 bottles)', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, mins).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, mins).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
|
||||
it('on-site pickup uses pickupMinQty independently of localMinQty', () => {
|
||||
const split = { pickupMinQty: 3, localMinQty: 2, crossMinQty: 6 };
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, split).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 3, split).ok).toBe(true);
|
||||
expect(validateMinPurchase('LOCAL', 2, split).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('box SKU: 1 box (6 bottles) satisfies local, cross and pickup mins', () => {
|
||||
const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
|
||||
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
|
||||
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
|
||||
expect(validateMinPurchase('LOCAL', 1, mins, opts).ok).toBe(true);
|
||||
expect(validateMinPurchase('CROSS_CITY', 1, mins, opts).ok).toBe(true);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, mins, opts).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('box SKU message uses 箱', () => {
|
||||
const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' });
|
||||
const r = validateMinPurchase('LOCAL', 0, mins, { bottlesPerUnit: 6, saleUnit: 'BOX' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.message).toContain('箱');
|
||||
});
|
||||
});
|
||||
|
||||
describe('minBottleQtyForDelivery', () => {
|
||||
it('picks pickup / local / cross independently', () => {
|
||||
const mins = { pickupMinQty: 2, localMinQty: 3, crossMinQty: 6 };
|
||||
expect(minBottleQtyForDelivery('ON_SITE_PICKUP', mins)).toBe(2);
|
||||
expect(minBottleQtyForDelivery('LOCAL', mins)).toBe(3);
|
||||
expect(minBottleQtyForDelivery('CROSS_CITY', mins)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBottleQuantity / toMinSaleQuantity', () => {
|
||||
it('converts sale qty to bottles', () => {
|
||||
expect(toBottleQuantity(2, 1)).toBe(2);
|
||||
|
||||
@@ -33,36 +33,48 @@ export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefi
|
||||
return saleUnit === 'BOX' ? '箱' : '瓶';
|
||||
}
|
||||
|
||||
export type DeliveryFulfillmentType = 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
||||
|
||||
/** 城市履约起购(瓶当量) */
|
||||
export type MinPurchaseMins = {
|
||||
pickupMinQty: number;
|
||||
localMinQty: number;
|
||||
crossMinQty: number;
|
||||
};
|
||||
|
||||
export function minBottleQtyForDelivery(
|
||||
deliveryType: DeliveryFulfillmentType,
|
||||
mins: MinPurchaseMins,
|
||||
): number {
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
return mins.pickupMinQty > 0 ? mins.pickupMinQty : 2;
|
||||
}
|
||||
if (deliveryType === 'LOCAL') {
|
||||
return mins.localMinQty;
|
||||
}
|
||||
return mins.crossMinQty;
|
||||
}
|
||||
|
||||
export function validateMinPurchase(
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||
deliveryType: DeliveryFulfillmentType,
|
||||
quantity: number,
|
||||
localMinQty: number,
|
||||
crossMinQty: number,
|
||||
mins: MinPurchaseMins,
|
||||
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
||||
): { ok: boolean; message?: string } {
|
||||
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
||||
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
||||
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
||||
const unit = saleUnitLabel(saleUnit);
|
||||
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
const minBottles = localMinQty > 0 ? localMinQty : 2;
|
||||
if (bottleQty < minBottles) {
|
||||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||
const minBottles = minBottleQtyForDelivery(deliveryType, mins);
|
||||
if (bottleQty < minBottles) {
|
||||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
deliveryType === 'LOCAL'
|
||||
const message =
|
||||
deliveryType === 'ON_SITE_PICKUP'
|
||||
? `现场提货至少购买 ${minSale}${unit}`
|
||||
: deliveryType === 'LOCAL'
|
||||
? `同城配送至少购买 ${minSale}${unit}`
|
||||
: `跨城配送至少购买 ${minSale}${unit}`,
|
||||
};
|
||||
: `跨城配送至少购买 ${minSale}${unit}`;
|
||||
return { ok: false, message };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ProductDto {
|
||||
allowOnlinePurchase?: boolean;
|
||||
/** 是否允许跨城配送 */
|
||||
allowCrossCityDelivery?: boolean;
|
||||
/** v3.5.4:是否配置了销售规格(多 SKU) */
|
||||
/** C 端是否展示规格选择(可售 SKU > 1 且已配规格轴) */
|
||||
specEnabled?: boolean;
|
||||
/** 列表轻量:展示 SKU 销售单位 */
|
||||
saleUnit?: ProductSaleUnit;
|
||||
|
||||
@@ -30,11 +30,16 @@ export interface AppConfig {
|
||||
tencentLbsSecretKey: string;
|
||||
/** C 端 H5 落地页(推广码二维码链接前缀) */
|
||||
userH5Url: string;
|
||||
/** 门店 H5 落地页(用户核销码二维码链接前缀) */
|
||||
shopH5Url: string;
|
||||
}
|
||||
|
||||
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
|
||||
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||
|
||||
/** 门店 H5 默认落地页(系统设置 SHOP_H5_URL 未配时回退;HQ 可改) */
|
||||
export const DEFAULT_SHOP_H5_URL = 'https://shop.dukanghaoke.com';
|
||||
|
||||
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
|
||||
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
||||
|
||||
@@ -58,12 +63,15 @@ export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualifi
|
||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||
|
||||
/**
|
||||
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
|
||||
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
|
||||
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页 / 小程序 openCustomerServiceChat)
|
||||
* 可在系统设置 CUSTOMER_SERVICE_WECOM_URL 覆盖
|
||||
*/
|
||||
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||
|
||||
/** 企业微信 CorpID(小程序 wx.openCustomerServiceChat 必填;系统设置 WECOM_CORP_ID) */
|
||||
export const WECOM_CORP_ID = '';
|
||||
|
||||
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
|
||||
export type ClientBrandRuntime = {
|
||||
userH5Url: string;
|
||||
@@ -74,6 +82,8 @@ export type ClientBrandRuntime = {
|
||||
miniUserStaticOssBase: string;
|
||||
qualificationDisclosureUrl: string;
|
||||
customerServicePhone: string;
|
||||
customerServiceWecomUrl: string;
|
||||
wecomCorpId: string;
|
||||
};
|
||||
|
||||
export function resolveClientBrandRuntime(
|
||||
@@ -95,6 +105,8 @@ export function resolveClientBrandRuntime(
|
||||
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
|
||||
`${staticBase}qualification-disclosure.png`,
|
||||
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
|
||||
customerServiceWecomUrl: (e.CUSTOMER_SERVICE_WECOM_URL || CUSTOMER_SERVICE_WECOM_URL).trim(),
|
||||
wecomCorpId: (e.WECOM_CORP_ID || WECOM_CORP_ID).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +277,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
||||
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
|
||||
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
||||
shopH5Url: (e.SHOP_H5_URL || DEFAULT_SHOP_H5_URL).replace(/\/$/, ''),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface DevPlanTaskDto {
|
||||
|
||||
attachmentUrls?: string[] | null;
|
||||
|
||||
versions?: Array<{ id: string; versionNo: string }>;
|
||||
|
||||
versionIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +170,8 @@ export interface UpdateDevPlanTaskInput {
|
||||
|
||||
attachmentUrls?: string[];
|
||||
|
||||
versionIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isXfxProviderCode, sanitizeDeliveryHintHtml } from './fulfillment-provider';
|
||||
|
||||
describe('isXfxProviderCode', () => {
|
||||
it('匹配标准编码与城市前缀', () => {
|
||||
expect(isXfxProviderCode('XFX')).toBe(true);
|
||||
expect(isXfxProviderCode('xiaofeixia')).toBe(true);
|
||||
expect(isXfxProviderCode('ZZXFX')).toBe(true);
|
||||
expect(isXfxProviderCode('XFX_ZZ')).toBe(true);
|
||||
});
|
||||
|
||||
it('不匹配普通承运商', () => {
|
||||
expect(isXfxProviderCode('')).toBe(false);
|
||||
expect(isXfxProviderCode('LOGISTICS')).toBe(false);
|
||||
expect(isXfxProviderCode('MANUAL')).toBe(false);
|
||||
expect(isXfxProviderCode('SF')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDeliveryHintHtml', () => {
|
||||
const styled =
|
||||
'<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>';
|
||||
|
||||
it('保留允许的 style', () => {
|
||||
expect(sanitizeDeliveryHintHtml(styled)).toBe(styled);
|
||||
});
|
||||
|
||||
it('解码 " 后保留 style', () => {
|
||||
const raw =
|
||||
'<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span><br />\n<span>13点之前下单,当日送达</span>';
|
||||
expect(sanitizeDeliveryHintHtml(raw)).toBe(
|
||||
`${styled}<br/>\n<span>13点之前下单,当日送达</span>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('弯引号与无引号 style 也能保留', () => {
|
||||
expect(
|
||||
sanitizeDeliveryHintHtml(
|
||||
'<span style=\u201Ccolor:#A61D24;font-weight:700;font-size:13px\u201D>同城配送,预计24小时内送到</span>',
|
||||
),
|
||||
).toBe(styled);
|
||||
expect(
|
||||
sanitizeDeliveryHintHtml(
|
||||
'<span style=color:#A61D24;font-weight:700;font-size:13px>同城配送,预计24小时内送到</span>',
|
||||
),
|
||||
).toBe(styled);
|
||||
});
|
||||
|
||||
it('仍去掉脚本与非法样式', () => {
|
||||
expect(sanitizeDeliveryHintHtml('<span style="color:red;background:url(x)">x</span>')).toBe(
|
||||
'<span style="color:red">x</span>',
|
||||
);
|
||||
expect(sanitizeDeliveryHintHtml('<script>alert(1)</script><span>ok</span>')).toBe('<span>ok</span>');
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ export interface FulfillmentProviderDto {
|
||||
settlementMethod: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
prepaidBalance: number;
|
||||
/** C 端同城配送提示(已消毒 HTML) */
|
||||
deliveryHintHtml?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -74,6 +76,7 @@ export interface CreateFulfillmentProviderInput {
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
deliveryHintHtml?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateFulfillmentProviderInput {
|
||||
@@ -89,6 +92,7 @@ export interface UpdateFulfillmentProviderInput {
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
deliveryHintHtml?: string | null;
|
||||
}
|
||||
|
||||
export { DEFAULT_XFX_LOGISTICS_PRICING };
|
||||
@@ -110,6 +114,119 @@ export interface WarehouseFulfillmentConfig {
|
||||
|
||||
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
||||
|
||||
/** 小飞侠承运商编码:精确 XFX/XIAOFEIXIA,以及城市前缀如 ZZXFX */
|
||||
export function isXfxProviderCode(code: string): boolean {
|
||||
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
||||
const c = code.trim().toUpperCase();
|
||||
if (!c) return false;
|
||||
if ((XFX_PROVIDER_CODES as readonly string[]).includes(c)) return true;
|
||||
if (c.includes('XIAOFEIXIA')) return true;
|
||||
return c.endsWith('XFX') || c.startsWith('XFX');
|
||||
}
|
||||
|
||||
/** 承运商未配置提示时 C 端回退文案 */
|
||||
export const DEFAULT_LOCAL_DELIVERY_HINT = '同城配送,预计24小时内送到';
|
||||
|
||||
export const LOCAL_DELIVERY_HINT_MAX_LEN = 2000;
|
||||
|
||||
const HINT_ALLOWED_TAGS = new Set(['span', 'p', 'br', 'b', 'strong', 'i', 'em', 'font']);
|
||||
const HINT_ALLOWED_STYLES = new Set(['color', 'font-weight', 'font-size', 'font-style']);
|
||||
|
||||
export type LocalDeliveryDto = {
|
||||
city: { id: string; code: string; name: string };
|
||||
warehouse: { id: string; name: string; fulfillmentMode: string } | null;
|
||||
provider: { id: string; code: string; name: string } | null;
|
||||
hintHtml: string | null;
|
||||
};
|
||||
|
||||
/** 粘贴 HTML 源码时常带 " / 弯引号;不解码则 style 正则匹配失败,整段样式被剥掉 */
|
||||
function decodeHintHtmlEntities(html: string): string {
|
||||
let prev = '';
|
||||
let out = html;
|
||||
for (let i = 0; i < 3 && out !== prev; i += 1) {
|
||||
prev = out;
|
||||
out = out
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�*34;/g, '"')
|
||||
.replace(/�*22;/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/�*39;/g, "'")
|
||||
.replace(/�*27;/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/ /gi, ' ');
|
||||
}
|
||||
return out
|
||||
.replace(/[\u201c\u201d\u201e\u00ab\u00bb]/g, '"')
|
||||
.replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
function sanitizeHintStyle(raw: string): string {
|
||||
return raw
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const idx = part.indexOf(':');
|
||||
if (idx <= 0) return '';
|
||||
const key = part.slice(0, idx).trim().toLowerCase();
|
||||
const value = part.slice(idx + 1).trim();
|
||||
if (!HINT_ALLOWED_STYLES.has(key)) return '';
|
||||
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(value)) return '';
|
||||
return `${key}:${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function extractHintAttr(attrs: string, name: string): string {
|
||||
const re = new RegExp(
|
||||
`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
||||
'i',
|
||||
);
|
||||
const match = attrs.match(re);
|
||||
if (!match) return '';
|
||||
return (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||||
}
|
||||
|
||||
/** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
|
||||
export function deliveryHintHtmlToRichNodes(html: string): string {
|
||||
return html
|
||||
.replace(/\r\n|\r|\n/g, '<br/>')
|
||||
.replace(/(?:<br\s*\/?>){3,}/gi, '<br/><br/>');
|
||||
}
|
||||
|
||||
/** 承运商配送提示 HTML:去掉脚本/事件,只保留字号颜色粗细 */
|
||||
export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
|
||||
if (raw == null) return null;
|
||||
let html = String(raw).trim();
|
||||
if (!html) return null;
|
||||
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
|
||||
html = html.slice(0, LOCAL_DELIVERY_HINT_MAX_LEN);
|
||||
}
|
||||
html = decodeHintHtmlEntities(html);
|
||||
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||
html = html.replace(/javascript\s*:/gi, '');
|
||||
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (_full, tag: string, attrs: string) => {
|
||||
const name = String(tag).toLowerCase();
|
||||
const closing = String(_full).startsWith('</');
|
||||
if (!HINT_ALLOWED_TAGS.has(name)) return '';
|
||||
if (name === 'br') return closing ? '' : '<br/>';
|
||||
if (closing) return `</${name}>`;
|
||||
const style = sanitizeHintStyle(extractHintAttr(String(attrs), 'style'));
|
||||
let color = '';
|
||||
let size = '';
|
||||
if (name === 'font') {
|
||||
color = extractHintAttr(String(attrs), 'color');
|
||||
size = extractHintAttr(String(attrs), 'size');
|
||||
}
|
||||
const extra: string[] = [];
|
||||
if (style) extra.push(`style="${style}"`);
|
||||
if (color) extra.push(`color="${color.replace(/"/g, '')}"`);
|
||||
if (size) extra.push(`size="${size.replace(/"/g, '')}"`);
|
||||
return extra.length ? `<${name} ${extra.join(' ')}>` : `<${name}>`;
|
||||
});
|
||||
const cleaned = html.replace(/ /g, ' ').trim();
|
||||
return cleaned || null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface AdminOrdersListQuery extends AdminListQuery {
|
||||
status?: string | string[];
|
||||
orderType?: string;
|
||||
userId?: string;
|
||||
userKeyword?: string;
|
||||
productKeyword?: string;
|
||||
cityId?: string;
|
||||
receiverPhone?: string;
|
||||
fulfillmentHold?: string | boolean;
|
||||
@@ -18,6 +20,16 @@ export interface AdminOrdersListQuery extends AdminListQuery {
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
export interface AdminUsersListQuery extends AdminListQuery {
|
||||
phone?: string;
|
||||
userNo?: string;
|
||||
userId?: string;
|
||||
deviceKey?: string;
|
||||
phoneVerified?: string;
|
||||
status?: number;
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAdminUserRequest {
|
||||
hqRemark: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PromoCodeItem = {
|
||||
qrcodeId: string;
|
||||
status: PromoCodeStatus;
|
||||
scanCount: number;
|
||||
/** 已完成订单数(status=COMPLETED) */
|
||||
orderCount: number;
|
||||
landingUrl: string;
|
||||
qrcodeUrl?: string | null;
|
||||
@@ -47,6 +48,7 @@ export type PromoCodeItem = {
|
||||
export type PromoCodeStats = {
|
||||
/** 扫码进入次数 */
|
||||
scanCount: number;
|
||||
/** 已完成订单数(status=COMPLETED) */
|
||||
orderCount: number;
|
||||
conversionRate: number;
|
||||
/** 归因用户数(user_promo_attribution) */
|
||||
@@ -66,6 +68,7 @@ export type PromoCodeAttributedUser = {
|
||||
sourceType: string;
|
||||
sourceRefId: string | null;
|
||||
firstTouchAt: string | null;
|
||||
/** 该用户已完成订单数 */
|
||||
orderCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -98,7 +101,9 @@ export type PromoMetricEventItem = {
|
||||
id: string;
|
||||
eventType: PromoMetricEventType;
|
||||
userId: string | null;
|
||||
userNo?: string | null;
|
||||
orderId: string | null;
|
||||
orderNo?: string | null;
|
||||
sessionId: string | null;
|
||||
clientIp: string | null;
|
||||
ipProvince: string | null;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildShopRedeemLandingUrl, parseRedeemTokenFromScan } from './redeem';
|
||||
|
||||
const TOKEN = 'a1b2c3d4e5f6789012345678901234ab';
|
||||
|
||||
describe('buildShopRedeemLandingUrl', () => {
|
||||
it('joins shop H5 origin with /redeem?token=', () => {
|
||||
expect(buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN)).toBe(
|
||||
`https://shop.dukanghaoke.com/redeem?token=${TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash on the base', () => {
|
||||
expect(buildShopRedeemLandingUrl('https://shop-test.dukanghaoke.com/', TOKEN)).toBe(
|
||||
`https://shop-test.dukanghaoke.com/redeem?token=${TOKEN}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRedeemTokenFromScan', () => {
|
||||
it('accepts a raw 32-hex token', () => {
|
||||
expect(parseRedeemTokenFromScan(TOKEN.toUpperCase())).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('extracts token from shop landing URL', () => {
|
||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
||||
expect(parseRedeemTokenFromScan(url)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('strips WeChat QR_CODE prefix before parsing URL', () => {
|
||||
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
|
||||
expect(parseRedeemTokenFromScan(`QR_CODE,${url}`)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('returns null for empty or unrelated content', () => {
|
||||
expect(parseRedeemTokenFromScan('')).toBeNull();
|
||||
expect(parseRedeemTokenFromScan('https://shop.dukanghaoke.com/records')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,48 @@ export interface RedeemTokenResult {
|
||||
expireAt: string;
|
||||
amount: number;
|
||||
boundStoreId?: string | null;
|
||||
/** 门店 H5 核销确认页落地 URL(写入二维码,微信扫一扫可直达) */
|
||||
landingUrl?: string;
|
||||
}
|
||||
|
||||
const REDEEM_TOKEN_HEX = /^[a-f0-9]{32}$/i;
|
||||
|
||||
/** 门店核销确认页落地 URL,供用户端核销码使用 */
|
||||
export function buildShopRedeemLandingUrl(shopH5Url: string, token: string): string {
|
||||
const base = shopH5Url.replace(/\/$/, '');
|
||||
return `${base}/redeem?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** 微信扫码偶发 `QR_CODE,payload` 前缀 */
|
||||
function stripScanTypePrefix(raw: string): string {
|
||||
const comma = raw.indexOf(',');
|
||||
if (comma > 0 && comma < 24 && /^[A-Z0-9_]+$/.test(raw.slice(0, comma))) {
|
||||
return raw.slice(comma + 1).trim();
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = stripScanTypePrefix(raw.trim());
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (REDEEM_TOKEN_HEX.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 && REDEEM_TOKEN_HEX.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export interface RedeemPreviewDto {
|
||||
|
||||
@@ -146,6 +146,7 @@ export interface StoreBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
storeId: string;
|
||||
/** 出账日 YYYY-MM-DD(T+1 窗口「昨日 00:00–今日 00:00」中的今天) */
|
||||
billDate: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface OrderTrackDto {
|
||||
provider?: string;
|
||||
trackingNo?: string | null;
|
||||
logisticsCompany?: string | null;
|
||||
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
|
||||
queryError?: string | null;
|
||||
}
|
||||
|
||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
||||
|
||||
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
|
||||
qualificationDisclosureUrl?: string;
|
||||
/** 总部客服电话 */
|
||||
customerServicePhone?: string;
|
||||
/** 企业微信「微信客服」链接(kfid) */
|
||||
customerServiceWecomUrl?: string;
|
||||
/** 企业微信 CorpID(小程序调起企微客服) */
|
||||
wecomCorpId?: string;
|
||||
/** 合伙人入驻:企微客服二维码图片 URL */
|
||||
partnerOnboardCsQrUrl?: string | null;
|
||||
/** 合伙人入驻:企微客服提示文案 */
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WECOM_PUSH_CONDITION_GROUPS,
|
||||
WECOM_PUSH_CONDITION_LABELS,
|
||||
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
|
||||
WECOM_SETTLEMENT_PUSH_NAME,
|
||||
WECOM_TEMPLATE_PLACEHOLDERS,
|
||||
parseWecomPushConditions,
|
||||
} from './wecom-message-push';
|
||||
|
||||
describe('wecom-message-push', () => {
|
||||
it('alert.settlement 展示为结算任务失败通知且在系统监控组', () => {
|
||||
expect(WECOM_PUSH_CONDITION_LABELS['alert.settlement']).toBe('结算任务失败通知');
|
||||
const system = WECOM_PUSH_CONDITION_GROUPS.find((g) => g.key === 'system');
|
||||
expect(system?.conditions).toEqual(['alert.system', 'alert.settlement']);
|
||||
});
|
||||
|
||||
it('默认结算通知群条件含任务失败与四账单', () => {
|
||||
expect(WECOM_SETTLEMENT_PUSH_NAME).toBe('结算通知群');
|
||||
expect(WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS).toEqual(
|
||||
expect.arrayContaining([
|
||||
'alert.settlement',
|
||||
'store.withdraw_approved',
|
||||
'finance.store_bill',
|
||||
'finance.partner_bill',
|
||||
'finance.winery_bill',
|
||||
'finance.logistics_bill',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('解析新财务条件', () => {
|
||||
expect(parseWecomPushConditions(['finance.store_bill', 'bogus'])).toEqual([
|
||||
'finance.store_bill',
|
||||
]);
|
||||
});
|
||||
|
||||
it('支付与核销模板含昵称、明文手机、备注占位符', () => {
|
||||
expect(WECOM_TEMPLATE_PLACEHOLDERS['order.paid']).toEqual(
|
||||
expect.arrayContaining(['userName', 'userPhone', 'userRemark']),
|
||||
);
|
||||
expect(WECOM_TEMPLATE_PLACEHOLDERS['redeem.success']).toEqual(
|
||||
expect.arrayContaining(['userName', 'userPhone', 'userRemark']),
|
||||
);
|
||||
});
|
||||
|
||||
it('结算账单模板含累计金额与明细摘要', () => {
|
||||
expect(WECOM_TEMPLATE_PLACEHOLDERS['finance.store_bill']).toEqual(
|
||||
expect.arrayContaining(['billCount', 'totalAmount', 'billSummary']),
|
||||
);
|
||||
expect(WECOM_TEMPLATE_PLACEHOLDERS['store.withdraw_approved']).toEqual(
|
||||
expect.arrayContaining(['cityName', 'partnerLabel', 'payee', 'accountNo', 'bankBranch']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */
|
||||
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 + v3.5.11 结算通知) */
|
||||
export const WECOM_PUSH_CONDITIONS = [
|
||||
'alert.ops',
|
||||
'support_ticket.created',
|
||||
@@ -11,9 +11,14 @@ export const WECOM_PUSH_CONDITIONS = [
|
||||
'store.package_audit_pending',
|
||||
'store.info_change_pending',
|
||||
'store.withdraw_pending',
|
||||
'store.withdraw_approved',
|
||||
'order.paid',
|
||||
'redeem.success',
|
||||
'invoice.pending',
|
||||
'finance.store_bill',
|
||||
'finance.partner_bill',
|
||||
'finance.winery_bill',
|
||||
'finance.logistics_bill',
|
||||
] as const;
|
||||
|
||||
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
||||
@@ -24,15 +29,20 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
|
||||
'alert.pay': '支付异常',
|
||||
'alert.redeem': '核销异常',
|
||||
'alert.system': '系统监控(5xx、回调、定时任务)',
|
||||
'alert.settlement': '结算任务告警',
|
||||
'alert.settlement': '结算任务失败通知',
|
||||
'dev_plan.task_dispatch': '开发任务评审派发',
|
||||
'store.audit_pending': '门店提交/重提待审',
|
||||
'store.package_audit_pending': '套餐变更待审',
|
||||
'store.info_change_pending': '门店信息变更待审',
|
||||
'store.withdraw_pending': '门店手动提现待审',
|
||||
'store.withdraw_approved': '门店手动提现已通过',
|
||||
'order.paid': '订单支付成功',
|
||||
'redeem.success': '门店核销成功',
|
||||
'invoice.pending': '发票申请待开票',
|
||||
'finance.store_bill': '门店日账单已生成',
|
||||
'finance.partner_bill': '合伙人月账单已生成',
|
||||
'finance.winery_bill': '酒厂日账单已生成',
|
||||
'finance.logistics_bill': '物流月对账已生成',
|
||||
};
|
||||
|
||||
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
||||
@@ -68,9 +78,20 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统与结算',
|
||||
label: '系统监控',
|
||||
conditions: ['alert.system', 'alert.settlement'],
|
||||
},
|
||||
{
|
||||
key: 'settlement',
|
||||
label: '结算通知',
|
||||
conditions: [
|
||||
'store.withdraw_approved',
|
||||
'finance.store_bill',
|
||||
'finance.partner_bill',
|
||||
'finance.winery_bill',
|
||||
'finance.logistics_bill',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dev_plan',
|
||||
label: '开发计划',
|
||||
@@ -115,9 +136,20 @@ export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[]
|
||||
'redeem.success',
|
||||
];
|
||||
|
||||
/** 默认「结算通知群」 */
|
||||
export const WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS: WecomPushCondition[] = [
|
||||
'alert.settlement',
|
||||
'store.withdraw_approved',
|
||||
'finance.store_bill',
|
||||
'finance.partner_bill',
|
||||
'finance.winery_bill',
|
||||
'finance.logistics_bill',
|
||||
];
|
||||
|
||||
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
|
||||
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
|
||||
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
|
||||
export const WECOM_SETTLEMENT_PUSH_NAME = '结算通知群';
|
||||
|
||||
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
|
||||
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
|
||||
@@ -131,7 +163,12 @@ export const WECOM_TEMPLATE_EVENT_KEYS = [
|
||||
'store.package_audit_pending',
|
||||
'store.info_change_pending',
|
||||
'store.withdraw_pending',
|
||||
'store.withdraw_approved',
|
||||
'invoice.pending',
|
||||
'finance.store_bill',
|
||||
'finance.partner_bill',
|
||||
'finance.winery_bill',
|
||||
'finance.logistics_bill',
|
||||
] as const;
|
||||
|
||||
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
|
||||
@@ -143,14 +180,54 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string>
|
||||
'store.package_audit_pending': '套餐变更待审',
|
||||
'store.info_change_pending': '门店信息变更待审',
|
||||
'store.withdraw_pending': '门店手动提现待审',
|
||||
'store.withdraw_approved': '门店手动提现已通过',
|
||||
'invoice.pending': '发票申请待开票',
|
||||
'finance.store_bill': '门店日账单已生成',
|
||||
'finance.partner_bill': '合伙人月账单已生成',
|
||||
'finance.winery_bill': '酒厂日账单已生成',
|
||||
'finance.logistics_bill': '物流月对账已生成',
|
||||
};
|
||||
|
||||
/** 各事件可用占位符说明(HQ 模板编辑) */
|
||||
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
|
||||
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
|
||||
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
|
||||
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
|
||||
'order.paid': [
|
||||
'orderNo',
|
||||
'payAmount',
|
||||
'cityName',
|
||||
'skuSummary',
|
||||
'deliveryLabel',
|
||||
'receiverInfo',
|
||||
'userName',
|
||||
'userPhone',
|
||||
'userRemark',
|
||||
'phoneMasked',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'redeem.success': [
|
||||
'redeemNo',
|
||||
'amount',
|
||||
'storeName',
|
||||
'channel',
|
||||
'userName',
|
||||
'userPhone',
|
||||
'userRemark',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'store.audit_pending': [
|
||||
'storeName',
|
||||
'cityName',
|
||||
'submitter',
|
||||
'partnerLabel',
|
||||
'action',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
'storeId',
|
||||
],
|
||||
'store.package_audit_pending': [
|
||||
'storeName',
|
||||
'cityName',
|
||||
@@ -181,6 +258,21 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
||||
'handleLabel',
|
||||
'storeId',
|
||||
],
|
||||
'store.withdraw_approved': [
|
||||
'cityName',
|
||||
'storeName',
|
||||
'partnerLabel',
|
||||
'withdrawNo',
|
||||
'amount',
|
||||
'payee',
|
||||
'accountNo',
|
||||
'bankBranch',
|
||||
'bankAccount',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
'storeId',
|
||||
],
|
||||
'invoice.pending': [
|
||||
'invoiceNo',
|
||||
'orderNo',
|
||||
@@ -191,6 +283,51 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'finance.store_bill': [
|
||||
'period',
|
||||
'billCount',
|
||||
'totalAmount',
|
||||
'billSummary',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'finance.partner_bill': [
|
||||
'period',
|
||||
'billCount',
|
||||
'totalAmount',
|
||||
'billSummary',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'finance.winery_bill': [
|
||||
'period',
|
||||
'billCount',
|
||||
'totalAmount',
|
||||
'cityNames',
|
||||
'partnerNames',
|
||||
'orderCount',
|
||||
'orderAmount',
|
||||
'rate',
|
||||
'amount',
|
||||
'payee',
|
||||
'accountNo',
|
||||
'bankBranch',
|
||||
'bankAccount',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
'finance.logistics_bill': [
|
||||
'period',
|
||||
'billCount',
|
||||
'totalAmount',
|
||||
'billSummary',
|
||||
'time',
|
||||
'handleUrl',
|
||||
'handleLabel',
|
||||
],
|
||||
};
|
||||
|
||||
export type WecomPushTemplateDto = {
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Generated
+3
@@ -279,6 +279,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
|
||||
|
||||
packages/shared-ui:
|
||||
devDependencies:
|
||||
|
||||
@@ -31,6 +31,9 @@ MOCK_WECHAT=true
|
||||
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
|
||||
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
|
||||
# USER_H5_URL=https://user.runxian.top/user
|
||||
# 门店 H5 落地页(用户核销码二维码链接前缀,SHOP_H5_URL)
|
||||
# 未配置时默认 https://shop.dukanghaoke.com;本地开发可设为 http://localhost:5174
|
||||
# SHOP_H5_URL=https://shop.dukanghaoke.com
|
||||
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
TRUST_PROXY=true
|
||||
|
||||
@@ -30,6 +30,8 @@ TRUST_PROXY=true
|
||||
|
||||
# C 端 H5 落地页(推广码二维码;生产统一入口)
|
||||
USER_H5_URL=https://user.runxian.top/user
|
||||
# 门店 H5 落地页(用户核销码二维码)
|
||||
SHOP_H5_URL=https://shop.dukanghaoke.com
|
||||
|
||||
MOCK_WECHAT=false
|
||||
WX_APP_ID=
|
||||
|
||||
@@ -32,6 +32,8 @@ TRUST_PROXY=true
|
||||
|
||||
# C 端 H5(测试域)
|
||||
USER_H5_URL=https://user-test.dukanghaoke.com/user
|
||||
# 门店 H5(测试域,核销码落地页)
|
||||
SHOP_H5_URL=https://shop-test.dukanghaoke.com
|
||||
|
||||
# 正式号配置可与生产相同,但 Mock 打开后不走真实支付
|
||||
WX_APP_ID=
|
||||
|
||||
@@ -191,6 +191,7 @@ CREATE TABLE common_city (
|
||||
name VARCHAR(64) NOT NULL,
|
||||
province VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
pickup_min_qty INT NOT NULL DEFAULT 2,
|
||||
local_min_qty INT NOT NULL DEFAULT 2,
|
||||
cross_min_qty INT NOT NULL DEFAULT 6,
|
||||
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- v3.5.11:城市履约起购 — 现场提货独立阈值(瓶当量)
|
||||
ALTER TABLE `common_city`
|
||||
ADD COLUMN `pickup_min_qty` INT NOT NULL DEFAULT 2 AFTER `status`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user