27 Commits

Author SHA1 Message Date
jacy bb831f0580 merge(dev_jacy): v4.0.2 活动图模板与合伙人选择持久化
CI / verify (push) Waiting to run
2026-08-30 21:37:04 +08:00
jacy 3166467518 feat(ops): v4.0.2 活动图模板、合伙人选择持久化与用户管理主图
HQ 上传底图与码栏;合伙人单选写入 partner_account.activity_poster_id,用户管理下次登录仍显示同一张图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:36:43 +08:00
jacy 0ff61c2cd1 chore(deploy): shop/partner/admin 禁止搜索引擎抓取
nginx 直出 robots.txt 并加 X-Robots-Tag,避免内部三端被收录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:50:22 +08:00
jacy fb66258b6a merge(dev_jacy): 门店账单确认打款支持上传凭证照片
CI / verify (push) Waiting to run
2026-08-30 15:26:32 +08:00
jacy e68eb4d38c feat(settlement): 门店账单确认打款支持上传凭证照片
财务确认打款与提现通过可附带 OSS 凭证图,详情与导出展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:25:56 +08:00
jacy b375fab44a v3.5.16小程序首页动画 2026-08-30 15:07:58 +08:00
jacy 58010feed8 Merge branch 'main' of git.yqidian.com:jacy/dukang into dev
CI / verify (push) Waiting to run
2026-08-30 14:40:56 +08:00
jacy 9f14c59f22 merge(dev_jacy): v4.0.1 partner assoc and H5 user management 2026-08-30 14:35:56 +08:00
jacy 9c8d5f2cad feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 14:35:39 +08:00
jacy 870494563d merge(dev): store bill issue date to today
CI / verify (push) Has been cancelled
2026-08-28 14:14:39 +08:00
jacy 4c9b3f72bc merge(dev_jacy): store bill issue date to today 2026-08-28 14:13:28 +08:00
jacy 3b669f7e38 fix(settlement): 门店 T+1 出账日改为窗口中的今天
HQ 门店账单列名改为出账日;billDate 记今日(昨日 00:00–今日 00:00 右端),启动时把旧数据从核销自然日对齐过来。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:12:35 +08:00
jacy 14c5c8c3f3 merge(dev): v3.5.18 HQ 明文手机、任务版本与核销快链
CI / verify (push) Has been cancelled
2026-08-28 13:58:57 +08:00
jacy a7f3283ae5 merge(dev_jacy): v3.5.18 HQ 明文手机、任务版本与核销快链 2026-08-28 13:58:37 +08:00
jacy da19c39965 feat(admin): v3.5.18 用户详情明文手机、任务关联版本与核销快链
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:58:08 +08:00
jacy c06251f0a4 merge(dev): HQ 订单筛选改版与用户商品模糊搜索
CI / verify (push) Has been cancelled
2026-08-28 13:12:52 +08:00
jacy faa1830bde merge(dev_jacy): HQ 订单筛选改版与用户商品模糊搜索 2026-08-28 13:11:17 +08:00
jacy b226af2adc feat(admin): 订单页筛选改版并支持用户与商品模糊搜索
收起次要筛选项,去掉过滤测试;列表可按用户编号/昵称/备注/手机与商品名称/规格查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:10:36 +08:00
jacy 2b5f42acdf merge(dev): HQ 推广码订单只计已完成并补快链
CI / verify (push) Has been cancelled
2026-08-28 12:57:03 +08:00
jacy 71149044ca merge(dev_jacy): HQ 推广码订单只计已完成并补快链 2026-08-28 12:56:02 +08:00
jacy fccfd7abbe feat(promo): HQ 推广码订单只计已完成并补扫码订单快链
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:55:19 +08:00
jacy 1ecc6a11df merge(dev): 企微结算账单明细与成交播报用户明文
CI / verify (push) Has been cancelled
2026-08-27 11:46:12 +08:00
jacy cfd42dfc32 merge(dev_jacy): 企微结算账单明细与成交播报用户明文 2026-08-27 11:45:59 +08:00
jacy 737efb89a3 feat(wecom): 结算账单明细与成交播报用户明文
支付/核销补 HQ 备注且手机号不脱敏;结算通知按城市、合伙人、笔数和累计金额分块推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 11:45:08 +08:00
jacy a423fb71e8 merge(dev): v3.5.14 提交订单/支付成功日志端修复
CI / verify (push) Has been cancelled
2026-08-26 17:25:25 +08:00
jacy 4111e5c0a6 merge(dev_jacy): v3.5.14 提交订单/支付成功日志端修复 2026-08-26 17:25:15 +08:00
jacy 3dbb73e428 fix(trade): v3.5.14 提交订单/支付成功日志使用真实 clientApp
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:24:56 +08:00
190 changed files with 10106 additions and 2027 deletions
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>杜康好客 · HQ 管理后台</title> <title>杜康好客 · HQ 管理后台</title>
</head> </head>
<body> <body>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+2
View File
@@ -25,6 +25,7 @@ import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage'; import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodesPage from './pages/PromoCodesPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage'; import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage'; import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
@@ -86,6 +87,7 @@ export default function App() {
<Route path="/wechat-bindings" element={<WechatBindingsPage />} /> <Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} /> <Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} /> <Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/activity-posters" element={<ActivityPostersPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}> <Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} /> <Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} /> <Route path="users" element={<PromoCodeUsersPage />} />
+54
View File
@@ -74,6 +74,60 @@ body.admin-col-resizing * {
user-select: none !important; user-select: none !important;
} }
/* 订单页筛选:第一行常驻,第二行起可收起 */
.admin-orders-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: flex-end;
margin-bottom: 12px;
}
.admin-orders-filter-row--tools {
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.admin-orders-filter-item {
margin-bottom: 0 !important;
}
.admin-orders-filter-item--sm {
width: 140px;
}
.admin-orders-filter-item--md {
width: 168px;
}
.admin-orders-filter-item--search {
width: 180px;
}
.admin-users-filter-item--keyword {
width: 240px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
flex: 1 1 180px;
max-width: 280px;
}
.admin-orders-filter-item--date {
flex: 1 1 auto;
}
.admin-orders-filter-actions {
margin-left: auto;
}
.admin-orders-date-picker {
width: 240px;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */ /* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header { .admin-list-header {
display: flex; display: flex;
@@ -0,0 +1,110 @@
import { useCallback, useRef } from 'react';
import { DEFAULT_ACTIVITY_POSTER_QR_SLOT, type ActivityPosterQrSlot } from '@dukang/shared-types';
type Props = {
imageUrl?: string;
value?: ActivityPosterQrSlot;
onChange?: (slot: ActivityPosterQrSlot) => void;
};
export default function ActivityPosterQrSlotEditor({ imageUrl, value, onChange }: Props) {
const boxRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
mode: 'move' | 'resize';
startX: number;
startY: number;
start: ActivityPosterQrSlot;
} | null>(null);
const slot = value ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT;
const apply = useCallback((next: ActivityPosterQrSlot) => {
const size = Math.min(50, Math.max(5, next.qrSizePct));
const box = boxRef.current;
const ratio = box && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 1;
const heightPct = size * ratio;
const x = Math.min(100 - size, Math.max(0, next.qrXPct));
const y = Math.min(Math.max(0, 100 - heightPct), Math.max(0, next.qrYPct));
onChange?.({
qrXPct: Number(x.toFixed(2)),
qrYPct: Number(y.toFixed(2)),
qrSizePct: Number(size.toFixed(2)),
});
}, [onChange]);
function onPointerDown(mode: 'move' | 'resize', e: React.PointerEvent) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: { ...slot } };
}
function onPointerMove(e: React.PointerEvent) {
const drag = dragRef.current;
const box = boxRef.current;
if (!drag || !box) return;
const dx = ((e.clientX - drag.startX) / box.clientWidth) * 100;
const dy = ((e.clientY - drag.startY) / box.clientHeight) * 100;
if (drag.mode === 'move') {
apply({
...drag.start,
qrXPct: drag.start.qrXPct + dx,
qrYPct: drag.start.qrYPct + dy,
});
} else {
apply({ ...drag.start, qrSizePct: drag.start.qrSizePct + dx });
}
}
function endDrag() {
dragRef.current = null;
}
if (!imageUrl) {
return <div style={{ color: 'rgba(0,0,0,0.45)' }}></div>;
}
return (
<div>
<div
ref={boxRef}
style={{ position: 'relative', width: '100%', maxWidth: 420, userSelect: 'none', touchAction: 'none' }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<img src={imageUrl} alt="活动图预览" style={{ width: '100%', display: 'block' }} draggable={false} />
<div
style={{
position: 'absolute',
left: `${slot.qrXPct}%`,
top: `${slot.qrYPct}%`,
width: `${slot.qrSizePct}%`,
aspectRatio: '1',
border: '2px dashed #1677ff',
background: 'rgba(22,119,255,0.14)',
cursor: 'move',
boxSizing: 'border-box',
}}
onPointerDown={(e) => onPointerDown('move', e)}
>
<div
style={{
position: 'absolute',
right: -6,
bottom: -6,
width: 14,
height: 14,
background: '#1677ff',
borderRadius: 2,
cursor: 'nwse-resize',
}}
onPointerDown={(e) => onPointerDown('resize', e)}
/>
</div>
</div>
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
{slot.qrSizePct}%
</div>
</div>
);
}
@@ -26,10 +26,12 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect'; import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
import PartnerAssocPanel from './PartnerAssocPanel';
type PartnerRow = { type PartnerRow = {
id: string; id: string;
@@ -186,7 +188,7 @@ export default function CityPartnersPanel({
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...omitNullFields(v),
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
@@ -345,6 +347,11 @@ export default function CityPartnersPanel({
/> />
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
)} )}
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Image, Input, Modal, Space, Typography } from 'antd';
import { PAYMENT_PROOF_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
export function parsePaymentProofUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((u) => String(u ?? '').trim()).filter(Boolean);
}
export function PaymentProofGallery({ urls }: { urls?: unknown }) {
const list = parsePaymentProofUrls(urls);
if (!list.length) return <></>;
return (
<Image.PreviewGroup>
<Space wrap size={8}>
{list.map((url, index) => (
<Image
key={`${url}-${index}`}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
))}
</Space>
</Image.PreviewGroup>
);
}
type FinancePayProofModalProps = {
open: boolean;
title: string;
hint: string;
okText: string;
confirmLoading?: boolean;
onCancel: () => void;
onOk: (payload: { paymentRef?: string; paymentProofUrls?: string[] }) => Promise<void>;
};
export function FinancePayProofModal({
open,
title,
hint,
okText,
confirmLoading,
onCancel,
onOk,
}: FinancePayProofModalProps) {
const [paymentRef, setPaymentRef] = useState('');
const [proofUrls, setProofUrls] = useState<string[]>([]);
useEffect(() => {
if (open) {
setPaymentRef('');
setProofUrls([]);
}
}, [open]);
return (
<Modal
title={title}
open={open}
okText={okText}
cancelText="取消"
confirmLoading={confirmLoading}
destroyOnClose
width={480}
onCancel={onCancel}
onOk={async () => {
await onOk({
paymentRef: paymentRef.trim() || undefined,
paymentProofUrls: proofUrls.length ? proofUrls : undefined,
});
}}
>
<Typography.Paragraph style={{ marginBottom: 12 }}>{hint}</Typography.Paragraph>
<Input
placeholder="打款凭证号(可选)"
value={paymentRef}
onChange={(e) => setPaymentRef(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 8 }}>
<MultiImageUpload
bizType="PAYMENT_PROOF"
value={proofUrls}
onChange={setProofUrls}
maxCount={PAYMENT_PROOF_IMAGE_MAX_COUNT}
buttonText="上传凭证照片"
tip={`最多 ${PAYMENT_PROOF_IMAGE_MAX_COUNT} 张,支持一次选择多张`}
/>
</div>
</Modal>
);
}
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
const name = row.nickname?.trim() || '—';
const remark = row.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
}
type PartnerAssocPanelProps = {
partnerId: string;
};
export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) {
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
const [users, setUsers] = useState<PartnerAssocUserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [issuing, setIssuing] = useState(false);
const [canEditAssoc, setCanEditAssoc] = useState(false);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
setSummary(data);
}, [partnerId]);
const loadUsers = useCallback(async (p = 1) => {
setLoading(true);
try {
const res = await request<Paginated<PartnerAssocUserItem>>(
`/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`,
);
setUsers(res.items ?? []);
setTotal(res.total ?? 0);
setPage(p);
} finally {
setLoading(false);
}
}, [partnerId]);
useEffect(() => {
void request<HqProfile>('/admin/auth/me')
.then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc')))
.catch(() => setCanEditAssoc(false));
}, []);
useEffect(() => {
void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败'));
void loadUsers(1).catch(() => undefined);
}, [loadSummary, loadUsers]);
async function reissue() {
setIssuing(true);
try {
await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' });
message.success('已重新生成关联码');
await loadSummary();
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败');
} finally {
setIssuing(false);
}
}
async function unbind(userId: string) {
try {
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
message.success('已解绑');
await Promise.all([loadSummary(), loadUsers(page)]);
} catch (e) {
message.error(e instanceof Error ? e.message : '解绑失败');
}
}
const columns: ColumnsType<PartnerAssocUserItem> = [
{ title: '用户编号', dataIndex: 'userNo', width: 140 },
{
title: '用户',
dataIndex: 'nickname',
render: (_, row) => (
<Link
className="admin-primary-link"
to={`/users?userId=${encodeURIComponent(row.id)}`}
title="在用户列表中查看"
>
{formatNicknameWithRemark(row)}
</Link>
),
},
{ title: '手机', dataIndex: 'phone', render: (v) => v || '—' },
{ title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') },
{ title: '已付订单', dataIndex: 'orderCount', width: 90 },
...(canEditAssoc
? [
{
title: '操作',
width: 90,
render: (_: unknown, row: PartnerAssocUserItem) => (
<Popconfirm title="解绑后该用户新单不再计订单佣金" onConfirm={() => void unbind(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
} satisfies ColumnsType<PartnerAssocUserItem>[number],
]
: []),
];
return (
<div>
<Space align="start" style={{ marginBottom: 16 }} wrap>
{summary?.qrcodeUrl ? (
<img src={summary.qrcodeUrl} alt="关联码" style={{ width: 160, height: 160, background: '#fff' }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<div>
<Typography.Paragraph style={{ marginBottom: 8 }}>
<Link to={`/users?assocPartnerAccountId=${encodeURIComponent(partnerId)}`}>
{summary?.userCount ?? 0}
</Link>
</Typography.Paragraph>
<Button loading={issuing} onClick={() => void reissue()}>
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
</Button>
</div>
</Space>
<Table
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={users}
pagination={{
current: page,
pageSize: 20,
total,
onChange: (p) => void loadUsers(p),
}}
/>
</div>
);
}
@@ -54,6 +54,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
const [skuId, setSkuId] = useState<string>(); const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2); const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>(); const [promoCodeId, setPromoCodeId] = useState<string>();
const [assocPartnerAccountId, setAssocPartnerAccountId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS'); const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false); const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null); const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
@@ -168,6 +169,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setProductId(undefined); setProductId(undefined);
setSkuId(undefined); setSkuId(undefined);
setPromoCodeId(undefined); setPromoCodeId(undefined);
setAssocPartnerAccountId(undefined);
setDeliveryMode('ADDRESS'); setDeliveryMode('ADDRESS');
setAutoReceive(false); setAutoReceive(false);
setPreview(null); setPreview(null);
@@ -235,6 +237,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined, skuId: skuId || undefined,
assocPartnerAccountId: assocPartnerAccountId || undefined,
}; };
setSubmitting(true); setSubmitting(true);
@@ -388,6 +391,21 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/> />
</Form.Item> </Form.Item>
<Form.Item label="关联合伙人(选填)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="不选则本单不计订单佣金"
value={assocPartnerAccountId}
onChange={setAssocPartnerAccountId}
options={(options?.partners ?? []).map((p) => ({
value: p.id,
label: `${p.companyName || p.name}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? ( {(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)"> <Form.Item label="绑定推广码(选填)">
<Select <Select
@@ -15,7 +15,13 @@ type Props = {
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) { export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
const user = detail.user as const user = detail.user as
| { userNo?: string; nickname?: string | null; phone?: string | null } | {
id?: string;
userNo?: string;
nickname?: string | null;
phone?: string | null;
hqRemark?: string | null;
}
| undefined; | undefined;
const store = detail.store as const store = detail.store as
| { | {
@@ -41,7 +47,13 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item> <Descriptions.Item label="用户昵称">
{(() => {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
})()}
</Descriptions.Item>
<Descriptions.Item label="用户手机"> <Descriptions.Item label="用户手机">
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'} {maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
</Descriptions.Item> </Descriptions.Item>
@@ -91,6 +103,9 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
<Descriptions.Item label="评价"> <Descriptions.Item label="评价">
{(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '} {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(detail.rating as { envScore?: number }).envScore ?? '—'} {(detail.rating as { envScore?: number }).envScore ?? '—'}
{(detail.rating as { comment?: string | null }).comment
? ` · ${(detail.rating as { comment?: string }).comment}`
: ''}
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
</Descriptions> </Descriptions>
@@ -19,6 +19,7 @@ import {
SettingOutlined, SettingOutlined,
AccountBookOutlined, AccountBookOutlined,
ProjectOutlined, ProjectOutlined,
PictureOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
hasAnySystemSettingsPermission, hasAnySystemSettingsPermission,
@@ -118,6 +119,7 @@ const MENU_ITEMS: MenuProps['items'] = [
}, },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票' }, { key: '/invoices', icon: <FileTextOutlined />, label: '发票' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' }, { key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/activity-posters', icon: <PictureOutlined />, label: '活动图' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' }, { key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{ {
key: 'wecom-group', key: 'wecom-group',
@@ -180,6 +182,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/product-detail-templates': 'products', '/product-detail-templates': 'products',
'/orders': 'orders', '/orders': 'orders',
'/promo-codes': 'promo_codes', '/promo-codes': 'promo_codes',
'/activity-posters': 'activity_posters',
'/wecom/bots': 'wecom_bots', '/wecom/bots': 'wecom_bots',
'/wecom/pushes': 'wecom_bots', '/wecom/pushes': 'wecom_bots',
'wecom-group': 'wecom_bots', 'wecom-group': 'wecom_bots',
+7 -1
View File
@@ -183,6 +183,12 @@ export type AdminUserRow = {
benefitUsedAmount?: number; benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */ /** 好客权益·剩余未使用 */
benefitBalance?: number; benefitBalance?: number;
assocPartner?: {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
} | null;
}; };
export type AdminOrderItem = { export type AdminOrderItem = {
@@ -229,7 +235,7 @@ export type AdminOrderRow = {
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null; proxyPartnerPhone?: string | null;
isTest?: boolean; isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
trackingNo: string | null; trackingNo: string | null;
+1
View File
@@ -120,6 +120,7 @@ export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
QRCODE: '二维码', QRCODE: '二维码',
SIGN_PHOTO: '签收照', SIGN_PHOTO: '签收照',
VIDEO: '视频', VIDEO: '视频',
ACTIVITY_POSTER: '活动图',
}; };
export const RESOURCE_STATUS_LABELS: Record<string, string> = { export const RESOURCE_STATUS_LABELS: Record<string, string> = {
+1
View File
@@ -43,6 +43,7 @@ export const REF_TYPE_LABELS: Record<string, string> = {
INVOICE: '发票', INVOICE: '发票',
PROMO: '推广码', PROMO: '推广码',
PROMO_CODE: '推广码', PROMO_CODE: '推广码',
ACTIVITY_POSTER: '活动图',
HQ: '总部', HQ: '总部',
HQ_ACCOUNT: 'HQ 账号', HQ_ACCOUNT: 'HQ 账号',
STORE_ACCOUNT: '门店账号', STORE_ACCOUNT: '门店账号',
+5
View File
@@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
{ value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' },
{ value: 'USER_DELETE', label: '删除用户' }, { value: 'USER_DELETE', label: '删除用户' },
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' }, { value: 'USER_BATCH_DELETE', label: '批量删除用户' },
{ value: 'ORDER_SHIP', label: '订单发货' }, { value: 'ORDER_SHIP', label: '订单发货' },
@@ -60,6 +61,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' }, { value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' }, { value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' }, { value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
{ value: 'ACTIVITY_POSTER_CREATE', label: '新增活动图' },
{ value: 'ACTIVITY_POSTER_UPDATE', label: '编辑活动图' },
{ value: 'ACTIVITY_POSTER_UPDATE_STATUS', label: '活动图上下架' },
{ value: 'ACTIVITY_POSTER_DELETE', label: '删除活动图' },
] as const; ] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries( export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
@@ -0,0 +1,9 @@
/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */
export function omitNullFields<T extends Record<string, unknown>>(input: T): Partial<T> {
const out: Partial<T> = {};
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
(out as Record<string, unknown>)[key] = value;
}
return out;
}
@@ -0,0 +1,242 @@
import { useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ACTIVITY_POSTER_STATUS_LABELS,
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
type ActivityPosterItem,
type ActivityPosterQrSlot,
type ActivityPosterStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import OssUpload from '../components/OssUpload';
import ActivityPosterQrSlotEditor from '../components/ActivityPosterQrSlotEditor';
type FormValues = {
title: string;
copyText?: string;
imageUrl: string;
qrXPct: number;
qrYPct: number;
qrSizePct: number;
sortOrder?: number;
status?: ActivityPosterStatus;
};
export default function ActivityPostersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<ActivityPosterItem>(
'/admin/activity-posters',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ActivityPosterItem | null>(null);
const imageUrl = Form.useWatch('imageUrl', editForm);
const qrXPct = Form.useWatch('qrXPct', editForm);
const qrYPct = Form.useWatch('qrYPct', editForm);
const qrSizePct = Form.useWatch('qrSizePct', editForm);
const slot: ActivityPosterQrSlot = {
qrXPct: qrXPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct,
qrYPct: qrYPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct,
qrSizePct: qrSizePct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
};
function openCreate() {
setEditing(null);
editForm.setFieldsValue({
title: '',
copyText: '',
imageUrl: '',
...DEFAULT_ACTIVITY_POSTER_QR_SLOT,
sortOrder: 0,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: ActivityPosterItem) {
setEditing(row);
editForm.setFieldsValue({
title: row.title,
copyText: row.copyText,
imageUrl: row.imageUrl,
qrXPct: row.qrXPct,
qrYPct: row.qrYPct,
qrSizePct: row.qrSizePct,
sortOrder: row.sortOrder,
status: row.status,
});
setModalOpen(true);
}
async function save() {
const v = await editForm.validateFields();
const body = JSON.stringify(v);
if (editing) {
await request(`/admin/activity-posters/${editing.id}`, { method: 'PUT', body });
message.success('已保存');
} else {
await request('/admin/activity-posters', { method: 'POST', body });
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const baseColumns: ColumnsType<ActivityPosterItem> = [
{
title: '标题',
dataIndex: 'title',
width: 180,
render: (v, row) => <AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>,
},
{
title: '封面',
dataIndex: 'imageUrl',
width: 90,
render: (url: string) => <Image src={url} width={56} height={56} style={{ objectFit: 'cover' }} />,
},
{
title: '文案',
dataIndex: 'copyText',
width: 240,
render: (v: string) => v || '—',
},
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: ActivityPosterStatus) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{ACTIVITY_POSTER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 200,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
<Button
type="link"
size="small"
onClick={async () => {
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
await request(`/admin/activity-posters/${row.id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
message.success(next === 'ACTIVE' ? '已上架' : '已下架');
void reload();
}}
>
{row.status === 'ACTIVE' ? '下架' : '上架'}
</Button>
<Popconfirm
title="确认删除该活动图?"
onConfirm={async () => {
await request(`/admin/activity-posters/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('activity-posters', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader
title="活动图"
settings={settingsButton}
actions={<Button type="primary" onClick={openCreate}></Button>}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title={editing ? '编辑活动图' : '新建活动图'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
width={720}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
<Input maxLength={128} />
</Form.Item>
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
<OssUpload bizType="ACTIVITY_POSTER" />
</Form.Item>
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
<Form.Item name="qrSizePct" hidden><InputNumber /></Form.Item>
<Form.Item label="二维码栏">
<ActivityPosterQrSlotEditor
imageUrl={imageUrl}
value={slot}
onChange={(next) => editForm.setFieldsValue(next)}
/>
</Form.Item>
<Form.Item name="copyText" label="推广文案">
<Input.TextArea rows={4} maxLength={4000} placeholder="合伙人可一键复制" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+41 -13
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -111,9 +111,16 @@ type CouponDetail = Row & {
export default function BenefitCouponsPage() { export default function BenefitCouponsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialCouponNo) init.couponNo = initialCouponNo;
return init;
});
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false); const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -132,6 +139,35 @@ export default function BenefitCouponsPage() {
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null); const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
async function openCouponDetail(id: string) {
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
}, [form, initialCouponNo]);
useEffect(() => {
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
if (openCouponId && !deepLinkOpenedRef.current) {
deepLinkOpenedRef.current = true;
void openCouponDetail(openCouponId).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
return;
}
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.couponNo) === initialCouponNo) {
deepLinkOpenedRef.current = true;
void openCouponDetail(first.id).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
}
}, [data, initialCouponNo, loading, location.state]);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
width: 200, width: 200,
ellipsis: false, ellipsis: false,
render: (v, row) => ( render: (v, row) => (
<AdminPrimaryLink <AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v} {v}
</AdminPrimaryLink> </AdminPrimaryLink>
), ),
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={async () => { onClick={() => void openCouponDetail(row.id)}
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
> >
</Button> </Button>
+33 -1
View File
@@ -31,11 +31,13 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -60,6 +62,7 @@ type Row = {
managedWarehouseName?: string | null; managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null; maxPartnerCommissionRate?: number | null;
storeCount: number; storeCount: number;
assocUserCount?: number;
accountCount: number; accountCount: number;
subAccounts?: SubRow[]; subAccounts?: SubRow[];
createdAt: string; createdAt: string;
@@ -209,7 +212,7 @@ export default function CityPartnersPage() {
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...omitNullFields(v),
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
@@ -332,6 +335,19 @@ export default function CityPartnersPage() {
</Link> </Link>
), ),
}, },
{
title: '关联用户',
dataIndex: 'assocUserCount',
width: 80,
render: (n: number, row) => (
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
title={`查看「${row.companyName || row.phone}」关联用户`}
>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
@@ -355,6 +371,8 @@ export default function CityPartnersPage() {
title="城市合伙人" title="城市合伙人"
settings={settingsButton} settings={settingsButton}
actions={ actions={
<>
<Link to="/users?assocPartnerAccountId=any"></Link>
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
@@ -372,6 +390,7 @@ export default function CityPartnersPage() {
> >
</Button> </Button>
</>
} }
/> />
@@ -486,6 +505,14 @@ export default function CityPartnersPage() {
{detail.storeCount ?? 0} {detail.storeCount ?? 0}
</Link> </Link>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="关联用户">
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
title="查看该城市合伙人关联用户"
>
{detail.assocUserCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}> <Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? ( {detail.managedWarehouseName ?? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -570,6 +597,11 @@ export default function CityPartnersPage() {
/> />
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
</> </>
+35 -3
View File
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => { useEffect(() => {
if (!batchEditOpen) return; if (!modalOpen && !batchEditOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100') request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? [])) .then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([])); .catch(() => setVersions([]));
}, [batchEditOpen]); }, [modalOpen, batchEditOpen]);
useEffect(() => { useEffect(() => {
if (!modalOpen || editing) return; if (!modalOpen || editing) return;
@@ -108,6 +108,7 @@ export default function DevPlanTasksPage() {
content: row.content, content: row.content,
type: row.type, type: row.type,
status: row.status, status: row.status,
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''], attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
}); });
setModalOpen(true); setModalOpen(true);
@@ -121,7 +122,11 @@ export default function DevPlanTasksPage() {
if (editing) { if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, { await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ ...values, attachmentUrls }), body: JSON.stringify({
...values,
attachmentUrls,
versionIds: values.versionIds ?? [],
}),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
@@ -285,6 +290,21 @@ export default function DevPlanTasksPage() {
), ),
}, },
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{
title: '关联版本',
dataIndex: 'versions',
width: 140,
render: (_, row) =>
row.versions?.length ? (
<Space size={4} wrap>
{row.versions.map((v) => (
<Tag key={v.id}>{v.versionNo}</Tag>
))}
</Space>
) : (
'—'
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -405,9 +425,21 @@ export default function DevPlanTasksPage() {
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
{editing ? ( {editing ? (
<>
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="versionIds" label="关联版本">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</>
) : ( ) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单"> <Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select <Select
@@ -244,7 +244,7 @@ export default function HqPermissionsPage() {
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
// //
/ /
// //
</Typography.Paragraph> </Typography.Paragraph>
@@ -274,7 +274,7 @@ export default function HqPermissionsPage() {
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。" message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
/> />
) : ( ) : (
<> <>
+247 -75
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Col,
Collapse, Collapse,
DatePicker, DatePicker,
Descriptions, Descriptions,
@@ -14,7 +13,6 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Radio, Radio,
Row,
Select, Select,
Space, Space,
Table, Table,
@@ -24,6 +22,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types'; import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
@@ -58,6 +57,7 @@ type ShipDefaults = {
}; };
type CityOption = { id: string; name: string; code: string }; type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
type WarehouseOption = { type WarehouseOption = {
id: string; id: string;
@@ -94,6 +94,20 @@ type OrderRedeemSummary = {
redeemRecordSum: number; redeemRecordSum: number;
}; };
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
orderCommissionRate?: number | null;
};
function formatAssocPartner(p?: AssocPartnerBrief | null) {
if (!p) return '—';
const title = p.companyName || p.name;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type OrderDetail = AdminOrderRow & { type OrderDetail = AdminOrderRow & {
receiverAddress?: string; receiverAddress?: string;
receiverProvince?: string; receiverProvince?: string;
@@ -137,6 +151,11 @@ type OrderDetail = AdminOrderRow & {
lng?: number | null; lng?: number | null;
lat?: number | null; lat?: number | null;
} | null; } | null;
assocPartnerAtPay?: AssocPartnerBrief | null;
orderCommissionRateAtPay?: number | null;
user?: AdminOrderRow['user'] & {
assocPartner?: AssocPartnerBrief | null;
};
}; };
type ShipMode = 'WAREHOUSE' | 'EXPRESS'; type ShipMode = 'WAREHOUSE' | 'EXPRESS';
@@ -150,9 +169,12 @@ type OrderExportFilters = {
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean; fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string; deliveryType?: string;
promoCodeId?: string;
assocPartnerAccountId?: string;
dateRange?: [Dayjs, Dayjs]; dateRange?: [Dayjs, Dayjs];
}; };
@@ -212,6 +234,17 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
{ key: 'year', label: '当年' }, { key: 'year', label: '当年' },
]; ];
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
function formatUserWithRemark(user?: {
userNo?: string;
hqRemark?: string | null;
} | null) {
const userNo = user?.userNo || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${userNo}${remark}` : userNo;
}
function formatBenefitBrief(row: AdminOrderRow) { function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon; const coupon = row.benefitCoupon;
if (coupon) { if (coupon) {
@@ -244,9 +277,12 @@ function buildExportPayload(
if (filters.orderType) payload.orderType = filters.orderType; if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId; if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone; if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType; if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true; if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true; if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
if (filters.assocPartnerAccountId) payload.assocPartnerAccountId = filters.assocPartnerAccountId;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD'); if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD'); if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload; return payload;
@@ -285,8 +321,15 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
export default function OrdersPage() { export default function OrdersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
const initialStatusParam = searchParams.getAll('status').join(',');
const initialStatuses = useMemo(
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
[initialStatusParam],
);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
@@ -309,6 +352,7 @@ export default function OrdersPage() {
const [shipping, setShipping] = useState(false); const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [shipModalOpen, setShipModalOpen] = useState(false); const [shipModalOpen, setShipModalOpen] = useState(false);
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null); const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
@@ -317,6 +361,13 @@ export default function OrdersPage() {
const [trackOpen, setTrackOpen] = useState(false); const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx'); const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders'); const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
@@ -336,6 +387,20 @@ export default function OrdersPage() {
} }
}, [form, initialOrderNo]); }, [form, initialOrderNo]);
useEffect(() => {
if (initialStatuses.length) {
form.setFieldsValue({ status: initialStatuses });
}
}, [form, initialStatuses]);
useEffect(() => {
const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId;
if (openOrderId) {
void openDetail(openOrderId);
navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
}
}, [location.state, location.pathname, location.search, navigate]);
useEffect(() => { useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return; if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0]; const first = data?.items?.[0];
@@ -366,13 +431,18 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo); if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.orderNo) qs.set('orderNo', values.orderNo);
for (const status of selectedStatuses(values.status)) qs.append('status', status); const statuses = selectedStatuses(values.status);
const statusList = statuses.length ? statuses : initialStatuses;
for (const status of statusList) qs.append('status', status);
if (values.orderType) qs.set('orderType', values.orderType); if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType); if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD')); if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD')); if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
@@ -380,7 +450,7 @@ export default function OrdersPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [form, page, pageSize, initialOrderNo]); }, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -391,6 +461,9 @@ export default function OrdersPage() {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? [])) .then((res) => setCities(res.items ?? []))
.catch(() => {}); .catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []); }, []);
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) { function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
@@ -589,7 +662,16 @@ export default function OrdersPage() {
} }
setExporting(true); setExporting(true);
try { try {
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys); const payload = buildExportPayload(
scope,
exportFormat,
{
...values,
promoCodeId: initialPromoCodeId || values.promoCodeId,
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
},
selectedRowKeys,
);
const result = await request<OrderExportResult>('/admin/orders/export', { const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -618,14 +700,14 @@ export default function OrdersPage() {
{ {
title: '用户', title: '用户',
key: 'user', key: 'user',
width: 120, width: 200,
render: (_, row) => render: (_, row) =>
row.user?.id ? ( row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}> <AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
{row.user.userNo} {formatUserWithRemark(row.user)}
</AdminPrimaryLink> </AdminPrimaryLink>
) : ( ) : (
(row.user?.userNo || '—') formatUserWithRemark(row.user)
), ),
}, },
{ {
@@ -778,15 +860,117 @@ export default function OrdersPage() {
/> />
) : null} ) : null}
{initialPromoCodeId ? (
<Alert
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
message={
initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
? '已按推广码筛选已完成订单'
: '已按推广码筛选订单'
}
action={
<Button size="small" onClick={() => navigate('/orders')}>
</Button>
}
onClose={() => navigate('/orders')}
/>
) : null}
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}> <div className="admin-orders-filter-row">
<Col xs={24} md={14} lg={12}> <Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Form.Item label="下单日期" style={{ marginBottom: 12 }}> <Select
<Space direction="vertical" size={8} style={{ width: '100%' }}> allowClear
<Form.Item name="dateRange" noStyle> showSearch
<DatePicker.RangePicker allowClear style={{ width: '100%' }} /> optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item> </Form.Item>
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="名称/规格" />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'none', label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner({
id: p.id,
name: p.name || p.id,
companyName: p.companyName,
phone: p.phone,
}),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap> <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) => ( {DATE_PRESETS.map((preset) => (
<Button <Button
key={preset.key} key={preset.key}
@@ -797,61 +981,10 @@ export default function OrdersPage() {
</Button> </Button>
))} ))}
</Space> </Space>
</Space>
</Form.Item> </Form.Item>
</Col> </div>
<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 name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Space>
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}> <div className="admin-orders-filter-row admin-orders-filter-row--tools">
{canExportOrders ? ( {canExportOrders ? (
<Space size={12} wrap> <Space size={12} wrap>
<Radio.Group <Radio.Group
@@ -876,8 +1009,19 @@ export default function OrdersPage() {
</Space> </Space>
) : <span />} ) : <span />}
<Space size={12} wrap> <Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button> <Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button> <Checkbox></Checkbox>
</Form.Item>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? ( {canDeleteOrders ? (
<Button <Button
danger danger
@@ -889,6 +1033,8 @@ export default function OrdersPage() {
) : null} ) : null}
</Space> </Space>
</div> </div>
</>
) : null}
</Form> </Form>
<Table <Table
@@ -987,7 +1133,33 @@ export default function OrdersPage() {
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item> <Descriptions.Item label="关联合伙人(本单佣金)">
{detail.assocPartnerAtPay
? `${formatAssocPartner(detail.assocPartnerAtPay)}${
detail.assocPartnerAtPay.orderCommissionRate != null
? ` · 费率 ${(Number(detail.assocPartnerAtPay.orderCommissionRate) * 100).toFixed(2)}%`
: ''
}`
: '—'}
</Descriptions.Item>
{detail.user?.assocPartner &&
detail.user.assocPartner.id !== detail.assocPartnerAtPay?.id ? (
<Descriptions.Item label="用户当前关联">
{formatAssocPartner(detail.user.assocPartner)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
>
{formatUserWithRemark(detail.user)}
</AdminPrimaryLink>
) : (
formatUserWithRemark(detail.user)
)}
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item> <Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item> <Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item> <Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
@@ -9,6 +9,7 @@ import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
@@ -413,6 +414,13 @@ export default function PartnerAccountsPage() {
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} /> dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
), ),
}, },
...(!detail.parentAccountId
? [{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
}]
: []),
]} /> ]} />
)} )}
</Drawer> </Drawer>
+95 -1
View File
@@ -12,6 +12,7 @@ import {
Space, Space,
Statistic, Statistic,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
@@ -27,6 +28,19 @@ import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillItemRow = {
id: string;
kind: 'ORDER' | 'REDEEM';
refId: string;
refNo: string;
title?: string | null;
extra?: string | null;
baseAmount: number;
rate: number;
commission: number;
occurredAt: string;
};
type Row = { type Row = {
id: string; id: string;
billNo: string; billNo: string;
@@ -39,6 +53,8 @@ type Row = {
rejectReason?: string | null; rejectReason?: string | null;
paymentRef?: string | null; paymentRef?: string | null;
paidAt?: string | null; paidAt?: string | null;
orderItems?: BillItemRow[];
redeemItems?: BillItemRow[];
partner?: { partner?: {
companyName?: string; companyName?: string;
phone?: string; phone?: string;
@@ -531,7 +547,7 @@ export default function PartnerBillsPage() {
title="合伙人账单明细" title="合伙人账单明细"
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
width={560} width={720}
> >
{detail && ( {detail && (
<> <>
@@ -562,6 +578,84 @@ export default function PartnerBillsPage() {
{detail.paymentRef ? String(detail.paymentRef) : '—'} {detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Tabs
style={{ marginTop: 16 }}
items={[
{
key: 'orders',
label: `酒订单 (${detail.orderItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.orderItems ?? []}
columns={[
{ title: '订单号', dataIndex: 'refNo', width: 160 },
{ title: '商品', dataIndex: 'title' },
{ title: '数量', dataIndex: 'extra', width: 70 },
{
title: '实付',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.orderCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
{
key: 'redeems',
label: `核销订单 (${detail.redeemItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.redeemItems ?? []}
columns={[
{ title: '核销单号', dataIndex: 'refNo', width: 160 },
{ title: '门店', dataIndex: 'title' },
{
title: '核销额',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.redeemCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}> <Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title> </Typography.Title>
+8 -1
View File
@@ -23,6 +23,7 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -30,6 +31,7 @@ import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
type Row = { type Row = {
@@ -162,7 +164,7 @@ export default function PartnersPage() {
try { try {
const v = await editForm.validateFields(); const v = await editForm.validateFields();
const body = { const body = {
...v, ...omitNullFields(v),
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
@@ -320,6 +322,11 @@ export default function PartnersPage() {
</> </>
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
)} )}
+22 -30
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string }; type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() { export default function PromoCodesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
</Tag> </Tag>
), ),
}, },
{ title: '扫码', dataIndex: 'scanCount', width: 70 }, {
{ title: '订单', dataIndex: 'orderCount', width: 70 }, title: '扫码',
dataIndex: 'scanCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
{v}
</AdminPrimaryLink>
),
},
{
title: <Tooltip title="仅统计已完成订单"></Tooltip>,
dataIndex: 'orderCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '转化率', title: '转化率',
width: 90, width: 90,
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 220, width: 140,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size="small" wrap> <Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button> </Button>
<Button
type="link"
size="small"
disabled={!row.qrcodeUrl}
onClick={() => {
if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button> </Button>
+59 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -20,17 +20,28 @@ type Row = {
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
isTest?: boolean; isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null }; user?: {
id?: string;
userNo: string;
phone: string | null;
nickname?: string | null;
hqRemark?: string | null;
};
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: string }; coupon?: { id?: string; couponNo: string };
}; };
function maskPhone(phone: string | null | undefined) { function formatNicknameWithRemark(user?: {
if (!phone || phone.length < 7) return phone ?? '—'; nickname?: string | null;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`; hqRemark?: string | null;
} | null) {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
} }
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || ''; const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 }, { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{v || '—'}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ {
title: '用户昵称', title: '用户昵称',
dataIndex: ['user', 'nickname'], dataIndex: ['user', 'nickname'],
width: 100, width: 160,
render: (v: string | null | undefined) => v || '—', render: (_: string | null | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{formatNicknameWithRemark(row.user)}
</AdminPrimaryLink>
) : (
formatNicknameWithRemark(row.user)
),
}, },
{ {
title: '用户手机', title: '用户手机',
dataIndex: ['user', 'phone'], dataIndex: ['user', 'phone'],
width: 120, width: 120,
render: (v: string | null | undefined) => maskPhone(v), render: (v: string | null | undefined) => v || '—',
}, },
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 }, {
title: '券号',
dataIndex: ['coupon', 'couponNo'],
width: 160,
render: (v: string | undefined, row) =>
v ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
})
}
>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
+120 -75
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
@@ -14,9 +14,11 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { import {
@@ -75,6 +77,7 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
}; };
export default function StoreBillsPage() { export default function StoreBillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || ''; const initialStoreId = searchParams.get('storeId') || '';
@@ -111,6 +114,13 @@ export default function StoreBillsPage() {
pendingCount: number; pendingCount: number;
overdueCount: number; overdueCount: number;
} | null>(null); } | null>(null);
const [payModal, setPayModal] = useState<{
ids: string[];
amountHint?: number;
} | null>(null);
const [paySubmitting, setPaySubmitting] = useState(false);
const [withdrawApproveId, setWithdrawApproveId] = useState<string | null>(null);
const [withdrawSubmitting, setWithdrawSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
form.setFieldsValue({ form.setFieldsValue({
@@ -132,77 +142,11 @@ export default function StoreBillsPage() {
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = ''; setPayModal({ ids, amountHint });
Modal.confirm({
title: '确认打款?',
content: (
<div>
<div>
{ids.length} T+1
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
setBatchLoading(true);
try {
await request('/admin/store-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
} finally {
setBatchLoading(false);
}
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
} }
function approveWithdraw(id: string) { function approveWithdraw(id: string) {
let paymentRef = ''; setWithdrawApproveId(id);
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
} }
function rejectWithdraw(id: string) { function rejectWithdraw(id: string) {
@@ -294,7 +238,11 @@ export default function StoreBillsPage() {
), ),
}, },
{ {
title: '日期', title: (
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
</Tooltip>
),
dataIndex: 'date', dataIndex: 'date',
width: 160, width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
@@ -389,6 +337,7 @@ export default function StoreBillsPage() {
<> <>
T+1 沿 T+1 沿
= T+1 = = T+1 =
T+1 00:00 00:00
{overdueSummary && overdueSummary.overdueCount > 0 ? ( {overdueSummary && overdueSummary.overdueCount > 0 ? (
<div> <div>
<Typography.Text type="danger"> <Typography.Text type="danger">
@@ -469,7 +418,7 @@ export default function StoreBillsPage() {
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="日"> <Form.Item name="range" label="出账日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -543,7 +492,7 @@ export default function StoreBillsPage() {
<> <>
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账日"> <Descriptions.Item label="账日">
{String(detail.billDate || '').slice(0, 10)} {String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付"> <Descriptions.Item label="应付">
@@ -558,6 +507,9 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款凭证"> <Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'} {detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</Descriptions.Item>
</Descriptions> </Descriptions>
{(detail.storeAccount as { {(detail.storeAccount as {
bankAccountName?: string; bankAccountName?: string;
@@ -592,8 +544,22 @@ export default function StoreBillsPage() {
columns={[ columns={[
{ {
title: '核销单号', title: '核销单号',
render: (_, r) => render: (_, r) => {
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), const redeemNo = String(
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
);
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
},
}, },
{ {
title: '金额', title: '金额',
@@ -628,6 +594,9 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款凭证"> <Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'} {detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</Descriptions.Item>
</Descriptions> </Descriptions>
{storeAccount ? ( {storeAccount ? (
<> <>
@@ -662,7 +631,18 @@ export default function StoreBillsPage() {
const payout = r.storePayout as const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined; | undefined;
return String(payout?.redeemRecord?.redeemNo || ''); const redeemNo = payout?.redeemRecord?.redeemNo || '';
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
}, },
}, },
{ {
@@ -687,6 +667,71 @@ export default function StoreBillsPage() {
</> </>
)} )}
</Drawer> </Drawer>
<FinancePayProofModal
open={!!payModal}
title="确认打款?"
hint={`将确认 ${payModal?.ids.length ?? 0} 笔 T+1 门店对账单${
payModal?.amountHint != null ? `,合计约 ¥${payModal.amountHint.toFixed(2)}` : ''
}。此操作不可撤销。`}
okText="确认打款"
confirmLoading={paySubmitting}
onCancel={() => setPayModal(null)}
onOk={async (payload) => {
if (!payModal) return;
const { ids } = payModal;
setPaySubmitting(true);
if (ids.length > 1) setBatchLoading(true);
try {
const body = JSON.stringify({
...payload,
...(ids.length > 1 ? { ids } : {}),
});
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/store-bills/batch-confirm', { method: 'POST', body });
}
message.success('已确认打款');
setPayModal(null);
setSelectedKeys([]);
reload();
} finally {
setPaySubmitting(false);
setBatchLoading(false);
}
}}
/>
<FinancePayProofModal
open={!!withdrawApproveId}
title="审核通过并标记已结算?"
hint="通过后将标记该提现为已结算,此操作不可撤销。"
okText="通过并结算"
confirmLoading={withdrawSubmitting}
onCancel={() => setWithdrawApproveId(null)}
onOk={async (payload) => {
if (!withdrawApproveId) return;
setWithdrawSubmitting(true);
try {
await request(`/admin/store-withdrawals/${withdrawApproveId}/approve`, {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已通过并标记已结算');
setWithdrawApproveId(null);
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
} finally {
setWithdrawSubmitting(false);
}
}}
/>
</div> </div>
); );
} }
@@ -13,6 +13,7 @@ type Row = {
id: string; id: string;
serviceScore: number; serviceScore: number;
envScore: number; envScore: number;
comment?: string | null;
createdAt: string; createdAt: string;
redeemNo: string; redeemNo: string;
redeemAmount: number; redeemAmount: number;
@@ -62,6 +63,12 @@ export default function StoreRatingsPage() {
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '服务分', dataIndex: 'serviceScore', width: 80 }, { title: '服务分', dataIndex: 'serviceScore', width: 80 },
{ title: '环境分', dataIndex: 'envScore', width: 80 }, { title: '环境分', dataIndex: 'envScore', width: 80 },
{
title: '评语',
dataIndex: 'comment',
ellipsis: true,
render: (v: string | null) => v || '—',
},
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
]; ];
@@ -23,6 +23,7 @@ import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
type Row = { type Row = {
@@ -70,6 +71,8 @@ export default function StoreWithdrawalsPage() {
pendingCount: number; pendingCount: number;
overdueCount: number; overdueCount: number;
} | null>(null); } | null>(null);
const [approveId, setApproveId] = useState<string | null>(null);
const [approveSubmitting, setApproveSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -89,34 +92,7 @@ export default function StoreWithdrawalsPage() {
} }
function approve(id: string) { function approve(id: string) {
let paymentRef = ''; setApproveId(id);
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
} }
function reject(id: string) { function reject(id: string) {
@@ -356,6 +332,9 @@ export default function StoreWithdrawalsPage() {
{detail.paymentRef ? ( {detail.paymentRef ? (
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item> <Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
) : null} ) : null}
<Descriptions.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</Descriptions.Item>
<Descriptions.Item label="收款户名"> <Descriptions.Item label="收款户名">
{storeAccount?.bankAccountName || '—'} {storeAccount?.bankAccountName || '—'}
</Descriptions.Item> </Descriptions.Item>
@@ -394,6 +373,36 @@ export default function StoreWithdrawalsPage() {
</> </>
) : null} ) : null}
</Drawer> </Drawer>
<FinancePayProofModal
open={!!approveId}
title="审核通过并标记已结算?"
hint="通过后将标记该提现为已结算,此操作不可撤销。"
okText="通过并结算"
confirmLoading={approveSubmitting}
onCancel={() => setApproveId(null)}
onOk={async (payload) => {
if (!approveId) return;
setApproveSubmitting(true);
try {
await request(`/admin/store-withdrawals/${approveId}/approve`, {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已通过并标记已结算');
setApproveId(null);
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
} finally {
setApproveSubmitting(false);
}
}}
/>
</div> </div>
); );
} }
@@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -560,19 +561,11 @@ export default function SupportTicketsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<div <AdminListHeader
style={{ title="技术支持"
display: 'flex', settings={settingsButton}
justifyContent: 'space-between', actions={
alignItems: 'center', <>
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Space>
{isSuperAdmin ? ( {isSuperAdmin ? (
<> <>
<Button <Button
@@ -602,8 +595,9 @@ export default function SupportTicketsPage() {
<Button type="primary" onClick={() => setCreateOpen(true)}> <Button type="primary" onClick={() => setCreateOpen(true)}>
</Button> </Button>
</Space> </>
</div> }
/>
<Form <Form
layout="inline" layout="inline"
+225 -31
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -17,12 +17,13 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels'; import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
type UserOrderRow = { type UserOrderRow = {
@@ -120,6 +121,24 @@ type UserBehaviorLog = {
extraJson?: Record<string, unknown> | null; extraJson?: Record<string, unknown> | null;
}; };
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
};
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
const FILTERS_COLLAPSED_KEY = 'admin-users-filters-collapsed';
const ASSOC_UNBOUND = 'none';
function formatAssocPartner(p?: AssocPartnerBrief | PartnerOption | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type UserDetail = AdminUserRow & { type UserDetail = AdminUserRow & {
wxUnionId?: string | null; wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null; cityPref?: Record<string, unknown> | null;
@@ -128,6 +147,8 @@ type UserDetail = AdminUserRow & {
orders?: UserOrderRow[]; orders?: UserOrderRow[];
mergedFromCount?: number; mergedFromCount?: number;
addressCount?: number; addressCount?: number;
assocPartner?: AssocPartnerBrief | null;
assocBoundAt?: string | null;
}; };
type BatchDeletePreviewItem = { type BatchDeletePreviewItem = {
@@ -155,6 +176,7 @@ type BatchDeletePreview = {
export default function UsersPage() { export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
@@ -175,44 +197,88 @@ export default function UsersPage() {
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete'); const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
const canEditAssoc = (profile?.permissionKeys ?? []).includes('users_partner_assoc');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [assocPartnerId, setAssocPartnerId] = useState<string | undefined>();
const [savingAssoc, setSavingAssoc] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const partnerOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
if (detail?.assocPartner && !map.has(detail.assocPartner.id)) {
map.set(detail.assocPartner.id, detail.assocPartner);
}
return [...map.values()];
}, [partners, detail?.assocPartner]);
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const values = form.getFieldsValue(); const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
const userId = String(values.userId || '').trim();
if (userId) qs.set('userId', userId);
if (values.phone) qs.set('phone', values.phone); if (values.phone) qs.set('phone', values.phone);
if (values.userNo) qs.set('userNo', values.userNo); if (values.userNo) qs.set('userNo', values.userNo);
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
if (values.phoneVerified !== undefined && values.phoneVerified !== '') { if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
qs.set('phoneVerified', values.phoneVerified); qs.set('phoneVerified', values.phoneVerified);
} }
if (values.status !== undefined && values.status !== '') { if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status)); qs.set('status', String(values.status));
} }
if (values.excludeTest) qs.set('excludeTest', 'true'); const keyword = String(values.keyword || '').trim();
if (keyword) qs.set('keyword', keyword);
const assocPartnerAccountId = String(
values.assocPartnerAccountId || searchParams.get('assocPartnerAccountId') || '',
).trim();
if (assocPartnerAccountId) qs.set('assocPartnerAccountId', assocPartnerAccountId);
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`); const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res); setData(res);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [form, page, pageSize]); }, [form, page, pageSize, searchParams]);
useEffect(() => {
const userId = searchParams.get('userId');
const assocPartnerAccountId = searchParams.get('assocPartnerAccountId');
form.setFieldsValue({
...(userId ? { userId } : {}),
...(assocPartnerAccountId ? { assocPartnerAccountId } : {}),
});
}, [searchParams, form]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load, searchParams]);
useEffect(() => { useEffect(() => {
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId; const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
if (openUserId) { if (!openUserId) return;
form.setFieldsValue({ userId: openUserId });
setPage(1);
void openDetail(openUserId); void openDetail(openUserId);
navigate(location.pathname, { replace: true, state: null }); navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
} replace: true,
}, [location.state, location.pathname, navigate]); state: null,
});
}, [location.state, location.pathname, navigate, form]);
async function openDetail(id: string) { async function openDetail(id: string) {
const [res, logs] = await Promise.all([ const [res, logs] = await Promise.all([
@@ -222,10 +288,29 @@ export default function UsersPage() {
), ),
]); ]);
setDetail(res); setDetail(res);
setAssocPartnerId(res.assocPartner?.id);
setBehaviorLogs(logs.items ?? []); setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true); setDrawerOpen(true);
} }
async function saveAssoc() {
if (!detail) return;
setSavingAssoc(true);
try {
await request(`/admin/users/${detail.id}/assoc`, {
method: 'PUT',
body: JSON.stringify({ partnerAccountId: assocPartnerId || null }),
});
message.success(assocPartnerId ? '已更新关联合伙人' : '已解除关联');
await openDetail(detail.id);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingAssoc(false);
}
}
function openDeleteModal() { function openDeleteModal() {
setDeleteConfirm(''); setDeleteConfirm('');
setDeleteOpen(true); setDeleteOpen(true);
@@ -408,6 +493,12 @@ export default function UsersPage() {
width: 120, width: 120,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{
title: '关联合伙人',
dataIndex: 'assocPartner',
width: 180,
render: (p: AdminUserRow['assocPartner']) => formatAssocPartner(p),
},
{ {
title: 'deviceKey', title: 'deviceKey',
dataIndex: 'deviceKey', dataIndex: 'deviceKey',
@@ -498,37 +589,108 @@ export default function UsersPage() {
) : null ) : null
} }
/> />
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form
<Form.Item name="phone" label="手机号"> form={form}
layout="vertical"
onFinish={() => {
setPage(1);
const next = new URLSearchParams();
const userId = String(form.getFieldValue('userId') || '').trim();
const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim();
if (userId) next.set('userId', userId);
if (assoc) next.set('assocPartnerAccountId', assoc);
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
void load();
}}
>
<div className="admin-orders-filter-row">
<Form.Item name="keyword" label="搜索" className="admin-orders-filter-item admin-users-filter-item--keyword">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'any', label: '已关联(全部)' },
{ value: ASSOC_UNBOUND, label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner(p),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.toString()) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
</Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<div className="admin-orders-filter-row">
<Form.Item name="phone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="模糊搜索" allowClear /> <Input placeholder="模糊搜索" allowClear />
</Form.Item> </Form.Item>
<Form.Item name="userNo" label="用户编号"> <Form.Item name="userNo" label="用户编号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="DK..." allowClear /> <Input placeholder="DK..." allowClear />
</Form.Item> </Form.Item>
<Form.Item name="deviceKey" label="deviceKey"> <Form.Item name="userId" label="用户ID" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Input placeholder="UUID" allowClear style={{ width: 200 }} /> <Input placeholder="精确匹配" allowClear />
</Form.Item> </Form.Item>
<Form.Item name="phoneVerified" label="验手机"> <Form.Item name="phoneVerified" label="验手机" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear style={{ width: 100 }} options={[ <Select allowClear placeholder="全部" options={[
{ value: '1', label: '已验证' }, { value: '1', label: '已验证' },
{ value: '0', label: '访客' }, { value: '0', label: '访客' },
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear style={{ width: 90 }} options={[ <Select allowClear placeholder="全部" options={[
{ value: 1, label: '正常' }, { value: 1, label: '正常' },
{ value: 0, label: '停用' }, { value: 0, label: '停用' },
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked"> </div>
<Checkbox></Checkbox> ) : null}
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
</Space>
</Form.Item>
</Form> </Form>
<Table <Table
@@ -570,7 +732,7 @@ export default function UsersPage() {
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item> <Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item> </Descriptions.Item>
@@ -592,6 +754,38 @@ export default function UsersPage() {
) : '—'} ) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item> <Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
{canEditAssoc ? (
<Space wrap>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="未关联"
style={{ minWidth: 260 }}
value={assocPartnerId}
onChange={setAssocPartnerId}
options={partnerOptions.map((p) => ({
value: p.id,
label: `${p.companyName || p.name || p.id}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
<Button type="primary" size="small" loading={savingAssoc} onClick={() => void saveAssoc()}>
</Button>
<Typography.Text type="secondary"></Typography.Text>
</Space>
) : detail.assocPartner ? (
`${detail.assocPartner.companyName || detail.assocPartner.name}${
detail.assocPartner.phone ? ` / ${detail.assocPartner.phone}` : ''
}`
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="关联时间">
{detail.assocBoundAt ? fmtTime(detail.assocBoundAt) : '—'}
</Descriptions.Item>
{detail.sourcePromo && ( {detail.sourcePromo && (
<Descriptions.Item label="推广码"> <Descriptions.Item label="推广码">
<Link to={`/promo-codes/${detail.sourcePromo.id}`}> <Link to={`/promo-codes/${detail.sourcePromo.id}`}>
@@ -604,7 +798,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item> <Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至"> <Descriptions.Item label="合并至">
{detail.mergedInto {detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -1,5 +1,6 @@
import { useState, type CSSProperties } from 'react'; import { useState, type CSSProperties } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
@@ -13,6 +14,7 @@ import {
Select, Select,
Space, Space,
Statistic, Statistic,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
@@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = {
wordBreak: 'break-all', wordBreak: 'break-all',
}; };
const ATTRIBUTION_HINT =
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
async function downloadQrcode(url: string, filename: string) { async function downloadQrcode(url: string, filename: string) {
try { try {
const res = await fetch(url); const res = await fetch(url);
@@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) {
} }
export default function PromoCodeDetailPage() { export default function PromoCodeDetailPage() {
const navigate = useNavigate();
const { detail, reload } = useOutletContext<PromoCodeDetailContext>(); const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}> <Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
>
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title="归因用户数" title={
<span>
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
</Tooltip>
</span>
}
value={stats?.attributionCount ?? 0} value={stats?.attributionCount ?? 0}
/> />
</Card> </Card>
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} /> size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
>
<Statistic
title={
<span>
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
</Tooltip>
</span>
}
value={stats?.orderCount ?? detail.orderCount}
/>
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Card, Card,
DatePicker, DatePicker,
@@ -21,6 +22,7 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../../lib/api'; import { request, type Paginated } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
type Props = { type Props = {
promoId: string; promoId: string;
@@ -57,11 +59,45 @@ function buildEventsQs(
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`; return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
} }
function formatRefId(row: PromoMetricEventItem): string { function parseEventType(raw: string | null): PromoMetricEventType | undefined {
if (row.orderId) return `订单 ${row.orderId}`; if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
if (row.userId) return `用户 ${row.userId}`; return undefined;
if (row.sessionId) return `会话 ${row.sessionId}`; }
return '—';
function EventRefLinks({ row }: { row: PromoMetricEventItem }) {
const navigate = useNavigate();
const links = [];
if (row.orderId) {
links.push(
<AdminPrimaryLink
key="order"
onClick={() => {
if (row.orderNo) {
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
} else {
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
}
}}
>
{row.orderNo || row.orderId}
</AdminPrimaryLink>,
);
}
if (row.userId) {
links.push(
<AdminPrimaryLink
key="user"
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
>
{row.userNo || row.userId}
</AdminPrimaryLink>,
);
}
if (links.length) {
return <Space size={12}>{links}</Space>;
}
if (row.sessionId) return <> {row.sessionId}</>;
return <></>;
} }
function formatLocation(row: PromoMetricEventItem): string { function formatLocation(row: PromoMetricEventItem): string {
@@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string {
} }
export default function PromoCodeMetricsPanel({ promoId }: Props) { export default function PromoCodeMetricsPanel({ promoId }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const urlEventType = parseEventType(searchParams.get('eventType'));
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]); const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null); const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
const [timelineLoading, setTimelineLoading] = useState(false); const [timelineLoading, setTimelineLoading] = useState(false);
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(); const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
const [eventsPage, setEventsPage] = useState(1); const [eventsPage, setEventsPage] = useState(1);
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null); const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
const [eventsLoading, setEventsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false);
useEffect(() => {
setEventType(urlEventType);
setEventsPage(1);
if (urlEventType) {
window.requestAnimationFrame(() => {
document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
}, [urlEventType]);
const loadTimeline = useCallback(() => { const loadTimeline = useCallback(() => {
setTimelineLoading(true); setTimelineLoading(true);
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity)) return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
{ {
title: 'ID', title: 'ID',
key: 'ref', key: 'ref',
render: (_, row) => formatRefId(row), render: (_, row) => <EventRefLinks row={row} />,
}, },
{ {
title: 'IP', title: 'IP',
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
notMerge notMerge
/> />
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}> <Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
</Typography.Title> </Typography.Title>
<Space wrap style={{ marginBottom: 12 }}> <Space wrap style={{ marginBottom: 12 }}>
@@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
onChange={(v) => { onChange={(v) => {
setEventType(v); setEventType(v);
setEventsPage(1); setEventsPage(1);
const next = new URLSearchParams(searchParams);
if (v) next.set('eventType', v);
else next.delete('eventType');
setSearchParams(next, { replace: true });
}} }}
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({ options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
value, value,
@@ -1,5 +1,5 @@
import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { Button, Space, Table, Tag } from 'antd'; import { Button, Space, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
USER_SOURCE_TYPE_LABELS, USER_SOURCE_TYPE_LABELS,
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
width: 160, width: 160,
render: (v) => (v ? fmtTime(v) : '—'), render: (v) => (v ? fmtTime(v) : '—'),
}, },
{ title: '订单数', dataIndex: 'orderCount', width: 80 }, { title: <Tooltip title="仅统计已完成订单"></Tooltip>, dataIndex: 'orderCount', width: 80 },
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>城市合伙人</title> <title>城市合伙人</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+11
View File
@@ -22,12 +22,17 @@ import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
import StaffListPage from './pages/StaffListPage'; import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage'; import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage'; import LeaderboardPage from './pages/LeaderboardPage';
import UsersManagePage from './pages/UsersManagePage';
import AssocOrdersPage from './pages/AssocOrdersPage';
import CommissionOrdersPage from './pages/CommissionOrdersPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
function PrimaryRoutes() { function PrimaryRoutes() {
return ( return (
<Routes> <Routes>
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/users" element={<UsersManagePage />} />
<Route path="/stores" element={<StoreListPage />} /> <Route path="/stores" element={<StoreListPage />} />
<Route path="/center" element={<CenterPage />} /> <Route path="/center" element={<CenterPage />} />
</Route> </Route>
@@ -37,6 +42,12 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} /> <Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} /> <Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} /> <Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
<Route path="/users/orders" element={<AssocOrdersPage />} />
<Route path="/users/:userId/orders" element={<AssocOrdersPage />} />
<Route path="/center/commissions" element={<CommissionOrdersPage />} />
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} /> <Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} /> <Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} /> <Route path="/proxy-order" element={<ProxyOrderPage />} />
@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from 'react';
import type { ActivityPosterItem, PartnerAssocSummary } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
copyActivityCopy,
downloadBlob,
fetchActivityPosterImage,
fetchAssocQrcodeImage,
getActivityPosterSelection,
listActivityPosters,
saveActivityPosterSelection,
} from '../lib/activity-posters';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
const NONE_ID = 'none';
type Props = {
initialId?: string | null;
};
export default function ActivityPosterPicker({ initialId }: Props) {
const [items, setItems] = useState<ActivityPosterItem[]>([]);
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string>('');
const [selectedId, setSelectedId] = useState<string>(NONE_ID);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [listLoading, setListLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false);
const [downloading, setDownloading] = useState(false);
const blobsRef = useRef(new Map<string, Blob>());
const urlsRef = useRef<string[]>([]);
useEffect(() => () => {
urlsRef.current.forEach((url) => URL.revokeObjectURL(url));
}, []);
useEffect(() => {
let cancelled = false;
Promise.all([
listActivityPosters(),
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc', { silent: true }),
getActivityPosterSelection().catch(() => ({ posterId: null })),
])
.then(([list, assoc, selection]) => {
if (cancelled) return;
setItems(list);
setQrcodeUrl(assoc.qrcodeUrl);
setPartnerId(assoc.partnerId);
const saved = selection.posterId && list.some((item) => item.id === selection.posterId)
? selection.posterId
: null;
const fromQuery = initialId && list.some((item) => item.id === initialId) ? initialId : null;
const next = saved || fromQuery || NONE_ID;
setSelectedId(next);
if (!saved && fromQuery) {
void saveActivityPosterSelection(fromQuery).catch((err) => {
toastError(err instanceof Error ? err.message : '保存失败');
});
}
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载活动图失败'))
.finally(() => {
if (!cancelled) setListLoading(false);
});
return () => {
cancelled = true;
};
}, [initialId]);
useEffect(() => {
if (selectedId === NONE_ID || previewUrls[selectedId] || blobsRef.current.has(selectedId)) return;
let cancelled = false;
setPreviewLoading(true);
fetchActivityPosterImage(selectedId)
.then((blob) => {
if (cancelled) return;
blobsRef.current.set(selectedId, blob);
const url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setPreviewUrls((prev) => ({ ...prev, [selectedId]: url }));
})
.catch((e) => {
if (!cancelled) toastError(e instanceof Error ? e.message : '预览失败');
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedId, previewUrls]);
const selected = items.find((item) => item.id === selectedId) ?? null;
const previewUrl = selectedId !== NONE_ID ? previewUrls[selectedId] : undefined;
async function downloadSelected() {
setDownloading(true);
try {
if (selectedId === NONE_ID) {
const blob = await fetchAssocQrcodeImage();
if (isWechatEnv()) {
const url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setQrcodeUrl(url);
toastSuccess('请长按上方图片保存到相册');
return;
}
downloadBlob(blob, `partner-assoc-${partnerId || 'qr'}.png`);
toastSuccess('已开始下载');
return;
}
if (!selected) return;
let blob = blobsRef.current.get(selected.id);
if (!blob) {
blob = await fetchActivityPosterImage(selected.id);
blobsRef.current.set(selected.id, blob);
}
if (isWechatEnv()) {
let url = previewUrls[selected.id];
if (!url) {
url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setPreviewUrls((prev) => ({ ...prev, [selected.id]: url }));
}
toastSuccess('请长按上方图片保存到相册');
return;
}
downloadBlob(blob, `activity-poster-${selected.id}.png`);
toastSuccess('已开始下载');
} catch (e) {
toastError(e instanceof Error ? e.message : '下载失败');
} finally {
setDownloading(false);
}
}
async function selectPoster(id: string) {
const prev = selectedId;
setSelectedId(id);
try {
await saveActivityPosterSelection(id === NONE_ID ? null : id);
} catch (e) {
setSelectedId(prev);
toastError(e instanceof Error ? e.message : '保存失败');
}
}
function renderRadioRow(id: string, label: string) {
return (
<label className="partner-activity-radio-row">
<span className="headline-md">{label}</span>
<input
type="radio"
name="activity-poster"
checked={selectedId === id}
onChange={() => void selectPoster(id)}
/>
</label>
);
}
if (listLoading) {
return <div className="empty"></div>;
}
return (
<div className="partner-activity-picker">
<section className="partner-bill-card" style={{ overflow: 'hidden' }}>
{renderRadioRow(NONE_ID, '无')}
{selectedId === NONE_ID && (
<div className="partner-activity-panel">
{qrcodeUrl ? (
<div className="partner-activity-preview partner-activity-preview--qr">
<img src={qrcodeUrl} alt="关联码" />
</div>
) : (
<p className="body-md text-muted" style={{ marginBottom: 12 }}></p>
)}
<button
type="button"
className="partner-btn-primary"
disabled={downloading || !qrcodeUrl}
onClick={() => void downloadSelected()}
>
{downloading ? '下载中…' : '下载二维码'}
</button>
{isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 12 }}>
</p>
)}
</div>
)}
</section>
{items.map((item) => (
<section key={item.id} className="partner-bill-card" style={{ marginTop: 12, overflow: 'hidden' }}>
{renderRadioRow(item.id, item.title)}
{selectedId === item.id && (
<div className="partner-activity-panel">
<div className="partner-activity-preview">
<img src={previewUrl || item.imageUrl} alt={item.title} />
{previewLoading && !previewUrl && (
<div className="partner-activity-preview-mask"></div>
)}
</div>
{item.copyText ? (
<p className="body-md" style={{ whiteSpace: 'pre-wrap', margin: '12px 0' }}>{item.copyText}</p>
) : (
<p className="body-md text-muted" style={{ margin: '12px 0' }}>广</p>
)}
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
className="partner-btn-outline"
onClick={() => {
void copyActivityCopy(item.copyText)
.then(() => toastSuccess('文案已复制'))
.catch((e) => toastError(e instanceof Error ? e.message : '复制失败'));
}}
>
</button>
<button
type="button"
className="partner-btn-primary"
style={{ flex: 1, width: 'auto', padding: 0, height: 48 }}
disabled={downloading || previewLoading}
onClick={() => void downloadSelected()}
>
{downloading || previewLoading ? '生成中…' : '下载活动图'}
</button>
</div>
{previewUrl && isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 12 }}>
</p>
)}
</div>
)}
</section>
))}
</div>
);
}
@@ -3,6 +3,7 @@ import { isLoggedIn } from '../lib/api';
const TABS = [ const TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' }, { to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/users', icon: 'group', label: '用户管理' },
{ to: '/stores', icon: 'store', label: '门店管理' }, { to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/center', icon: 'account_circle', label: '合伙人中心' }, { to: '/center', icon: 'account_circle', label: '合伙人中心' },
] as const; ] as const;
@@ -0,0 +1,74 @@
import type { ActivityPosterItem, ActivityPosterSelection } from '@dukang/shared-types';
import { request } from './api';
export async function listActivityPosters() {
const data = await request<ActivityPosterItem[]>('PARTNER_H5', '/partner/activity-posters', {
silent: true,
});
return Array.isArray(data) ? data : [];
}
export async function fetchAssocQrcodeImage(): Promise<Blob> {
const token = localStorage.getItem('accessToken');
const res = await fetch('/api/v1/partner/assoc/qrcode', {
headers: {
Authorization: token ? `Bearer ${token}` : '',
'X-Client-App': 'PARTNER_H5',
},
});
if (!res.ok) throw new Error('下载二维码失败');
return res.blob();
}
export async function fetchActivityPosterImage(id: string): Promise<Blob> {
const token = localStorage.getItem('accessToken');
const res = await fetch(`/api/v1/partner/activity-posters/${id}/image`, {
headers: {
Authorization: token ? `Bearer ${token}` : '',
'X-Client-App': 'PARTNER_H5',
},
});
if (!res.ok) {
const json = await res.json().catch(() => null) as { message?: string } | null;
throw new Error(json?.message || '生成活动图失败');
}
return res.blob();
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export async function getActivityPosterSelection() {
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
silent: true,
});
}
export async function saveActivityPosterSelection(posterId: string | null) {
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
method: 'PUT',
body: JSON.stringify({ posterId }),
silent: true,
});
}
export async function copyActivityCopy(text: string) {
const value = text.trim();
if (!value) throw new Error('暂无文案');
try {
await navigator.clipboard.writeText(value);
} catch {
const ta = document.createElement('textarea');
ta.value = value;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
@@ -12,6 +12,7 @@ export type ProxyOrderDraft = {
promoCodeId: string; promoCodeId: string;
deliveryMode: PartnerProxyDeliveryMode; deliveryMode: PartnerProxyDeliveryMode;
autoReceive: boolean; autoReceive: boolean;
assocEnabled: boolean;
}; };
export function saveProxyOrderDraft(draft: ProxyOrderDraft) { export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
@@ -40,6 +41,7 @@ export function loadProxyOrderDraft(): ProxyOrderDraft | null {
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '', promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS', deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
autoReceive: parsed.autoReceive === true, autoReceive: parsed.autoReceive === true,
assocEnabled: parsed.assocEnabled !== false,
}; };
} catch { } catch {
return null; return null;
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ActivityPosterPicker from '../components/ActivityPosterPicker';
import { usePartnerPageView } from '../lib/usePageView';
export default function ActivityPostersPage() {
usePartnerPageView('partner_activity_posters_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialId = searchParams.get('id');
useEffect(() => {
document.title = '活动图';
}, []);
return (
<div>
<PageHeader title="活动图" onBack={() => navigate(-1)} />
<div style={{ padding: 16 }}>
<ActivityPosterPicker initialId={initialId} />
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerAssocUserOrderItem } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
type ListRes = { items: PartnerAssocUserOrderItem[]; total: number };
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function AssocOrdersPage() {
const { userId } = useParams();
usePartnerPageView(userId ? 'partner_assoc_user_orders_view' : 'partner_assoc_orders_view');
const navigate = useNavigate();
const [items, setItems] = useState<PartnerAssocUserOrderItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const path = userId
? `/partner/assoc/users/${userId}/orders?page=1&pageSize=50`
: '/partner/assoc/orders?page=1&pageSize=50';
const res = await request<ListRes>('PARTNER_H5', path);
setItems(res.items ?? []);
setTotal(res.total ?? 0);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
void load();
}, [load]);
return (
<PullToRefresh onRefresh={load}>
<PageHeader title={userId ? '用户订单' : '关联用户订单'} onBack={() => navigate(-1)} />
<div style={{ padding: '0 20px 24px' }}>
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
{loading ? '加载中…' : `${total} 笔已付购酒单`}
</p>
{!loading && items.length === 0 && <div className="empty"></div>}
{items.map((o) => (
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{o.productName}</p>
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.payAmount)}</span>
</div>
</div>
))}
</div>
</PullToRefresh>
);
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerAssocSummary } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
import { usePartnerPageView } from '../lib/usePageView';
export default function AssocQrcodePage() {
usePartnerPageView('partner_assoc_qrcode_view');
const navigate = useNavigate();
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc')
.then(setSummary)
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
}, [previewUrl]);
async function downloadQr() {
const token = localStorage.getItem('accessToken');
try {
const res = await fetch('/api/v1/partner/assoc/qrcode', {
headers: {
Authorization: token ? `Bearer ${token}` : '',
'X-Client-App': 'PARTNER_H5',
},
});
if (!res.ok) throw new Error('下载失败');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
if (isWechatEnv()) {
setPreviewUrl(url);
toastSuccess('请长按图片保存到相册');
return;
}
const a = document.createElement('a');
a.href = url;
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
a.click();
URL.revokeObjectURL(url);
toastSuccess('已开始下载');
} catch (e) {
if (summary?.qrcodeUrl) {
setPreviewUrl(summary.qrcodeUrl);
toastSuccess('请长按图片保存到相册');
return;
}
toastError(e instanceof Error ? e.message : '下载失败');
}
}
return (
<div>
<PageHeader title="关联码" onBack={() => navigate(-1)} />
{loading && <div className="empty"></div>}
{!loading && (
<section className="partner-bill-card" style={{ margin: 16 }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
</p>
{summary?.qrcodeUrl ? (
<img
src={previewUrl || summary.qrcodeUrl}
alt="关联码"
style={{ width: 220, height: 220, background: '#fff' }}
/>
) : (
<p className="body-md text-muted"></p>
)}
<p className="label-md text-muted" style={{ marginTop: 12 }}>
{summary?.userCount ?? 0}
</p>
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
</button>
{previewUrl && isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 12 }}>
</p>
)}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerAssocUserItem } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
type ListRes = { items: PartnerAssocUserItem[]; total: number; page: number; pageSize: number };
export default function AssocUsersPage() {
usePartnerPageView('partner_assoc_users_view');
const navigate = useNavigate();
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const res = await request<ListRes>('PARTNER_H5', '/partner/assoc/users?page=1&pageSize=50');
setItems(res.items ?? []);
setTotal(res.total ?? 0);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
return (
<PullToRefresh onRefresh={load}>
<PageHeader title="关联用户" onBack={() => navigate(-1)} />
<div style={{ padding: '0 20px 24px' }}>
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
{loading ? '加载中…' : `${total}`}
</p>
{!loading && items.length === 0 && <div className="empty"></div>}
{items.map((u) => (
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{u.nickname || u.userNo}</p>
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
<p className="label-md text-muted">{u.boundAt ? u.boundAt.slice(0, 16).replace('T', ' ') : ''}</p>
</div>
<span className="label-md">{u.orderCount} </span>
</div>
</div>
))}
</div>
</PullToRefresh>
);
}
+42 -11
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView'; import { usePartnerPageView } from '../lib/usePageView';
@@ -34,6 +34,7 @@ export default function BillsPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detail, setDetail] = useState<PartnerBillDetailDto | null>(null);
async function loadBills() { async function loadBills() {
setLoading(true); setLoading(true);
@@ -67,6 +68,44 @@ export default function BillsPage() {
const isRejected = status === 'REJECTED'; const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID'; const isPaid = status === 'PAID';
useEffect(() => {
if (!bill?.id) {
setDetail(null);
return;
}
request<PartnerBillDetailDto>('PARTNER_H5', `/partner/settlement/bills/${bill.id}`)
.then(setDetail)
.catch(() => setDetail(null));
}, [bill?.id]);
function renderItemList(title: string, items: PartnerBillItemDto[] | undefined, subtotal: number) {
const list = items ?? [];
return (
<div style={{ marginTop: 16 }}>
<div className="partner-info-row" style={{ marginBottom: 8 }}>
<span className="body-md" style={{ fontWeight: 700 }}>{title}</span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(subtotal)}</span>
</div>
{list.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
list.map((it) => (
<div key={it.id} className="partner-info-row" style={{ alignItems: 'flex-start' }}>
<div>
<p className="body-md">{it.title || it.refNo}</p>
<p className="label-md text-muted">
{it.refNo}
{it.extra ? ` ${it.extra}` : ''} · {((it.rate ?? 0) * 100).toFixed(2)}%
</p>
</div>
<span className="body-md">¥ {fmtMoney(it.commission)}</span>
</div>
))
)}
</div>
);
}
function askConfirm(ids: string[]) { function askConfirm(ids: string[]) {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) { if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return; return;
@@ -183,16 +222,8 @@ export default function BillsPage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> {renderItemList('酒订单', detail?.orderItems, Number(detail?.orderCommission ?? bill.orderCommission ?? 0))}
<div className="partner-info-row"> {renderItemList('核销订单', detail?.redeemItems, Number(detail?.redeemCommission ?? bill.redeemCommission ?? 0))}
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div>
</div>
</div> </div>
</section> </section>
)} )}
+27
View File
@@ -219,6 +219,33 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<span className="material-symbols-outlined text-muted">chevron_right</span> <span className="material-symbols-outlined text-muted">chevron_right</span>
</div> </div>
</Link> </Link>
<Link to="/center/activity-posters" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">image</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/users" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">group</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/commissions" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">payments</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/proxy-orders" className="partner-menu-item"> <Link to="/center/proxy-orders" className="partner-menu-item">
<div className="partner-menu-item-left"> <div className="partner-menu-item-left">
<div className="partner-menu-icon"> <div className="partner-menu-icon">
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerCommissionOrderItem } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type ListRes = { items: PartnerCommissionOrderItem[]; total: number };
export default function CommissionOrdersPage() {
usePartnerPageView('partner_commission_orders_view');
const navigate = useNavigate();
const [items, setItems] = useState<PartnerCommissionOrderItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const res = await request<ListRes>('PARTNER_H5', '/partner/commissions/orders?page=1&pageSize=50');
setItems(res.items ?? []);
setTotal(res.total ?? 0);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
return (
<PullToRefresh onRefresh={load}>
<PageHeader title="订单佣金" onBack={() => navigate(-1)} />
<div style={{ padding: '0 20px 24px' }}>
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
{loading ? '加载中…' : `${total} 笔关联酒单`}
</p>
{!loading && items.length === 0 && <div className="empty"></div>}
{items.map((o) => (
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header">
<div>
<p className="body-md">{o.productName}</p>
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.commission)}</span>
</div>
<p className="label-md text-muted">
¥{fmtMoney(o.payAmount)} × {((o.rate ?? 0) * 100).toFixed(2)}%
</p>
</div>
))}
</div>
</PullToRefresh>
);
}
+58 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerAssocStats, PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { usePartnerPageView } from '../lib/usePageView'; import { usePartnerPageView } from '../lib/usePageView';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
@@ -153,6 +153,7 @@ export default function HomePage() {
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]); const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]); const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
const [assocStats, setAssocStats] = useState<PartnerAssocStats | null>(null);
useEffect(() => { useEffect(() => {
document.title = '工作台'; document.title = '工作台';
@@ -197,6 +198,16 @@ export default function HomePage() {
setStores([]); setStores([]);
} }
if (isPrimary) {
tasks.push(
request<PartnerAssocStats>('PARTNER_H5', '/partner/assoc/stats', { silent: true })
.then(setAssocStats)
.catch(() => setAssocStats(null)),
);
} else {
setAssocStats(null);
}
tasks.push( tasks.push(
fetchPartnerLeaderboard('month') fetchPartnerLeaderboard('month')
.then((data) => { .then((data) => {
@@ -208,7 +219,7 @@ export default function HomePage() {
); );
return Promise.all(tasks).then(() => undefined); return Promise.all(tasks).then(() => undefined);
}, [navigate, account, canOrders, canDashboard, canStores]); }, [navigate, account, canOrders, canDashboard, canStores, isPrimary]);
useEffect(() => { useEffect(() => {
void loadHome(); void loadHome();
@@ -287,6 +298,51 @@ export default function HomePage() {
</> </>
)} )}
{isPrimary && (
<>
<Link to="/users" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.userTotal ?? 0}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{assocStats?.userToday ?? 0}</p>
</div>
</div>
<div className="partner-store-stat">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{assocStats?.userMonth ?? 0}</p>
</div>
</div>
</div>
</Link>
<Link to="/users/orders" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.orderTotal ?? 0}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{assocStats?.orderToday ?? 0}</p>
</div>
</div>
<div className="partner-store-stat">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{assocStats?.orderMonth ?? 0}</p>
</div>
</div>
</div>
</Link>
</>
)}
{isWarehouse && ( {isWarehouse && (
<p className="headline-md" style={{ marginBottom: 8 }}></p> <p className="headline-md" style={{ marginBottom: 8 }}></p>
)} )}
@@ -42,6 +42,7 @@ function initialDraft(): ProxyOrderDraft {
promoCodeId: '', promoCodeId: '',
deliveryMode: 'ADDRESS', deliveryMode: 'ADDRESS',
autoReceive: false, autoReceive: false,
assocEnabled: true,
} }
); );
} }
@@ -63,6 +64,7 @@ export default function ProxyOrderPage() {
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId); const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode); const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive); const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
const [assocEnabled, setAssocEnabled] = useState(draft0.assocEnabled !== false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null); const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -108,6 +110,7 @@ export default function ProxyOrderPage() {
promoCodeId, promoCodeId,
deliveryMode, deliveryMode,
autoReceive, autoReceive,
assocEnabled,
...overrides, ...overrides,
}); });
} }
@@ -154,6 +157,7 @@ export default function ProxyOrderPage() {
promoCodeId, promoCodeId,
deliveryMode, deliveryMode,
autoReceive, autoReceive,
assocEnabled,
]); ]);
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
@@ -390,6 +394,9 @@ export default function ProxyOrderPage() {
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined, skuId: skuId || undefined,
assocPartnerAccountId: assocEnabled
? options?.partners?.[0]?.id || undefined
: undefined,
}; };
setSubmitting(true); setSubmitting(true);
@@ -595,6 +602,17 @@ export default function ProxyOrderPage() {
</div> </div>
</section> </section>
<section className="partner-form-section">
<label className="partner-checkbox-row">
<input
type="checkbox"
checked={assocEnabled}
onChange={(e) => setAssocEnabled(e.target.checked)}
/>
<span></span>
</label>
</section>
{(options?.promoCodes.length ?? 0) > 0 && ( {(options?.promoCodes.length ?? 0) > 0 && (
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label">广</label> <label className="partner-form-label">广</label>
@@ -0,0 +1,314 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type {
PartnerAssocSummary,
PartnerAssocUserItem,
PartnerAssocUserSort,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import {
downloadBlob,
fetchActivityPosterImage,
fetchAssocQrcodeImage,
} from '../lib/activity-posters';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
import { usePartnerPageView } from '../lib/usePageView';
type ListRes = { items: PartnerAssocUserItem[]; total: number };
const SORTS: { key: PartnerAssocUserSort; label: string }[] = [
{ key: 'boundAt', label: '关联时间' },
{ key: 'createdAt', label: '注册时间' },
{ key: 'orderCount', label: '订单数' },
];
function formatUserLabel(u: PartnerAssocUserItem) {
const name = u.nickname?.trim() || u.userNo || '—';
const remark = u.partnerRemark?.trim();
return remark ? `${name}${remark}` : name;
}
function fmtTime(iso?: string | null) {
if (!iso) return '—';
return iso.slice(0, 16).replace('T', ' ');
}
export default function UsersManagePage() {
usePartnerPageView('partner_users_manage_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [heroUrl, setHeroUrl] = useState<string | null>(null);
const [heroLoading, setHeroLoading] = useState(false);
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [sort, setSort] = useState<PartnerAssocUserSort>('boundAt');
const [editing, setEditing] = useState<PartnerAssocUserItem | null>(null);
const [remarkDraft, setRemarkDraft] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
document.title = '用户管理';
}, []);
useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
}, [previewUrl]);
useEffect(() => {
const posterId = summary?.activityPosterId;
if (!posterId) {
setHeroUrl(null);
setHeroLoading(false);
return;
}
let cancelled = false;
const acc = { url: null as string | null };
setHeroLoading(true);
fetchActivityPosterImage(posterId)
.then((blob) => {
if (cancelled) return;
acc.url = URL.createObjectURL(blob);
setHeroUrl(acc.url);
})
.catch(() => {
if (!cancelled) setHeroUrl(null);
})
.finally(() => {
if (!cancelled) setHeroLoading(false);
});
return () => {
cancelled = true;
if (acc.url) URL.revokeObjectURL(acc.url);
};
}, [summary?.activityPosterId]);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
setSummary(data);
}, []);
const loadUsers = useCallback(async () => {
const qs = new URLSearchParams({ page: '1', pageSize: '50', sort });
const q = appliedKeyword.trim();
if (q) qs.set('keyword', q);
const res = await request<ListRes>('PARTNER_H5', `/partner/assoc/users?${qs}`);
setItems(res.items ?? []);
setTotal(res.total ?? 0);
}, [appliedKeyword, sort]);
const load = useCallback(async () => {
setLoading(true);
try {
await Promise.all([loadSummary(), loadUsers()]);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, [loadSummary, loadUsers]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const t = window.setTimeout(() => setAppliedKeyword(keyword.trim()), 400);
return () => window.clearTimeout(t);
}, [keyword]);
useEffect(() => {
const uid = searchParams.get('userId');
if (uid) navigate(`/users/${uid}/orders`, { replace: true });
}, [searchParams, navigate]);
async function downloadMainImage() {
const posterId = summary?.activityPosterId;
try {
const blob = posterId
? await fetchActivityPosterImage(posterId)
: await fetchAssocQrcodeImage();
if (isWechatEnv()) {
setPreviewUrl(URL.createObjectURL(blob));
toastSuccess('请长按图片保存到相册');
return;
}
downloadBlob(blob, posterId
? `activity-poster-${posterId}.png`
: `partner-assoc-${summary?.partnerId || 'qr'}.png`);
toastSuccess('已开始下载');
} catch (e) {
if (!posterId && summary?.qrcodeUrl) {
setPreviewUrl(summary.qrcodeUrl);
toastSuccess('请长按图片保存到相册');
return;
}
toastError(e instanceof Error ? e.message : '下载失败');
}
}
async function saveRemark() {
if (!editing) return;
setSaving(true);
try {
await request(`PARTNER_H5`, `/partner/assoc/users/${editing.id}/remark`, {
method: 'PUT',
body: JSON.stringify({ remark: remarkDraft }),
});
toastSuccess(remarkDraft.trim() ? '备注已保存' : '已清除备注');
setEditing(null);
await loadUsers();
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<PullToRefresh onRefresh={load} className="page partner-users-page">
<div style={{ padding: '16px 20px 24px' }}>
<h1 className="headline-lg" style={{ marginBottom: 16 }}></h1>
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
</p>
{summary?.activityPosterId && (previewUrl || heroUrl) ? (
<img
src={previewUrl || heroUrl || ''}
alt="活动图"
style={{ width: '100%', maxWidth: 360, background: '#f5f5f5' }}
/>
) : summary?.activityPosterId && heroLoading ? (
<p className="body-md text-muted"></p>
) : previewUrl || summary?.qrcodeUrl ? (
<img
src={previewUrl || summary?.qrcodeUrl || ''}
alt="关联码"
style={{ width: 200, height: 200, background: '#fff' }}
/>
) : (
<p className="body-md text-muted">{loading ? '加载中…' : '关联码尚未生成'}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{summary?.userCount ?? total}
</p>
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadMainImage()}>
{summary?.activityPosterId ? '下载活动图' : '下载二维码'}
</button>
{previewUrl && isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 8 }}></p>
)}
</section>
<Link to="/center/activity-posters" className="partner-menu-card" style={{ display: 'block', marginBottom: 20, textDecoration: 'none', color: 'inherit' }}>
<div className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">image</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<h2 className="headline-md" style={{ marginBottom: 12 }}></h2>
<div className="partner-search">
<span className="material-symbols-outlined">search</span>
<input
value={keyword}
placeholder="搜索昵称 / 手机 / 备注 / 编号"
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void loadUsers();
}}
/>
</div>
<div className="partner-filter-row" style={{ marginBottom: 12, paddingLeft: 0, paddingRight: 0 }}>
{SORTS.map((s) => (
<button
key={s.key}
type="button"
className={`partner-filter-tab${sort === s.key ? ' active' : ''}`}
onClick={() => setSort(s.key)}
>
{s.label}
</button>
))}
</div>
<p className="label-md text-muted" style={{ marginBottom: 12 }}>
{loading ? '加载中…' : `${total}`}
</p>
{!loading && items.length === 0 && <div className="empty"></div>}
{items.map((u) => (
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header" style={{ marginBottom: 8 }}>
<div>
<p className="body-md">{formatUserLabel(u)}</p>
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
<p className="label-md text-muted"> {fmtTime(u.createdAt)}</p>
</div>
<button
type="button"
className="label-md text-primary"
style={{ background: 'none', border: 0, padding: 0 }}
onClick={() => navigate(`/users/${u.id}/orders`)}
>
{u.orderCount}
</button>
</div>
<button
type="button"
className="label-md text-primary"
style={{ background: 'none', border: 0, padding: 0 }}
onClick={() => {
setEditing(u);
setRemarkDraft(u.partnerRemark ?? '');
}}
>
{u.partnerRemark ? '改备注' : '添加备注'}
</button>
</div>
))}
</div>
{editing && (
<div className="partner-remark-mask" onClick={() => setEditing(null)}>
<div className="partner-remark-sheet" onClick={(e) => e.stopPropagation()}>
<p className="headline-md" style={{ marginBottom: 8 }}></p>
<p className="label-md text-muted" style={{ marginBottom: 12 }}></p>
<textarea
value={remarkDraft}
maxLength={128}
rows={3}
placeholder="最多 128 字"
style={{ width: '100%', padding: 12, borderRadius: 8, border: '1px solid #eee' }}
onChange={(e) => setRemarkDraft(e.target.value)}
/>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setEditing(null)}>
</button>
<button
type="button"
className="partner-btn-primary"
disabled={saving}
onClick={() => void saveRemark()}
>
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
);
}
+156 -1
View File
@@ -278,6 +278,22 @@ html {
.partner-btn-primary:active { transform: scale(0.98); } .partner-btn-primary:active { transform: scale(0.98); }
.partner-btn-secondary {
width: 100%;
padding: 16px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
color: var(--color-on-surface);
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.partner-btn-ghost { .partner-btn-ghost {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
@@ -1344,7 +1360,7 @@ nav.app-tabbar {
nav.app-tabbar .app-tabbar-item { nav.app-tabbar .app-tabbar-item {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
max-width: 33.333%; max-width: 25%;
height: 56px; height: 56px;
padding: 6px 0 4px; padding: 6px 0 4px;
border-radius: 0; border-radius: 0;
@@ -3932,3 +3948,142 @@ header:has(> .app-page-title:only-child),
margin-left: var(--space-page); margin-left: var(--space-page);
margin-right: var(--space-page); margin-right: var(--space-page);
} }
.partner-users-page {
padding-bottom: 96px;
}
.partner-remark-mask {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
}
.partner-remark-sheet {
width: 100%;
max-width: 480px;
background: var(--color-card, #fff);
border-radius: 16px 16px 0 0;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.partner-activity-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.partner-activity-chip {
flex: 0 0 92px;
border: 1px solid var(--color-outline-variant, #eee);
border-radius: 12px;
background: #fff;
padding: 6px;
text-align: left;
cursor: pointer;
}
.partner-activity-chip img {
width: 100%;
height: 92px;
object-fit: cover;
border-radius: 8px;
display: block;
background: #f5f5f5;
}
.partner-activity-chip span {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-activity-chip.is-active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px var(--color-heritage-red);
}
.partner-activity-preview {
position: relative;
background: #f5f5f5;
}
.partner-activity-preview img {
width: 100%;
display: block;
}
.partner-activity-preview-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.62);
color: var(--color-on-surface-variant, #666);
font-size: 14px;
}
.partner-activity-center-card {
flex: 0 0 120px;
text-decoration: none;
color: inherit;
}
.partner-activity-center-card img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 12px;
display: block;
background: #f5f5f5;
}
.partner-activity-center-card span {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-activity-radio-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
}
.partner-activity-radio-row input[type='radio'] {
width: 20px;
height: 20px;
flex-shrink: 0;
accent-color: var(--color-heritage-red);
}
.partner-activity-panel {
padding: 0 16px 16px;
}
.partner-activity-preview--qr {
display: flex;
justify-content: center;
background: #fff;
margin-bottom: 12px;
}
.partner-activity-preview--qr img {
width: 200px;
height: 200px;
}
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>门店管理中心</title> <title>门店管理中心</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "3.5.10", "version": "3.5.16",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+2 -1
View File
@@ -26,6 +26,7 @@ export default defineAppConfig({
{ root: 'pages/login', pages: ['index'] }, { root: 'pages/login', pages: ['index'] },
{ root: 'pages/user-agreement', pages: ['index'] }, { root: 'pages/user-agreement', pages: ['index'] },
{ root: 'pages/privacy-policy', pages: ['index'] }, { root: 'pages/privacy-policy', pages: ['index'] },
{ root: 'pages/benefit-rules', pages: ['index'] },
{ root: 'pages/invoice-titles', pages: ['index'] }, { root: 'pages/invoice-titles', pages: ['index'] },
{ root: 'pages/invoice-apply', pages: ['index'] }, { root: 'pages/invoice-apply', pages: ['index'] },
], ],
@@ -75,7 +76,7 @@ export default defineAppConfig({
pagePath: 'pages/benefit/index', pagePath: 'pages/benefit/index',
text: '好客权益', text: '好客权益',
iconPath: 'assets/tabbar/benefit.png', iconPath: 'assets/tabbar/benefit.png',
selectedIconPath: 'assets/tabbar/benefit-active.png', selectedIconPath: 'assets/icons/store-benefit-y.png',
}, },
{ {
pagePath: 'pages/mine/index', pagePath: 'pages/mine/index',
+2
View File
@@ -6,6 +6,8 @@
@import './styles/benefit.css'; @import './styles/benefit.css';
@import './styles/mine.css'; @import './styles/mine.css';
@import './styles/address.css'; @import './styles/address.css';
@import './styles/benefit-promo.css';
@import './components/HomeSplash.css';
page, page,
body { body {
+2
View File
@@ -4,12 +4,14 @@ import Taro, { useDidShow } from '@tarojs/taro';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error'; import { installClientErrorReporting } from './lib/client-error';
import { prefetchShareBrandAssets } from './lib/wechat-share'; import { prefetchShareBrandAssets } from './lib/wechat-share';
import { prefetchHomeSplashAssets } from './lib/home-splash';
import { capturePromoSceneAndTouchScan } from './lib/promo'; import { capturePromoSceneAndTouchScan } from './lib/promo';
import { initClientVersionChecks } from './lib/client-version'; import { initClientVersionChecks } from './lib/client-version';
import './app.css'; import './app.css';
installClientErrorReporting(); installClientErrorReporting();
prefetchShareBrandAssets(); prefetchShareBrandAssets();
prefetchHomeSplashAssets();
function App({ children }: PropsWithChildren) { function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false); const handlingRef = useRef(false);
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

@@ -1,5 +1,5 @@
import { Image, Text, View } from '@tarojs/components'; import { Image, Text, View } from '@tarojs/components';
import iconStoreBenefit from '../assets/icons/store-benefit.png'; import iconStoreBenefit from '../assets/icons/store-benefit-y.png';
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl'; type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -0,0 +1,53 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import {
BENEFIT_INTRO,
BENEFIT_INTRO_EMPHASIS,
BENEFIT_RULES_PATH,
BENEFIT_SLOGAN,
} from '../lib/benefit-copy';
type BenefitIntroCardProps = {
showLink?: boolean;
className?: string;
/** 左侧金色粗条(门店详情等强调卡) */
accent?: boolean;
};
export default function BenefitIntroCard({
showLink = false,
className,
accent = false,
}: BenefitIntroCardProps) {
const prefix = BENEFIT_INTRO.endsWith(BENEFIT_INTRO_EMPHASIS)
? BENEFIT_INTRO.slice(0, -BENEFIT_INTRO_EMPHASIS.length)
: BENEFIT_INTRO;
function goRules() {
Taro.navigateTo({ url: BENEFIT_RULES_PATH });
}
const inner = (
<>
<Text className="benefit-intro-card-title">{BENEFIT_SLOGAN}</Text>
<Text className="benefit-intro-card-body">
{prefix}
<Text className="benefit-intro-card-em">{BENEFIT_INTRO_EMPHASIS}</Text>
</Text>
{showLink ? (
<Text className="benefit-intro-card-link" onClick={goRules}>
</Text>
) : null}
</>
);
return (
<View
className={`benefit-intro-card${accent ? ' benefit-intro-card--accent' : ''}${className ? ` ${className}` : ''}`}
>
{accent ? <View className="benefit-intro-card-bar" /> : null}
{accent ? <View className="benefit-intro-card-main">{inner}</View> : inner}
</View>
);
}
@@ -0,0 +1,15 @@
import { View, Text } from '@tarojs/components';
import { BENEFIT_SLOGAN } from '../lib/benefit-copy';
type BenefitSloganBarProps = {
className?: string;
};
export default function BenefitSloganBar({ className }: BenefitSloganBarProps) {
return (
<View className={`benefit-slogan-bar${className ? ` ${className}` : ''}`}>
<View className="benefit-slogan-bar-accent" />
<Text className="benefit-slogan-bar-text">{BENEFIT_SLOGAN}</Text>
</View>
);
}
@@ -24,7 +24,7 @@ export const EMPTY_CATEGORY: CategorySelection = {
export function formatCategoryLabel(sel: CategorySelection): string { export function formatCategoryLabel(sel: CategorySelection): string {
if (sel.childName) return sel.childName; if (sel.childName) return sel.childName;
if (sel.parentName) return sel.parentName; if (sel.parentName) return sel.parentName;
return '全部分类'; return '全部菜系';
} }
type CategoryPickerProps = { type CategoryPickerProps = {
@@ -0,0 +1,272 @@
/* 首页开场:半透明底 + 酒瓶 + 分流光 + 文字渐显 */
.home-splash {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 10010;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
pointer-events: auto;
}
.home-splash--out {
animation: home-splash-out 0.8s ease-in forwards;
pointer-events: none;
}
.home-splash-veil {
position: absolute;
z-index: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(20, 8, 8, 0.58);
background-image: radial-gradient(
ellipse at 50% 48%,
rgba(166, 29, 36, 0.42) 0%,
rgba(20, 8, 8, 0.62) 62%,
rgba(10, 4, 4, 0.72) 100%
);
opacity: 0;
animation: home-splash-fade 0.7s ease-out forwards;
}
.home-splash-copy {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 8px;
}
.home-splash-title {
font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif;
font-size: 40px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.22em;
color: #f5d76e;
text-shadow: 0 0 14px rgba(255, 191, 0, 0.55), 0 2px 10px rgba(20, 8, 8, 0.45);
opacity: 0;
animation: home-splash-fade 1.1s 0.65s ease-out forwards;
}
.home-splash-sub {
margin-top: 10px;
font-size: 13px;
letter-spacing: 0.16em;
color: rgba(255, 248, 210, 0.88);
opacity: 0;
animation: home-splash-fade 1.1s 1.35s ease-out forwards;
}
.home-splash-stage {
position: relative;
z-index: 2;
width: 220px;
height: 420px;
overflow: hidden;
opacity: 0;
animation: home-splash-fade 1s 0.2s ease-out forwards;
}
.home-splash-glow {
position: absolute;
z-index: 0;
left: 18px;
right: 18px;
top: 18%;
bottom: 8%;
border-radius: 50%;
background: radial-gradient(ellipse at 50% 50%, rgba(255, 191, 0, 0.34) 0%, rgba(255, 191, 0, 0) 72%);
opacity: 0;
animation: home-splash-glow 2.6s 0.8s ease-in-out infinite;
}
.home-splash-beam {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: 50%;
width: 46px;
margin-left: -23px;
background: linear-gradient(
180deg,
rgba(255, 248, 210, 0) 0%,
rgba(255, 248, 210, 0.16) 28%,
rgba(255, 191, 0, 0.22) 50%,
rgba(255, 248, 210, 0.1) 78%,
rgba(255, 248, 210, 0) 100%
);
opacity: 0;
animation: home-splash-beam 3.2s 1.1s ease-in-out infinite;
}
.home-splash-bottle {
position: relative;
z-index: 2;
display: block;
width: 220px;
height: 420px;
}
.home-splash-bottle img {
width: 100%;
height: 100%;
object-fit: contain;
}
.home-splash-lights {
position: absolute;
z-index: 3;
top: 0;
left: 0;
width: 220px;
height: 420px;
overflow: hidden;
pointer-events: none;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-mode: alpha;
}
.home-splash-sheen {
position: absolute;
z-index: 3;
top: -12%;
bottom: -12%;
pointer-events: none;
opacity: 0;
}
.home-splash-sheen--a {
width: 48px;
left: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 248, 210, 0.08) 28%,
rgba(255, 248, 210, 0.55) 50%,
rgba(255, 191, 0, 0.18) 72%,
rgba(255, 255, 255, 0) 100%
);
transform: translateX(-70px) skewX(-22deg);
animation: home-splash-sheen-a 2.8s 1.4s ease-in-out infinite;
}
.home-splash-sheen--b {
width: 22px;
left: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.42) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: translateX(-50px) skewX(-18deg);
animation: home-splash-sheen-b 3.6s 2.3s ease-in-out infinite;
}
.home-splash-skip {
position: absolute;
top: 16px;
right: 16px;
z-index: 4;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid rgba(245, 215, 110, 0.45);
background: rgba(20, 8, 8, 0.35);
opacity: 0;
animation: home-splash-fade 0.6s 0.35s ease-out forwards;
}
.home-splash-skip-text {
color: rgba(255, 248, 210, 0.92);
font-size: 12px;
line-height: 1.4;
letter-spacing: 0.08em;
}
@keyframes home-splash-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes home-splash-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes home-splash-glow {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 0.85;
}
}
@keyframes home-splash-beam {
0%,
100% {
opacity: 0;
}
40% {
opacity: 0.7;
}
70% {
opacity: 0.15;
}
}
@keyframes home-splash-sheen-a {
0% {
opacity: 0;
transform: translateX(-70px) skewX(-22deg);
}
18% {
opacity: 1;
}
82% {
opacity: 0.7;
}
100% {
opacity: 0;
transform: translateX(250px) skewX(-22deg);
}
}
@keyframes home-splash-sheen-b {
0% {
opacity: 0;
transform: translateX(-40px) skewX(-18deg);
}
22% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translateX(240px) skewX(-18deg);
}
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Image, Text, View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { HOME_SPLASH_BOTTLE_URL } from '@dukang/shared-types';
import { markHomeSplashPlayed } from '../lib/home-splash';
const SPLASH_NAV_BG = '#140808';
const HOME_NAV_BG = '#FAF9F7';
const FADE_AT_MS = 4800;
const FADE_MS = 800;
type HomeSplashProps = {
onDone: () => void;
};
function applySplashChrome() {
try {
void Taro.hideTabBar({ animation: false });
} catch {
/* H5 无原生 TabBar */
}
void Taro.setNavigationBarColor({
frontColor: '#ffffff',
backgroundColor: SPLASH_NAV_BG,
animation: { duration: 200, timingFunc: 'easeIn' },
}).catch(() => {});
}
function restoreChrome() {
try {
void Taro.showTabBar({ animation: false });
} catch {
/* H5 无原生 TabBar */
}
void Taro.setNavigationBarColor({
frontColor: '#000000',
backgroundColor: HOME_NAV_BG,
animation: { duration: 200, timingFunc: 'easeOut' },
}).catch(() => {});
}
export default function HomeSplash({ onDone }: HomeSplashProps) {
const finishedRef = useRef(false);
const fadingRef = useRef(false);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const [leaving, setLeaving] = useState(false);
const finish = useCallback(() => {
if (finishedRef.current) return;
finishedRef.current = true;
restoreChrome();
onDoneRef.current();
}, []);
const beginExit = useCallback(() => {
if (fadingRef.current || finishedRef.current) return;
fadingRef.current = true;
setLeaving(true);
setTimeout(finish, FADE_MS);
}, [finish]);
useEffect(() => {
markHomeSplashPlayed();
applySplashChrome();
const timer = setTimeout(beginExit, FADE_AT_MS);
return () => {
clearTimeout(timer);
if (!finishedRef.current) restoreChrome();
};
}, [beginExit]);
return (
<View
className={`home-splash${leaving ? ' home-splash--out' : ''}`}
catchMove
onTouchMove={(e) => {
e.stopPropagation?.();
}}
>
<View className="home-splash-veil" />
<View className="home-splash-copy">
<Text className="home-splash-title"></Text>
<Text className="home-splash-sub"></Text>
</View>
<View className="home-splash-stage">
<View className="home-splash-glow" />
<Image
className="home-splash-bottle"
src={HOME_SPLASH_BOTTLE_URL}
mode="aspectFit"
style={{ width: '220px', height: '420px' }}
/>
<View
className="home-splash-lights"
style={{
width: '220px',
height: '420px',
WebkitMaskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
maskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
}}
>
<View className="home-splash-beam" />
<View className="home-splash-sheen home-splash-sheen--a" />
<View className="home-splash-sheen home-splash-sheen--b" />
</View>
</View>
<View className="home-splash-skip" onClick={finish}>
<Text className="home-splash-skip-text"></Text>
</View>
</View>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { View, Text, Input } from '@tarojs/components';
type OrderQtyControlsProps = {
value: number;
unitLabel: string;
onChange: (next: number) => void;
};
const MAX_QTY = 999;
function parseQty(raw: string): number | null {
const n = parseInt(String(raw).replace(/\D/g, ''), 10);
if (!Number.isFinite(n)) return null;
return Math.min(MAX_QTY, Math.max(1, n));
}
/** 下单数量:加减 + 手动输入,旁注单位(瓶/箱) */
export default function OrderQtyControls({ value, unitLabel, onChange }: OrderQtyControlsProps) {
const [draft, setDraft] = useState(String(value));
useEffect(() => {
setDraft(String(value));
}, [value]);
function current(): number {
return parseQty(draft) ?? value;
}
function commit() {
const next = parseQty(draft);
if (next == null) {
setDraft(String(value));
return;
}
setDraft(String(next));
if (next !== value) onChange(next);
}
return (
<View className="order-qty-row">
<Text></Text>
<View className="order-qty-controls">
<View className="order-qty-btn" onClick={() => onChange(Math.max(1, current() - 1))}>
<Text></Text>
</View>
<Input
className="order-qty-input"
type="number"
maxlength={3}
value={draft}
onInput={(e) => setDraft(String(e.detail.value ?? ''))}
onBlur={commit}
onConfirm={commit}
/>
<Text className="order-qty-unit">{unitLabel}</Text>
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
<Text></Text>
</View>
</View>
</View>
);
}
@@ -1,215 +1,67 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useMemo } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import Taro from '@tarojs/taro';
export type StoreRedeemMarqueeItem = {
userLabel: string;
amount: string;
};
type StoreRedeemMarqueeProps = { type StoreRedeemMarqueeProps = {
lines: string[]; items: StoreRedeemMarqueeItem[];
}; };
const FLY_SPEED = 56; const STAY_MS = 20000;
const MIN_FLY_MS = 2400;
const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000;
const TICK_MS = 16;
/** 全文滚出视口后,再向左多走 10px */
const EXTRA_AFTER_EXIT_PX = 10;
function estimateTextWidth(text: string): number { function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
let w = 0; return (
for (const ch of text) { <View className="store-detail-marquee-inner">
w += /[^\x00-\xff]/.test(ch) ? 12 : 7; <View className="store-detail-marquee-dot" />
} <Text className="store-detail-marquee-text">{item.userLabel} </Text>
return Math.max(Math.ceil(w), 80); <Text className="store-detail-marquee-amount">{item.amount}</Text>
</View>
);
} }
function randomPauseMs() { /** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1)); export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
} const list = useMemo(
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
function getBoxWidthFallback(): number {
try {
const sys = Taro.getSystemInfoSync();
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
// 与 section 同宽:左右 var(--space-page)
return Math.max(220, Math.floor(screenW - 32));
} catch {
return 300;
}
}
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
resolve(w > 8 ? Math.ceil(w) : fallback);
});
} catch {
resolve(fallback);
}
});
});
}
function measureTextWidth(selector: string, text: string): Promise<number> {
const fallback = estimateTextWidth(text);
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
}
/**
* 15
*
*
* - useReady opacity 0
* - Text + transform View + left
* -
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const items = useMemo(
() => () =>
lines items
.map((s) => String(s || '').trim()) .map((row) => ({
.filter(Boolean), userLabel: String(row.userLabel || '用户***').trim() || '用户***',
[lines], amount: String(row.amount || '').trim(),
}))
.filter((row) => row.amount),
[items],
); );
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`); if (!list.length) return null;
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
const indexRef = useRef(0);
const boxWidthRef = useRef(getBoxWidthFallback());
const itemsKey = items.join('\n');
const [displayIndex, setDisplayIndex] = useState(0); if (list.length === 1) {
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current); return (
<View className="store-detail-marquee">
useEffect(() => { <MarqueeRow item={list[0]} />
if (!items.length) return; </View>
);
let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>();
let tickTimer: ReturnType<typeof setInterval> | undefined;
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const id = setTimeout(() => {
waiters.delete(id);
resolve();
}, ms);
waiters.add(id);
});
const clearTick = () => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
} }
};
const fly = (from: number, to: number, durationMs: number) =>
new Promise<void>((resolve) => {
const began = Date.now();
setLeftPx(from);
clearTick();
tickTimer = setInterval(() => {
if (cancelled) {
clearTick();
resolve();
return;
}
const t = Math.min(1, (Date.now() - began) / durationMs);
setLeftPx(from + (to - from) * t);
if (t >= 1) {
clearTick();
resolve();
}
}, TICK_MS);
});
const loop = async () => {
indexRef.current = 0;
setDisplayIndex(0);
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
boxWidthRef.current = measured;
if (cancelled) return;
while (!cancelled && items.length) {
const idx = indexRef.current % items.length;
const text = items[idx];
const box = boxWidthRef.current;
setDisplayIndex(idx);
const from = box;
setLeftPx(from);
await sleep(48);
if (cancelled) break;
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
// 全文 left 边缘移出容器左边界后再走 10px
const to = -(textW + EXTRA_AFTER_EXIT_PX);
const distance = from - to;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await sleep(32);
if (cancelled) break;
await fly(from, to, durationMs);
if (cancelled) break;
await sleep(randomPauseMs());
if (cancelled) break;
indexRef.current = (idx + 1) % items.length;
}
};
void loop();
return () => {
cancelled = true;
clearTick();
waiters.forEach(clearTimeout);
waiters.clear();
};
}, [itemsKey, items]);
if (!items.length) return null;
const current = items[displayIndex] || items[0];
const innerStyle: CSSProperties = { left: `${leftPx}px` };
return ( return (
<View id={rootIdRef.current} className="store-detail-marquee"> <View className="store-detail-marquee">
<View className="store-detail-marquee-inner" style={innerStyle}> <Swiper
<Text id={textIdRef.current} className="store-detail-marquee-text"> className="store-detail-marquee-swiper"
{current} vertical
</Text> circular
</View> autoplay
interval={STAY_MS}
duration={400}
indicatorDots={false}
>
{list.map((row, index) => (
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
<MarqueeRow item={row} />
</SwiperItem>
))}
</Swiper>
</View> </View>
); );
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import iconHomeActive from '../assets/tabbar/home-active.png';
import iconStore from '../assets/tabbar/store.png'; import iconStore from '../assets/tabbar/store.png';
import iconStoreActive from '../assets/tabbar/store-active.png'; import iconStoreActive from '../assets/tabbar/store-active.png';
import iconBenefit from '../assets/tabbar/benefit.png'; import iconBenefit from '../assets/tabbar/benefit.png';
import iconBenefitActive from '../assets/tabbar/benefit-active.png'; import iconBenefitActive from '../assets/icons/store-benefit-y.png';
import iconMine from '../assets/tabbar/mine.png'; import iconMine from '../assets/tabbar/mine.png';
import iconMineActive from '../assets/tabbar/mine-active.png'; import iconMineActive from '../assets/tabbar/mine-active.png';
+68
View File
@@ -0,0 +1,68 @@
export const BENEFIT_SLOGAN = '购杜康好酒,赠用餐权益';
export const BENEFIT_INTRO =
'购杜康好酒,赠用餐权益,到签约饭店核销。权益随酒赠送,仅限签约饭店到店用餐,不可兑现、不可转卖。';
export const BENEFIT_INTRO_EMPHASIS = '不可兑现、不可转卖。';
export const BENEFIT_GIFT_TAG = '买酒即赠用餐权益';
export const BENEFIT_TAG = '好客权益';
export const BENEFIT_RULES_PATH = '/pages/benefit-rules/index';
export const BENEFIT_RULES_TITLE = '好客权益使用说明';
export const BENEFIT_RULES_SUMMARY =
'购杜康好酒,赠用餐权益,到签约饭店核销。权益随酒赠送,仅限杜康好客平台签约饭店到店用餐,不可兑现、不可转卖。';
export const BENEFIT_RULES_SECTIONS = [
{
heading: '一、权益从哪来',
paragraphs: [
'好客权益是购买杜康好酒时随酒赠送的用餐权益,用于在签约饭店到店用餐,不是储值卡、预付卡,也不是现金账户。',
],
},
{
heading: '二、如何获得',
paragraphs: [
'在小程序内购买杜康好酒并支付成功后,系统按商品说明发放对应用餐权益。未支付或已取消的订单不产生权益。不支持单独购买或充值权益。',
],
},
{
heading: '三、如何使用',
paragraphs: [
'1. 在「门店」中选择签约饭店,到店用餐时出示核销码或提供手机号,由门店完成核销。',
'2. 核销码有效期为 3 分钟,过期请重新生成。',
'3. 权益可按实际消费分次核销,累计核销金额不可超过已获得的权益总额。',
],
},
{
heading: '四、使用范围',
paragraphs: [
'仅限杜康好客平台签约饭店到店用餐使用。香烟、酒水一律不可核销。其他菜品是否可核销以门店当场说明为准。',
],
emphasize: '香烟、酒水一律不可核销。',
},
{
heading: '五、使用限制',
paragraphs: ['不可兑现、不可转卖、不可提现、不可充值,不可当现金使用。'],
emphasizeAll: true,
},
{
heading: '六、退货与权益收回',
paragraphs: [
'酒水退货退款时,未使用的权益将收回;已核销部分不退回。酒款按原支付路径退回。仅换货不退款的,一般保留原权益。',
],
},
{
heading: '七、有效期',
paragraphs: ['好客权益暂无使用期限,持续有效,直至收回或全部核销完毕。'],
},
{
heading: '八、联系客服',
paragraphs: ['如有疑问,可通过小程序「联系客服」咨询,或拨打客服电话。'],
},
] as const;
export const BENEFIT_RULES_FOOTER = '请理性饮酒。未满十八周岁不得饮酒。过量饮酒有害健康。';
+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat'; import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */ /** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.5.10'; export const APP_VERSION = '3.5.16';
export const APP_VERSION_LABEL = `v${APP_VERSION}`; export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+21
View File
@@ -0,0 +1,21 @@
import Taro from '@tarojs/taro';
import { HOME_SPLASH_BOTTLE_URL } from '@dukang/shared-types';
/** 冷启动会话内是否已播过首页开场(进程级,切 Tab 不重播) */
let played = false;
export function hasHomeSplashPlayed() {
return played;
}
export function markHomeSplashPlayed() {
played = true;
}
/** 冷启动预拉 OSS 开场图(仅 weappH5 的 getImageInfo 会走 CORS */
export function prefetchHomeSplashAssets() {
if (played) return;
if (process.env.TARO_ENV !== 'weapp') return;
void Taro.getImageInfo({ src: HOME_SPLASH_BOTTLE_URL }).catch(() => {});
}
+74 -1
View File
@@ -1,7 +1,8 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { request } from './api'; import { request, isLoggedIn, toast } from './api';
const PROMO_ID_KEY = 'dukang_promo_id'; const PROMO_ID_KEY = 'dukang_promo_id';
const ASSOC_SCENE_KEY = 'dukang_partner_assoc_scene';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */ /** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = ''; let lastScanTouchKey = '';
@@ -28,10 +29,54 @@ type EnterOptionsLike = {
path?: string; path?: string;
}; };
function normalizeAssocScene(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
return /^pa_\d+$/.test(s) ? s : null;
}
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizeAssocScene(q.scene) ||
normalizeAssocScene(opts.scene) ||
normalizeAssocScene(q.partnerId) ||
null
);
}
export function getStoredAssocScene(): string | null {
try {
return normalizeAssocScene(Taro.getStorageSync(ASSOC_SCENE_KEY));
} catch {
return null;
}
}
export function setStoredAssocScene(scene: string) {
const id = normalizeAssocScene(scene);
if (!id) return;
try {
Taro.setStorageSync(ASSOC_SCENE_KEY, id);
} catch {
/* ignore */
}
}
export function clearStoredAssocScene() {
try {
Taro.removeStorageSync(ASSOC_SCENE_KEY);
} catch {
/* ignore */
}
}
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */ /** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null { export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null; if (!opts) return null;
const q = opts.query ?? {}; const q = opts.query ?? {};
if (extractAssocSceneFromEnterOptions(opts)) return null;
return ( return (
normalizePromoId(q.scene) || normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) || normalizePromoId(q.promoId) ||
@@ -92,8 +137,35 @@ function readEnterOptions(): EnterOptionsLike | null {
* scene ID /promo/touch * scene ID /promo/touch
* *
*/ */
async function bindStoredAssocIfLoggedIn(): Promise<void> {
const scene = getStoredAssocScene();
if (!scene || !isLoggedIn()) return;
try {
const result = await request<{ alreadyBound?: boolean; bound?: boolean }>('/user/partner-assoc/bind', {
method: 'POST',
data: { scene },
});
clearStoredAssocScene();
if (result.alreadyBound) toast('已关联');
else if (result.bound) toast('关联成功', 'success');
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('已关联')) {
clearStoredAssocScene();
toast('已关联');
}
}
}
export async function capturePromoSceneAndTouchScan(): Promise<void> { export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions(); const opts = readEnterOptions();
const assocScene = extractAssocSceneFromEnterOptions(opts);
if (assocScene) {
setStoredAssocScene(assocScene);
await bindStoredAssocIfLoggedIn();
return;
}
const fromEnter = extractPromoIdFromEnterOptions(opts); const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) { if (fromEnter) {
setStoredPromoId(fromEnter); setStoredPromoId(fromEnter);
@@ -109,6 +181,7 @@ export async function capturePromoSceneAndTouchScan(): Promise<void> {
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */ /** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> { export async function touchStoredPromoAfterLogin(): Promise<void> {
await bindStoredAssocIfLoggedIn();
const promoId = getStoredPromoId(); const promoId = getStoredPromoId();
if (!promoId) return; if (!promoId) return;
await touchPromo({ promoId, countScan: false }); await touchPromo({ promoId, countScan: false });
+53
View File
@@ -0,0 +1,53 @@
export type StoreCategoryLike = {
id?: string;
name?: string;
parentId?: string | null;
parent?: { name?: string } | null;
};
export type StoreCategoryTreeNode = {
id: string;
name: string;
children?: { id: string; name: string }[];
};
export function storeStarCount(rating?: number | string | null): number {
const n = Number(rating);
if (!Number.isFinite(n) || n <= 0) return 5;
return Math.min(5, Math.max(1, Math.round(n)));
}
export function storeCategoryTags(
store: {
tags?: unknown;
categoryId?: string | null;
category?: StoreCategoryLike | null;
},
tree: StoreCategoryTreeNode[] = [],
): string[] {
const fromJson = Array.isArray(store.tags)
? store.tags.map((t) => String(t).trim()).filter(Boolean)
: [];
if (fromJson.length) return fromJson;
const names: string[] = [];
const childName = String(store.category?.name || '').trim();
const parentName = String(store.category?.parent?.name || '').trim();
if (parentName) names.push(parentName);
if (childName && childName !== parentName) names.push(childName);
const storeCatId = String(store.categoryId || store.category?.id || '');
const storeParentId = String(store.category?.parentId || '');
for (const root of tree) {
if (root.id === storeParentId || root.id === storeCatId) {
if (root.name && !names.includes(root.name)) names.unshift(root.name);
}
for (const child of root.children ?? []) {
if (child.id === storeCatId) {
if (root.name && !names.includes(root.name)) names.unshift(root.name);
if (child.name && !names.includes(child.name)) names.push(child.name);
}
}
}
return names;
}
+7 -2
View File
@@ -19,6 +19,8 @@ export type StoresSessionCategory = {
childName: string; childName: string;
}; };
export type StoreSortKey = 'nearby' | 'rating' | 'redeem';
export type StoresListCache = { export type StoresListCache = {
cityKey: string; cityKey: string;
cityCode: string; cityCode: string;
@@ -30,6 +32,7 @@ export type StoresListCache = {
keyword: string; keyword: string;
keywordInput: string; keywordInput: string;
category: StoresSessionCategory; category: StoresSessionCategory;
sort?: StoreSortKey;
}; };
type StoresSession = { type StoresSession = {
@@ -37,7 +40,7 @@ type StoresSession = {
cache: StoresListCache | null; cache: StoresListCache | null;
}; };
const STORAGE_KEY = 'dukang_stores_session_v1'; const STORAGE_KEY = 'dukang_stores_session_v2';
let memory: StoresSession | null = null; let memory: StoresSession | null = null;
@@ -94,7 +97,7 @@ export function setStoresListCache(cache: StoresListCache | null): void {
export function patchStoresFilterCache( export function patchStoresFilterCache(
patch: Partial< patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'> Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category' | 'sort'>
>, >,
): void { ): void {
const cur = readSession(); const cur = readSession();
@@ -105,6 +108,7 @@ export function patchStoresFilterCache(
cache: { cache: {
cityKey: '', cityKey: '',
cityCode: '', cityCode: '',
authKey: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' }, listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [], items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' }, filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
@@ -116,6 +120,7 @@ export function patchStoresFilterCache(
childId: '', childId: '',
childName: '', childName: '',
}, },
sort: patch.sort ?? 'nearby',
}, },
}); });
return; return;
@@ -0,0 +1,59 @@
import Taro from '@tarojs/taro';
import { STORE_RATING_MAX_IMAGES } from '@dukang/shared-types';
export async function chooseAndUploadRatingImages(already: number): Promise<string[]> {
const remain = STORE_RATING_MAX_IMAGES - already;
if (remain <= 0) {
throw new Error(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
}
const picked = await Taro.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
});
const paths = picked.tempFilePaths || [];
if (!paths.length) return [];
const urls: string[] = [];
for (const path of paths) {
urls.push(await uploadRatingImage(path));
}
return urls;
}
export async function uploadRatingImage(tempFilePath: string): Promise<string> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const { compressWeappImageIfNeeded } = await import('./compress-image');
const token = getToken();
if (!token) throw new Error('请先登录');
const filePath = await compressWeappImageIfNeeded(tempFilePath);
const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`,
filePath,
name: 'file',
formData: {
bizType: 'STORE_RATING',
mediaType: 'IMAGE',
},
header: {
Authorization: `Bearer ${token}`,
'X-Client-App': CLIENT_APP,
},
});
let body: { code?: number; message?: string; data?: { url?: string } } = {};
try {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
throw new Error('图片上传响应异常');
}
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) {
throw new Error(body.message || '图片上传失败');
}
return body.data.url;
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '好客权益使用说明',
});
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import '../../styles/legal.css';
import Taro from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { toast } from '../../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
import {
BENEFIT_RULES_FOOTER,
BENEFIT_RULES_SECTIONS,
BENEFIT_RULES_SUMMARY,
BENEFIT_RULES_TITLE,
} from '../../lib/benefit-copy';
export default function BenefitRulesPage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
useEffect(() => {
void loadBrandAssets().then((brand) => setPhone(brand.customerServicePhone));
}, []);
function dial() {
const tel = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
}
return (
<PageShell variant="sub" className="legal-page benefit-rules-page">
<SubPageHeader title={BENEFIT_RULES_TITLE} />
<View className="sub-page-body inset-page legal-body">
<View className="benefit-rules-summary">
<Text className="benefit-rules-summary-text">
{BENEFIT_RULES_SUMMARY.replace(/不可兑现、不可转卖。$/, '')}
<Text className="benefit-rules-em"></Text>
</Text>
</View>
{BENEFIT_RULES_SECTIONS.map((section) => (
<View key={section.heading} className="benefit-rules-section">
<View className="benefit-rules-heading-row">
<View className="benefit-rules-heading-bar" />
<Text className="benefit-rules-heading">{section.heading}</Text>
</View>
{section.paragraphs.map((p, i) => {
const emphasize = 'emphasize' in section ? section.emphasize : '';
const emphasizeAll = 'emphasizeAll' in section && section.emphasizeAll;
if (emphasizeAll) {
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph benefit-rules-em">
{p}
</Text>
);
}
if (emphasize && p.includes(emphasize)) {
const [before, after] = p.split(emphasize);
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
{before}
<Text className="benefit-rules-em">{emphasize}</Text>
{after}
</Text>
);
}
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
{p}
</Text>
);
})}
{section.heading === '八、联系客服' ? (
<Text className="benefit-rules-phone" onClick={dial}>
{phone}
</Text>
) : null}
</View>
))}
<Text className="benefit-rules-footer">{BENEFIT_RULES_FOOTER}</Text>
</View>
</PageShell>
);
}
+56 -24
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
@@ -7,14 +7,16 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
import BenefitFigure from '../../components/BenefitFigure'; import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar'; import { navBarStyle, useNavBarMetrics } from '../../lib/nav-bar';
import { import {
buildSceneSharePayload, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline, toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import { formatMoney } from '../../lib/money'; import { formatMoney } from '../../lib/money';
import iconBenefit from '../../assets/tabbar/benefit-active.png'; import { BENEFIT_SLOGAN } from '../../lib/benefit-copy';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import type { StoreRatingDto } from '@dukang/shared-types';
type BenefitSummary = { type BenefitSummary = {
totalBalance: number; totalBalance: number;
@@ -36,10 +38,30 @@ type RedeemHistoryItem = {
id: string; id: string;
redeemNo: string; redeemNo: string;
amount: number; amount: number;
storeId?: string;
storeName: string; storeName: string;
createdAt: string; createdAt: string;
rating?: StoreRatingDto | null;
}; };
const BENEFIT_TAB_KEY = 'dukang_benefit_tab';
function readBenefitTab(): 'available' | 'history' {
try {
return Taro.getStorageSync(BENEFIT_TAB_KEY) === 'history' ? 'history' : 'available';
} catch {
return 'available';
}
}
function writeBenefitTab(next: 'available' | 'history') {
try {
Taro.setStorageSync(BENEFIT_TAB_KEY, next);
} catch {
/* ignore */
}
}
function usagePercent(coupon: CouponItem) { function usagePercent(coupon: CouponItem) {
const total = Number(coupon.totalAmount); const total = Number(coupon.totalAmount);
if (total <= 0) return 0; if (total <= 0) return 0;
@@ -52,7 +74,7 @@ export default function BenefitPage() {
const [summary, setSummary] = useState<BenefitSummary | null>(null); const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [coupons, setCoupons] = useState<CouponItem[]>([]); const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]); const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
const [tab, setTab] = useState<'available' | 'history'>('available'); const [tab, setTab] = useState<'available' | 'history'>(readBenefitTab);
const resetGuestState = useCallback(() => { const resetGuestState = useCallback(() => {
setSummary(null); setSummary(null);
@@ -119,18 +141,7 @@ export default function BenefitPage() {
<PageShell variant="tab" className="benefit-page"> <PageShell variant="tab" className="benefit-page">
<WechatShareReady payload={sharePayload} /> <WechatShareReady payload={sharePayload} />
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益"> <View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
{process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text> <Text className="benefit-header-title"></Text>
) : null}
<View
className="benefit-header__content"
style={tabNavContentStyle(metrics)}
>
<View className="benefit-header-city">
<View className="benefit-header-city-pin" />
<Text></Text>
</View>
</View>
</View> </View>
{!loggedIn ? ( {!loggedIn ? (
@@ -146,10 +157,10 @@ export default function BenefitPage() {
</View> </View>
) : ( ) : (
<View className="benefit-main"> <View className="benefit-main">
<BenefitIntroCard className="benefit-intro-card--page" />
<View className="benefit-hero"> <View className="benefit-hero">
<View className="benefit-hero-top"> <View className="benefit-hero-top">
<View> <Text className="benefit-hero-label"></Text>
<Text className="benefit-hero-label"></Text>
<View className="benefit-hero-amount"> <View className="benefit-hero-amount">
<BenefitFigure <BenefitFigure
value={summary ? formatMoney(summary.totalBalance) : '--'} value={summary ? formatMoney(summary.totalBalance) : '--'}
@@ -158,28 +169,38 @@ export default function BenefitPage() {
/> />
</View> </View>
</View> </View>
<View className="benefit-hero-logo"> <View className="benefit-hero-actions">
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" /> <View
</View> className="benefit-hero-cta benefit-hero-cta--primary"
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
>
<Text></Text>
</View> </View>
<View <View
className="benefit-hero-cta" className="benefit-hero-cta benefit-hero-cta--secondary"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })} onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
> >
<Text>使</Text> <Text>使</Text>
</View> </View>
</View> </View>
</View>
<View className="benefit-tabs"> <View className="benefit-tabs">
<Text <Text
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`} className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('available')} onClick={() => {
setTab('available');
writeBenefitTab('available');
}}
> >
</Text> </Text>
<Text <Text
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`} className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('history')} onClick={() => {
setTab('history');
writeBenefitTab('history');
}}
> >
</Text> </Text>
@@ -187,7 +208,7 @@ export default function BenefitPage() {
{tab === 'available' ? ( {tab === 'available' ? (
available.length === 0 ? ( available.length === 0 ? (
<View className="u-empty"></View> <View className="u-empty">{BENEFIT_SLOGAN}</View>
) : ( ) : (
available.map((c) => ( available.map((c) => (
<View key={c.id} className="benefit-coupon"> <View key={c.id} className="benefit-coupon">
@@ -243,6 +264,17 @@ export default function BenefitPage() {
<Text className="benefit-coupon-meta"> <Text className="benefit-coupon-meta">
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'} {r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text> </Text>
<Text
className={`benefit-coupon-btn${r.rating ? ' benefit-coupon-btn--ghost' : ''}`}
onClick={() => {
writeBenefitTab('history');
Taro.navigateTo({
url: `/pages/redeem-success/index?id=${r.id}&from=history`,
});
}}
>
{r.rating ? '已评价' : '去评价'}
</Text>
</View> </View>
</View> </View>
)) ))
+38 -3
View File
@@ -9,10 +9,13 @@ import Taro, {
} from '@tarojs/taro'; } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge'; import BenefitSloganBar from '../../components/BenefitSloganBar';
import { BENEFIT_GIFT_TAG, BENEFIT_TAG } from '../../lib/benefit-copy';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import HomeSplash from '../../components/HomeSplash';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { hasHomeSplashPlayed } from '../../lib/home-splash';
import { getToken, isLoggedIn, request, toast } from '../../lib/api'; import { getToken, isLoggedIn, request, toast } from '../../lib/api';
import { import {
getHomeCatalogCache, getHomeCatalogCache,
@@ -36,6 +39,7 @@ import {
toWeappShareTimeline, toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import { trackPageView } from '../../lib/analytics'; import { trackPageView } from '../../lib/analytics';
import iconStoreBenefit from '../../assets/icons/store-benefit-y.png';
type Product = { type Product = {
id: string; id: string;
name: string; name: string;
@@ -71,7 +75,15 @@ function aromaSectionId(key: AromaKey) {
return `aroma-section-${key}`; return `aroma-section-${key}`;
} }
/** 商品图左上角权益角标:权益额 = benefitDisplay ?? price */
function formatBenefitCorner(p: Product): string {
const n = Number(p.benefitDisplay ?? p.price);
if (!Number.isFinite(n) || n <= 0) return '';
return String(Math.round(n));
}
export default function HomePage() { export default function HomePage() {
const [showSplash, setShowSplash] = useState(() => !hasHomeSplashPlayed());
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG'); const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -282,6 +294,7 @@ export default function HomePage() {
function renderProductCard(p: Product) { function renderProductCard(p: Product) {
const thumb = getProductMainImage(p); const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || ''; const spec = p.subtitle || p.spec || '';
const benefitCorner = formatBenefitCorner(p);
return ( return (
<View key={p.id} className="home-product-card"> <View key={p.id} className="home-product-card">
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}> <View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
@@ -291,6 +304,20 @@ export default function HomePage() {
) : ( ) : (
<View className="home-product-thumb home-product-thumb--empty" /> <View className="home-product-thumb home-product-thumb--empty" />
)} )}
{benefitCorner ? (
<View className="home-benefit-ribbon-clip">
<View className="home-benefit-ribbon">
<View className="home-benefit-ribbon-dk">
<Image
className="home-benefit-ribbon-dk-icon"
src={iconStoreBenefit}
mode="aspectFit"
/>
</View>
<Text className="home-benefit-ribbon-num">{benefitCorner}</Text>
</View>
</View>
) : null}
</View> </View>
<View className="home-product-main"> <View className="home-product-main">
<View className="home-product-row"> <View className="home-product-row">
@@ -299,7 +326,10 @@ export default function HomePage() {
</View> </View>
{spec ? <Text className="home-product-sub">{spec}</Text> : null} {spec ? <Text className="home-product-sub">{spec}</Text> : null}
<View className="home-product-footer"> <View className="home-product-footer">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" /> <View className="home-product-tags">
<Text className="home-gift-tag">{BENEFIT_GIFT_TAG}</Text>
{/* <Text className="home-benefit-tag">{BENEFIT_TAG}</Text> */}
</View>
</View> </View>
<View className="home-product-actions"> <View className="home-product-actions">
{canPickupOnSite(p) ? ( {canPickupOnSite(p) ? (
@@ -354,6 +384,10 @@ export default function HomePage() {
</View> </View>
) : null} ) : null}
<View className="home-slogan-wrap">
<BenefitSloganBar />
</View>
<View className="home-aroma-nav"> <View className="home-aroma-nav">
<View className="home-aroma-tabs"> <View className="home-aroma-tabs">
{visibleAromaTabs.map((t) => ( {visibleAromaTabs.map((t) => (
@@ -393,7 +427,8 @@ export default function HomePage() {
</View> </View>
) : null} ) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null} {shouldRenderPageTabBar() && !showSplash ? <UserTabBar selected={0} /> : null}
{showSplash ? <HomeSplash onDone={() => setShowSplash(false)} /> : null}
</PageShell> </PageShell>
); );
} }
+5 -6
View File
@@ -44,6 +44,8 @@ import iconCs from '../../assets/icons/联系客服.png';
import iconQualification from '../../assets/icons/资质公示.png'; import iconQualification from '../../assets/icons/资质公示.png';
import iconAbout from '../../assets/icons/关于我们.png'; import iconAbout from '../../assets/icons/关于我们.png';
import { formatMoney } from '../../lib/money'; import { formatMoney } from '../../lib/money';
import BenefitSloganBar from '../../components/BenefitSloganBar';
import { BENEFIT_RULES_PATH } from '../../lib/benefit-copy';
const ORDER_SHORTCUTS = [ const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' }, { tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
@@ -56,7 +58,7 @@ const SERVICES = [
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' }, { icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' }, { icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const }, { icon: iconQualification, label: '资质公示', action: 'qualification' as const },
{ icon: iconAbout, label: '关于我们', action: 'about' as const }, { icon: iconAbout, label: '好客权益规则', url: BENEFIT_RULES_PATH },
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' }, { icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
] as const; ] as const;
@@ -309,10 +311,6 @@ export default function MinePage() {
} }
if ('action' in item && item.action === 'qualification') { if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true); setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
} }
} }
@@ -435,6 +433,7 @@ export default function MinePage() {
<View className="mine-main"> <View className="mine-main">
<View className="mine-card"> <View className="mine-card">
<BenefitSloganBar className="mine-benefit-slogan" />
<View className="mine-card-head"> <View className="mine-card-head">
<Text className="mine-card-title"></Text> <Text className="mine-card-title"></Text>
<Text <Text
@@ -446,7 +445,7 @@ export default function MinePage() {
</View> </View>
<View className="mine-asset-panel"> <View className="mine-asset-panel">
<View> <View>
<Text className="mine-asset-label"></Text> <Text className="mine-asset-label"></Text>
<View className="mine-asset-amount"> <View className="mine-asset-amount">
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" /> <BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
</View> </View>
@@ -11,6 +11,7 @@ import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure'; import BenefitFigure from '../../components/BenefitFigure';
import OrderQtyControls from '../../components/OrderQtyControls';
type PreviewProduct = { type PreviewProduct = {
id: string; id: string;
@@ -211,21 +212,11 @@ export default function OrderConfirmPickupPage() {
</Text> </Text>
</View> </View>
</View> </View>
<View className="order-qty-row"> <OrderQtyControls
<Text></Text> value={quantity}
<View className="order-qty-controls"> unitLabel={unitLabel}
<View onChange={updateQuantity}
className="order-qty-btn" />
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
<Text></Text>
</View>
</View>
</View>
{!quantityOk ? ( {!quantityOk ? (
<Text className="order-qty-hint"> <Text className="order-qty-hint">
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`} {`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
@@ -16,6 +16,7 @@ import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment'
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery'; import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure'; import BenefitFigure from '../../components/BenefitFigure';
import OrderQtyControls from '../../components/OrderQtyControls';
type Address = { type Address = {
id: string; id: string;
@@ -379,24 +380,11 @@ export default function OrderConfirmPage() {
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text> <Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
</View> </View>
</View> </View>
<View className="order-qty-row"> <OrderQtyControls
<Text></Text> value={quantity}
<View className="order-qty-controls"> unitLabel={unitLabel}
<View onChange={updateQuantity}
className="order-qty-btn" />
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity + 1)}
>
<Text></Text>
</View>
</View>
</View>
{!quantityOk ? ( {!quantityOk ? (
<Text className="order-qty-hint"> <Text className="order-qty-hint">
{isCross {isCross
@@ -340,7 +340,7 @@ export default function OrderDetailPage() {
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
<View className="order-row"> <View className="order-row">
<Text className="order-row-label">{productName}</Text> <Text className="order-row-label">{productName}</Text>
<Text className="order-row-value">x{quantity}</Text> <Text className="order-row-value">x{quantity}</Text>
</View> </View>
<View className="order-row"> <View className="order-row">
<Text className="order-row-label"></Text> <Text className="order-row-label"></Text>
+1 -1
View File
@@ -159,7 +159,7 @@ export default function OrdersPage() {
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
<Text className="order-list-name">{productName}</Text> <Text className="order-list-name">{productName}</Text>
<View className="order-list-meta-row"> <View className="order-list-meta-row">
<Text className="order-list-meta"> {qty}</Text> <Text className="order-list-meta"> {qty}</Text>
<Text className="order-list-meta"> ¥{unitPrice.toFixed(2)}</Text> <Text className="order-list-meta"> ¥{unitPrice.toFixed(2)}</Text>
</View> </View>
</View> </View>
+2 -2
View File
@@ -19,7 +19,7 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin'; import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import payLogo from '../../assets/logo2.png'; import { getBrandAssetsSync } from '../../lib/brand-assets';
export default function PayPage() { export default function PayPage() {
const router = useRouter(); const router = useRouter();
@@ -173,7 +173,7 @@ export default function PayPage() {
<View className="sub-page-body"> <View className="sub-page-body">
<View className="pay-status"> <View className="pay-status">
<View className="pay-status-icon"> <View className="pay-status-icon">
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" /> <Image className="pay-status-brand" src={getBrandAssetsSync().brandLogoMarkUrl} mode="aspectFit" />
</View> </View>
<Text className="pay-status-title"> <Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'} {needsWechatAuth ? '需完成微信授权' : '待支付'}
@@ -135,7 +135,7 @@ export default function PickupReceivePage() {
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text className="order-product-name">{name}</Text> <Text className="order-product-name">{name}</Text>
{spec ? <Text className="u-muted">{spec}</Text> : null} {spec ? <Text className="u-muted">{spec}</Text> : null}
<Text className="u-muted">×{order.quantity ?? 1}</Text> <Text className="u-muted">×{order.quantity ?? 1}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import '../../styles/product-detail.css'; import '../../styles/product-detail.css';
import '../../styles/benefit-promo.css';
import Taro, { import Taro, {
useDidShow, useDidShow,
usePageScroll, usePageScroll,
@@ -13,7 +14,7 @@ import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import BenefitFigure from '../../components/BenefitFigure'; import BenefitIntroCard from '../../components/BenefitIntroCard';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
@@ -156,9 +157,6 @@ export default function ProductDetailPage() {
}, [product, specEnabled, skus, selected, attrs]); }, [product, specEnabled, skus, selected, attrs]);
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0); const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
const displayBenefit = activeSku
? Number(activeSku.benefitAmount)
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
const fulfillment = activeSku const fulfillment = activeSku
? { ? {
allowOnlinePurchase: activeSku.allowOnlinePurchase, allowOnlinePurchase: activeSku.allowOnlinePurchase,
@@ -276,6 +274,8 @@ export default function ProductDetailPage() {
</View> </View>
<View className="product-detail-info"> <View className="product-detail-info">
<BenefitIntroCard showLink className="product-detail-benefit-intro" />
<View className="product-detail-price"> <View className="product-detail-price">
<Text className="product-detail-price-symbol">¥</Text> <Text className="product-detail-price-symbol">¥</Text>
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text> <Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
@@ -317,22 +317,6 @@ export default function ProductDetailPage() {
))} ))}
</View> </View>
) : null} ) : null}
<View className="product-detail-promo">
<View className="product-detail-promo-glow" />
<View className="product-detail-promo-head">
<View className="product-detail-promo-icon">
<Text className="product-detail-promo-icon-text"></Text>
</View>
<View className="product-detail-promo-title">
<Text> · </Text>
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
</View>
</View>
<Text className="product-detail-promo-desc">
</Text>
</View>
</View> </View>
<View className="product-detail-content"> <View className="product-detail-content">
@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '核销成功', navigationBarTitleText: '评价门店',
}); });
+266 -85
View File
@@ -1,16 +1,24 @@
import { useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Image, Textarea } from '@tarojs/components';
import '../../styles/redeem.css'; import '../../styles/redeem.css';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useLoad, useRouter } from '@tarojs/taro';
import {
STORE_RATING_MAX_COMMENT,
STORE_RATING_MAX_IMAGES,
STORE_RATING_QUICK_TAGS,
type StoreRatingDto,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { formatShanghaiDateTime } from '../../lib/datetime'; import { formatShanghaiDateTime } from '../../lib/datetime';
import { formatMoney, toMoneyNumber } from '../../lib/money'; import { toMoneyNumber } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure'; import { chooseAndUploadRatingImages } from '../../lib/upload-rating-image';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult'; const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
const SCORE_LABELS = ['', '较差', '一般', '还行', '很好', '非常好'] as const;
type RedeemRecord = { type RedeemRecord = {
id: string; id: string;
redeemNo: string; redeemNo: string;
@@ -18,59 +26,106 @@ type RedeemRecord = {
storeId: string; storeId: string;
storeName: string; storeName: string;
createdAt: string; createdAt: string;
rating?: StoreRatingDto | null;
}; };
/** 与权益「历史记录」一致:2026-08-03 13:53:03Asia/Shanghai */ function formatAmountYuan(amount: unknown) {
function formatChinaDateTime(input?: string | null) { const n = toMoneyNumber(amount);
return formatShanghaiDateTime(input ?? new Date()); if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
} }
function StarRating({ function formatVisitLine(createdAt?: string | null, amount?: unknown) {
label, const full = formatShanghaiDateTime(createdAt ?? new Date());
value, if (full === '—') return `核销用餐权益 ${formatAmountYuan(amount)}`;
onChange, const datePart = full.slice(0, 10);
}: { const time = full.slice(11, 16);
label: string; const today = formatShanghaiDateTime(new Date()).slice(0, 10);
value: number; const prefix = datePart === today ? '今日' : datePart.slice(5);
onChange: (score: number) => void; return `${prefix} ${time} · 核销用餐权益 ${formatAmountYuan(amount)}`;
}) {
return (
<View className="redeem-rating-row">
<Text className="redeem-rating-label">{label}</Text>
<View className="redeem-star-row">
{[1, 2, 3, 4, 5].map((score) => (
<Text
key={score}
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
onClick={() => onChange(score)}
>
</Text>
))}
</View>
</View>
);
} }
export default function RedeemSuccessPage() { function readCachedRecord(): RedeemRecord | null {
const router = useRouter();
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [loading, setLoading] = useState(false);
const record = useMemo<RedeemRecord | null>(() => {
try { try {
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY); const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null; return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
} catch { } catch {
return null; return null;
} }
}
export default function RedeemSuccessPage() {
const router = useRouter();
const [fromHistory, setFromHistory] = useState(false);
const [record, setRecord] = useState<RedeemRecord | null>(null);
const [score, setScore] = useState(5);
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
const [comment, setComment] = useState('');
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [coverUrl, setCoverUrl] = useState('');
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const rated = Boolean(record?.rating);
const applyRating = useCallback((rating: StoreRatingDto) => {
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
setTags(Array.isArray(rating.tags) ? rating.tags : []);
setComment(String(rating.comment || ''));
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
}, []); }, []);
const loadRecord = useCallback(
async (id: string) => {
try {
let data: RedeemRecord | null = null;
try {
data = await request<RedeemRecord>(`/redeem/records/${id}`);
} catch {
const records = await request<{ list?: RedeemRecord[] } | RedeemRecord[]>(
'/redeem/records?page=1&pageSize=50',
);
const list = Array.isArray(records) ? records : records?.list ?? [];
data = list.find((item) => String(item.id) === id) ?? null;
}
if (!data) {
toast('核销记录不存在');
return;
}
setRecord(data);
if (data.rating) applyRating(data.rating);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
}
},
[applyRating],
);
useLoad((options) => {
const id = String(options?.id || router.params.id || '').trim();
const from = String(options?.from || router.params.from || '');
setFromHistory(from === 'history');
if (id) {
void loadRecord(id);
return;
}
const cached = readCachedRecord();
if (cached) setRecord(cached);
});
const amount = toMoneyNumber(record?.amount ?? router.params.amount); const amount = toMoneyNumber(record?.amount ?? router.params.amount);
const storeName = record?.storeName || '门店'; const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—'; const visitLine = formatVisitLine(record?.createdAt, amount);
const redeemedAt = formatChinaDateTime(record?.createdAt);
useEffect(() => {
if (!record?.storeId) return;
void request<{ coverUrl?: string | null }>(`/stores/${record.storeId}`)
.then((store) => {
const url = String(store?.coverUrl || '').trim();
if (url) setCoverUrl(url);
})
.catch(() => undefined);
}, [record?.storeId]);
function clearCache() { function clearCache() {
try { try {
@@ -80,81 +135,207 @@ export default function RedeemSuccessPage() {
} }
} }
function goBenefit() { function leave() {
clearCache(); clearCache();
const pages = Taro.getCurrentPages();
if (fromHistory && pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/benefit/index' }); Taro.switchTab({ url: '/pages/benefit/index' });
} }
function goHome() { function toggleTag(tag: string) {
clearCache(); if (rated) return;
Taro.switchTab({ url: '/pages/home/index' }); setTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
}
async function addPhotos() {
if (rated || uploading) return;
if (imageUrls.length >= STORE_RATING_MAX_IMAGES) {
toast(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
return;
}
setUploading(true);
try {
const urls = await chooseAndUploadRatingImages(imageUrls.length);
if (urls.length) setImageUrls((prev) => [...prev, ...urls].slice(0, STORE_RATING_MAX_IMAGES));
} catch (e) {
toast(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
}
function removePhoto(url: string) {
if (rated) return;
setImageUrls((prev) => prev.filter((item) => item !== url));
} }
async function submitRatingAndFinish() { async function submitRatingAndFinish() {
if (!record?.id) {
toast('找不到核销记录');
return;
}
if (rated) {
leave();
return;
}
setLoading(true); setLoading(true);
try { try {
if (record?.id) {
await request('/redeem/ratings', { await request('/redeem/ratings', {
method: 'POST', method: 'POST',
data: { data: {
redeemRecordId: record.id, redeemRecordId: record.id,
serviceScore, serviceScore: score,
envScore, envScore: score,
comment: comment.trim(),
tags,
imageUrls,
}, },
}); });
toast('评价已提交', 'success'); toast('评价已提交', 'success');
setRecord((prev) =>
prev
? {
...prev,
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
} }
} catch { : prev,
/* 评价失败不阻塞返回 */ );
setTimeout(() => leave(), 400);
} catch (e) {
toast(e instanceof Error ? e.message : '评价失败');
} finally { } finally {
setLoading(false); setLoading(false);
goBenefit();
} }
} }
return ( return (
<PageShell variant="sub" className="redeem-success-page"> <PageShell variant="sub" className="redeem-success-page">
<SubPageHeader title="核销成功" onBack={goBenefit} /> <SubPageHeader title={rated ? '查看评价' : '评价门店'} onBack={leave} />
<View className="sub-page-body"> <View className="sub-page-body review-page-body">
<View className="redeem-success-icon"> <View className="review-store-card">
<Text></Text> {coverUrl ? (
</View> <Image className="review-store-cover" src={coverUrl} mode="aspectFill" />
<Text className="redeem-success-title"></Text> ) : (
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" /> <View className="review-store-cover review-store-cover--empty" />
<Text className="redeem-success-desc"> {storeName} </Text> )}
<View className="review-store-meta">
<View className="redeem-success-details"> <Text className="review-store-name">{storeName}</Text>
<View className="redeem-success-detail-row"> <Text className="review-store-visit">{visitLine}</Text>
<Text></Text>
<Text>{storeName}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text>{redeemedAt}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text className="redeem-success-mono">{redeemNo}</Text>
</View> </View>
</View> </View>
<View className="redeem-success-rating"> <View className="review-card">
<Text className="redeem-success-rating-title"></Text> <Text className="review-card-title"></Text>
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} /> <View className="review-star-row">
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} /> {[1, 2, 3, 4, 5].map((value) => (
</View> <Text
key={value}
<View className={`review-star${value <= score ? ' review-star--active' : ''}`}
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
onClick={() => { onClick={() => {
if (!loading) void submitRatingAndFinish(); if (!rated) setScore(value);
}} }}
> >
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
</Text>
))}
</View> </View>
<View className="redeem-cancel-btn" onClick={goHome}> <Text className="review-score-label">{SCORE_LABELS[score]}</Text>
<Text></Text>
</View> </View>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
<View className="review-tag-list">
{STORE_RATING_QUICK_TAGS.map((tag) => (
<Text
key={tag}
className={`review-tag${tags.includes(tag) ? ' review-tag--active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</Text>
))}
</View>
</View>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
{process.env.TARO_ENV === 'h5' ? (
<textarea
className="review-comment review-comment--native"
placeholder="口味、环境、服务都可以写,选填"
rows={4}
maxLength={STORE_RATING_MAX_COMMENT}
value={comment}
disabled={rated}
onChange={(e) => setComment(e.currentTarget.value)}
/>
) : (
<Textarea
className="review-comment"
placeholder="口味、环境、服务都可以写,选填"
maxlength={STORE_RATING_MAX_COMMENT}
value={comment}
disabled={rated}
onInput={(e) => setComment(e.detail.value)}
/>
)}
</View>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
<View className="review-photos">
{imageUrls.map((url) => (
<View key={url} className="review-photo">
<Image
className="review-photo-img"
src={url}
mode="aspectFill"
onClick={() => Taro.previewImage({ current: url, urls: imageUrls })}
/>
{!rated ? (
<Text className="review-photo-remove" onClick={() => removePhoto(url)}>
×
</Text>
) : null}
</View>
))}
{!rated && imageUrls.length < STORE_RATING_MAX_IMAGES ? (
<View className="review-photo-add" onClick={() => void addPhotos()}>
<Text className="review-photo-add-plus">{uploading ? '…' : '+'}</Text>
<Text className="review-photo-add-text">{uploading ? '上传中' : '添加'}</Text>
</View>
) : null}
</View>
</View>
{!rated ? (
<View
className={`review-submit${loading || uploading ? ' review-submit--disabled' : ''}`}
onClick={() => {
if (!loading && !uploading) void submitRatingAndFinish();
}}
>
<Text>{loading ? '提交中…' : '提交评价'}</Text>
</View>
) : null}
{!rated && !fromHistory ? (
<Text className="review-skip" onClick={leave}>
</Text>
) : null}
<Text className="review-disclaimer"></Text>
</View> </View>
</PageShell> </PageShell>
); );
+6 -6
View File
@@ -100,11 +100,11 @@ export default function RedeemPage() {
async function submit() { async function submit() {
const value = Math.round(Number(amount) * 100) / 100; const value = Math.round(Number(amount) * 100) / 100;
if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) { if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} `); toast(`核销权益不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} `);
return; return;
} }
if (value > redeemableMax) { if (value > redeemableMax) {
toast(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额'); toast(couponId ? '核销权益不能超过该权益可用核销权益' : '核销权益不能超过可用核销权益');
return; return;
} }
@@ -136,7 +136,7 @@ export default function RedeemPage() {
<View className="sub-page-body"> <View className="sub-page-body">
<View className="redeem-hero"> <View className="redeem-hero">
<Text className="redeem-hero-label"> <Text className="redeem-hero-label">
{couponId ? '当前权益可用余额' : '可用余额'} {couponId ? '当前可用核销权益' : '可用核销权益'}
</Text> </Text>
<BenefitFigure <BenefitFigure
value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'} value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
@@ -149,7 +149,7 @@ export default function RedeemPage() {
key={inputKey} key={inputKey}
className="redeem-input" className="redeem-input"
type="digit" type="digit"
placeholder="输入核销金额" placeholder="输入核销权益"
placeholderClass="redeem-input-placeholder" placeholderClass="redeem-input-placeholder"
value={amount} value={amount}
maxlength={12} maxlength={12}
@@ -160,7 +160,7 @@ export default function RedeemPage() {
</View> </View>
<View className="redeem-amount-foot"> <View className="redeem-amount-foot">
<View className="redeem-amount-hint"> <View className="redeem-amount-hint">
<Text></Text> <Text></Text>
<BenefitFigure value={formatMoney(redeemableMax)} size="sm" /> <BenefitFigure value={formatMoney(redeemableMax)} size="sm" />
</View> </View>
<Text className="redeem-fill-max" onClick={fillMaxAmount}> <Text className="redeem-fill-max" onClick={fillMaxAmount}>
@@ -169,7 +169,7 @@ export default function RedeemPage() {
</View> </View>
<View className="redeem-tips"> <View className="redeem-tips">
<Text className="redeem-tips-text"> <Text className="redeem-tips-text">
0.01 3 0.01 3
</Text> </Text>
</View> </View>
<View <View
+87 -40
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, ScrollView } from '@tarojs/components'; import { View, Text, Image, ScrollView } from '@tarojs/components';
import '../../styles/store-detail.css'; import '../../styles/store-detail.css';
import '../../styles/benefit-promo.css';
import Taro, { import Taro, {
useDidShow, useDidShow,
useLoad, useLoad,
@@ -12,13 +13,18 @@ import Taro, {
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money'; import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone'; import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics'; import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime'; import {
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
} from '../../lib/store-display';
import { import {
buildSceneSharePayload, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
@@ -63,7 +69,16 @@ type Store = {
avgPrice?: number | null; avgPrice?: number | null;
latitude?: number | string | null; latitude?: number | string | null;
longitude?: number | string | null; longitude?: number | string | null;
category?: { name: string } | null; rating?: number | string | null;
tags?: unknown;
redeemCount?: number | null;
categoryId?: string | null;
category?: {
id?: string;
name: string;
parentId?: string | null;
parent?: { name?: string } | null;
} | null;
}; };
type RecentRedeem = { type RecentRedeem = {
@@ -104,11 +119,6 @@ function pickStoreId(raw?: string | null) {
.replace(/[^\d]/g, ''); .replace(/[^\d]/g, '');
} }
/** 与历史记录一致:2026-08-03 15:14:30Asia/Shanghai */
function formatRedeemTime(input?: string | null) {
return formatShanghaiDateTime(input);
}
function formatPackagePriceYuan(price: string | number) { function formatPackagePriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price); const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return '0'; if (!Number.isFinite(n)) return '0';
@@ -122,20 +132,29 @@ function formatRedeemAmountYuan(amount: unknown) {
return n.toFixed(2).replace(/\.?0+$/, ''); return n.toFixed(2).replace(/\.?0+$/, '');
} }
function formatRecentRedeemLine(row: RecentRedeem) { function SectionTitle({
children,
className,
}: {
children: string;
className?: string;
}) {
return (
<View className={`store-detail-section-title${className ? ` ${className}` : ''}`}>
<View className="store-detail-section-title-bar" />
<Text className="store-detail-section-title-text">{children}</Text>
</View>
);
}
function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
try { try {
// 优先用服务端拼好的 text,避免客户端时区 / Intl 差异 const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
if (row.text?.trim()) return row.text.trim();
const label = String(row.userLabel || '用户***').trim() || '用户***';
const rawTime = String(row.createdAt || '').trim();
const time =
/^\d{4}-\d{2}-\d{2}/.test(rawTime)
? rawTime.slice(0, 19).replace('T', ' ')
: formatRedeemTime(row.createdAt);
const amount = formatRedeemAmountYuan(row.amount); const amount = formatRedeemAmountYuan(row.amount);
return `${label} ${time} 核销${amount}`; if (!amount) return null;
return { userLabel, amount };
} catch { } catch {
return ''; return null;
} }
} }
@@ -160,6 +179,7 @@ export default function StoreDetailPage() {
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id)); const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
const [store, setStore] = useState<Store | null>(null); const [store, setStore] = useState<Store | null>(null);
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]); const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
const [categoryTree, setCategoryTree] = useState<StoreCategoryTreeNode[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
@@ -236,6 +256,12 @@ export default function StoreDetailPage() {
} }
}, [router.params.id, storeId, bootstrap]); }, [router.params.id, storeId, bootstrap]);
useEffect(() => {
void request<StoreCategoryTreeNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
// 登录态变化后回到本页:重拉详情与走马灯 // 登录态变化后回到本页:重拉详情与走马灯
useDidShow(() => { useDidShow(() => {
const id = pickStoreId(storeId || router.params.id); const id = pickStoreId(storeId || router.params.id);
@@ -254,8 +280,8 @@ export default function StoreDetailPage() {
}); });
}, [store, storeId]); }, [store, storeId]);
const marqueeLines = useMemo( const marqueeItems = useMemo(
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean), () => recentRedeems.map(toMarqueeItem).filter((row): row is StoreRedeemMarqueeItem => !!row),
[recentRedeems], [recentRedeems],
); );
@@ -369,7 +395,38 @@ export default function StoreDetailPage() {
</View> </View>
<View className="store-detail-info-card"> <View className="store-detail-info-card">
{marqueeItems.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee
key={marqueeItems.map((r) => `${r.userLabel}|${r.amount}`).join('|')}
items={marqueeItems}
/>
</View>
) : null}
<View className="store-detail-title-row">
<Text className="store-detail-name">{store.name}</Text> <Text className="store-detail-name">{store.name}</Text>
{storeCategoryTags(store, categoryTree).map((tag) => (
<Text key={tag} className="store-detail-tag">
{tag}
</Text>
))}
</View>
<View className="store-detail-rating-row">
<View className="store-detail-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Text
key={n}
className={`store-detail-star${n <= storeStarCount(store.rating) ? ' store-detail-star--on' : ''}`}
>
</Text>
))}
</View>
{Number(store.redeemCount) > 0 ? (
<Text className="store-detail-redeem">{store.redeemCount}</Text>
) : null}
</View>
<View className="store-detail-row"> <View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex"> <Text className="store-detail-meta store-detail-meta--flex">
@@ -404,23 +461,20 @@ export default function StoreDetailPage() {
</Text> </Text>
</View> </View>
) : null} ) : null}
{store.category?.name ? (
<View className="store-detail-tags">
<Text className="store-detail-tag">{store.category.name}</Text>
</View>
) : null}
</View> </View>
{marqueeLines.length > 0 ? ( <BenefitIntroCard showLink accent className="store-detail-benefit-intro" />
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} /> {benefitRule ? (
<View className="store-detail-section">
<SectionTitle className="store-detail-section-title--rule">使</SectionTitle>
<Text className="store-detail-intro">{benefitRule}</Text>
</View> </View>
) : null} ) : null}
{packages.length > 0 ? ( {packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages"> <View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text> <SectionTitle></SectionTitle>
<View className="store-detail-package-list"> <View className="store-detail-package-list">
{packages.map((pkg, index) => ( {packages.map((pkg, index) => (
<View <View
@@ -440,16 +494,9 @@ export default function StoreDetailPage() {
</View> </View>
) : null} ) : null}
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title store-detail-section-title--rule">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{intro ? ( {intro ? (
<View className="store-detail-section"> <View className="store-detail-section">
<Text className="store-detail-section-title"></Text> <SectionTitle></SectionTitle>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar> <ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text> <Text className="store-detail-intro">{intro}</Text>
</ScrollView> </ScrollView>
@@ -458,7 +505,7 @@ export default function StoreDetailPage() {
{envPhotos.length > 0 ? ( {envPhotos.length > 0 ? (
<View className="store-detail-section"> <View className="store-detail-section">
<Text className="store-detail-section-title"></Text> <SectionTitle></SectionTitle>
<View className="store-detail-env-grid"> <View className="store-detail-env-grid">
{envPhotos.map((url, index) => ( {envPhotos.map((url, index) => (
<View <View
+129 -57
View File
@@ -34,12 +34,15 @@ import {
markStoresSessionBootstrapped, markStoresSessionBootstrapped,
patchStoresFilterCache, patchStoresFilterCache,
setStoresListCache, setStoresListCache,
type StoreSortKey,
} from '../../lib/stores-session'; } from '../../lib/stores-session';
import { import {
buildSceneSharePayload, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline, toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import BenefitSloganBar from '../../components/BenefitSloganBar';
import { storeCategoryTags, storeStarCount } from '../../lib/store-display';
import openBadgeImg from '../../assets/icons/store-open-badge.png'; import openBadgeImg from '../../assets/icons/store-open-badge.png';
type Store = { type Store = {
@@ -57,12 +60,26 @@ type Store = {
avgPrice?: number | null; avgPrice?: number | null;
status?: string; status?: string;
categoryId?: string | null; categoryId?: string | null;
category?: { id?: string; name?: string; parentId?: string | null } | null; category?: {
id?: string;
name?: string;
parentId?: string | null;
parent?: { name?: string } | null;
} | null;
tags?: unknown;
rating?: number | string | null;
latitude?: number | string | null; latitude?: number | string | null;
longitude?: number | string | null; longitude?: number | string | null;
distanceMeters?: number | null; distanceMeters?: number | null;
redeemCount?: number | null;
}; };
const STORE_SORT_OPTIONS: { key: StoreSortKey; label: string }[] = [
{ key: 'nearby', label: '附近优先' },
{ key: 'rating', label: '好评优先' },
{ key: 'redeem', label: '核销次数' },
];
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string { function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
return `${region.province}|${region.city}`; return `${region.province}|${region.city}`;
} }
@@ -98,13 +115,15 @@ export default function StoresPage() {
); );
const [categoryOpen, setCategoryOpen] = useState(false); const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]); const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false); const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
const [sortOpen, setSortOpen] = useState(false);
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null); const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
const fetchSeqRef = useRef(0); const fetchSeqRef = useRef(0);
const regionRef = useRef(region); const regionRef = useRef(region);
regionRef.current = region; regionRef.current = region;
const regionLabel = formatRegionLabel(region); const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category); const categoryLabel = formatCategoryLabel(category);
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
const showBootLoading = loading && stores.length === 0; const showBootLoading = loading && stores.length === 0;
const childIdsByParent = useMemo(() => { const childIdsByParent = useMemo(() => {
@@ -151,6 +170,7 @@ export default function StoresPage() {
keyword: prev?.keyword ?? keyword, keyword: prev?.keyword ?? keyword,
keywordInput: prev?.keywordInput ?? keywordInput, keywordInput: prev?.keywordInput ?? keywordInput,
category: prev?.category ?? category, category: prev?.category ?? category,
sort: prev?.sort ?? sort,
}); });
} catch (e) { } catch (e) {
if (seq !== fetchSeqRef.current) return; if (seq !== fetchSeqRef.current) return;
@@ -278,13 +298,29 @@ export default function StoresPage() {
return siblings.includes(storeCatId); return siblings.includes(storeCatId);
} }
const filtered = stores.filter((s) => { const filtered = useMemo(() => {
const list = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false; if (!matchesRegionFilter(s, region)) return false;
if (!matchesCategory(s)) return false; if (!matchesCategory(s)) return false;
if (!keyword.trim()) return true; if (!keyword.trim()) return true;
const q = keyword.trim(); const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q); return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
}); });
const next = [...list];
next.sort((a, b) => {
if (sort === 'rating') {
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
if (diff !== 0) return diff;
} else if (sort === 'redeem') {
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
if (diff !== 0) return diff;
}
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
});
return next;
}, [stores, region, category, keyword, sort, childIdsByParent]);
function applySearch() { function applySearch() {
const next = keywordInput.trim(); const next = keywordInput.trim();
@@ -296,52 +332,18 @@ export default function StoresPage() {
setKeywordInput(''); setKeywordInput('');
setKeyword(''); setKeyword('');
setCategory(EMPTY_CATEGORY); setCategory(EMPTY_CATEGORY);
setSort('nearby');
setRegion(DEFAULT_REGION); setRegion(DEFAULT_REGION);
regionRef.current = DEFAULT_REGION; regionRef.current = DEFAULT_REGION;
patchStoresFilterCache({ patchStoresFilterCache({
keyword: '', keyword: '',
keywordInput: '', keywordInput: '',
category: EMPTY_CATEGORY, category: EMPTY_CATEGORY,
sort: 'nearby',
filterRegion: DEFAULT_REGION, filterRegion: DEFAULT_REGION,
}); });
} }
async function locateToUserRegion() {
if (locating) return;
const { confirm } = await Taro.showModal({
title: '获取当前位置',
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
confirmText: '允许',
cancelText: '暂不',
}).catch(() => ({ confirm: false, cancel: true }));
if (!confirm) return;
setLocating(true);
setLoading(true);
try {
const resolved = await resolveUserCity(true);
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
const filterRegion = resolved.region;
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(listRegion);
setRegion(filterRegion);
regionRef.current = filterRegion;
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
listRegion,
filterRegion,
);
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '定位失败');
setLoading(false);
} finally {
setLocating(false);
}
}
function hoursText(store: Store): string { function hoursText(store: Store): string {
const parts: string[] = []; const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
@@ -366,30 +368,59 @@ export default function StoresPage() {
<WechatShareReady payload={sharePayload} /> <WechatShareReady payload={sharePayload} />
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-slogan-wrap">
<BenefitSloganBar />
</View>
<View className="store-filter"> <View className="store-filter">
<View className="store-search-row"> <View className="store-search-row">
<Input <Input
className="store-search-input" className="store-search-input"
placeholder="搜索门店名称/地址" placeholder="搜索门店名称地址"
value={keywordInput} value={keywordInput}
confirmType="search" confirmType="search"
onInput={(e) => setKeywordInput(e.detail.value)} onInput={(e) => setKeywordInput(e.detail.value)}
onConfirm={applySearch} onConfirm={applySearch}
/> />
<View className="store-search-btn" onClick={applySearch} aria-label="搜索"> <View className="store-search-btn" onClick={applySearch}>
<View className="store-search-icon" /> <Text className="store-search-btn-text"></Text>
</View> </View>
</View> </View>
<View className="store-filter-row"> <View className="store-filter-row">
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}> <View
className="store-filter-chip"
onClick={() => {
setCategoryOpen(false);
setSortOpen(false);
setRegionOpen(true);
}}
>
<Text className="store-filter-chip-text">{regionLabel}</Text> <Text className="store-filter-chip-text">{regionLabel}</Text>
<Text className="store-filter-chip-arrow"></Text> <Text className="store-filter-chip-arrow"></Text>
</View> </View>
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}> <View
className="store-filter-chip"
onClick={() => {
setRegionOpen(false);
setSortOpen(false);
setCategoryOpen(true);
}}
>
<Text className="store-filter-chip-text">{categoryLabel}</Text> <Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text> <Text className="store-filter-chip-arrow"></Text>
</View> </View>
<View
className="store-filter-chip"
onClick={() => {
setRegionOpen(false);
setCategoryOpen(false);
setSortOpen(true);
}}
>
<Text className="store-filter-chip-text">{sortLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View <View
className="store-filter-icon-btn" className="store-filter-icon-btn"
onClick={resetFilters} onClick={resetFilters}
@@ -398,15 +429,6 @@ export default function StoresPage() {
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */} {/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
<Text className="store-filter-icon-glyph"></Text> <Text className="store-filter-icon-glyph"></Text>
</View> </View>
<View
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
onClick={() => {
void locateToUserRegion();
}}
aria-label="获取当前位置"
>
<Text className="store-filter-icon-glyph"></Text>
</View>
</View> </View>
</View> </View>
@@ -435,11 +457,34 @@ export default function StoresPage() {
/> />
</View> </View>
<View className="store-card-body"> <View className="store-card-body">
{/* 第1行:标题(截断无省略号,顶到最右) */}
<View className="store-card-row store-card-row--head"> <View className="store-card-row store-card-row--head">
<Text className="store-card-name">{s.name}</Text> <Text className="store-card-name">{s.name}</Text>
</View> </View>
{/* 第2行:地址(最多两行)+ 距离 */} {(() => {
const tags = storeCategoryTags(s, categoryTree);
return tags.length ? (
<View className="store-card-tags">
{tags.map((tag) => (
<Text key={tag} className="store-card-tag">
{tag}
</Text>
))}
</View>
) : null;
})()}
<View className="store-card-row store-card-row--rating">
<View className="store-card-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Text
key={n}
className={`store-card-star${n <= storeStarCount(s.rating) ? ' store-card-star--on' : ''}`}
>
</Text>
))}
</View>
<Text className="store-card-redeem">{s.redeemCount ?? 0}</Text>
</View>
<View className="store-card-row store-card-row--mid"> <View className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}> <Text className="store-card-address" numberOfLines={2}>
{s.address || (s.district ? `${s.district}` : '地址待完善')} {s.address || (s.district ? `${s.district}` : '地址待完善')}
@@ -448,12 +493,10 @@ export default function StoresPage() {
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
</View> </View>
{/* 第3行:营业时间(同行) */}
<View className="store-card-row store-card-row--hours"> <View className="store-card-row store-card-row--hours">
<Text className="store-card-hours">{hoursText(s)}</Text> <Text className="store-card-hours">{hoursText(s)}</Text>
</View> </View>
</View> </View>
<Text className="store-card-arrow"></Text>
</View> </View>
))} ))}
</View> </View>
@@ -480,6 +523,35 @@ export default function StoresPage() {
patchStoresFilterCache({ category: next }); patchStoresFilterCache({ category: next });
}} }}
/> />
{sortOpen ? (
<View className="region-picker-overlay" onClick={() => setSortOpen(false)}>
<View className="region-picker-sheet store-sort-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text className="region-picker-tab active"></Text>
</View>
<Text className="region-picker-confirm ready" onClick={() => setSortOpen(false)}>
</Text>
</View>
<View className="region-picker-list">
{STORE_SORT_OPTIONS.map((opt) => (
<View
key={opt.key}
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
onClick={() => {
setSort(opt.key);
patchStoresFilterCache({ sort: opt.key });
setSortOpen(false);
}}
>
<Text>{opt.label}</Text>
</View>
))}
</View>
</View>
</View>
) : null}
</PageShell> </PageShell>
); );
} }
@@ -0,0 +1,88 @@
/* 好客权益口号条 / 说明卡(多页共用) */
.benefit-slogan-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: #fff8e6;
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-slogan-bar-accent {
width: 3px;
height: 14px;
border-radius: 2px;
background: #c9a23e;
flex-shrink: 0;
}
.benefit-slogan-bar-text {
flex: 1;
min-width: 0;
text-align: center;
font-size: 13px;
font-weight: 700;
line-height: 20px;
color: #8b1a1a;
}
.benefit-intro-card {
padding: 14px;
border-radius: 12px;
background: #fff8e6;
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-intro-card-title {
display: block;
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
line-height: 22px;
color: #8b1a1a;
margin-bottom: 8px;
}
.benefit-intro-card-body {
display: block;
font-size: 13px;
line-height: 1.7;
color: #8b1a1a;
}
.benefit-intro-card-em {
font-weight: 700;
}
.benefit-intro-card-link {
display: block;
margin-top: 8px;
text-align: right;
font-size: 12px;
font-weight: 500;
color: #8b1a1a;
}
.benefit-intro-card--accent {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0;
overflow: hidden;
}
.benefit-intro-card-bar {
width: 5px;
flex-shrink: 0;
align-self: stretch;
background: #c9a23e;
}
.benefit-intro-card-main {
flex: 1;
min-width: 0;
padding: 14px;
}
+29 -57
View File
@@ -11,13 +11,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
.benefit-header__content {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.benefit-header-title { .benefit-header-title {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -39,34 +32,6 @@
z-index: 1; z-index: 1;
} }
.benefit-header-city {
display: flex;
align-items: center;
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 500;
max-width: 30vw;
position: relative;
z-index: 2;
}
.benefit-header__share .page-nav-bar__btn {
background: transparent;
}
.benefit-header__share .page-nav-bar__icon {
color: var(--color-heritage-red);
}
.benefit-header-city-pin {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 4px;
flex-shrink: 0;
}
.benefit-main { .benefit-main {
padding: 16px var(--space-page) 24px; padding: 16px var(--space-page) 24px;
} }
@@ -83,9 +48,6 @@
} }
.benefit-hero-top { .benefit-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -117,36 +79,32 @@
line-height: 1; line-height: 1;
} }
.benefit-hero-logo { .benefit-hero-actions {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 10px;
color: var(--color-heritage-red);
font-weight: 700;
font-size: 18px;
overflow: hidden;
}
.benefit-hero-logo-img {
width: 28px;
height: 28px;
display: block;
} }
.benefit-hero-cta { .benefit-hero-cta {
flex: 1;
height: 44px; height: 44px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-heritage-red);
color: #fff;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box;
}
.benefit-hero-cta--primary {
background: var(--color-heritage-red);
color: #fff;
}
.benefit-hero-cta--secondary {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
} }
.benefit-tabs { .benefit-tabs {
@@ -267,7 +225,21 @@
font-weight: 600; font-weight: 600;
} }
.benefit-coupon-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
}
.benefit-intro-card--page {
margin-bottom: 16px;
}
.benefit-intro-card--page .benefit-intro-card-body {
font-size: 11px;
line-height: 1.65;
}
.benefit-login-gate { .benefit-login-gate {
padding: 48px 24px; padding: 16px var(--space-page) 48px;
text-align: center; text-align: center;
} }
+89
View File
@@ -51,6 +51,10 @@
display: block; display: block;
} }
.home-slogan-wrap {
margin: 10px var(--space-page) 0;
}
.home-aroma-nav { .home-aroma-nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -175,6 +179,7 @@
} }
.home-product-thumb-wrap { .home-product-thumb-wrap {
position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 88px; width: 88px;
height: 88px; height: 88px;
@@ -183,6 +188,60 @@
background: var(--color-surface-container); background: var(--color-surface-container);
} }
.home-benefit-ribbon-clip {
position: absolute;
top: 0;
left: 0;
width: 64px;
height: 64px;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.home-benefit-ribbon {
position: absolute;
top: 14px;
left: -18px;
width: 86px;
height: 18px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 3px;
overflow: hidden;
background: #8b1a20;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
transform: rotate(-45deg);
}
.home-benefit-ribbon-dk {
flex-shrink: 0;
width: 14px;
height: 14px;
border-radius: 1px;
/* background: #fff; */
display: flex;
align-items: center;
justify-content: center;
}
.home-benefit-ribbon-dk-icon {
width: 12px;
height: 12px;
display: block;
}
.home-benefit-ribbon-num {
flex-shrink: 0;
color: #dcb46f;
font-size: 11px;
font-weight: 700;
line-height: 18px;
letter-spacing: 0.02em;
}
.home-product-thumb { .home-product-thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -241,6 +300,36 @@
margin-top: 2px; margin-top: 2px;
} }
.home-product-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.home-gift-tag,
.home-benefit-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 2px;
font-family: var(--font-label);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 16px;
}
.home-gift-tag {
background: var(--color-aged-amber);
color: var(--color-heritage-red);
}
.home-benefit-tag {
background: rgba(166, 29, 36, 0.1);
color: var(--color-heritage-red);
}
.home-product-actions { .home-product-actions {
margin-top: auto; margin-top: auto;
padding-top: 4px; padding-top: 4px;
+78
View File
@@ -42,3 +42,81 @@
color: #3d3530; color: #3d3530;
margin-bottom: 8px; margin-bottom: 8px;
} }
.benefit-rules-summary {
padding: 14px;
margin-bottom: 16px;
border-radius: 12px;
background: linear-gradient(90deg, #fff8e6 0%, #fff3cc 100%);
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-rules-summary-text {
display: block;
font-size: 13px;
line-height: 1.75;
color: #3d3530;
}
.benefit-rules-section {
background: #fff;
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 4px 16px rgba(166, 29, 36, 0.04);
}
.benefit-rules-heading-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.benefit-rules-heading-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--color-heritage-red, #a61d24);
flex-shrink: 0;
}
.benefit-rules-heading {
font-size: 15px;
font-weight: 700;
color: #1f1a17;
line-height: 1.5;
}
.benefit-rules-paragraph {
display: block;
font-size: 13px;
line-height: 1.75;
color: #3d3530;
margin-bottom: 6px;
}
.benefit-rules-em {
color: var(--color-heritage-red, #a61d24);
font-weight: 700;
}
.benefit-rules-phone {
display: block;
margin-top: 4px;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
color: var(--color-heritage-red, #a61d24);
}
.benefit-rules-footer {
display: block;
margin-top: 8px;
margin-bottom: 16px;
text-align: center;
font-size: 11px;
line-height: 1.6;
color: #999;
}
+9 -5
View File
@@ -10,7 +10,7 @@
.mine-header { .mine-header {
position: relative; position: relative;
z-index: 2; z-index: 1;
padding: 16px var(--space-page) 44px; padding: 16px var(--space-page) 44px;
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%); background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
overflow: visible; overflow: visible;
@@ -34,12 +34,10 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
z-index: 3;
} }
.mine-avatar-wrap { .mine-avatar-wrap {
position: relative; position: relative;
z-index: 4;
flex-shrink: 0; flex-shrink: 0;
margin-right: 12px; margin-right: 12px;
} }
@@ -162,7 +160,7 @@
.mine-main { .mine-main {
margin-top: -28px; margin-top: -28px;
position: relative; position: relative;
z-index: 1; z-index: 5;
padding: 0 var(--space-page) 0; padding: 0 var(--space-page) 0;
} }
@@ -185,6 +183,12 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.mine-benefit-slogan {
position: relative;
z-index: 6;
margin-bottom: 10px;
}
.mine-card-title { .mine-card-title {
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 15px; font-size: 15px;
@@ -356,7 +360,7 @@
margin-bottom: 8px; margin-bottom: 8px;
padding: 16px; padding: 16px;
position: relative; position: relative;
z-index: 2; z-index: 5;
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
+21
View File
@@ -176,6 +176,27 @@
text-align: center; text-align: center;
} }
.order-qty-input {
width: 52px;
height: 32px;
margin: 0 4px 0 8px;
padding: 0 4px;
box-sizing: border-box;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--color-on-surface);
background: var(--color-surface-container-low);
border-radius: 8px;
}
.order-qty-unit {
margin-right: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface);
}
.order-qty-hint { .order-qty-hint {
display: block; display: block;
margin-top: 10px; margin-top: 10px;

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