Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58010feed8 | |||
| 9f14c59f22 | |||
| 9c8d5f2cad | |||
| 870494563d | |||
| 4c9b3f72bc | |||
| 3b669f7e38 | |||
| 14c5c8c3f3 | |||
| a7f3283ae5 | |||
| da19c39965 | |||
| c06251f0a4 | |||
| faa1830bde | |||
| b226af2adc | |||
| 2b5f42acdf | |||
| 71149044ca | |||
| fccfd7abbe | |||
| 1ecc6a11df | |||
| cfd42dfc32 | |||
| 737efb89a3 |
@@ -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;
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: '订单发货' },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { omitNullFields } from '../lib/omit-null-fields';
|
||||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||||
|
import PartnerAssocPanel from '../components/PartnerAssocPanel';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
@@ -60,6 +62,7 @@ type Row = {
|
|||||||
managedWarehouseName?: string | null;
|
managedWarehouseName?: string | null;
|
||||||
maxPartnerCommissionRate?: number | null;
|
maxPartnerCommissionRate?: number | null;
|
||||||
storeCount: number;
|
storeCount: number;
|
||||||
|
assocUserCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -209,7 +212,7 @@ export default function CityPartnersPage() {
|
|||||||
await request(`/admin/partners/${detail.id}`, {
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...v,
|
...omitNullFields(v),
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
@@ -332,6 +335,19 @@ export default function CityPartnersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '关联用户',
|
||||||
|
dataIndex: 'assocUserCount',
|
||||||
|
width: 80,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
|
||||||
|
title={`查看「${row.companyName || row.phone}」关联用户`}
|
||||||
|
>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -355,7 +371,9 @@ export default function CityPartnersPage() {
|
|||||||
title="城市合伙人"
|
title="城市合伙人"
|
||||||
settings={settingsButton}
|
settings={settingsButton}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<>
|
||||||
|
<Link to="/users?assocPartnerAccountId=any">全部关联用户</Link>
|
||||||
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
@@ -372,6 +390,7 @@ export default function CityPartnersPage() {
|
|||||||
>
|
>
|
||||||
新建合伙人
|
新建合伙人
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -486,6 +505,14 @@ export default function CityPartnersPage() {
|
|||||||
{detail.storeCount ?? 0}
|
{detail.storeCount ?? 0}
|
||||||
</Link>
|
</Link>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联用户">
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
|
||||||
|
title="查看该城市合伙人关联用户"
|
||||||
|
>
|
||||||
|
{detail.assocUserCount ?? 0}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="管仓仓库" span={2}>
|
<Descriptions.Item label="管仓仓库" span={2}>
|
||||||
{detail.managedWarehouseName ?? (
|
{detail.managedWarehouseName ?? (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
@@ -570,6 +597,11 @@ export default function CityPartnersPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
|
|||||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!batchEditOpen) return;
|
if (!modalOpen && !batchEditOpen) return;
|
||||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||||
.then((res) => setVersions(res.items ?? []))
|
.then((res) => setVersions(res.items ?? []))
|
||||||
.catch(() => setVersions([]));
|
.catch(() => setVersions([]));
|
||||||
}, [batchEditOpen]);
|
}, [modalOpen, batchEditOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen || editing) return;
|
if (!modalOpen || editing) return;
|
||||||
@@ -108,6 +108,7 @@ export default function DevPlanTasksPage() {
|
|||||||
content: row.content,
|
content: row.content,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
|
||||||
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
@@ -121,7 +122,11 @@ export default function DevPlanTasksPage() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...values, attachmentUrls }),
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
attachmentUrls,
|
||||||
|
versionIds: values.versionIds ?? [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +290,21 @@ export default function DevPlanTasksPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '关联版本',
|
||||||
|
dataIndex: 'versions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.versions?.length ? (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{row.versions.map((v) => (
|
||||||
|
<Tag key={v.id}>{v.versionNo}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -405,9 +425,21 @@ export default function DevPlanTasksPage() {
|
|||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="versionIds" label="关联版本">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="可选:关联版本"
|
||||||
|
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export default function HqPermissionsPage() {
|
|||||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
||||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -274,7 +274,7 @@ export default function HqPermissionsPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Radio,
|
Radio,
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -24,6 +22,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -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"
|
||||||
</Form.Item>
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input allowClear placeholder="手机号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||||
|
<Input allowClear placeholder="编号/昵称/备注/手机" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
|
||||||
|
<Input allowClear placeholder="名称/规格" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="全部"
|
||||||
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assocPartnerAccountId"
|
||||||
|
label="关联合伙人"
|
||||||
|
className="admin-orders-filter-item admin-orders-filter-item--status"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="全部"
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: '未关联' },
|
||||||
|
...partners.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: formatAssocPartner({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name || p.id,
|
||||||
|
companyName: p.companyName,
|
||||||
|
phone: p.phone,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setFiltersCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtersCollapsed ? '展开' : '收起'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtersCollapsed ? (
|
||||||
|
<>
|
||||||
|
<div className="admin-orders-filter-row">
|
||||||
|
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
|
<Form.Item name="dateRange" noStyle>
|
||||||
|
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
|
||||||
|
</Form.Item>
|
||||||
{DATE_PRESETS.map((preset) => (
|
{DATE_PRESETS.map((preset) => (
|
||||||
<Button
|
<Button
|
||||||
key={preset.key}
|
key={preset.key}
|
||||||
@@ -797,98 +981,60 @@ export default function OrdersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Form.Item>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={6}>
|
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
|
||||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
{canExportOrders ? (
|
||||||
<Select
|
<Space size={12} wrap>
|
||||||
mode="multiple"
|
<Radio.Group
|
||||||
allowClear
|
optionType="button"
|
||||||
maxTagCount="responsive"
|
buttonStyle="solid"
|
||||||
placeholder="全部"
|
value={exportFormat}
|
||||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
onChange={(e) => setExportFormat(e.target.value)}
|
||||||
/>
|
>
|
||||||
</Form.Item>
|
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||||
</Col>
|
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
</Radio.Group>
|
||||||
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
<Button
|
||||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
disabled={!selectedRowKeys.length}
|
||||||
</Form.Item>
|
loading={exporting}
|
||||||
</Col>
|
onClick={() => void submitExport('selected')}
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
>
|
||||||
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
</Button>
|
||||||
</Form.Item>
|
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||||
</Col>
|
导出全部筛选
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
</Button>
|
||||||
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
</Space>
|
||||||
<Select
|
) : <span />}
|
||||||
allowClear
|
<Space size={12} wrap>
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="全部"
|
|
||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6} lg={6}>
|
|
||||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
|
||||||
<Input allowClear placeholder="手机号" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={8} lg={8}>
|
|
||||||
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
|
|
||||||
<Space size={16} wrap>
|
|
||||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
<Checkbox>大单拦截</Checkbox>
|
<Checkbox>大单拦截</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
<Button
|
||||||
<Checkbox>过滤测试</Checkbox>
|
onClick={() => {
|
||||||
</Form.Item>
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
|
||||||
|
else void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
{canDeleteOrders ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Col>
|
</>
|
||||||
</Row>
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
{canExportOrders ? (
|
|
||||||
<Space size={12} wrap>
|
|
||||||
<Radio.Group
|
|
||||||
optionType="button"
|
|
||||||
buttonStyle="solid"
|
|
||||||
value={exportFormat}
|
|
||||||
onChange={(e) => setExportFormat(e.target.value)}
|
|
||||||
>
|
|
||||||
<Radio.Button value="xlsx">Excel</Radio.Button>
|
|
||||||
<Radio.Button value="pdf">PDF</Radio.Button>
|
|
||||||
</Radio.Group>
|
|
||||||
<Button
|
|
||||||
disabled={!selectedRowKeys.length}
|
|
||||||
loading={exporting}
|
|
||||||
onClick={() => void submitExport('selected')}
|
|
||||||
>
|
|
||||||
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
|
||||||
导出全部筛选
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : <span />}
|
|
||||||
<Space size={12} wrap>
|
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
|
||||||
{canDeleteOrders ? (
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
disabled={!selectedRowKeys.length}
|
|
||||||
onClick={() => setBatchDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
|
|||||||
|
|
||||||
type SceneOption = { value: PromoCodeScene; label: string };
|
type SceneOption = { value: PromoCodeScene; label: string };
|
||||||
|
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
const blob = await res.blob();
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = objectUrl;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
} catch {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PromoCodesPage() {
|
export default function PromoCodesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
{
|
||||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
title: '扫码',
|
||||||
|
dataIndex: 'scanCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Tooltip title="仅统计已完成订单">订单</Tooltip>,
|
||||||
|
dataIndex: 'orderCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '转化率',
|
title: '转化率',
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 220,
|
width: 140,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size="small" wrap>
|
<Space size="small" wrap>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
disabled={!row.qrcodeUrl}
|
|
||||||
onClick={() => {
|
|
||||||
if (!row.qrcodeUrl) return;
|
|
||||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下载小程序码
|
|
||||||
</Button>
|
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||||
关联用户
|
关联用户
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
@@ -20,17 +20,28 @@ type Row = {
|
|||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
userNo: string;
|
||||||
|
phone: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
};
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { id?: string; couponNo: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
function formatNicknameWithRemark(user?: {
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
nickname?: string | null;
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
hqRemark?: string | null;
|
||||||
|
} | null) {
|
||||||
|
const name = user?.nickname?.trim() || '—';
|
||||||
|
const remark = user?.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||||
|
>
|
||||||
|
{v || '—'}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '用户昵称',
|
title: '用户昵称',
|
||||||
dataIndex: ['user', 'nickname'],
|
dataIndex: ['user', 'nickname'],
|
||||||
width: 100,
|
width: 160,
|
||||||
render: (v: string | null | undefined) => v || '—',
|
render: (_: string | null | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
|
||||||
|
>
|
||||||
|
{formatNicknameWithRemark(row.user)}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
formatNicknameWithRemark(row.user)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '用户手机',
|
title: '用户手机',
|
||||||
dataIndex: ['user', 'phone'],
|
dataIndex: ['user', 'phone'],
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string | null | undefined) => maskPhone(v),
|
render: (v: string | null | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
{
|
||||||
|
title: '券号',
|
||||||
|
dataIndex: ['coupon', 'couponNo'],
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | undefined, row) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
|
||||||
|
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -75,6 +76,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') || '';
|
||||||
@@ -294,7 +296,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 +395,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 +476,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 +550,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="应付">
|
||||||
@@ -592,8 +599,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: '金额',
|
||||||
@@ -662,7 +683,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>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
@@ -560,50 +561,43 @@ export default function SupportTicketsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="技术支持"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
actions={
|
||||||
alignItems: 'center',
|
<>
|
||||||
marginBottom: 16,
|
{isSuperAdmin ? (
|
||||||
}}
|
<>
|
||||||
>
|
<Button
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
disabled={!selectedRowKeys.length}
|
||||||
技术支持
|
loading={batchCreateSaving}
|
||||||
</Typography.Title>
|
onClick={() => void submitBatchCreateTasks()}
|
||||||
{settingsButton}
|
>
|
||||||
<Space>
|
一键创建任务
|
||||||
{isSuperAdmin ? (
|
</Button>
|
||||||
<>
|
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||||
<Button
|
一键发布
|
||||||
disabled={!selectedRowKeys.length}
|
</Button>
|
||||||
loading={batchCreateSaving}
|
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||||
onClick={() => void submitBatchCreateTasks()}
|
批量审核
|
||||||
>
|
</Button>
|
||||||
一键创建任务
|
<Button
|
||||||
</Button>
|
disabled={!selectedRowKeys.length}
|
||||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
onClick={() => {
|
||||||
一键发布
|
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||||
</Button>
|
setBatchStatusOpen(true);
|
||||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
}}
|
||||||
批量审核
|
>
|
||||||
</Button>
|
批量改状态
|
||||||
<Button
|
</Button>
|
||||||
disabled={!selectedRowKeys.length}
|
</>
|
||||||
onClick={() => {
|
) : null}
|
||||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
setBatchStatusOpen(true);
|
创建工单
|
||||||
}}
|
</Button>
|
||||||
>
|
</>
|
||||||
批量改状态
|
}
|
||||||
</Button>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
|
||||||
创建工单
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -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;
|
||||||
void openDetail(openUserId);
|
form.setFieldsValue({ userId: openUserId });
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
setPage(1);
|
||||||
}
|
void openDetail(openUserId);
|
||||||
}, [location.state, location.pathname, navigate]);
|
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
|
||||||
|
replace: true,
|
||||||
|
state: null,
|
||||||
|
});
|
||||||
|
}, [location.state, location.pathname, navigate, form]);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
const [res, logs] = await Promise.all([
|
const [res, logs] = await Promise.all([
|
||||||
@@ -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}
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
layout="vertical"
|
||||||
</Form.Item>
|
onFinish={() => {
|
||||||
<Form.Item name="userNo" label="用户编号">
|
setPage(1);
|
||||||
<Input placeholder="DK..." allowClear />
|
const next = new URLSearchParams();
|
||||||
</Form.Item>
|
const userId = String(form.getFieldValue('userId') || '').trim();
|
||||||
<Form.Item name="deviceKey" label="deviceKey">
|
const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim();
|
||||||
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
if (userId) next.set('userId', userId);
|
||||||
</Form.Item>
|
if (assoc) next.set('assocPartnerAccountId', assoc);
|
||||||
<Form.Item name="phoneVerified" label="验手机">
|
if (next.toString() !== searchParams.toString()) {
|
||||||
<Select allowClear style={{ width: 100 }} options={[
|
setSearchParams(next, { replace: true });
|
||||||
{ value: '1', label: '已验证' },
|
}
|
||||||
{ value: '0', label: '访客' },
|
void load();
|
||||||
]} />
|
}}
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item name="status" label="状态">
|
<div className="admin-orders-filter-row">
|
||||||
<Select allowClear style={{ width: 90 }} options={[
|
<Form.Item name="keyword" label="搜索" className="admin-orders-filter-item admin-users-filter-item--keyword">
|
||||||
{ value: 1, label: '正常' },
|
<Input allowClear placeholder="编号/昵称/备注/手机" />
|
||||||
{ value: 0, label: '停用' },
|
</Form.Item>
|
||||||
]} />
|
<Form.Item
|
||||||
</Form.Item>
|
name="assocPartnerAccountId"
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
label="关联合伙人"
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
className="admin-orders-filter-item admin-orders-filter-item--status"
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item>
|
<Select
|
||||||
<Space>
|
allowClear
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
showSearch
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
optionFilterProp="label"
|
||||||
</Space>
|
placeholder="全部"
|
||||||
</Form.Item>
|
options={[
|
||||||
|
{ value: 'any', label: '已关联(全部)' },
|
||||||
|
{ value: ASSOC_UNBOUND, label: '未关联' },
|
||||||
|
...partners.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: formatAssocPartner(p),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (searchParams.toString()) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
} else {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setFiltersCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtersCollapsed ? '展开' : '收起'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtersCollapsed ? (
|
||||||
|
<div className="admin-orders-filter-row">
|
||||||
|
<Form.Item name="phone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userNo" label="用户编号" className="admin-orders-filter-item admin-orders-filter-item--md">
|
||||||
|
<Input placeholder="DK..." allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userId" label="用户ID" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Input placeholder="精确匹配" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phoneVerified" label="验手机" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={[
|
||||||
|
{ value: '1', label: '已验证' },
|
||||||
|
{ value: '0', label: '访客' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
|
<Select allowClear placeholder="全部" options={[
|
||||||
|
{ value: 1, label: '正常' },
|
||||||
|
{ value: 0, label: '停用' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -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: '操作',
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ 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';
|
||||||
|
|
||||||
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 +41,11 @@ 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/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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerAssocUserOrderItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerAssocUserOrderItem[]; total: number };
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssocOrdersPage() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
usePartnerPageView(userId ? 'partner_assoc_user_orders_view' : 'partner_assoc_orders_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerAssocUserOrderItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const path = userId
|
||||||
|
? `/partner/assoc/users/${userId}/orders?page=1&pageSize=50`
|
||||||
|
: '/partner/assoc/orders?page=1&pageSize=50';
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', path);
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title={userId ? '用户订单' : '关联用户订单'} onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 笔已付购酒单`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无订单</div>}
|
||||||
|
{items.map((o) => (
|
||||||
|
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{o.productName}</p>
|
||||||
|
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||||
|
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.payAmount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import type { PartnerAssocSummary } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
export default function AssocQrcodePage() {
|
||||||
|
usePartnerPageView('partner_assoc_qrcode_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc')
|
||||||
|
.then(setSummary)
|
||||||
|
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
async function downloadQr() {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/partner/assoc/qrcode', {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
|
'X-Client-App': 'PARTNER_H5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('下载失败');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (isWechatEnv()) {
|
||||||
|
setPreviewUrl(url);
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toastSuccess('已开始下载');
|
||||||
|
} catch (e) {
|
||||||
|
if (summary?.qrcodeUrl) {
|
||||||
|
setPreviewUrl(summary.qrcodeUrl);
|
||||||
|
toastSuccess('请长按图片保存到相册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastError(e instanceof Error ? e.message : '下载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="关联码" onBack={() => navigate(-1)} />
|
||||||
|
{loading && <div className="empty">加载中…</div>}
|
||||||
|
{!loading && (
|
||||||
|
<section className="partner-bill-card" style={{ margin: 16 }}>
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
||||||
|
用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算
|
||||||
|
</p>
|
||||||
|
{summary?.qrcodeUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl || summary.qrcodeUrl}
|
||||||
|
alt="关联码"
|
||||||
|
style={{ width: 220, height: 220, background: '#fff' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="body-md text-muted">关联码尚未生成,请稍后重试或联系总部补发</p>
|
||||||
|
)}
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
已关联 {summary?.userCount ?? 0} 人
|
||||||
|
</p>
|
||||||
|
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
||||||
|
下载二维码
|
||||||
|
</button>
|
||||||
|
{previewUrl && isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
|
微信内请长按上方图片保存
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerAssocUserItem[]; total: number; page: number; pageSize: number };
|
||||||
|
|
||||||
|
export default function AssocUsersPage() {
|
||||||
|
usePartnerPageView('partner_assoc_users_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', '/partner/assoc/users?page=1&pageSize=50');
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title="关联用户" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 人`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||||
|
{items.map((u) => (
|
||||||
|
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{u.nickname || u.userNo}</p>
|
||||||
|
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||||
|
<p className="label-md text-muted">{u.boundAt ? u.boundAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="label-md">{u.orderCount} 单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
@@ -34,6 +34,7 @@ export default function BillsPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [detail, setDetail] = useState<PartnerBillDetailDto | null>(null);
|
||||||
|
|
||||||
async function loadBills() {
|
async function loadBills() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -67,6 +68,44 @@ export default function BillsPage() {
|
|||||||
const isRejected = status === 'REJECTED';
|
const isRejected = status === 'REJECTED';
|
||||||
const isPaid = status === 'PAID';
|
const isPaid = status === 'PAID';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bill?.id) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<PartnerBillDetailDto>('PARTNER_H5', `/partner/settlement/bills/${bill.id}`)
|
||||||
|
.then(setDetail)
|
||||||
|
.catch(() => setDetail(null));
|
||||||
|
}, [bill?.id]);
|
||||||
|
|
||||||
|
function renderItemList(title: string, items: PartnerBillItemDto[] | undefined, subtotal: number) {
|
||||||
|
const list = items ?? [];
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div className="partner-info-row" style={{ marginBottom: 8 }}>
|
||||||
|
<span className="body-md" style={{ fontWeight: 700 }}>{title}</span>
|
||||||
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="label-md text-muted">本账期无明细</p>
|
||||||
|
) : (
|
||||||
|
list.map((it) => (
|
||||||
|
<div key={it.id} className="partner-info-row" style={{ alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{it.title || it.refNo}</p>
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
{it.refNo}
|
||||||
|
{it.extra ? ` ${it.extra}` : ''} · {((it.rate ?? 0) * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="body-md">¥ {fmtMoney(it.commission)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function askConfirm(ids: string[]) {
|
function askConfirm(ids: string[]) {
|
||||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||||
return;
|
return;
|
||||||
@@ -183,16 +222,8 @@ export default function BillsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
{renderItemList('酒订单', detail?.orderItems, Number(detail?.orderCommission ?? bill.orderCommission ?? 0))}
|
||||||
<div className="partner-info-row">
|
{renderItemList('核销订单', detail?.redeemItems, Number(detail?.redeemCommission ?? bill.redeemCommission ?? 0))}
|
||||||
<span className="text-variant body-md">订单分佣收入</span>
|
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="text-variant body-md">核销权益分佣</span>
|
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -219,6 +219,24 @@ 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="/users" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">group</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>用户管理</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/center/commissions" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">payments</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>订单佣金</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
<Link to="/center/proxy-orders" className="partner-menu-item">
|
<Link to="/center/proxy-orders" className="partner-menu-item">
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon">
|
<div className="partner-menu-icon">
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerCommissionOrderItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerCommissionOrderItem[]; total: number };
|
||||||
|
|
||||||
|
export default function CommissionOrdersPage() {
|
||||||
|
usePartnerPageView('partner_commission_orders_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerCommissionOrderItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', '/partner/commissions/orders?page=1&pageSize=50');
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title="订单佣金" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 笔关联酒单`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无订单佣金</div>}
|
||||||
|
{items.map((o) => (
|
||||||
|
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{o.productName}</p>
|
||||||
|
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||||
|
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.commission)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
实付 ¥{fmtMoney(o.payAmount)} × {((o.rate ?? 0) * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
import type { PartnerAssocStats, PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
@@ -153,6 +153,7 @@ export default function HomePage() {
|
|||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||||
|
const [assocStats, setAssocStats] = useState<PartnerAssocStats | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = '工作台';
|
document.title = '工作台';
|
||||||
@@ -197,6 +198,16 @@ export default function HomePage() {
|
|||||||
setStores([]);
|
setStores([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
tasks.push(
|
||||||
|
request<PartnerAssocStats>('PARTNER_H5', '/partner/assoc/stats', { silent: true })
|
||||||
|
.then(setAssocStats)
|
||||||
|
.catch(() => setAssocStats(null)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setAssocStats(null);
|
||||||
|
}
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
fetchPartnerLeaderboard('month')
|
fetchPartnerLeaderboard('month')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -208,7 +219,7 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Promise.all(tasks).then(() => undefined);
|
return Promise.all(tasks).then(() => undefined);
|
||||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
}, [navigate, account, canOrders, canDashboard, canStores, isPrimary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHome();
|
void loadHome();
|
||||||
@@ -287,6 +298,51 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPrimary && (
|
||||||
|
<>
|
||||||
|
<Link to="/users" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
<div className="partner-bento-header">
|
||||||
|
<h2 className="headline-md">关联用户</h2>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.userTotal ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stats">
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本日</p>
|
||||||
|
<p className="headline-md">{assocStats?.userToday ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本月</p>
|
||||||
|
<p className="headline-md">{assocStats?.userMonth ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link to="/users/orders" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
<div className="partner-bento-header">
|
||||||
|
<h2 className="headline-md">关联用户订单</h2>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.orderTotal ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stats">
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本日</p>
|
||||||
|
<p className="headline-md">{assocStats?.orderToday ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">本月</p>
|
||||||
|
<p className="headline-md">{assocStats?.orderMonth ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isWarehouse && (
|
{isWarehouse && (
|
||||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function initialDraft(): ProxyOrderDraft {
|
|||||||
promoCodeId: '',
|
promoCodeId: '',
|
||||||
deliveryMode: 'ADDRESS',
|
deliveryMode: 'ADDRESS',
|
||||||
autoReceive: false,
|
autoReceive: false,
|
||||||
|
assocEnabled: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ export default function ProxyOrderPage() {
|
|||||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||||
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
||||||
|
const [assocEnabled, setAssocEnabled] = useState(draft0.assocEnabled !== false);
|
||||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -108,6 +110,7 @@ export default function ProxyOrderPage() {
|
|||||||
promoCodeId,
|
promoCodeId,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive,
|
autoReceive,
|
||||||
|
assocEnabled,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,6 +157,7 @@ export default function ProxyOrderPage() {
|
|||||||
promoCodeId,
|
promoCodeId,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive,
|
autoReceive,
|
||||||
|
assocEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
@@ -390,6 +394,9 @@ export default function ProxyOrderPage() {
|
|||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
skuId: skuId || undefined,
|
skuId: skuId || undefined,
|
||||||
|
assocPartnerAccountId: assocEnabled
|
||||||
|
? options?.partners?.[0]?.id || undefined
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -595,6 +602,17 @@ export default function ProxyOrderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assocEnabled}
|
||||||
|
onChange={(e) => setAssocEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>关联合伙人(本单计订单佣金;取消则本单不计)</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
{(options?.promoCodes.length ?? 0) > 0 && (
|
{(options?.promoCodes.length ?? 0) > 0 && (
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">绑定推广码(选填)</label>
|
<label className="partner-form-label">绑定推广码(选填)</label>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { 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 { 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 [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]);
|
||||||
|
|
||||||
|
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 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 : '下载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.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 downloadQr()}>
|
||||||
|
下载二维码
|
||||||
|
</button>
|
||||||
|
{previewUrl && isWechatEnv() && (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>微信内请长按上方图片保存</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="headline-md" style={{ marginBottom: 12 }}>已关联用户</h2>
|
||||||
|
<div className="partner-search">
|
||||||
|
<span className="material-symbols-outlined">search</span>
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
placeholder="搜索昵称 / 手机 / 备注 / 编号"
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void loadUsers();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="partner-filter-row" style={{ marginBottom: 12, paddingLeft: 0, paddingRight: 0 }}>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
className={`partner-filter-tab${sort === s.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setSort(s.key)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 12 }}>
|
||||||
|
{loading ? '加载中…' : `共 ${total} 人`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||||
|
{items.map((u) => (
|
||||||
|
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{formatUserLabel(u)}</p>
|
||||||
|
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||||
|
<p className="label-md text-muted">注册 {fmtTime(u.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="label-md text-primary"
|
||||||
|
style={{ background: 'none', border: 0, padding: 0 }}
|
||||||
|
onClick={() => navigate(`/users/${u.id}/orders`)}
|
||||||
|
>
|
||||||
|
{u.orderCount} 单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="label-md text-primary"
|
||||||
|
style={{ background: 'none', border: 0, padding: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(u);
|
||||||
|
setRemarkDraft(u.partnerRemark ?? '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.partnerRemark ? '改备注' : '添加备注'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="partner-remark-mask" onClick={() => setEditing(null)}>
|
||||||
|
<div className="partner-remark-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p className="headline-md" style={{ marginBottom: 8 }}>用户备注</p>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 12 }}>仅自己可见,总部看不到</p>
|
||||||
|
<textarea
|
||||||
|
value={remarkDraft}
|
||||||
|
maxLength={128}
|
||||||
|
rows={3}
|
||||||
|
placeholder="最多 128 字"
|
||||||
|
style={{ width: '100%', padding: 12, borderRadius: 8, border: '1px solid #eee' }}
|
||||||
|
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="partner-ship-actions">
|
||||||
|
<button type="button" className="partner-btn-secondary" onClick={() => setEditing(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveRemark()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -278,6 +278,22 @@ html {
|
|||||||
|
|
||||||
.partner-btn-primary:active { transform: scale(0.98); }
|
.partner-btn-primary:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.partner-btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-container-low);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-btn-ghost {
|
.partner-btn-ghost {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -1344,7 +1360,7 @@ nav.app-tabbar {
|
|||||||
nav.app-tabbar .app-tabbar-item {
|
nav.app-tabbar .app-tabbar-item {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 33.333%;
|
max-width: 25%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 6px 0 4px;
|
padding: 6px 0 4px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -3932,3 +3948,26 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineAppConfig({
|
|||||||
{ root: 'pages/login', pages: ['index'] },
|
{ root: 'pages/login', pages: ['index'] },
|
||||||
{ root: 'pages/user-agreement', pages: ['index'] },
|
{ root: 'pages/user-agreement', pages: ['index'] },
|
||||||
{ root: 'pages/privacy-policy', pages: ['index'] },
|
{ root: 'pages/privacy-policy', pages: ['index'] },
|
||||||
|
{ root: 'pages/benefit-rules', pages: ['index'] },
|
||||||
{ root: 'pages/invoice-titles', pages: ['index'] },
|
{ root: 'pages/invoice-titles', pages: ['index'] },
|
||||||
{ root: 'pages/invoice-apply', pages: ['index'] },
|
{ root: 'pages/invoice-apply', pages: ['index'] },
|
||||||
],
|
],
|
||||||
@@ -75,7 +76,7 @@ export default defineAppConfig({
|
|||||||
pagePath: 'pages/benefit/index',
|
pagePath: 'pages/benefit/index',
|
||||||
text: '好客权益',
|
text: '好客权益',
|
||||||
iconPath: 'assets/tabbar/benefit.png',
|
iconPath: 'assets/tabbar/benefit.png',
|
||||||
selectedIconPath: 'assets/tabbar/benefit-active.png',
|
selectedIconPath: 'assets/icons/store-benefit-y.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pagePath: 'pages/mine/index',
|
pagePath: 'pages/mine/index',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
@import './styles/benefit.css';
|
@import './styles/benefit.css';
|
||||||
@import './styles/mine.css';
|
@import './styles/mine.css';
|
||||||
@import './styles/address.css';
|
@import './styles/address.css';
|
||||||
|
@import './styles/benefit-promo.css';
|
||||||
|
@import './components/JiuzuSplash.css';
|
||||||
|
|
||||||
page,
|
page,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
|||||||
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 { prefetchJiuzuSplashAssets } from './lib/jiuzu-splash';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||||
patchTaroH5Hooks();
|
patchTaroH5Hooks();
|
||||||
installClientErrorReporting();
|
installClientErrorReporting();
|
||||||
prefetchShareBrandAssets();
|
prefetchShareBrandAssets();
|
||||||
|
prefetchJiuzuSplashAssets();
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
const handlingRef = useRef(false);
|
const handlingRef = useRef(false);
|
||||||
|
|||||||
@@ -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 { prefetchJiuzuSplashAssets } from './lib/jiuzu-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();
|
||||||
|
prefetchJiuzuSplashAssets();
|
||||||
|
|
||||||
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,266 @@
|
|||||||
|
/* 酒祖杜康开场:黑红底 → GIF 播完消失 → 四字上移 → 副标题跟上 */
|
||||||
|
|
||||||
|
.jiuzu-splash {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10010;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #140808;
|
||||||
|
background-image: radial-gradient(ellipse at 50% 42%, #5a1014 0%, #2a080a 48%, #140808 100%);
|
||||||
|
animation: jiuzu-bg-in 0.35s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash--out {
|
||||||
|
animation: jiuzu-bg-out 0.8s ease-in forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-mist {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-mist--a {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
left: -72px;
|
||||||
|
top: 12%;
|
||||||
|
background: rgba(166, 29, 36, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-mist--b {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
right: -56px;
|
||||||
|
bottom: 16%;
|
||||||
|
background: rgba(90, 16, 20, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-gif {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-gif--out {
|
||||||
|
animation: jiuzu-bg-out 0.35s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-gif img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-copy {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 26%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-lockup {
|
||||||
|
position: relative;
|
||||||
|
width: 320px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-title {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(48vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash--after-gif .jiuzu-splash-title {
|
||||||
|
animation: jiuzu-title-rise 2.4s cubic-bezier(0.22, 1, 0.32, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-mark {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 320px;
|
||||||
|
height: 180px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash--after-gif .jiuzu-splash-mark {
|
||||||
|
animation: fadeInTo4 1s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-mark img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-chars {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-char {
|
||||||
|
width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-char-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif;
|
||||||
|
font-size: 46px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-align: center;
|
||||||
|
color: #f5d76e;
|
||||||
|
text-shadow: 0 0 12px rgba(255, 191, 0, 0.85), 0 0 28px rgba(20, 8, 8, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
bottom: -10%;
|
||||||
|
width: 36px;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 248, 210, 0.55) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-80px) skewX(-18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash--after-gif .jiuzu-splash-shimmer {
|
||||||
|
animation: jiuzu-shimmer 0.7s 2.2s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-sub {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(36vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash--after-gif .jiuzu-splash-sub {
|
||||||
|
animation: jiuzu-sub-rise 0.4s 1.3s cubic-bezier(0.22, 1, 0.32, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-sub-text {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
color: rgba(245, 215, 110, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-skip {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(245, 215, 110, 0.45);
|
||||||
|
background: rgba(20, 8, 8, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jiuzu-splash-skip-text {
|
||||||
|
color: rgba(255, 248, 210, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInTo4 {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jiuzu-bg-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jiuzu-bg-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jiuzu-title-rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(48vh);
|
||||||
|
}
|
||||||
|
14% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(48vh);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jiuzu-sub-rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(36vh);
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(36vh);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jiuzu-shimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-80px) skewX(-18deg);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(280px) skewX(-18deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Image, Text, View } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types';
|
||||||
|
import { markJiuzuSplashPlayed } from '../lib/jiuzu-splash';
|
||||||
|
|
||||||
|
const CHARS = ['酒', '祖', '杜', '康'] as const;
|
||||||
|
|
||||||
|
const SPLASH_NAV_BG = '#140808';
|
||||||
|
const HOME_NAV_BG = '#FAF9F7';
|
||||||
|
/** GIF 21 帧 × 80ms,略提前淡出避免循环 */
|
||||||
|
const GIF_MS = 1650;
|
||||||
|
const GIF_FADE_MS = 350;
|
||||||
|
/** 四字显现并升到偏上位置 */
|
||||||
|
const TITLE_MS = 2400;
|
||||||
|
/** 副标题在四字到位后再升起 */
|
||||||
|
const SUB_DELAY_MS = 2300;
|
||||||
|
const SUB_MS = 1400;
|
||||||
|
const HOLD_MS = 900;
|
||||||
|
const FADE_MS = 800;
|
||||||
|
const FADE_AT_MS = GIF_MS + SUB_DELAY_MS + SUB_MS + HOLD_MS;
|
||||||
|
|
||||||
|
type JiuzuSplashProps = {
|
||||||
|
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 JiuzuSplash({ onDone }: JiuzuSplashProps) {
|
||||||
|
const finishedRef = useRef(false);
|
||||||
|
const fadingRef = useRef(false);
|
||||||
|
const onDoneRef = useRef(onDone);
|
||||||
|
onDoneRef.current = onDone;
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
const [gifDone, setGifDone] = useState(false);
|
||||||
|
const [gifGone, setGifGone] = 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(() => {
|
||||||
|
markJiuzuSplashPlayed();
|
||||||
|
applySplashChrome();
|
||||||
|
const gifTimer = setTimeout(() => setGifDone(true), GIF_MS);
|
||||||
|
const gifGoneTimer = setTimeout(() => setGifGone(true), GIF_MS + GIF_FADE_MS);
|
||||||
|
const exitTimer = setTimeout(beginExit, FADE_AT_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(gifTimer);
|
||||||
|
clearTimeout(gifGoneTimer);
|
||||||
|
clearTimeout(exitTimer);
|
||||||
|
if (!finishedRef.current) restoreChrome();
|
||||||
|
};
|
||||||
|
}, [beginExit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`jiuzu-splash${gifDone ? ' jiuzu-splash--after-gif' : ''}${leaving ? ' jiuzu-splash--out' : ''}`}
|
||||||
|
catchMove
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="jiuzu-splash-mist jiuzu-splash-mist--a" />
|
||||||
|
<View className="jiuzu-splash-mist jiuzu-splash-mist--b" />
|
||||||
|
|
||||||
|
{gifGone ? null : (
|
||||||
|
<Image
|
||||||
|
className={`jiuzu-splash-gif${gifDone ? ' jiuzu-splash-gif--out' : ''}`}
|
||||||
|
src={JIUZU_SPLASH_GIF_URL}
|
||||||
|
mode="aspectFill"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="jiuzu-splash-copy">
|
||||||
|
<View className="jiuzu-splash-lockup">
|
||||||
|
<Image
|
||||||
|
className="jiuzu-splash-mark"
|
||||||
|
src={JIUZU_SPLASH_MARK_URL}
|
||||||
|
mode="aspectFit"
|
||||||
|
style={{ width: '320px', height: '180px' }}
|
||||||
|
/>
|
||||||
|
<View className="jiuzu-splash-title">
|
||||||
|
<View className="jiuzu-splash-chars">
|
||||||
|
<View className="jiuzu-splash-shimmer" />
|
||||||
|
{CHARS.map((ch) => (
|
||||||
|
<View key={ch} className="jiuzu-splash-char">
|
||||||
|
<Text className="jiuzu-splash-char-text">{ch}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="jiuzu-splash-sub">
|
||||||
|
<Text className="jiuzu-splash-sub-text">千年酒祖 · 杜康好客</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="jiuzu-splash-skip" onClick={finish}>
|
||||||
|
<Text className="jiuzu-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';
|
|
||||||
|
|
||||||
type StoreRedeemMarqueeProps = {
|
export type StoreRedeemMarqueeItem = {
|
||||||
lines: string[];
|
userLabel: string;
|
||||||
|
amount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FLY_SPEED = 56;
|
type StoreRedeemMarqueeProps = {
|
||||||
const MIN_FLY_MS = 2400;
|
items: StoreRedeemMarqueeItem[];
|
||||||
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 {
|
const STAY_MS = 20000;
|
||||||
let w = 0;
|
|
||||||
for (const ch of text) {
|
|
||||||
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
|
|
||||||
}
|
|
||||||
return Math.max(Math.ceil(w), 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomPauseMs() {
|
|
||||||
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 容器宽兜底(不依赖 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
|
|
||||||
*
|
|
||||||
* 小程序注意:
|
|
||||||
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
|
|
||||||
* - 不用 Text + transform(支持差),改用 View + left
|
|
||||||
* - 字宽用估算,避免屏外元素测宽失败
|
|
||||||
*/
|
|
||||||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
|
||||||
const items = useMemo(
|
|
||||||
() =>
|
|
||||||
lines
|
|
||||||
.map((s) => String(s || '').trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
[lines],
|
|
||||||
);
|
|
||||||
|
|
||||||
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
|
|
||||||
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);
|
|
||||||
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!items.length) return;
|
|
||||||
|
|
||||||
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` };
|
|
||||||
|
|
||||||
|
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
||||||
return (
|
return (
|
||||||
<View id={rootIdRef.current} className="store-detail-marquee">
|
<View className="store-detail-marquee-inner">
|
||||||
<View className="store-detail-marquee-inner" style={innerStyle}>
|
<View className="store-detail-marquee-dot" />
|
||||||
<Text id={textIdRef.current} className="store-detail-marquee-text">
|
<Text className="store-detail-marquee-text">{item.userLabel} 刚刚到店核销</Text>
|
||||||
{current}
|
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
|
||||||
|
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||||
|
const list = useMemo(
|
||||||
|
() =>
|
||||||
|
items
|
||||||
|
.map((row) => ({
|
||||||
|
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
||||||
|
amount: String(row.amount || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.amount),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!list.length) return null;
|
||||||
|
|
||||||
|
if (list.length === 1) {
|
||||||
|
return (
|
||||||
|
<View className="store-detail-marquee">
|
||||||
|
<MarqueeRow item={list[0]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="store-detail-marquee">
|
||||||
|
<Swiper
|
||||||
|
className="store-detail-marquee-swiper"
|
||||||
|
vertical
|
||||||
|
circular
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '请理性饮酒。未满十八周岁不得饮酒。过量饮酒有害健康。';
|
||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/** Canvas 金龙:盘成一圈,仿照立体金龙的鳞片、须、角、爪与光晕 */
|
||||||
|
|
||||||
|
export type DragonCanvasNode = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
getContext: (type: '2d') => CanvasRenderingContext2D;
|
||||||
|
requestAnimationFrame?: (cb: (time: number) => void) => number;
|
||||||
|
cancelAnimationFrame?: (id: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpinePt = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ang: number;
|
||||||
|
nx: number;
|
||||||
|
ny: number;
|
||||||
|
w: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GOLD_HI = '#fff6c8';
|
||||||
|
const GOLD = '#ffbf00';
|
||||||
|
const GOLD_MID = '#e8a800';
|
||||||
|
const GOLD_DEEP = '#b87500';
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillOval(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
rw: number,
|
||||||
|
rh: number,
|
||||||
|
rot: number,
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(rot);
|
||||||
|
ctx.scale(Math.max(0.01, rw), Math.max(0.01, rh));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 1, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeInCubic(t: number) {
|
||||||
|
return t * t * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpine(cx: number, cy: number, r: number, phase: number, segs: number): SpinePt[] {
|
||||||
|
const pts: SpinePt[] = [];
|
||||||
|
const turns = 0.94;
|
||||||
|
for (let i = 0; i < segs; i++) {
|
||||||
|
const u = i / (segs - 1);
|
||||||
|
const ang = -Math.PI / 2 + u * Math.PI * 2 * turns;
|
||||||
|
const wobble = Math.sin(u * 14 + phase) * r * 0.042 + Math.sin(u * 5.5 - phase * 0.7) * r * 0.02;
|
||||||
|
const rr = r + wobble;
|
||||||
|
const nx = Math.cos(ang);
|
||||||
|
const ny = Math.sin(ang);
|
||||||
|
pts.push({
|
||||||
|
x: cx + nx * rr,
|
||||||
|
y: cy + ny * rr,
|
||||||
|
ang,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
w: lerp(20, 6.5, u ** 0.62),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeRibbon(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pts: SpinePt[],
|
||||||
|
widthScale: number,
|
||||||
|
color: string,
|
||||||
|
alpha: number,
|
||||||
|
) {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0].x, pts[0].y);
|
||||||
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||||
|
ctx.lineWidth = pts[Math.floor(pts.length * 0.15)].w * widthScale;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScales(ctx: CanvasRenderingContext2D, pts: SpinePt[]) {
|
||||||
|
for (let i = 2; i < pts.length - 1; i += 1) {
|
||||||
|
const p = pts[i];
|
||||||
|
const u = i / (pts.length - 1);
|
||||||
|
const ox = p.x + p.nx * p.w * 0.18;
|
||||||
|
const oy = p.y + p.ny * p.w * 0.18;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(ox, oy);
|
||||||
|
ctx.rotate(p.ang + Math.PI / 2);
|
||||||
|
ctx.fillStyle = i % 2 === 0 ? GOLD_HI : GOLD;
|
||||||
|
ctx.globalAlpha = 0.55 + (1 - u) * 0.25;
|
||||||
|
fillOval(ctx, 0, 0, p.w * 0.55, p.w * 0.38, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpines(ctx: CanvasRenderingContext2D, pts: SpinePt[]) {
|
||||||
|
ctx.fillStyle = GOLD_HI;
|
||||||
|
for (let i = 3; i < pts.length - 6; i += 3) {
|
||||||
|
const p = pts[i];
|
||||||
|
const len = p.w * 1.35;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x + p.nx * p.w * 0.2, p.y + p.ny * p.w * 0.2);
|
||||||
|
ctx.lineTo(
|
||||||
|
p.x + p.nx * (p.w + len),
|
||||||
|
p.y + p.ny * (p.w + len),
|
||||||
|
);
|
||||||
|
const tx = -p.ny;
|
||||||
|
const ty = p.nx;
|
||||||
|
ctx.lineTo(p.x + tx * 2.2, p.y + ty * 2.2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawClaw(ctx: CanvasRenderingContext2D, p: SpinePt, side: number) {
|
||||||
|
const tx = -p.ny * side;
|
||||||
|
const ty = p.nx * side;
|
||||||
|
const baseX = p.x + tx * p.w * 0.7;
|
||||||
|
const baseY = p.y + ty * p.w * 0.7;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(baseX, baseY);
|
||||||
|
ctx.rotate(Math.atan2(ty, tx));
|
||||||
|
ctx.fillStyle = GOLD;
|
||||||
|
ctx.strokeStyle = GOLD_DEEP;
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
for (let k = -1; k <= 1; k++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, k * 4);
|
||||||
|
ctx.quadraticCurveTo(10, k * 6 - 2, 18, k * 5);
|
||||||
|
ctx.quadraticCurveTo(10, k * 4, 0, k * 3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHead(ctx: CanvasRenderingContext2D, p: SpinePt, phase: number) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x + p.nx * 10, p.y + p.ny * 10);
|
||||||
|
ctx.rotate(Math.atan2(p.ny, p.nx) + Math.PI / 2);
|
||||||
|
|
||||||
|
const mane = 6;
|
||||||
|
for (let i = 0; i < mane; i++) {
|
||||||
|
const a = -0.9 + (i / (mane - 1)) * 1.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = i % 2 ? GOLD_HI : GOLD;
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.lineWidth = 2.2;
|
||||||
|
ctx.moveTo(Math.sin(a) * 6, -4);
|
||||||
|
ctx.quadraticCurveTo(Math.sin(a) * 16, -18 - Math.sin(phase + i) * 3, Math.sin(a) * 8, -28);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-7, -18);
|
||||||
|
ctx.quadraticCurveTo(-16, -32, -5, -38);
|
||||||
|
ctx.quadraticCurveTo(-2, -26, -3, -16);
|
||||||
|
ctx.fillStyle = GOLD_MID;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(7, -18);
|
||||||
|
ctx.quadraticCurveTo(16, -32, 5, -38);
|
||||||
|
ctx.quadraticCurveTo(2, -26, 3, -16);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const g = ctx.createRadialGradient(-4, -4, 2, 0, 4, 20);
|
||||||
|
g.addColorStop(0, GOLD_HI);
|
||||||
|
g.addColorStop(0.45, GOLD);
|
||||||
|
g.addColorStop(1, GOLD_DEEP);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
fillOval(ctx, 0, 2, 16, 18, 0);
|
||||||
|
|
||||||
|
ctx.fillStyle = GOLD_MID;
|
||||||
|
fillOval(ctx, 0, 10, 9, 8, 0);
|
||||||
|
|
||||||
|
for (const sx of [-6.5, 6.5]) {
|
||||||
|
ctx.fillStyle = '#3a1a00';
|
||||||
|
fillOval(ctx, sx, -2, 3.2, 3.6, 0);
|
||||||
|
ctx.fillStyle = '#ffe566';
|
||||||
|
fillOval(ctx, sx, -2.4, 1.5, 1.7, 0);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
fillOval(ctx, sx - 0.5, -3, 0.6, 0.6, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = GOLD_HI;
|
||||||
|
ctx.lineWidth = 1.15;
|
||||||
|
ctx.globalAlpha = 0.9;
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(side * 12, 6);
|
||||||
|
ctx.quadraticCurveTo(side * 36, 10 + Math.sin(phase) * 2, side * 42, 22);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(side * 10, 9);
|
||||||
|
ctx.quadraticCurveTo(side * 28, 18, side * 34, 28);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSparks(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
r: number,
|
||||||
|
phase: number,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < 28; i++) {
|
||||||
|
const a = (i / 28) * Math.PI * 2 + phase * 0.35;
|
||||||
|
const rr = r * (0.72 + ((i * 17) % 10) / 40);
|
||||||
|
const x = cx + Math.cos(a) * rr + Math.sin(phase * 1.4 + i) * 4;
|
||||||
|
const y = cy + Math.sin(a) * rr + Math.cos(phase * 1.1 + i) * 3;
|
||||||
|
const s = 1.1 + (i % 5) * 0.35;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.globalAlpha = 0.25 + (Math.sin(phase * 2 + i) + 1) * 0.25;
|
||||||
|
ctx.fillStyle = i % 3 === 0 ? GOLD_HI : GOLD;
|
||||||
|
ctx.arc(x, y, s, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawJiuzuDragonFrame(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
elapsedMs: number,
|
||||||
|
) {
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height * 0.42;
|
||||||
|
const radius = Math.min(width, height) * 0.3;
|
||||||
|
|
||||||
|
const fadeIn = Math.min(1, elapsedMs / 380);
|
||||||
|
const spinT = Math.min(1, Math.max(0, (elapsedMs - 120) / 2050));
|
||||||
|
const flyT = Math.min(1, Math.max(0, (elapsedMs - 2200) / 1200));
|
||||||
|
const spin = spinT * Math.PI * 2;
|
||||||
|
const fly = easeInCubic(flyT);
|
||||||
|
const phase = elapsedMs / 220;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = fadeIn * (1 - fly);
|
||||||
|
ctx.translate(cx, cy + fly * -height * 0.42);
|
||||||
|
ctx.scale(1 + fly * 0.55, 1 + fly * 0.55);
|
||||||
|
ctx.rotate(spin);
|
||||||
|
ctx.translate(-cx, -cy);
|
||||||
|
|
||||||
|
const pts = buildSpine(cx, cy, radius, phase, 56);
|
||||||
|
strokeRibbon(ctx, pts, 2.4, 'rgba(255, 191, 0, 0.18)', 1);
|
||||||
|
strokeRibbon(ctx, pts, 1.55, 'rgba(255, 214, 80, 0.4)', 1);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0].x, pts[0].y);
|
||||||
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||||
|
const bodyGrad = ctx.createLinearGradient(cx - radius, cy, cx + radius, cy);
|
||||||
|
bodyGrad.addColorStop(0, GOLD_DEEP);
|
||||||
|
bodyGrad.addColorStop(0.5, GOLD);
|
||||||
|
bodyGrad.addColorStop(1, GOLD_HI);
|
||||||
|
ctx.strokeStyle = bodyGrad;
|
||||||
|
ctx.lineWidth = pts[0].w * 1.15;
|
||||||
|
ctx.shadowColor = 'rgba(255, 191, 0, 0.7)';
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawScales(ctx, pts);
|
||||||
|
drawSpines(ctx, pts);
|
||||||
|
drawClaw(ctx, pts[Math.floor(pts.length * 0.32)], 1);
|
||||||
|
drawClaw(ctx, pts[Math.floor(pts.length * 0.68)], -1);
|
||||||
|
drawHead(ctx, pts[0], phase);
|
||||||
|
drawSparks(ctx, cx, cy, radius, phase);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleDragonFrame(
|
||||||
|
canvas: DragonCanvasNode,
|
||||||
|
cb: (time: number) => void,
|
||||||
|
): number {
|
||||||
|
if (typeof canvas.requestAnimationFrame === 'function') {
|
||||||
|
return canvas.requestAnimationFrame(cb);
|
||||||
|
}
|
||||||
|
return requestAnimationFrame(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDragonFrame(canvas: DragonCanvasNode, id: number) {
|
||||||
|
if (typeof canvas.cancelAnimationFrame === 'function') {
|
||||||
|
canvas.cancelAnimationFrame(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
/** 冷启动会话内是否已播过「酒祖杜康」开场(进程级,切 Tab 不重播) */
|
||||||
|
|
||||||
|
let played = false;
|
||||||
|
|
||||||
|
export function hasJiuzuSplashPlayed() {
|
||||||
|
return played;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markJiuzuSplashPlayed() {
|
||||||
|
played = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冷启动预拉 OSS 开场图(仅 weapp;H5 的 getImageInfo 会走 CORS) */
|
||||||
|
export function prefetchJiuzuSplashAssets() {
|
||||||
|
if (played) return;
|
||||||
|
if (process.env.TARO_ENV !== 'weapp') return;
|
||||||
|
void Taro.getImageInfo({ src: JIUZU_SPLASH_GIF_URL }).catch(() => {});
|
||||||
|
void Taro.getImageInfo({ src: JIUZU_SPLASH_MARK_URL }).catch(() => {});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,40 +157,50 @@ 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) : '--'}
|
size="xl"
|
||||||
size="xl"
|
className="benefit-hero-value"
|
||||||
className="benefit-hero-value"
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="benefit-hero-logo">
|
|
||||||
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View className="benefit-hero-actions">
|
||||||
className="benefit-hero-cta"
|
<View
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
className="benefit-hero-cta benefit-hero-cta--primary"
|
||||||
>
|
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||||
<Text>去使用</Text>
|
>
|
||||||
|
<Text>去选好酒</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="benefit-hero-cta benefit-hero-cta--secondary"
|
||||||
|
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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 JiuzuSplash from '../../components/JiuzuSplash';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
|
import { hasJiuzuSplashPlayed } from '../../lib/jiuzu-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(() => !hasJiuzuSplashPlayed());
|
||||||
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 ? <JiuzuSplash onDone={() => setShowSplash(false)} /> : null}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '评价门店',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:03(Asia/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">
|
function readCachedRecord(): RedeemRecord | null {
|
||||||
<Text className="redeem-rating-label">{label}</Text>
|
try {
|
||||||
<View className="redeem-star-row">
|
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||||
{[1, 2, 3, 4, 5].map((score) => (
|
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
||||||
<Text
|
} catch {
|
||||||
key={score}
|
return null;
|
||||||
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
|
}
|
||||||
onClick={() => onChange(score)}
|
|
||||||
>
|
|
||||||
★
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedeemSuccessPage() {
|
export default function RedeemSuccessPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [serviceScore, setServiceScore] = useState(5);
|
const [fromHistory, setFromHistory] = useState(false);
|
||||||
const [envScore, setEnvScore] = useState(5);
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const rated = Boolean(record?.rating);
|
||||||
|
|
||||||
const record = useMemo<RedeemRecord | null>(() => {
|
const applyRating = useCallback((rating: StoreRatingDto) => {
|
||||||
try {
|
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
|
||||||
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
|
||||||
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
||||||
} catch {
|
setComment(String(rating.comment || ''));
|
||||||
return null;
|
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: score,
|
||||||
serviceScore,
|
envScore: score,
|
||||||
envScore,
|
comment: comment.trim(),
|
||||||
},
|
tags,
|
||||||
});
|
imageUrls,
|
||||||
toast('评价已提交', 'success');
|
},
|
||||||
}
|
});
|
||||||
} catch {
|
toast('评价已提交', 'success');
|
||||||
/* 评价失败不阻塞返回 */
|
setRecord((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
||||||
|
}
|
||||||
|
: 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) => (
|
||||||
|
<Text
|
||||||
|
key={value}
|
||||||
|
className={`review-star${value <= score ? ' review-star--active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!rated) setScore(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View className="review-card">
|
||||||
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
|
<View className="review-section-head">
|
||||||
onClick={() => {
|
<View className="review-section-bar" />
|
||||||
if (!loading) void submitRatingAndFinish();
|
<Text className="review-section-title">快捷标签</Text>
|
||||||
}}
|
</View>
|
||||||
>
|
<View className="review-tag-list">
|
||||||
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
|
{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>
|
||||||
<View className="redeem-cancel-btn" onClick={goHome}>
|
|
||||||
<Text>回到首页</Text>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:30(Asia/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">
|
||||||
<Text className="store-detail-name">{store.name}</Text>
|
{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>
|
||||||
|
{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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
if (!matchesRegionFilter(s, region)) return false;
|
const list = stores.filter((s) => {
|
||||||
if (!matchesCategory(s)) return false;
|
if (!matchesRegionFilter(s, region)) return false;
|
||||||
if (!keyword.trim()) return true;
|
if (!matchesCategory(s)) return false;
|
||||||
const q = keyword.trim();
|
if (!keyword.trim()) return true;
|
||||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
const q = keyword.trim();
|
||||||
});
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -88,6 +88,10 @@
|
|||||||
padding: var(--space-md) var(--space-page) var(--space-lg);
|
padding: var(--space-md) var(--space-page) var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-intro {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail-price {
|
.product-detail-price {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|||||||
@@ -328,17 +328,247 @@
|
|||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-success-icon {
|
.review-page-body {
|
||||||
width: 80px;
|
padding-bottom: 32px;
|
||||||
height: 80px;
|
}
|
||||||
|
|
||||||
|
.review-store-card,
|
||||||
|
.review-card {
|
||||||
|
margin: 0 var(--space-page) 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-cover {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-surface-container);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-cover--empty {
|
||||||
|
background: var(--color-surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-name {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-store-visit {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card-title {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-star-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-star {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #d8d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-star--active {
|
||||||
|
color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-score-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-section-bar {
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f0ee;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-tag--active {
|
||||||
|
background: rgba(166, 29, 36, 0.1);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-comment {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f7f4ef;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-comment--native {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f0ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo-img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(45, 106, 79, 0.12);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
color: var(--color-success-green);
|
color: #fff;
|
||||||
font-size: 40px;
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo-add {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed #d0c8b8;
|
||||||
|
background: #faf7f2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo-add-plus {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #b09a78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-photo-add-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #b09a78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-skip {
|
||||||
|
display: block;
|
||||||
|
margin: 4px var(--space-page) 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-submit {
|
||||||
|
margin: 8px var(--space-page) 8px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 48px auto 16px;
|
}
|
||||||
|
|
||||||
|
.review-submit--disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-disclaimer {
|
||||||
|
display: block;
|
||||||
|
margin: 0 var(--space-page) 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-success-title {
|
.redeem-success-title {
|
||||||
|
|||||||
@@ -95,17 +95,54 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 20px 16px;
|
padding: 16px;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-detail-name {
|
.store-detail-name {
|
||||||
display: block;
|
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-ink-black);
|
color: var(--color-ink-black);
|
||||||
margin-bottom: 8px;
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-rating-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-stars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-star {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-star--on {
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-redeem {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-meta {
|
.store-detail-meta {
|
||||||
@@ -148,52 +185,73 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-tag {
|
.store-detail-tag {
|
||||||
padding: 4px 10px;
|
padding: 2px 8px;
|
||||||
margin-right: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(166, 29, 36, 0.08);
|
background: rgba(166, 29, 36, 0.08);
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-marquee-wrap {
|
.store-detail-marquee-wrap {
|
||||||
margin: 0 var(--space-page) 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-marquee {
|
.store-detail-marquee {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 10px;
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
background: #fff7f6;
|
background: #faf6ee;
|
||||||
border: 1px solid rgba(166, 29, 36, 0.12);
|
border: 1px solid rgba(201, 162, 62, 0.4);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 40px;
|
height: 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-detail-marquee-swiper {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-detail-marquee-inner {
|
.store-detail-marquee-inner {
|
||||||
position: absolute;
|
height: 32px;
|
||||||
top: 50%;
|
|
||||||
margin-top: -10px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-marquee-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8a317;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-marquee-text {
|
.store-detail-marquee-text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
color: #5a413f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-marquee-amount {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 700;
|
||||||
color: #a61d24;
|
color: #a61d24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.benefit-intro-card.store-detail-benefit-intro {
|
||||||
|
margin: 0 var(--space-page) 16px;
|
||||||
|
background: #fffaf0;
|
||||||
|
border-color: rgba(201, 162, 62, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.store-detail-section {
|
.store-detail-section {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
margin: 0 var(--space-page) 16px;
|
margin: 0 var(--space-page) 16px;
|
||||||
@@ -203,15 +261,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-section-title {
|
.store-detail-section-title {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-section-title-bar {
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--color-heritage-red, #a61d24);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-section-title-text {
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-section-title--rule {
|
.store-detail-section-title--rule .store-detail-section-title-text {
|
||||||
color: var(--color-heritage-red, #a61d24);
|
color: var(--color-heritage-red, #a61d24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-slogan-wrap {
|
||||||
|
padding: 0 var(--space-page) 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-filter {
|
.store-filter {
|
||||||
padding: 0 var(--space-page) 12px;
|
padding: 0 var(--space-page) 12px;
|
||||||
}
|
}
|
||||||
@@ -46,8 +50,8 @@
|
|||||||
|
|
||||||
.store-search-btn {
|
.store-search-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-heritage-red);
|
background: var(--color-heritage-red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -56,26 +60,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-search-icon {
|
.store-search-btn-text {
|
||||||
position: relative;
|
font-size: 14px;
|
||||||
width: 14px;
|
font-weight: 600;
|
||||||
height: 14px;
|
line-height: 1;
|
||||||
border: 2px solid currentColor;
|
color: #fff;
|
||||||
border-radius: 50%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-search-icon::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: -5px;
|
|
||||||
bottom: -4px;
|
|
||||||
width: 7px;
|
|
||||||
height: 2px;
|
|
||||||
background: currentColor;
|
|
||||||
border-radius: 1px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-filter-row {
|
.store-filter-row {
|
||||||
@@ -148,7 +137,7 @@
|
|||||||
.store-card {
|
.store-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -195,7 +184,7 @@
|
|||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -226,6 +215,57 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(166, 29, 36, 0.1);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-row--rating {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-stars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-star {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-star--on {
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-redeem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* 第2行:地址最多两行 + 右对齐距离 */
|
/* 第2行:地址最多两行 + 右对齐距离 */
|
||||||
.store-card-row--mid {
|
.store-card-row--mid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -289,3 +329,7 @@
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-sort-sheet {
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
}
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,7 @@
|
|||||||
# 杜康好客 · V3.0 PRD
|
# 杜康好客 · V3.0 PRD
|
||||||
|
|
||||||
> **v3.0**(2026-07-10)· 产品事实源 · 冲突时 **V3 > V2**
|
> **v3.0**(2026-07-10)· 3.x 产品事实源 · 冲突时 **V3 > V2**
|
||||||
|
> **4.0 起**(关联码 / 订单佣金归属 / 合伙人账单明细)见 [`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
||||||
> 实现:[`v3编码手册`](./杜康好客-v3编码手册.md) · 审计:[`v3-现状对照`](./杜康好客-v3-现状对照.md)
|
> 实现:[`v3编码手册`](./杜康好客-v3编码手册.md) · 审计:[`v3-现状对照`](./杜康好客-v3-现状对照.md)
|
||||||
|
|
||||||
## 0. 说明
|
## 0. 说明
|
||||||
@@ -30,10 +31,12 @@
|
|||||||
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
||||||
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
||||||
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
|
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
|
||||||
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户;结算任务失败通知 + 提现通过/四账单 | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
|
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
|
||||||
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
||||||
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
||||||
|
|
||||||
|
**4.0 起**不再写入本表,见 [`v4-PRD`](./杜康好客-v4-PRD.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
||||||
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
||||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||||
|
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
||||||
- 核销详情展示核销门店主账户收款信息
|
- 核销详情展示核销门店主账户收款信息
|
||||||
|
|
||||||
### 3.4~3.8 Wave 能力
|
### 3.4~3.8 Wave 能力
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
||||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
||||||
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 |
|
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) |
|
||||||
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
||||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
||||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
|
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
|
||||||
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
|
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
|
||||||
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
|
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
|
||||||
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
|
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 |
|
||||||
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
|
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
|
||||||
|
|
||||||
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
|
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
|
||||||
@@ -63,11 +63,12 @@
|
|||||||
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
|
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
|
||||||
- 物流:现场提货 / 同城 / 跨城
|
- 物流:现场提货 / 同城 / 跨城
|
||||||
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
|
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
|
||||||
|
- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —)
|
||||||
- 测试单不推
|
- 测试单不推
|
||||||
|
|
||||||
### 4.3 核销成功 `redeem.success`
|
### 4.3 核销成功 `redeem.success`
|
||||||
|
|
||||||
补 `userName`、`userPhone`(脱敏,与支付播报一致)。
|
补 `userName`、`userPhone`(明文)、`userRemark`(HQ 用户备注,空则 —)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
|
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
|
||||||
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
|
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
|
||||||
|
|
||||||
账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。
|
账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。
|
||||||
|
|
||||||
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
|
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
|
||||||
|
|
||||||
@@ -95,8 +96,8 @@
|
|||||||
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
|
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
|
||||||
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
|
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
|
||||||
- [ ] HQ 新建门店:企微可见 HQ 名称
|
- [ ] HQ 新建门店:企微可见 HQ 名称
|
||||||
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
|
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确;用户昵称、明文手机、备注
|
||||||
- [ ] 核销成功:有用户昵称与脱敏手机
|
- [ ] 核销成功:有用户昵称、明文手机、备注
|
||||||
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
|
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
|
||||||
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
||||||
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
|
- [ ] 提现通过、四类账单生成含城市/合伙人、笔数、累计金额、收款人/账号/开户行;无账单不刷屏
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 杜康好客 · V3 编码手册(交付业务版)
|
# 杜康好客 · V3 编码手册(交付业务版)
|
||||||
|
|
||||||
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
||||||
|
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
||||||
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
||||||
|
|
||||||
## 1. 交付目标(六条)
|
## 1. 交付目标(六条)
|
||||||
@@ -53,6 +54,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
||||||
|
|
||||||
|
**合伙人关联与订单佣金(v4.0.1)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `GET /partner/assoc` · `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。
|
||||||
|
|
||||||
## 5. 验收用例(必过)
|
## 5. 验收用例(必过)
|
||||||
|
|
||||||
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 杜康好客 · V4 PRD
|
||||||
|
|
||||||
|
> **v4.0**(2026-08-29)· 关联码与分佣事实源
|
||||||
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(仅本主题:订单佣金归属、关联码、合伙人账单明细)。
|
||||||
|
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
|
|
||||||
|
## 0. 版本
|
||||||
|
|
||||||
|
| 版 | 日期 | 要点 | 开发文档 |
|
||||||
|
|----|------|------|----------|
|
||||||
|
| 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) |
|
||||||
|
|
||||||
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
|
| 锚点 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 门店结算 | 核销额 × **60%** |
|
||||||
|
| 合伙人佣金池 | 订单 + 核销 ≤ 订单额 **5%**(默认 **0% + 3%**) |
|
||||||
|
| 订单佣金归属 | **仅关联用户**(或代下单显式选择的合伙人) |
|
||||||
|
| 核销佣金归属 | 门店拓店合伙人(不变) |
|
||||||
|
|
||||||
|
## 2. 关联码
|
||||||
|
|
||||||
|
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
||||||
|
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
||||||
|
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
||||||
|
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。
|
||||||
|
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
||||||
|
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
||||||
|
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。子账号无此 Tab。
|
||||||
|
- HQ:用户列表综合搜索 + 关联合伙人筛选(`none` 未关联 / `any` 已关联全部 / 指定主合伙人),筛选默认展开;订单列表按本单佣金快照筛关联合伙人;开城城市合伙人提供「关联用户」快链与「全部关联用户」快链,进入用户列表并带上筛选。
|
||||||
|
|
||||||
|
## 3. 订单佣金
|
||||||
|
|
||||||
|
支付快照字段:`user_order.partner_account_id_at_pay`、`order_commission_rate_at_pay`。
|
||||||
|
|
||||||
|
| 场景 | 写入 |
|
||||||
|
|------|------|
|
||||||
|
| C 端自助下单 | 用户已关联 → 关联合伙人 + 其订单费率;未关联 → 皆空 |
|
||||||
|
| 代下单选了合伙人 | 本单快照归该合伙人;用户尚未关联则 first-lock;已关联他人不换绑 |
|
||||||
|
| 代下单未选合伙人 | 本单不写佣金快照、不改用户关联 |
|
||||||
|
|
||||||
|
**删除**:按收货区县解析城市合伙人并写入订单佣金;代下单操作人回落填佣金快照。
|
||||||
|
|
||||||
|
管辖类型 / 区县仅作开城标识,不再参与订单佣金。
|
||||||
|
|
||||||
|
已支付订单不回刷。重算「待审核」账单只认快照字段(上线后仅关联/代下单选择会写入)。
|
||||||
|
|
||||||
|
## 4. 核销佣金
|
||||||
|
|
||||||
|
不变:核销发生在合伙人名下门店时,出账按该主合伙人当时核销费率 × 核销额。
|
||||||
|
|
||||||
|
## 5. 合伙人账单
|
||||||
|
|
||||||
|
月账(T+30)表头:`orderCommission` + `redeemCommission` = `totalAmount`。
|
||||||
|
|
||||||
|
明细两段:
|
||||||
|
|
||||||
|
| 段 | 来源 | 佣金 |
|
||||||
|
|----|------|------|
|
||||||
|
| 酒订单 | 账期内已付且 `partner_account_id_at_pay` = 本主合伙人 | 实付 × 支付快照费率 |
|
||||||
|
| 核销订单 | 账期内名下门店核销 | 核销额 × 出账时核销费率 |
|
||||||
|
|
||||||
|
HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照则全城已付单 × 当前费率」。
|
||||||
|
|
||||||
|
## 6. 不做
|
||||||
|
|
||||||
|
改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 杜康好客 · V4 现状对照
|
||||||
|
|
||||||
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
|
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
||||||
|
|
||||||
|
## 0. 总览(2026-08-29)
|
||||||
|
|
||||||
|
| 维度 | 结论 |
|
||||||
|
|------|------|
|
||||||
|
| 版本线 | **v4.0.1** 关联码 + 分佣账单 |
|
||||||
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
|
| 账单 | 酒订单 / 核销订单分列 |
|
||||||
|
|
||||||
|
## 1. 版本交付
|
||||||
|
|
||||||
|
| 版本 | 文档 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-08-29 | v4.0.1:合伙人关联码、订单佣金只认关联、账单两段明细、去掉区县酒单佣金、HQ 改费率修复 |
|
||||||
|
| 2026-08-30 | HQ 订单/用户详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户/订单筛选关联合伙人;开城关联用户快链 |
|
||||||
|
| 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 |
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 杜康好客 · v4.0.1 开发文档
|
||||||
|
|
||||||
|
> **2026-08-29 / 08-30** · iam / store / trade / settlement / ops / mini-user / h5-partner / admin-web
|
||||||
|
> **主题**:合伙人关联码;订单佣金只认关联;财务账单酒单/核销分列;去掉区县酒单佣金;修复 HQ 改订单佣金报错;HQ 用户/订单/开城关联筛选与快链
|
||||||
|
|
||||||
|
需求事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 关联码 | 需求 | 主合伙人自动小程序码;C 端首次扫码锁定;HQ 持 `users_partner_assoc` 可解绑/改绑 |
|
||||||
|
| 2 | 订单佣金 | 需求 | 只认关联 / 代下单显式选择;删除区县解析 |
|
||||||
|
| 3 | 代下单 | 需求 | HQ / 合伙人可选关联合伙人,可不选 |
|
||||||
|
| 4 | 账单 | 需求 | 酒订单 + 核销订单明细,分别汇总 |
|
||||||
|
| 5 | 合伙人 H5 | 需求 | 码/下载、用户管理 Tab(码+列表+备注)、首页关联统计、订单佣金列表、账单两段 |
|
||||||
|
| 6 | HQ 改费率 | 修复 | Decimal 写入 + 改费率不跑区县绑定校验 |
|
||||||
|
| 7 | HQ 关联运营 | 需求 | 用户/订单详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户列表搜索+关联合伙人筛选(默认展开);订单列表按本单快照筛关联合伙人;开城合伙人关联用户快链 |
|
||||||
|
|
||||||
|
**不做**:改推广码;改核销归属;子账号码;回刷已打款账单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
见 v4-PRD §2–§5。费率默认仍 **0% + 3%**,合计 ≤ 城市上限 5%。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 变更面
|
||||||
|
|
||||||
|
- Domain:删除 `resolveOrderCityPartner`
|
||||||
|
- Trade:支付快照只读用户关联或代下单 `assocPartnerAccountId`
|
||||||
|
- Settlement:`PartnerBillItem`;去掉全城回落
|
||||||
|
- Store / IAM:补码、绑定、解绑
|
||||||
|
- mini-user:scene `pa_*` 绑定
|
||||||
|
- h5-partner / admin-web:码、列表、代下单可选合伙人、账单两 Tab
|
||||||
|
- HQ 用户列表:`keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID);筛选默认展开;去掉 deviceKey / 过滤测试账号
|
||||||
|
- HQ 订单列表:`assocPartnerAccountId` 筛 `partner_account_id_at_pay`(`none`=无快照)
|
||||||
|
- HQ 开城城市合伙人:列表/详情「关联用户」快链 → `/users?assocPartnerAccountId=`;顶栏「全部关联用户」→ `=any`;关联码用户列表展示「昵称(备注)」并快链 `/users?userId=`
|
||||||
|
- 合伙人 H5 主账号:首页「关联用户 / 关联用户订单」卡(`GET /partner/assoc/stats`);底部「用户管理」Tab(`/users`,原 `/center/assoc*` 跳转);列表 `GET /partner/assoc/users?keyword&sort`;用户已付单 `GET /partner/assoc/users/:id/orders`;备注 `PUT /partner/assoc/users/:id/remark`(空串删除)
|
||||||
|
- 合伙人备注表 [`partner_user_note`](../server/dukang-api/prisma/schema.prisma);合伙人 API 去掉 `hqRemark`
|
||||||
|
- SQL:[`migrate-partner-assoc-v401.sql`](../server/dukang-api/prisma/migrate-partner-assoc-v401.sql) · [`migrate-partner-user-note-v401.sql`](../server/dukang-api/prisma/migrate-partner-user-note-v401.sql)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验收
|
||||||
|
|
||||||
|
- 新主合伙人有码;扫码未登录→登录后绑定;二次扫码不换绑
|
||||||
|
- 未关联用户支付:`partnerAccountIdAtPay` 空
|
||||||
|
- 已关联 / 代下单选了合伙人:快照正确
|
||||||
|
- HQ 只改订单佣金可保存
|
||||||
|
- 账单酒单 / 核销分列,合计与表头一致
|
||||||
|
- 合伙人 H5 可下载 PNG(微信内可预览长按保存)
|
||||||
|
- HQ 用户列表可按搜索/关联合伙人查询;筛选默认展开
|
||||||
|
- HQ 订单列表可按本单关联合伙人查询
|
||||||
|
- 开城城市合伙人可点关联用户数进入用户列表,并可查全部已关联用户
|
||||||
|
- HQ 持 `users_partner_assoc` 可在用户详情改绑/解绑
|
||||||
|
- 合伙人 H5 主账号首页两张统计卡可点进用户管理 / 关联用户订单;用户管理可搜排、改备注;子账号无该 Tab / 卡
|
||||||
|
- 合伙人备注只写 `partner_user_note`,换绑后下一合伙人看不到;H5 列表不出现 `hqRemark`
|
||||||
@@ -1,49 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
resolveOrderCityPartner,
|
|
||||||
validatePartnerCityBinding,
|
validatePartnerCityBinding,
|
||||||
validatePartnerCommissionRates,
|
validatePartnerCommissionRates,
|
||||||
} from './city-partner';
|
} from './city-partner';
|
||||||
|
|
||||||
describe('resolveOrderCityPartner', () => {
|
|
||||||
const bindings = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
partnerAccountId: '10',
|
|
||||||
scopeType: 'CITY_WIDE' as const,
|
|
||||||
districtCodes: null,
|
|
||||||
orderCommissionRate: 0,
|
|
||||||
redeemCommissionRate: 0.03,
|
|
||||||
bindingStatus: 'ACTIVE' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
partnerAccountId: '20',
|
|
||||||
scopeType: 'DISTRICT' as const,
|
|
||||||
districtCodes: ['410105', '金水区'],
|
|
||||||
orderCommissionRate: 0.01,
|
|
||||||
redeemCommissionRate: 0.03,
|
|
||||||
bindingStatus: 'ACTIVE' as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('prefers district partner when district matches adcode', () => {
|
|
||||||
const ref = resolveOrderCityPartner(bindings, '410105');
|
|
||||||
expect(ref?.partnerAccountId).toBe('20');
|
|
||||||
expect(ref?.scopeType).toBe('DISTRICT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to city-wide partner', () => {
|
|
||||||
const ref = resolveOrderCityPartner(bindings, '410102');
|
|
||||||
expect(ref?.partnerAccountId).toBe('10');
|
|
||||||
expect(ref?.scopeType).toBe('CITY_WIDE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when no bindings', () => {
|
|
||||||
expect(resolveOrderCityPartner([], '410105')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validatePartnerCityBinding', () => {
|
describe('validatePartnerCityBinding', () => {
|
||||||
it('rejects duplicate partner', () => {
|
it('rejects duplicate partner', () => {
|
||||||
const result = validatePartnerCityBinding(
|
const result = validatePartnerCityBinding(
|
||||||
|
|||||||
@@ -11,72 +11,11 @@ export interface PartnerCityBindingInput {
|
|||||||
companyName?: string | null;
|
companyName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartnerCityResolveRef {
|
|
||||||
id: string;
|
|
||||||
partnerAccountId: string;
|
|
||||||
scopeType: CityPartnerScopeType;
|
|
||||||
orderCommissionRate: number;
|
|
||||||
redeemCommissionRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartnerCityValidationResult {
|
export interface PartnerCityValidationResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDistrictCodes(codes?: string[] | null): string[] {
|
|
||||||
if (!codes?.length) return [];
|
|
||||||
return [...new Set(codes.map((c) => String(c).trim()).filter(Boolean))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 区县 adcode 或名称命中区域合伙;否则全城合伙 */
|
|
||||||
export function resolveOrderCityPartner(
|
|
||||||
bindings: Array<{
|
|
||||||
id: string;
|
|
||||||
partnerAccountId: string;
|
|
||||||
scopeType: CityPartnerScopeType;
|
|
||||||
districtCodes?: string[] | null;
|
|
||||||
orderCommissionRate: number;
|
|
||||||
redeemCommissionRate: number;
|
|
||||||
bindingStatus?: CityPartnerStatus;
|
|
||||||
}>,
|
|
||||||
receiverDistrict?: string | null,
|
|
||||||
): PartnerCityResolveRef | null {
|
|
||||||
const active = bindings.filter((b) => b.bindingStatus !== 'PAUSED');
|
|
||||||
if (!active.length) return null;
|
|
||||||
|
|
||||||
const districtKey = receiverDistrict?.trim();
|
|
||||||
if (districtKey) {
|
|
||||||
const districtHit = active.find((b) => {
|
|
||||||
if (b.scopeType !== 'DISTRICT') return false;
|
|
||||||
const codes = normalizeDistrictCodes(b.districtCodes);
|
|
||||||
return codes.some((code) => code === districtKey || districtKey.includes(code) || code.includes(districtKey));
|
|
||||||
});
|
|
||||||
if (districtHit) {
|
|
||||||
return {
|
|
||||||
id: districtHit.id,
|
|
||||||
partnerAccountId: districtHit.partnerAccountId,
|
|
||||||
scopeType: districtHit.scopeType,
|
|
||||||
orderCommissionRate: districtHit.orderCommissionRate,
|
|
||||||
redeemCommissionRate: districtHit.redeemCommissionRate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cityWide = active.find((b) => b.scopeType === 'CITY_WIDE');
|
|
||||||
if (cityWide) {
|
|
||||||
return {
|
|
||||||
id: cityWide.id,
|
|
||||||
partnerAccountId: cityWide.partnerAccountId,
|
|
||||||
scopeType: cityWide.scopeType,
|
|
||||||
orderCommissionRate: cityWide.orderCommissionRate,
|
|
||||||
redeemCommissionRate: cityWide.redeemCommissionRate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validatePartnerCityBinding(
|
export function validatePartnerCityBinding(
|
||||||
existing: PartnerCityBindingInput[],
|
existing: PartnerCityBindingInput[],
|
||||||
input: PartnerCityBindingInput,
|
input: PartnerCityBindingInput,
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export const MINI_USER_STATIC_OSS_BASE =
|
|||||||
/** 「我的」页资质公示长图(默认;系统设置 QUALIFICATION_DISCLOSURE_URL 可覆盖) */
|
/** 「我的」页资质公示长图(默认;系统设置 QUALIFICATION_DISCLOSURE_URL 可覆盖) */
|
||||||
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
||||||
|
|
||||||
|
/** 小程序开场金龙 GIF(冷启动;不进主包,走 OSS) */
|
||||||
|
export const JIUZU_SPLASH_GIF_URL = `${MINI_USER_STATIC_OSS_BASE}jiuzu-dragon.gif`;
|
||||||
|
|
||||||
|
/** 小程序开场酒祖印记图(冷启动;不进主包,走 OSS) */
|
||||||
|
export const JIUZU_SPLASH_MARK_URL = `${MINI_USER_STATIC_OSS_BASE}jiuzu-dragon-mark.png`;
|
||||||
|
|
||||||
/** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖) */
|
/** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖) */
|
||||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export interface DevPlanTaskDto {
|
|||||||
|
|
||||||
attachmentUrls?: string[] | null;
|
attachmentUrls?: string[] | null;
|
||||||
|
|
||||||
|
versions?: Array<{ id: string; versionNo: string }>;
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +170,8 @@ export interface UpdateDevPlanTaskInput {
|
|||||||
|
|
||||||
attachmentUrls?: string[];
|
attachmentUrls?: string[];
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user