34 Commits

Author SHA1 Message Date
jacy 309b180087 feat(admin): link city-partner store count; fix benefitUsageRule null string
CI / verify (pull_request) Has been cancelled
City partner storeCount jumps to /stores?partnerId=; normalize empty/null text so usage rule is not saved as literal "null.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:03:56 +08:00
jacy 118d57d710 feat(admin): delete store staff; city store count links to filtered list
CI / verify (pull_request) Has been cancelled
HQ can remove store sub-accounts from primary account detail; cities table store count jumps to /stores?cityId=.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:43:09 +08:00
jacy 0cb2b2cebb feat(admin): store detail edit with login phone sync; tighten HQ UI
CI / verify (pull_request) Has been cancelled
HQ store update now syncs store_account.phone for shop login; expand editable store fields. System version only for SUPER_ADMIN; permissions checklist layout polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:10:14 +08:00
jacy daeb24bbeb feat(admin): gate user/order/city delete behind assignable HQ permissions
CI / verify (pull_request) Has been cancelled
Add users_delete, orders_delete, cities_delete; default HQ (incl. super admin) has none until explicitly granted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:01:18 +08:00
jacy f4766d32ff fix(admin): make partner company/address/district optional on create UI
CI / verify (pull_request) Has been cancelled
Remove required asterisks on CityPartnersPage create form (main entry).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:44:19 +08:00
jacy b920c894b9 feat(admin): city delete with impact preview; optional partner fields
CI / verify (pull_request) Has been cancelled
Allow deleting open cities after listing partners/stores; company name, address, and districts optional on partner create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:33:12 +08:00
jacy 37038a7591 fix(api): surface LLM network errors for WeCom AI replies
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:59:49 +08:00
jacy 944306e45c fix(admin,partner): replace Tencent iframe picker with server LBS search
CI / verify (pull_request) Has been cancelled
Avoid locpicker formatted_addresses crash; proxy suggest/nearby/reverse via /common/lbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:56:50 +08:00
jacy 9ed0c24d11 fix(admin,partner): harden Tencent locpicker coords and confirm flow
CI / verify (pull_request) Has been cancelled
Accept latlng-only postMessage, confirm before apply, and read LBS key per request.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:47:08 +08:00
jacy 50349dd8e9 fix(mini-user): center qualification image on translucent mask
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:41:13 +08:00
jacy e2fd28a35b fix(partner): drop store-create SMS verify; keep qualification aspect ratio
CI / verify (pull_request) Has been cancelled
Partner H5 no longer requires store phone SMS on create; qualification overlay uses height auto with widthFix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:34:31 +08:00
jacy 15c35c49de 分享功能和资质文件展示 2026-07-28 00:31:31 +08:00
jacy a49d8d99b6 fix(partner): drop pickup store and reorder promo on proxy order
CI / verify (pull_request) Has been cancelled
On-site pickup no longer requires a store; move optional promo above fulfillment mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:19:52 +08:00
jacy aa9d689c76 fix(partner): guard null region on proxy order preview
CI / verify (pull_request) Has been cancelled
Avoid crash when province/city is not selected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:10:47 +08:00
jacy b59d4484ec feat(partner): complete proxy order with dual SMS and pickup
CI / verify (pull_request) Has been cancelled
Add partner/customer SMS confirm, address auto-receive or on-site
pickup, proxy placer fields, PARTNER_PROXY user source, and C-end badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:04:59 +08:00
jacy cd23119470 feat(mini-user): enable share on home/benefit and OSS qualification
CI / verify (pull_request) Has been cancelled
Wire WeChat share on home and benefit tabs; serve mine qualification
from OSS; mask phones on admin users and wechat bindings pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:41:29 +08:00
jacy 18ab639669 fix(store): use line comment for benefitUsageRule in Prisma schema
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:45 +08:00
jacy fc03905777 feat(store): benefit coupon usage rules and show env photos on store detail
CI / verify (pull_request) Has been cancelled
Add benefitUsageRule on store create/edit (HQ + partner) and C-end detail between intro and env gallery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:18 +08:00
jacy ea8152ebf3 fix(deploy): use scp -P for sync-api-env port option
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:20:43 +08:00
jacy 78973e3fbf fix(api): seed empty system settings from env including Tencent LBS key
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:54 +08:00
jacy 317f910f3c feat(admin,partner): Tencent map location picker and expose LBS key
Store create/edit on HQ and partner can pick coords via Tencent locpicker; client-config exposes TENCENT_LBS_KEY; seed empty system settings from env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:40 +08:00
jacy d36fe13bcd fix(admin,mini-user): store lat/lng edit with Baidu map, center pickup qty hint
CI / verify (pull_request) Has been cancelled
HQ store detail can set coordinates and open Baidu Map search by name/address; admin update API persists lat/lng.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:53:13 +08:00
jacy 15b878f2a4 fix(admin,mini-user): product on-sale tag, store coords inputs, home UX
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:40:57 +08:00
jacy 09eda9a11e H5端开店增加获取当前店铺位置 2026-07-27 22:37:15 +08:00
jacy 23c0cc7a5f 小程序优化,商铺定位等 2026-07-27 22:33:00 +08:00
jacy 1725b9e4b4 小程序加上公司资质
CI / verify (pull_request) Has been cancelled
2026-07-26 13:31:21 +08:00
jacy cc9933d1ce feat(admin): require business hours when creating stores
CI / verify (pull_request) Has been cancelled
HQ create-store wizard now collects 1-2 hour segments and avgPrice, and API validates them on create/update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:19:58 +08:00
jacy b810cabc6d fix(partner): restore clearAllStoreDrafts export
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:18:06 +08:00
jacy 25aee745cc fix(partner): export normalizeStringArray for store detail
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:24 +08:00
jacy 097c7dec4b fix(shop): resolve duplicate open binding on home page
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:03 +08:00
jacy 92cf51ba3d feat(store): dual business hours, avg price, and status lock
CI / verify (pull_request) Has been cancelled
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:16:22 +08:00
jacy 94d2a88581 chore(settlement): add finance bills mock seed script
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:08:01 +08:00
jacy da21cd3daa 系统判断配送限制 2026-07-26 09:59:38 +08:00
jacy 19cb312465 物流对账单 2026-07-26 09:51:33 +08:00
120 changed files with 7800 additions and 892 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
|-----|------|----------| |-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 | | ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 | | ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3手机号建用户;权益自动发 | | ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 | | ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 | | ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 | | ACC-018 | 话术 | OPT-012 3 日内对齐 |
+2
View File
@@ -31,6 +31,7 @@ import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage'; import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage'; import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage'; import InvoicesPage from './pages/InvoicesPage';
@@ -95,6 +96,7 @@ export default function App() {
<Route path="/finance/store-bills" element={<StoreBillsPage />} /> <Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} /> <Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} /> <Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} /> <Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} /> <Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} /> <Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
@@ -281,11 +281,11 @@ export default function CityPartnersPanel({
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
} }
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
};
function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
const b = typeof lng === 'number' ? lng : Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
}
export default function TencentLocPickerModal({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索,或加载附近地点');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '加载附近地点失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) {
message.warning('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '搜索失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
message.error('定位失败,请检查浏览器定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
function confirmPick() {
if (!pending) {
message.warning('请先从列表中选择一个地点');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="地图选点(腾讯位置服务)"
open={open}
onCancel={onClose}
width={720}
destroyOnClose
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '搜索或选择附近地点后确认'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="输入小区 / 写字楼 / 门店名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void runSearch(keyword)}
/>
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
</Button>
</Space.Compact>
<Space wrap>
<Button onClick={useBrowserLocation} disabled={loading}>
</Button>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Space>
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
{loading && !items.length ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="加载中…" />
</div>
) : items.length ? (
<List
size="small"
dataSource={items}
renderItem={(item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<List.Item
style={{
cursor: 'pointer',
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
paddingInline: 12,
}}
onClick={() => setPending(placeToPicked(item))}
>
<List.Item.Meta
title={item.title}
description={
<span>
{item.address}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</Typography.Text>
</span>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty style={{ marginTop: 80 }} description={hint} />
)}
</div>
</Space>
</Modal>
);
}
@@ -75,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
], ],
}, },
{ {
@@ -156,6 +157,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/finance/store-bills': 'finance', '/finance/store-bills': 'finance',
'/finance/partner-bills': 'finance', '/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance', '/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'benefit-group': 'benefit', 'benefit-group': 'benefit',
'/benefit/coupons': 'benefit', '/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit', '/benefit/ledgers': 'benefit',
+2
View File
@@ -146,6 +146,8 @@ export type AdminOrderRow = {
city?: { id: string; name: string; code: string }; city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null; fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null; fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
+17 -2
View File
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
export const STORE_STATUS_LABELS: Record<string, string> = { export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中', OPEN: '营业中',
PAUSED: '暂停', PAUSED: '临时闭店',
CLOSED: '关闭', CLOSED: '永久关闭',
}; };
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = { export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
@@ -128,3 +128,18 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
export function fmtTime(v?: string | null) { export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—'; return v ? new Date(v).toLocaleString('zh-CN') : '—';
} }
/** 手机号脱敏展示:138****5678;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
}
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
+1
View File
@@ -27,6 +27,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' }, { value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' }, { value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' }, { value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' }, { value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
+45 -1
View File
@@ -11,7 +11,15 @@ export type StoreCreateForm = {
name: string; name: string;
phone: string; phone: string;
address: string; address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string; intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string; coverUrl?: string;
envPhotoUrls?: string[]; envPhotoUrls?: string[];
contractUrl?: string; contractUrl?: string;
@@ -23,6 +31,12 @@ export type StoreCreateForm = {
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; const BANK_RE = /^\d{16,19}$/;
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreCreateStep1( export function validateStoreCreateStep1(
form: Pick< form: Pick<
@@ -35,6 +49,12 @@ export function validateStoreCreateStep1(
| 'phone' | 'phone'
| 'address' | 'address'
| 'intro' | 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>, >,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -45,9 +65,33 @@ export function validateStoreCreateStep1(
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址'; if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
const closeTime = String(form.closeTime || '').trim();
if (!openTime || !closeTime) return '请填写营业时间';
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
const openTime2 = String(form.openTime2 || '').trim();
const closeTime2 = String(form.closeTime2 || '').trim();
if (openTime2 || closeTime2) {
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
}
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (form.intro?.trim()) { if (form.intro?.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; return null;
} }
@@ -0,0 +1,26 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+270 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -17,7 +18,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -50,6 +51,40 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null }; type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() { export default function CitiesPage() {
@@ -82,6 +117,18 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm); const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => { const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,22 +176,85 @@ export default function CitiesPage() {
setDrawerOpen(true); setDrawerOpen(true);
}; };
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, { title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ title: '门店', dataIndex: 'storeCount', width: 70 }, {
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 }, { title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
), ),
}, },
]; ];
@@ -264,6 +374,26 @@ export default function CitiesPage() {
setDetail(refreshed); setDetail(refreshed);
void reload(); void reload();
}}></Button> }}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form> </Form>
</> </>
), ),
@@ -407,6 +537,140 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div> </div>
); );
} }
+24 -12
View File
@@ -301,7 +301,16 @@ export default function CityPartnersPage() {
ellipsis: true, ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ title: '门店', dataIndex: 'storeCount', width: 60 }, {
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${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 },
{ {
@@ -444,7 +453,11 @@ export default function CityPartnersPage() {
<> <>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item> <Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item> <Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 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">
@@ -460,8 +473,8 @@ export default function CityPartnersPage() {
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -472,8 +485,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话"> <Form.Item name="contactPhone" label="业务联系电话">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
@@ -580,8 +593,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)} onChange={(id) => void onCreateCityChange(id)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -589,8 +602,8 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
@@ -599,8 +612,7 @@ export default function CityPartnersPage() {
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={createCityCode} /> <CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
+22 -12
View File
@@ -52,8 +52,18 @@ export default function DashboardPage() {
request<DashboardStats>('/admin/dashboard/stats') request<DashboardStats>('/admin/dashboard/stats')
.then(setStats) .then(setStats)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
void loadVersion(); request<HqProfile>('/admin/auth/me')
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); .then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]); }, [loadVersion]);
function handleDeploy() { function handleDeploy() {
@@ -83,6 +93,7 @@ export default function DashboardPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card <Card
title="系统版本" title="系统版本"
loading={versionLoading} loading={versionLoading}
@@ -92,16 +103,14 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}> <Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button> </Button>
{isSuperAdmin && ( <Button
<Button type="primary"
type="primary" icon={<CloudUploadOutlined />}
icon={<CloudUploadOutlined />} loading={deploying}
loading={deploying} onClick={handleDeploy}
onClick={handleDeploy} >
>
</Button>
</Button>
)}
</Space> </Space>
} }
> >
@@ -122,6 +131,7 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
)} )}
</Card> </Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Button, Button,
Divider,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
@@ -14,10 +16,13 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS, FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS, FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus, FulfillmentProviderStatus,
FulfillmentProviderType, FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode, isXfxProviderCode,
type FulfillmentProviderDto, type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -32,6 +37,9 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
value, value,
label, label,
})); }));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [ const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' }, { value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' }, { value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
@@ -77,6 +85,12 @@ export default function FulfillmentProvidersPage() {
status: FulfillmentProviderStatus.ACTIVE, status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL, apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5', signType: 'MD5',
settlementMethod: LogisticsSettlementMethod.PREPAID,
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
}); });
setOpen(true); setOpen(true);
} }
@@ -84,6 +98,7 @@ export default function FulfillmentProvidersPage() {
function openEdit(row: FulfillmentProviderDto) { function openEdit(row: FulfillmentProviderDto) {
setEditRow(row); setEditRow(row);
const xfx = row.xiaofeixiaConfig; const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({ form.setFieldsValue({
code: row.code, code: row.code,
name: row.name, name: row.name,
@@ -94,6 +109,16 @@ export default function FulfillmentProvidersPage() {
apiKey: '', apiKey: '',
signType: xfx?.signType || 'MD5', signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '', appId: xfx?.appId || '',
bankAccountName: row.bankAccountName || '',
bankName: row.bankName || '',
bankBranch: row.bankBranch || '',
bankAccountNo: row.bankAccountNo || '',
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
}); });
setOpen(true); setOpen(true);
} }
@@ -104,6 +129,18 @@ export default function FulfillmentProvidersPage() {
name: v.name, name: v.name,
type: v.type, type: v.type,
status: v.status, status: v.status,
bankAccountName: v.bankAccountName || null,
bankName: v.bankName || null,
bankBranch: v.bankBranch || null,
bankAccountNo: v.bankAccountNo || null,
settlementMethod: v.settlementMethod,
pricingRules: {
baseBottles: Number(v.baseBottles),
baseFee: Number(v.baseFee),
extraBottleFee: Number(v.extraBottleFee),
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
}; };
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -144,6 +181,19 @@ export default function FulfillmentProvidersPage() {
dataIndex: 'type', dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v, render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
}, },
{
title: '结算',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 100,
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -188,7 +238,7 @@ export default function FulfillmentProvidersPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
使 API
</Typography.Text> </Typography.Text>
</div> </div>
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
@@ -200,7 +250,7 @@ export default function FulfillmentProvidersPage() {
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。" message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/> />
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
@@ -210,7 +260,7 @@ export default function FulfillmentProvidersPage() {
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => void submit()} onOk={() => void submit()}
width={560} width={640}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@@ -227,11 +277,58 @@ export default function FulfillmentProvidersPage() {
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
<Select options={SETTLEMENT_OPTIONS} />
</Form.Item>
<Form.Item name="bankAccountName" label="收款户名">
<Input />
</Form.Item>
<Form.Item name="bankName" label="开户银行">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行账号">
<Input />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item
name="baseBottles"
label="起送瓶数"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="baseFee"
label="起送费用(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="extraBottleFee"
label="加一瓶(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
{showXfxFields && ( {showXfxFields && (
<> <>
<Typography.Title level={5} style={{ marginTop: 8 }}> <Divider orientation="left"></Divider>
</Typography.Title>
<Form.Item <Form.Item
name="apiUrl" name="apiUrl"
label="API 地址" label="API 地址"
+102 -42
View File
@@ -5,7 +5,6 @@ import {
Card, Card,
Checkbox, Checkbox,
Col, Col,
Divider,
Form, Form,
Row, Row,
Select, Select,
@@ -36,6 +35,13 @@ const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.lab
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))]; const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
function groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({ function PermissionChecklist({
value, value,
onChange, onChange,
@@ -54,19 +60,39 @@ function PermissionChecklist({
> >
{CATALOG_GROUPS.map((group) => { {CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group); const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return ( return (
<div key={group} style={{ marginBottom: 16 }}> <div
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}> key={group}
style={{
marginBottom: 16,
padding: '12px 16px',
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
{group} {group}
</Typography.Text> </Typography.Text>
<Row gutter={[8, 8]}> {compact ? (
{items.map((item) => ( <Space size={[24, 8]} wrap>
<Col key={item.key} span={8}> {items.map((item) => (
<Checkbox value={item.key}>{item.label}</Checkbox> <Checkbox key={item.key} value={item.key}>
</Col> {item.label}
))} </Checkbox>
</Row> ))}
<Divider style={{ margin: '12px 0 0' }} /> </Space>
) : (
<Row gutter={[12, 10]}>
{items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
)}
</div> </div>
); );
})} })}
@@ -88,6 +114,12 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false); const [accountSaving, setAccountSaving] = useState(false);
const selectedAccount = useMemo(
() => accounts.find((a) => a.id === accountId),
[accounts, accountId],
);
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys], [roleInheritedKeys, accountKeys],
@@ -172,7 +204,8 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
//
// //
</Typography.Paragraph> </Typography.Paragraph>
@@ -198,7 +231,11 @@ export default function HqPermissionsPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{role === 'SUPER_ADMIN' ? ( {role === 'SUPER_ADMIN' ? (
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" /> <Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : ( ) : (
<> <>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} /> <PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -227,12 +264,10 @@ export default function HqPermissionsPage() {
value={accountId} value={accountId}
onChange={setAccountId} onChange={setAccountId}
optionFilterProp="label" optionFilterProp="label"
options={accounts options={accounts.map((a) => ({
.filter((a) => a.adminRole !== 'SUPER_ADMIN') value: a.id,
.map((a) => ({ label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
value: a.id, }))}
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -240,32 +275,57 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<Space wrap style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{roleInheritedKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); <Tag color="blue"></Tag>
return ( ) : (
<Tag key={key} color="blue"> <Space wrap size={[4, 4]}>
{item?.group ? `${item.group}·${item.label}` : item?.label || key} {roleInheritedKeys.map((key) => {
</Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
); return (
})} <Tag key={key} color="blue">
</Space> {item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<Space wrap style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{previewEffectiveKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); <Space wrap size={[4, 4]}>
return ( <Tag></Tag>
<Tag key={key}> {accountKeys.map((key) => {
{item?.group ? `${item.group}·${item.label}` : item?.label || key} const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
</Tag> return (
); <Tag key={key} color="orange">
})} {item?.label || key}
</Space> </Tag>
);
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
@@ -0,0 +1,625 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
LOGISTICS_SETTLEMENT_METHOD_LABELS,
type LogisticsSettlementMethod,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type BillRow = {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: string;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
};
type ProviderSummary = {
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName?: string | null;
bankName?: string | null;
bankAccountNo?: string | null;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId?: string | null;
billStatus?: string | null;
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未结算',
PAID: '已结算',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function LogisticsBillsPage() {
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
'/admin/logistics-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
const [rechargeForm] = Form.useForm();
useEffect(() => {
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
.catch(() => {});
}, []);
async function loadSummary(period: Dayjs) {
setSummaryLoading(true);
try {
const res = await request<{ items: ProviderSummary[] }>(
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
);
setSummaryRows(res.items ?? []);
} finally {
setSummaryLoading(false);
}
}
useEffect(() => {
void loadSummary(summaryPeriod);
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认结算');
setSelectedKeys([]);
reload();
void loadSummary(summaryPeriod);
},
});
}
async function openDetail(id: string) {
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(
`/admin/logistics-bills/export?${qs}`,
);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function generateBills() {
const v = await genForm.validateFields();
const month = v.month as Dayjs;
await request('/admin/logistics-bills/generate', {
method: 'POST',
body: JSON.stringify({
year: month.year(),
month: month.month() + 1,
providerId: v.providerId || undefined,
}),
});
message.success('已生成物流对账单');
setGenOpen(false);
reload();
void loadSummary(summaryPeriod);
}
async function submitRecharge() {
if (!rechargeTarget) return;
const v = await rechargeForm.validateFields();
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
method: 'POST',
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
});
message.success('充值成功');
setRechargeOpen(false);
void loadSummary(summaryPeriod);
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '承运商',
width: 140,
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button
type="link"
size="small"
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
>
</Button>
)}
</Space>
),
},
];
const summaryColumns: ColumnsType<ProviderSummary> = [
{
title: '承运商',
render: (_, r) => `${r.providerName} (${r.providerCode})`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 110,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{
title: '计价标准',
width: 220,
render: (_, r) => {
const p = r.pricingRules;
if (!p) return '—';
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
p.boxBottles ? `${p.boxBottles}瓶箱¥${p.boxFee}` : ''
}`;
},
},
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
: '—',
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '应付物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单',
width: 100,
render: (_, r) =>
r.billStatus ? (
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
{STATUS_LABELS[r.billStatus] || r.billStatus}
</Tag>
) : (
'未生成'
),
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.settlementMethod === 'PREPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
setRechargeTarget(r);
rechargeForm.resetFields();
setRechargeOpen(true);
}}
>
</Button>
) : null,
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs
items={[
{
key: 'summary',
label: '按承运商汇总',
children: (
<>
<Form
form={summaryForm}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{ month: summaryPeriod }}
onFinish={(v: { month?: Dayjs }) => {
const m = v.month || dayjs().subtract(1, 'month');
setSummaryPeriod(m);
}}
>
<Form.Item name="month" label="账期">
<DatePicker picker="month" allowClear={false} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="providerId"
loading={summaryLoading}
columns={summaryColumns}
dataSource={summaryRows}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'bills',
label: '月账单',
children: (
<>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic
title="瓶数合计"
value={summary.bottleCount ?? 0}
/>
<Statistic
title="物流费合计"
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
prefix="¥"
precision={2}
/>
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: {
status?: string;
providerId?: string;
month?: Dayjs;
}) => {
setFilters({
status: v.status || '',
providerId: v.providerId || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'UNPAID', label: '未结算' },
{ value: 'PAID', label: '已结算' },
]}
/>
</Form.Item>
<Form.Item name="providerId" label="承运商">
<Select
allowClear
style={{ width: 180 }}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
setGenOpen(true);
}}
>
</Button>
<Button loading={exporting} onClick={() => void exportExcel()}>
</Button>
<Button
disabled={!selectedKeys.length}
onClick={() =>
confirmPay(
selectedKeys.map(String),
selectedAmount,
)
}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={billColumns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
</>
),
},
]}
/>
<Drawer
title="物流对账明细"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<>
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="承运商">
{detail.providerName} ({detail.providerCode})
</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="物流费">
¥{Number(detail.logisticsAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '发货时间',
dataIndex: 'shippedAt',
width: 160,
render: fmtTime,
},
]}
/>
</>
)}
</Drawer>
<Modal
title="生成物流月账单"
open={genOpen}
onCancel={() => setGenOpen(false)}
onOk={() => void generateBills()}
destroyOnClose
>
<Form form={genForm} layout="vertical">
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="providerId" label="承运商(空=全部)">
<Select
allowClear
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
destroyOnClose
>
<Form form={rechargeForm} layout="vertical">
<Form.Item
name="amount"
label="金额"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,如预充运费" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+57 -16
View File
@@ -18,7 +18,7 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
@@ -26,6 +26,7 @@ import {
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -153,6 +154,7 @@ export default function OrdersPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null); const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -173,12 +175,17 @@ export default function OrdersPage() {
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');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const selectedOrders = useMemo( const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
[data?.items, selectedRowKeys], [data?.items, selectedRowKeys],
); );
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
setRedeemDrawerOpen(true); setRedeemDrawerOpen(true);
@@ -202,6 +209,7 @@ export default function OrdersPage() {
if (values.status) qs.set('status', values.status); if (values.status) qs.set('status', values.status);
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.fulfillmentHold) qs.set('fulfillmentHold', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -395,9 +403,12 @@ export default function OrdersPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 140,
render: (s) => ( render: (s, row) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag> <Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
</Space>
), ),
}, },
{ {
@@ -449,13 +460,15 @@ export default function OrdersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button {canDeleteOrders ? (
danger <Button
disabled={!selectedRowKeys.length} danger
onClick={() => setBatchDeleteOpen(true)} disabled={!selectedRowKeys.length}
> onClick={() => setBatchDeleteOpen(true)}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} >
</Button> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号"> <Form.Item name="orderNo" label="订单号">
@@ -477,6 +490,14 @@ export default function OrdersPage() {
<Form.Item name="receiverPhone" label="收货手机"> <Form.Item name="receiverPhone" label="收货手机">
<Input allowClear /> <Input allowClear />
</Form.Item> </Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -491,10 +512,10 @@ export default function OrdersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={canDeleteOrders ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} } : undefined}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -550,9 +571,17 @@ export default function OrdersPage() {
{detail.fulfillmentWarehouse?.name || '未分配'} {detail.fulfillmentWarehouse?.name || '未分配'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}> <Space>
{ORDER_STATUS_LABELS[detail.status] || detail.status} <Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
</Tag> {ORDER_STATUS_LABELS[detail.status] || detail.status}
</Tag>
{detail.fulfillmentHold ? (
<Tag color="orange">
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
'大单待确认'}
</Tag>
) : null}
</Space>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item> <Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item> <Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
@@ -864,6 +893,18 @@ export default function OrdersPage() {
> >
{shipTarget && ( {shipTarget && (
<> <>
{shipTarget.fulfillmentHold ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="大单已拦截自动推小飞侠"
description={
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
'≥10箱订单需总部确认:可选仓推小飞侠,或改用快递自配送。'
}
/>
) : null}
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}> <Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item> <Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="收货"> <Descriptions.Item label="收货">
+5 -6
View File
@@ -248,11 +248,11 @@ export default function PartnersPage() {
label: '基本信息', label: '基本信息',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
@@ -348,10 +348,10 @@ export default function PartnersPage() {
}} }}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
@@ -359,8 +359,7 @@ export default function PartnersPage() {
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={createCityCode} /> <CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
+3 -1
View File
@@ -271,7 +271,9 @@ export default function ProductsPage() {
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true }, { title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{ {
title: '现场取货', title: '现场取货',
dataIndex: 'allowOnSitePickup', dataIndex: 'allowOnSitePickup',
+45 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() { async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items); setStores(res.items);
} }
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
dataIndex: 'status', dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
}, },
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]} ]}
/> />
</> </>
) : null} ) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</> </>
)} )}
</Drawer> </Drawer>
+622 -94
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -14,12 +14,13 @@ import {
Space, Space,
Steps, Steps,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons'; import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
@@ -37,6 +38,7 @@ import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader'; import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
const CREATE_STEPS = [ const CREATE_STEPS = [
{ title: '基本信息' }, { title: '基本信息' },
@@ -44,6 +46,31 @@ const CREATE_STEPS = [
{ title: '结算资质' }, { title: '结算资质' },
]; ];
function fillGeolocation(
setCoords: (lat: number, lng: number) => void,
setLoading: (v: boolean) => void,
) {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setCoords(pos.coords.latitude, pos.coords.longitude);
message.success(
`已获取坐标 ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`,
);
setLoading(false);
},
(err) => {
message.error(err.message || '定位失败');
setLoading(false);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
}
type StoreMediaItem = { type StoreMediaItem = {
id?: string; id?: string;
bizType?: string; bizType?: string;
@@ -262,7 +289,20 @@ type StoreRow = {
createdAt: string; createdAt: string;
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { companyName: string };
account?: { phone: string; name: string; status: string }; account?: {
phone: string;
name: string;
status: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
settlementRate?: number;
category?: { id: string; name: string; parentId?: string | null } | null; category?: { id: string; name: string; parentId?: string | null } | null;
}; };
@@ -282,10 +322,18 @@ type CategoryNode = {
export default function StoresPage() { export default function StoresPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>(); const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
'/admin/stores', '/admin/stores',
() => { () => {
@@ -294,10 +342,14 @@ export default function StoresPage() {
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus); if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
return qs; return qs;
}, },
[filters], [filters],
); );
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
@@ -306,15 +358,55 @@ export default function StoresPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0); const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [mapPickerTarget, setMapPickerTarget] = useState<'create' | 'edit'>('create');
const [partners, setPartners] = useState<PartnerOption[]>([]); const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]); const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterCities(res.items))
.catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterPartners(res.items))
.catch(() => {});
}, []);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
let changed = false;
setFilters((prev) => {
const next = { ...prev };
if ((prev.cityId ?? '') !== cityId) {
changed = true;
if (cityId) next.cityId = cityId;
else delete next.cityId;
}
if ((prev.partnerId ?? '') !== partnerId) {
changed = true;
if (partnerId) next.partnerId = partnerId;
else delete next.partnerId;
}
return changed ? next : prev;
});
form.setFieldsValue({
cityId: cityId || undefined,
partnerId: partnerId || undefined,
});
if (cityId || partnerId) setPage(1);
}, [searchParams, form, setPage]);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm); const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm); const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm); const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm); const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
const categoryParentOptions = useMemo( const categoryParentOptions = useMemo(
() => () =>
@@ -330,6 +422,140 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name })); .map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, selectedCategoryParentId]); }, [categoryTree, selectedCategoryParentId]);
const editCategoryChildOptions = useMemo(() => {
const parent = categoryTree.find((n) => n.id === editCategoryParentId);
return (parent?.children ?? [])
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, editCategoryParentId]);
async function openStoreDetail(row: StoreRow) {
const [d, cats] = await Promise.all([
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
]);
setCategoryTree(Array.isArray(cats) ? cats : []);
setDetail(d);
const category = d.category && typeof d.category === 'object'
? (d.category as { id?: string; parentId?: string | null })
: null;
const account = d.account && typeof d.account === 'object'
? (d.account as {
phone?: string | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
})
: null;
const categoryId = category?.id != null ? String(category.id) : undefined;
let parentId = category?.parentId != null ? String(category.parentId) : undefined;
if (!parentId && categoryId) {
for (const parent of cats) {
if (parent.id === categoryId) {
parentId = parent.id;
break;
}
if ((parent.children ?? []).some((c) => c.id === categoryId)) {
parentId = parent.id;
break;
}
}
}
const loginPhone =
(typeof d.loginPhone === 'string' && d.loginPhone) ||
account?.phone ||
(typeof d.phone === 'string' ? d.phone : undefined);
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({
name: d.name,
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
phone: storePhone || loginPhone,
intro: d.intro,
benefitUsageRule:
d.benefitUsageRule != null &&
String(d.benefitUsageRule).trim() &&
!/^null$/i.test(String(d.benefitUsageRule).trim())
? String(d.benefitUsageRule)
: '',
coverUrl: d.coverUrl,
province: d.province,
city: d.cityName,
district: d.district,
address: d.address,
categoryParentId: parentId,
categoryId,
latitude: d.latitude != null ? Number(d.latitude) : undefined,
longitude: d.longitude != null ? Number(d.longitude) : undefined,
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
openTime: d.openTime || '10:00',
closeTime: d.closeTime || '22:00',
openTime2: d.openTime2 || undefined,
closeTime2: d.closeTime2 || undefined,
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
bankAccountName: account?.bankAccountName || undefined,
bankAccountNo: account?.bankAccountNo || undefined,
bankBranch: account?.bankBranch || undefined,
});
setDrawerOpen(true);
}
async function saveStoreDetail() {
if (!detail) return;
setSaving(true);
try {
const v = await editForm.validateFields();
const hasCoords =
v.latitude != null &&
v.longitude != null &&
Number.isFinite(Number(v.latitude)) &&
Number.isFinite(Number(v.longitude));
const payload = {
name: v.name,
phone: v.phone,
coverUrl: v.coverUrl,
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
v.benefitUsageRule.trim() &&
!/^null$/i.test(v.benefitUsageRule.trim())
? v.benefitUsageRule.trim()
: null,
categoryId: v.categoryId,
province: v.province,
city: v.city,
district: v.district,
address: v.address,
avgPrice: v.avgPrice,
openTime: v.openTime,
closeTime: v.closeTime,
openTime2: v.openTime2 || null,
closeTime2: v.closeTime2 || null,
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
bankAccountName: v.bankAccountName ?? null,
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
};
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('门店信息已保存');
setDetail(updated);
setPhoneMismatch(null);
void reload();
} catch (e) {
if (e && typeof e === 'object' && 'errorFields' in e) return;
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
function bindRegionSelection(codes: string[], partnerAccountId?: string) { function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId); const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
if (!binding) { if (!binding) {
@@ -356,7 +582,7 @@ export default function StoresPage() {
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`; return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]); }, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() { async function loadOptions(opts?: { quiet?: boolean }) {
setOptionsLoading(true); setOptionsLoading(true);
try { try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`; const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
@@ -368,10 +594,12 @@ export default function StoresPage() {
setPartners(p.items); setPartners(p.items);
setCities(c.items); setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []); setCategoryTree(Array.isArray(cats) ? cats : []);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人'); if (!opts?.quiet) {
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建'); if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!Array.isArray(cats) || !cats.length) { if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置'); if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
}
} }
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败'); message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
@@ -392,6 +620,11 @@ export default function StoresPage() {
createForm.setFieldsValue({ createForm.setFieldsValue({
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
settlementRate: 60, settlementRate: 60,
openTime: '10:00',
closeTime: '22:00',
openTime2: undefined,
closeTime2: undefined,
avgPrice: undefined,
}); });
setCreateStep(0); setCreateStep(0);
setCreateError(''); setCreateError('');
@@ -447,7 +680,25 @@ export default function StoresPage() {
phone: values.phone.trim(), phone: values.phone.trim(),
district: values.district.trim(), district: values.district.trim(),
address: values.address.trim(), address: values.address.trim(),
...(values.latitude != null &&
values.longitude != null &&
Number.isFinite(Number(values.latitude)) &&
Number.isFinite(Number(values.longitude))
? {
latitude: Number(values.latitude),
longitude: Number(values.longitude),
}
: {}),
intro: values.intro?.trim() || undefined, intro: values.intro?.trim() || undefined,
benefitUsageRule: values.benefitUsageRule?.trim() || undefined,
openTime: values.openTime?.trim() || '10:00',
closeTime: values.closeTime?.trim() || '22:00',
...(values.openTime2?.trim() && values.closeTime2?.trim()
? { openTime2: values.openTime2.trim(), closeTime2: values.closeTime2.trim() }
: {}),
...(values.avgPrice != null && values.avgPrice !== ''
? { avgPrice: Number(values.avgPrice) }
: {}),
coverUrl: values.coverUrl?.trim() || undefined, coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined, contractUrl: values.contractUrl?.trim() || undefined,
@@ -514,20 +765,7 @@ export default function StoresPage() {
{ {
title: '操作', width: 80, title: '操作', width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
intro: d.intro,
coverUrl: d.coverUrl,
address: d.address,
district: d.district,
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
});
setDrawerOpen(true);
}}></Button>
), ),
}, },
]; ];
@@ -544,6 +782,26 @@ export default function StoresPage() {
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item> <Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={filterCities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="partnerId" label="城市合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
placeholder="全部"
options={filterPartners.map((p) => ({ value: p.id, label: p.companyName || p.id }))}
/>
</Form.Item>
<Form.Item name="status" label="营业状态"> <Form.Item name="status" label="营业状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
@@ -556,7 +814,20 @@ export default function StoresPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button></Form.Item> <Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
if (searchParams.has('cityId') || searchParams.has('partnerId')) {
navigate('/stores', { replace: true });
}
}}
>
</Button>
</Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
@@ -597,7 +868,7 @@ export default function StoresPage() {
</Button> </Button>
</> </>
) : null} ) : null}
<Select defaultValue={String(detail.status)} style={{ width: 120 }} <Select value={String(detail.status)} style={{ width: 120 }}
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => { onChange={async (status) => {
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) }); await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
@@ -605,76 +876,228 @@ export default function StoresPage() {
setDetail({ ...detail, status }); setDetail({ ...detail, status });
void reload(); void reload();
}} /> }} />
<Button type="primary" onClick={async () => { <Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}></Button>
const v = await editForm.validateFields();
const payload = {
...v,
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
};
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDetail({ ...detail, ...v });
void reload();
}}></Button>
</Space> </Space>
)}> )}>
{detail && ( {detail && (
<> <Form form={editForm} layout="vertical">
<StoreAuditMediaSection detail={detail} /> <Tabs
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> destroyInactiveTabPane={false}
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> items={[
<Descriptions.Item label="门店分类"> {
{detail.category && typeof detail.category === 'object' && 'name' in detail.category key: 'basic',
? String((detail.category as { name?: string }).name || '—') label: '基本信息',
: '—'} forceRender: true,
</Descriptions.Item> children: (
<Descriptions.Item label="审核状态"> <>
<Tag color={ <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
String(detail.auditStatus) === 'PENDING' ? 'orange' <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green' <Descriptions.Item label="审核状态">
}> <Tag color={
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)} String(detail.auditStatus) === 'PENDING' ? 'orange'
</Tag> : String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
</Descriptions.Item> }>
{String(detail.auditStatus) === 'REJECTED' ? ( {STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item> </Tag>
) : null} </Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item> {String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="核销结算比例"> <Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'} ) : null}
</Descriptions.Item> <Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item> <Descriptions.Item label="操作">
<Descriptions.Item label="操作"> <Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
</Button>
</Button> </Descriptions.Item>
</Descriptions.Item> {Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? ( <Descriptions.Item label="审核记录">
<Descriptions.Item label="审核记录"> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}> {(detail.audits as Array<Record<string, unknown>>).map((a) => (
{(detail.audits as Array<Record<string, unknown>>).map((a) => ( <Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}> {fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')} </Typography.Text>
</Typography.Text> ))}
))} </Space>
</Space> </Descriptions.Item>
</Descriptions.Item> ) : null}
) : null} </Descriptions>
</Descriptions> <Typography.Title level={5} style={{ marginTop: 0 }}></Typography.Title>
<Form form={editForm} layout="vertical"> {phoneMismatch ? (
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item> <Alert
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item> type="warning"
<Form.Item name="coverUrl" label="封面图"> showIcon
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" /> style={{ marginBottom: 16 }}
</Form.Item> message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item> />
<Form.Item name="district" label="区县"><Input /></Form.Item> ) : null}
<Form.Item name="address" label="详细地址"><Input /></Form.Item> <Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}> <Form.Item
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" /> name="phone"
</Form.Item> label="登录手机号"
</Form> rules={[{ required: true }]}
</> extra="门店端短信登录使用此号码;修改后需用新号重新登录"
>
<Input />
</Form.Item>
<Form.Item
name="categoryParentId"
label="门店分类(大类)"
rules={[{ required: true, message: '请选择门店大类' }]}
>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
options={categoryParentOptions}
onChange={() => editForm.setFieldValue('categoryId', undefined)}
/>
</Form.Item>
<Form.Item
name="categoryId"
label="门店分类(细类)"
rules={[{ required: true, message: '请选择门店细类' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder={editCategoryParentId ? '选择细类' : '请先选大类'}
disabled={!editCategoryParentId}
options={editCategoryChildOptions}
/>
</Form.Item>
<Form.Item name="coverUrl" label="封面图 / 门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
<Form.Item
name="benefitUsageRule"
label="好客权益券使用规则"
extra="展示在用户端门店详情「门店详情」下方"
>
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="province" label="省份" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="city" label="城市" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="district" label="区县" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 34.7466000"
/>
</Form.Item>
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 113.6253000"
/>
</Form.Item>
</Space>
<Space wrap style={{ marginBottom: 16 }}>
<Button
loading={locating}
onClick={() => {
fillGeolocation(
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
setLocating,
);
}}
>
</Button>
<Button
icon={<EnvironmentOutlined />}
onClick={() => {
setMapPickerTarget('edit');
setMapPickerOpen(true);
}}
>
</Button>
</Space>
<Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime2" label="第二段开始(选填)">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime2" label="第二段结束">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
</>
),
},
{
key: 'settlement',
label: '结算资质',
forceRender: true,
children: (
<>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
<Form.Item name="bankAccountName" label="结算户名">
<Input placeholder="开户名" />
</Form.Item>
<Form.Item name="bankBranch" label="开户银行">
<Input placeholder="如 中国工商银行某某支行" />
</Form.Item>
<Form.Item
name="bankAccountNo"
label="银行卡号"
rules={[
{
validator: async (_, value) => {
const v = String(value || '').trim();
if (!v) return;
if (!/^\d{16,19}$/.test(v)) {
throw new Error('银行卡号须为 1619 位数字');
}
},
},
]}
>
<Input placeholder="1619 位数字" maxLength={19} />
</Form.Item>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
</>
),
},
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
},
]}
/>
</Form>
)} )}
</Drawer> </Drawer>
<Modal <Modal
@@ -774,8 +1197,77 @@ export default function StoresPage() {
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}> <Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" /> <Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item> </Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 34.7466000"
/>
</Form.Item>
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 113.6253000"
/>
</Form.Item>
</Space>
<Space wrap style={{ marginBottom: 16 }}>
<Button
loading={locating}
onClick={() => {
fillGeolocation(
(lat, lng) => createForm.setFieldsValue({ latitude: lat, longitude: lng }),
setLocating,
);
}}
>
</Button>
<Button
icon={<EnvironmentOutlined />}
onClick={() => {
setMapPickerTarget('create');
setMapPickerOpen(true);
}}
>
</Button>
<Typography.Text type="secondary">
/ /
</Typography.Text>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true, message: '请填写营业结束时间' }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime2" label="第二段开始(选填)">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime2" label="第二段结束">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item>
<Form.Item name="intro" label="门店简介"> <Form.Item name="intro" label="门店简介">
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} /> <Input.TextArea rows={3} placeholder="选填,2~500字" showCount maxLength={500} />
</Form.Item>
<Form.Item
name="benefitUsageRule"
label="好客权益券使用规则"
extra="展示在用户端门店详情"
>
<Input.TextArea rows={3} placeholder="选填,最多1000字" showCount maxLength={1000} />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: createStep === 1 ? 'block' : 'none' }}> <div style={{ display: createStep === 1 ? 'block' : 'none' }}>
@@ -856,6 +1348,42 @@ export default function StoresPage() {
onChange={(e) => setRejectReason(e.target.value)} onChange={(e) => setRejectReason(e.target.value)}
/> />
</Modal> </Modal>
<TencentLocPickerModal
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={
mapPickerTarget === 'edit'
? Number(editForm.getFieldValue('latitude') ?? detail?.latitude)
: Number(createForm.getFieldValue('latitude'))
}
longitude={
mapPickerTarget === 'edit'
? Number(editForm.getFieldValue('longitude') ?? detail?.longitude)
: Number(createForm.getFieldValue('longitude'))
}
onPick={(loc) => {
if (mapPickerTarget === 'edit') {
editForm.setFieldsValue({
latitude: loc.latitude,
longitude: loc.longitude,
...(loc.address && !editForm.getFieldValue('address')
? { address: loc.address }
: {}),
});
} else {
createForm.setFieldsValue({
latitude: loc.latitude,
longitude: loc.longitude,
...(loc.address && !createForm.getFieldValue('address')
? { address: loc.address }
: {}),
});
}
message.success(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
} }
+25 -17
View File
@@ -18,8 +18,8 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
id: string; id: string;
@@ -66,6 +66,7 @@ export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -82,6 +83,11 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
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');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -222,7 +228,7 @@ export default function UsersPage() {
title: '手机', title: '手机',
dataIndex: 'phone', dataIndex: 'phone',
width: 120, width: 120,
render: (v) => v || '—', render: (v) => maskPhone(v),
}, },
{ {
title: '验手机', title: '验手机',
@@ -309,13 +315,15 @@ export default function UsersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button {canDeleteUsers ? (
danger <Button
disabled={!selectedRowKeys.length} danger
onClick={() => void openBatchDeleteModal()} disabled={!selectedRowKeys.length}
> onClick={() => void openBatchDeleteModal()}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} >
</Button> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
@@ -353,11 +361,11 @@ export default function UsersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} } : undefined}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -375,9 +383,9 @@ export default function UsersPage() {
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}></Button> <Button danger onClick={openDeleteModal}></Button>
)} ) : undefined}
> >
{detail && ( {detail && (
<> <>
@@ -385,7 +393,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<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.phone || '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(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>
@@ -419,7 +427,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 || '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -593,7 +601,7 @@ export default function UsersPage() {
columns={[ columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) },
{ {
title: '风险', title: '风险',
width: 200, width: 200,
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
title: '手机号', title: '手机号',
dataIndex: 'primaryPhone', dataIndex: 'primaryPhone',
width: 140, width: 140,
render: (v) => v || '—', render: (v) => maskPhone(v),
}, },
{ {
title: '身份摘要', title: '身份摘要',
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
<AdminCellLine <AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities secondary={r.identities
.map((i) => i.refLabel || i.name || i.phone) .map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
/> />
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.name || '—'} primary={r.name || '—'}
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')} secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
/> />
), ),
}, },
+2
View File
@@ -15,6 +15,7 @@ import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage'; import SettlementPage from './pages/SettlementPage';
import WeeklyReportPage from './pages/WeeklyReportPage'; import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage'; import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
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';
@@ -33,6 +34,7 @@ function PrimaryRoutes() {
<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="/proxy-order" element={<ProxyOrderPage />} /> <Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} /> <Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} /> <Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/stores/:id" element={<StoreDetailPage />} />
@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from 'react';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
region?: string | null;
};
export default function TencentLocPickerOverlay({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '加载附近地点失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch() {
const trimmed = keyword.trim();
if (!trimmed) {
setHint('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/suggest?${params.toString()}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '搜索失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
setHint('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
setHint('定位失败,请检查定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: '#fff',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
gap: 8,
}}
>
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
</button>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div>
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runSearch();
}}
placeholder="输入小区 / 写字楼 / 门店名称"
style={{
flex: 1,
padding: '10px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 8,
fontSize: 14,
}}
/>
<button
type="button"
className="partner-btn-primary"
style={{ width: 'auto', padding: '8px 14px' }}
disabled={loading}
onClick={() => void runSearch()}
>
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '6px 12px' }}
disabled={loading}
onClick={useBrowserLocation}
>
</button>
<span className="label-md text-muted">{hint}</span>
</div>
{pending ? (
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && !items.length ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : items.length ? (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
<button
type="button"
onClick={() => setPending(placeToPicked(item))}
style={{
width: '100%',
textAlign: 'left',
padding: '12px 16px',
border: 0,
borderBottom: '1px solid rgba(0,0,0,0.06)',
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
<div className="label-md text-muted" style={{ marginTop: 4 }}>
{item.address}
</div>
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</div>
</button>
</li>
);
})}
</ul>
) : (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
{hint}
</p>
)}
</div>
</div>
);
}
+73 -48
View File
@@ -6,13 +6,23 @@ export type StoreDraftForm = {
district: string; district: string;
name: string; name: string;
phone: string; phone: string;
storeSmsCode: string;
address: string; address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string; openTime: string;
closeTime: string; closeTime: string;
/** 是否启用第二段营业时间 */
dualHours: boolean;
openTime2: string;
closeTime2: string;
/** 人均费用(选填) */
avgPrice: string;
categoryParentId: string; categoryParentId: string;
categoryId: string; categoryId: string;
intro: string; intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
contractUrl: string; contractUrl: string;
@@ -39,13 +49,19 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '', cityId: '',
name: '', name: '',
phone: '', phone: '',
storeSmsCode: '',
address: '', address: '',
latitude: '',
longitude: '',
openTime: '10:00', openTime: '10:00',
closeTime: '22:00', closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '', categoryParentId: '',
categoryId: '', categoryId: '',
intro: '', intro: '',
benefitUsageRule: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
contractUrl: '', contractUrl: '',
@@ -54,39 +70,38 @@ export const defaultStoreForm = (): StoreDraftForm => ({
bankBranch: '', bankBranch: '',
}); });
export function normalizeStringArray(raw: unknown, length: number): string[] { const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (!Array.isArray(raw)) return Array.from({ length }, () => ''); const PHONE_RE = /^1[3-9]\d{9}$/;
const items = raw.map((item) => String(item ?? '')); const BANK_RE = /^\d{16,19}$/;
while (items.length < length) items.push('');
return items.slice(0, length); function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
} }
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm { export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
const base = defaultStoreForm(); const base = defaultStoreForm();
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3 if (!raw) return base;
? raw.regionCodes.map((code) => String(code))
: base.regionCodes;
return { return {
regionCodes, ...base,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId), cityId: String(raw.cityId ?? base.cityId),
province: String(raw.province ?? base.province), latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
city: String(raw.city ?? base.city), longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
district: String(raw.district ?? base.district),
name: String(raw.name ?? base.name),
phone: String(raw.phone ?? base.phone),
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
address: String(raw.address ?? base.address),
openTime: String(raw.openTime ?? base.openTime), openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime), closeTime: String(raw.closeTime ?? base.closeTime),
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId), dualHours: Boolean(raw.dualHours),
categoryId: String(raw.categoryId ?? base.categoryId), openTime2: String(raw.openTime2 ?? base.openTime2),
intro: String(raw.intro ?? base.intro), closeTime2: String(raw.closeTime2 ?? base.closeTime2),
coverUrl: String(raw.coverUrl ?? base.coverUrl), avgPrice: String(raw.avgPrice ?? base.avgPrice),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
contractUrl: String(raw.contractUrl ?? base.contractUrl),
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
bankBranch: String(raw.bankBranch ?? base.bankBranch),
}; };
} }
@@ -102,14 +117,11 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
} }
} }
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as StoreDraft;
if (parsed.form && typeof parsed.form === 'object') { return {
return { step: Number(parsed.step) || 0,
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)), form: normalizeStoreDraftForm(parsed.form),
form: normalizeForm(parsed.form as Record<string, unknown>), };
};
}
return { step: 1, form: normalizeForm(parsed) };
} catch { } catch {
return null; return null;
} }
@@ -129,15 +141,6 @@ export function clearAllStoreDrafts(accountId?: string) {
localStorage.removeItem(STORE_DRAFT_KEY); localStorage.removeItem(STORE_DRAFT_KEY);
} }
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
function timeToMinutes(value: string): number {
const [h, m] = value.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreStep1( export function validateStoreStep1(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
@@ -147,8 +150,13 @@ export function validateStoreStep1(
| 'address' | 'address'
| 'openTime' | 'openTime'
| 'closeTime' | 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId' | 'categoryId'
| 'intro' | 'intro'
| 'benefitUsageRule'
>, >,
): string | null { ): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
@@ -162,10 +170,29 @@ export function validateStoreStep1(
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) { if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间'; return '营业结束时间须晚于开始时间';
} }
if (form.dualHours) {
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
return '第二段时间格式须为 HH:MM';
}
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
return '第二段结束时间须晚于开始时间';
}
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
return '第二段开始时间须晚于第一段结束时间';
}
}
if (form.avgPrice.trim()) {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) return '请选择店铺类型'; if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) { if (form.intro.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; return null;
} }
@@ -189,7 +216,7 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3( export function validateStoreStep3(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode' 'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>, >,
): string | null { ): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountName.trim()) return '请填写户主姓名';
@@ -198,7 +225,5 @@ export function validateStoreStep3(
if (!form.bankBranch.trim()) return '请填写开户支行'; if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话'; if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
return null; return null;
} }
+26
View File
@@ -0,0 +1,26 @@
import { weixinSdk } from './weixin';
export type StorePosition = {
latitude: number;
longitude: number;
};
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation */
export async function locateStorePosition(): Promise<StorePosition> {
const outcome = await weixinSdk.getLocationDetailed();
if (!outcome.location) {
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
}
const { latitude, longitude } = outcome.location;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error('定位结果无效');
}
return { latitude, longitude };
}
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
const a = lat != null && lat !== '' ? Number(lat) : NaN;
const b = lng != null && lng !== '' ? Number(lng) : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
}
-8
View File
@@ -8,12 +8,4 @@ export function checkStorePhoneAvailable(phone: string) {
); );
} }
export function sendStorePhoneSms(phone: string) {
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
}
export type { PartnerStorePhoneAvailableResponse }; export type { PartnerStorePhoneAvailableResponse };
+2 -2
View File
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string { export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase(); const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中'; if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '时闭店'; if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '关闭'; if (s === 'CLOSED') return '永久关闭';
return status; return status;
} }
@@ -0,0 +1,26 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+286 -112
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } 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 ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region'; import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import type { import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest, PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions, PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult, PartnerProxyOrderPreviewResult,
@@ -17,19 +18,25 @@ function fmtMoney(n: number) {
export default function ProxyOrderPage() { export default function ProxyOrderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null); const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true); const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState(''); const [customerSmsCode, setCustomerSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState(''); const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]); const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState(''); const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState(''); const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2); const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState(''); const [promoCodeId, setPromoCodeId] = useState('');
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(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 [smsCooldown, setSmsCooldown] = useState(0); const [customerCooldown, setCustomerCooldown] = useState(0);
const [partnerCooldown, setPartnerCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -39,12 +46,26 @@ export default function ProxyOrderPage() {
useEffect(() => { useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options') request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => { .then((data) => {
setOptions(data); setOptions({
if (data.products[0]) setProductId(data.products[0].id); ...data,
stores: Array.isArray(data.stores) ? data.stores : [],
});
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
}) })
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败')) .catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false)); .finally(() => setLoadingOptions(false));
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
}, [searchParams]);
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) setProductId(fromQuery);
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!productId || quantity < 1) { if (!productId || quantity < 1) {
@@ -58,8 +79,9 @@ export default function ProxyOrderPage() {
body: JSON.stringify({ body: JSON.stringify({
productId, productId,
quantity, quantity,
receiverCity: region.city || undefined, deliveryMode,
receiverDistrict: region.district || undefined, receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}), }),
silent: true, silent: true,
}) })
@@ -68,67 +90,109 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false)); .finally(() => setPreviewLoading(false));
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [productId, quantity, region.city, region.district]); }, [productId, quantity, deliveryMode, region?.city, region?.district]);
async function sendSms() { function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = setInterval(() => {
setter((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
setMsg(''); setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号'); setMsg('请输入有效手机号');
return; return;
} }
try { try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', { const res = await request<{ maskedPhone: string }>(
method: 'POST', 'PARTNER_H5',
body: JSON.stringify({ phone: phone.trim() }), '/partner/proxy-orders/send-customer-sms',
silent: true, {
}); method: 'POST',
setMsg(`验证码已发送至 ${res.maskedPhone}`); body: JSON.stringify({ phone: phone.trim() }),
setSmsCooldown(60); silent: true,
const timer = setInterval(() => { },
setSmsCooldown((s) => { );
if (s <= 1) { setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
clearInterval(timer); startCooldown(setCustomerCooldown);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败'); setMsg(e instanceof Error ? e.message : '发送失败');
} }
} }
async function sendPartnerSms() {
setMsg('');
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-partner-sms',
{ method: 'POST', body: '{}', silent: true },
);
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
startCooldown(setPartnerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode !== 'ADDRESS') {
// 现场提货不需要地址与门店
} else {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function openConfirmStep() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
setConfirmStep(true);
setPartnerSmsCode('');
await sendPartnerSms();
}
async function submit() { async function submit() {
setMsg(''); setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) { const err = validateForm();
setMsg('请输入有效手机号'); if (err) {
setMsg(err);
return; return;
} }
if (!smsCode.trim()) { if (!partnerSmsCode.trim()) {
setMsg('请输入验证码'); setMsg('请输入合伙人确认验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
return; return;
} }
const payload: PartnerProxyOrderCreateRequest = { const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(), phone: phone.trim(),
smsCode: smsCode.trim(), customerSmsCode: customerSmsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined, receiverName: receiverName.trim() || undefined,
province: region.province, province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: region.city, city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: region.district, district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: addressDetail.trim(), addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId, productId,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
@@ -151,6 +215,12 @@ export default function ProxyOrderPage() {
} }
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return ( return (
<div className="page partner-proxy-order-page"> <div className="page partner-proxy-order-page">
@@ -158,7 +228,7 @@ export default function ProxyOrderPage() {
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}> <main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? ( {loadingOptions ? (
<p className="label-md text-muted">广</p> <p className="label-md text-muted"></p>
) : ( ) : (
<> <>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -175,70 +245,49 @@ export default function ProxyOrderPage() {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
disabled={smsCooldown > 0} disabled={customerCooldown > 0}
onClick={() => void sendSms()} onClick={() => void sendCustomerSms()}
> >
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'} {customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</button> </button>
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<div className="partner-input-wrap"> <div className="partner-input-wrap">
<input <input
className="partner-input" className="partner-input"
placeholder="线下代发货确认码" placeholder="发至客户手机"
value={smsCode} value={customerSmsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/> />
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<div className="partner-input-wrap"> <button
<input type="button"
className="partner-input" className="partner-proxy-product-picker"
placeholder="默认:用户+手机尾号" onClick={() =>
value={receiverName} navigate(
onChange={(e) => setReceiverName(e.target.value)} `/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
/> )
</div> }
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
> >
{(options?.products ?? []).map((p) => ( {selectedProduct ? (
<option key={p.id} value={p.id}> <>
{p.name}{p.spec}¥{fmtMoney(p.price)} <span className="partner-proxy-product-name">{selectedProduct.name}</span>
</option> <span className="label-md text-muted">
))} {selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
</select> </span>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -261,7 +310,12 @@ export default function ProxyOrderPage() {
className="partner-input" className="partner-input"
value={promoCodeId} value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)} onChange={(e) => setPromoCodeId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }} style={{
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
> >
<option value=""></option> <option value=""></option>
{options!.promoCodes.map((p) => ( {options!.promoCodes.map((p) => (
@@ -273,6 +327,73 @@ export default function ProxyOrderPage() {
</section> </section>
)} )}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
</section>
{deliveryMode === 'ADDRESS' ? (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{regionLabel}
</p>
) : null}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<label className="partner-proxy-auto-receive">
<input
type="checkbox"
checked={autoReceive}
onChange={(e) => setAutoReceive(e.target.checked)}
/>
<span>线</span>
</label>
</>
) : null}
<section className="partner-proxy-fee-card"> <section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3> <h3 className="headline-md"></h3>
{previewLoading ? ( {previewLoading ? (
@@ -288,8 +409,8 @@ export default function ProxyOrderPage() {
<span className="body-md">×{quantity}</span> <span className="body-md">×{quantity}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span> <span className="body-md">{deliveryLabel}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
@@ -302,25 +423,78 @@ export default function ProxyOrderPage() {
</> </>
) : ( ) : (
<p className="label-md text-muted"> <p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'} {selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
</p> </p>
)} )}
</section> </section>
{msg && <p className="partner-form-error" role="alert">{msg}</p>} {confirmStep ? (
<section className="partner-form-section partner-proxy-confirm">
<label className="partner-form-label"></label>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
线
</p>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="发至合伙人手机"
value={partnerSmsCode}
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={partnerCooldown > 0}
onClick={() => void sendPartnerSms()}
>
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
</button>
</div>
</section>
) : null}
<button {msg ? (
type="button" <p className="partner-form-error" role="alert">
className="btn btn-primary btn-block" {msg}
disabled={submitting || !preview} </p>
onClick={() => void submit()} ) : null}
style={{ marginTop: 16 }}
> {!confirmStep ? (
{submitting ? '提交中…' : '验证码确认并代下单'} <button
</button> type="button"
className="btn btn-primary btn-block"
disabled={!preview}
onClick={() => void openConfirmStep()}
style={{ marginTop: 16 }}
>
</button>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setConfirmStep(false)}
>
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !preview}
onClick={() => void submit()}
>
{submitting ? '提交中…' : '验证并提交'}
</button>
</div>
)}
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}> <p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
/线 线/
</p> </p>
</> </>
)} )}
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
<button
key={p.id}
type="button"
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
onClick={() => pick(p)}
>
<div className="partner-proxy-product-thumb">
{p.coverUrl ? (
<img src={p.coverUrl} alt="" />
) : (
<span className="partner-proxy-product-thumb-empty" />
)}
</div>
<div className="partner-proxy-product-main">
<div className="partner-proxy-product-row">
<span className="partner-proxy-product-name">{p.name}</span>
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
</div>
</button>
);
})}
</main>
</div>
);
}
+187 -167
View File
@@ -11,12 +11,16 @@ import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone'; import { checkStorePhoneAvailable } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
clearAllStoreDrafts, clearAllStoreDrafts,
@@ -51,7 +55,6 @@ type StoreCategoryNode = {
type FieldErrors = { type FieldErrors = {
phone?: string; phone?: string;
storeSmsCode?: string;
}; };
@@ -66,7 +69,7 @@ function isPhoneConflictMessage(message: string) {
function isPhoneValidationMessage(message: string) { function isPhoneValidationMessage(message: string) {
return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码'); return message.includes('联系电话') || message.includes('手机号');
} }
@@ -96,12 +99,12 @@ export default function StoreCreatePage() {
const [citiesError, setCitiesError] = useState(''); const [citiesError, setCitiesError] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0);
const [smsHint, setSmsHint] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]); const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const draftSaveDisabledRef = useRef(false); const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) { function reportFormError(message: string) {
@@ -222,25 +225,13 @@ export default function StoreCreatePage() {
setSubmitError(''); setSubmitError('');
let nextPatch = patch;
if ('phone' in patch) { if ('phone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined })); setFieldErrors((prev) => ({ ...prev, phone: undefined }));
setSmsHint('');
nextPatch = { ...patch, storeSmsCode: '' };
} }
if ('storeSmsCode' in patch) { setForm((prev) => ({ ...prev, ...patch }));
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
}
setForm((prev) => ({ ...prev, ...nextPatch }));
} }
@@ -300,72 +291,6 @@ export default function StoreCreatePage() {
} }
async function sendStorePhoneCode() {
const phone = form.phone.trim();
if (!/^1\d{10}$/.test(phone)) {
setFieldErrors({ phone: '请先填写正确的11位手机号' });
return;
}
setSmsHint('');
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
try {
const phoneCheck = await checkStorePhoneAvailable(phone);
if (!phoneCheck.available) {
setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' });
return;
}
const res = await sendStorePhoneSms(phone);
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
const msg = e instanceof Error ? e.message : '验证码发送失败';
setFieldErrors({ phone: msg });
}
}
async function handleNext() { async function handleNext() {
if (step === 1) { if (step === 1) {
@@ -402,11 +327,7 @@ export default function StoreCreatePage() {
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) { if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) { setFieldErrors({ phone: msg });
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
return; return;
} }
reportFormError(msg); reportFormError(msg);
@@ -502,19 +423,31 @@ export default function StoreCreatePage() {
phone: form.phone.trim(), phone: form.phone.trim(),
smsCode: form.storeSmsCode.trim(),
district: form.district.trim(), district: form.district.trim(),
address: form.address.trim(), address: form.address.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
openTime: form.openTime.trim(), openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(), closeTime: form.closeTime.trim(),
...(form.dualHours
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
: {}),
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
categoryId: form.categoryId.trim(), categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined, intro: form.intro.trim() || undefined,
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined, coverUrl: form.coverUrl.trim() || undefined,
@@ -539,7 +472,6 @@ export default function StoreCreatePage() {
setForm(defaultStoreForm()); setForm(defaultStoreForm());
setFieldErrors({}); setFieldErrors({});
setSubmitError(''); setSubmitError('');
setSmsHint('');
setParams({ step: '1' }, { replace: true }); setParams({ step: '1' }, { replace: true });
toastSuccess('门店录入成功'); toastSuccess('门店录入成功');
navigate(`/stores/${result.store.id}`); navigate(`/stores/${result.store.id}`);
@@ -549,11 +481,6 @@ export default function StoreCreatePage() {
setFieldErrors({ phone: message }); setFieldErrors({ phone: message });
return; return;
} }
if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message });
goStep(3);
return;
}
setSubmitError(message); setSubmitError(message);
toastError(message); toastError(message);
} finally { } finally {
@@ -734,13 +661,54 @@ export default function StoreCreatePage() {
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} /> <textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
try {
const pos = await locateStorePosition();
patchForm({
latitude: String(pos.latitude),
longitude: String(pos.longitude),
});
toastSuccess('已获取当前坐标');
} catch (e) {
toastError(e instanceof Error ? e.message : '定位失败');
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位(可定位或地图选点)'}
</span>
</div>
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label> <span className="text-primary">*</span></label> <label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}> <div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input <input
@@ -774,19 +742,97 @@ export default function StoreCreatePage() {
</div> </div>
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={form.dualHours}
onChange={(e) => patchForm({ dualHours: e.target.checked })}
/>
<span className="label-md">/</span>
</label>
{form.dualHours ? (
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.openTime2}
onChange={(e) => patchForm({ openTime2: e.target.value })}
aria-label="第二段开始"
/>
<span className="label-md text-muted"></span>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.closeTime2}
onChange={(e) => patchForm({ closeTime2: e.target.value })}
aria-label="第二段结束"
/>
</div>
) : null}
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
10:0022:00 1 09:00-22:00 2 09:00-14:0017:00-21:00
</p> </p>
</div> </div>
<div className="partner-field">
<label></label>
<input
className="partner-field-input partner-field-input--block"
type="number"
min={0}
step={1}
placeholder="如 88,用户端门店页展示"
value={form.avgPrice}
onChange={(e) => patchForm({ avgPrice: e.target.value })}
/>
</div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} /> <textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
@@ -796,6 +842,26 @@ export default function StoreCreatePage() {
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => patchForm({ benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<div className="partner-info-banner"> <div className="partner-info-banner">
@@ -1040,74 +1106,11 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
</p> </p>
</div> </div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || submitting}
onClick={() => void sendStorePhoneCode()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.storeSmsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
)}
</div>
</section> </section>
</> </>
@@ -1150,6 +1153,23 @@ export default function StoreCreatePage() {
</footer> </footer>
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
patchForm({
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
});
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
+110 -3
View File
@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft'; import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -38,13 +40,23 @@ export default function StoreDetailPage() {
const canMutate = canManagePartnerStore(account); const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' }); const [form, setForm] = useState({
name: '',
phone: '',
address: '',
intro: '',
benefitUsageRule: '',
latitude: '',
longitude: '',
});
const [coverUrl, setCoverUrl] = useState(''); const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']); const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN'); const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false); const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
@@ -55,6 +67,12 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''), phone: String(data.phone || ''),
address: String(data.address || ''), address: String(data.address || ''),
intro: String(data.intro || ''), intro: String(data.intro || ''),
benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
}); });
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue); setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || '')); setCoverUrl(String(data.coverUrl || ''));
@@ -149,6 +167,13 @@ export default function StoreDetailPage() {
phone: form.phone.trim(), phone: form.phone.trim(),
address: form.address.trim(), address: form.address.trim(),
intro: form.intro.trim(), intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}), }),
}); });
applyStore(data); applyStore(data);
@@ -319,14 +344,78 @@ export default function StoreDetailPage() {
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
{!readOnly ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
setActionError('');
try {
const pos = await locateStorePosition();
setForm((prev) => ({
...prev,
latitude: String(pos.latitude),
longitude: String(pos.longitude),
}));
toastSuccess('已获取当前坐标');
} catch (e) {
const msg = e instanceof Error ? e.message : '定位失败';
setActionError(msg);
toastError(msg);
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位'}
</span>
</div>
) : formatStoreCoords(form.latitude, form.longitude) ? (
<p className="label-md text-muted" style={{ marginTop: 6 }}>
{formatStoreCoords(form.latitude, form.longitude)}
</p>
) : null}
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} /> <textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span> <span className="label-md text-muted">{form.intro.length} / 500</span>
</div> </div>
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
disabled={readOnly}
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -421,6 +510,24 @@ export default function StoreDetailPage() {
</div> </div>
</div> </div>
)} )}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
setForm((prev) => ({
...prev,
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
} }
+139
View File
@@ -3315,6 +3315,145 @@ body {
border-top: 1px dashed rgba(166, 29, 36, 0.15); border-top: 1px dashed rgba(166, 29, 36, 0.15);
} }
.partner-proxy-mode-tabs {
display: flex;
gap: 8px;
}
.partner-proxy-mode-tab {
flex: 1;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
font-size: 14px;
color: var(--color-ink-black);
}
.partner-proxy-mode-tab.is-active {
border-color: var(--color-heritage-red);
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.06);
font-weight: 600;
}
.partner-proxy-auto-receive {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 16px;
font-size: 13px;
line-height: 1.45;
color: var(--color-ink-black);
}
.partner-proxy-auto-receive input {
margin-top: 3px;
flex-shrink: 0;
}
.partner-proxy-product-picker {
position: relative;
width: 100%;
text-align: left;
padding: 12px 36px 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
display: flex;
flex-direction: column;
gap: 4px;
}
.partner-proxy-product-picker-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 22px;
color: var(--color-muted, #999);
}
.partner-proxy-product-list {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.partner-proxy-product-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: #fff;
}
.partner-proxy-product-card--active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.2);
}
.partner-proxy-product-thumb {
width: 72px;
height: 72px;
border-radius: 12px;
overflow: hidden;
background: #f3f1ee;
flex-shrink: 0;
}
.partner-proxy-product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.partner-proxy-product-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f3f1ee, #e8e4de);
}
.partner-proxy-product-main {
flex: 1;
min-width: 0;
}
.partner-proxy-product-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.partner-proxy-product-name {
font-weight: 600;
font-size: 15px;
color: var(--color-ink-black);
}
.partner-proxy-product-price {
color: var(--color-heritage-red);
font-weight: 600;
flex-shrink: 0;
}
.partner-proxy-product-spec,
.partner-proxy-product-benefit {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-muted, #888);
}
.partner-proxy-confirm {
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.partner-warehouse-denied { .partner-warehouse-denied {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+11 -7
View File
@@ -10,13 +10,11 @@ function formatMoney(n: number) {
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const loadDashboard = useCallback(() => { const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard') return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { .then((d) => {
setDash(d); setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -44,8 +42,14 @@ export default function HomePage() {
const store = dash?.store as Record<string, unknown> | undefined; const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00'); const status = String(store?.status || '');
const closeTime = String(store?.closeTime || '22:00'); const open = status === 'OPEN';
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00';
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return ( return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page"> <PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
@@ -91,12 +95,12 @@ export default function HomePage() {
</div> </div>
<div> <div>
<p className="shop-home-status-title"></p> <p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p> <p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {openTime} - {closeTime}</p> <p className="shop-home-status-sub">: {hoursText}</p>
</div> </div>
</div> </div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> <label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open} readOnly tabIndex={-1} /> <input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> <span className="shop-home-switch-track" />
</label> </label>
</section> </section>
+5 -3
View File
@@ -58,8 +58,10 @@ export default function MinePage() {
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败')); .catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]); }, [applySession, searchParams, setSearchParams, wxAuthorize]);
const openTime = String(store?.openTime || '09:30'); const hoursParts: string[] = [];
const closeTime = String(store?.closeTime || '22:00'); if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '09:30 - 22:00';
const multiStore = (profile?.stores?.length ?? 0) > 1; const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false; const showBindWechat = wxAuthorize && hasWechat === false;
@@ -126,7 +128,7 @@ export default function MinePage() {
<div className="shop-mine-info-row"> <div className="shop-mine-info-row">
<div> <div>
<p className="shop-mine-info-label"></p> <p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{openTime} - {closeTime}</p> <p className="shop-mine-info-value">{hoursText}</p>
</div> </div>
<span className="material-symbols-outlined shop-mine-lock">lock</span> <span className="material-symbols-outlined shop-mine-lock">lock</span>
</div> </div>
+2 -1
View File
@@ -63,7 +63,8 @@ export default function SelectStorePage() {
function statusLabel(status: string) { function statusLabel(status: string) {
if (status === 'OPEN') return '营业中'; if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '暂停营业'; if (status === 'PAUSED') return '临时闭店';
if (status === 'CLOSED') return '永久关闭';
return status || '门店'; return status || '门店';
} }
+61 -29
View File
@@ -3,20 +3,35 @@ import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import { request } from '../lib/api';
function formatShopHours(store: Record<string, unknown> | null) {
const parts: string[] = [];
const openTime = store?.openTime ? String(store.openTime) : '';
const closeTime = store?.closeTime ? String(store.closeTime) : '';
const openTime2 = store?.openTime2 ? String(store.openTime2) : '';
const closeTime2 = store?.closeTime2 ? String(store.closeTime2) : '';
if (openTime && closeTime) parts.push(`${openTime} - ${closeTime}`);
if (openTime2 && closeTime2) parts.push(`${openTime2} - ${closeTime2}`);
return parts.length ? parts.join('') : '09:30 - 22:00';
}
export default function StatusPage() { export default function StatusPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { resetSession } = useStoreSession(); const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState(''); const [lastUpdate, setLastUpdate] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null); const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => { request('SHOP_H5', '/shop/dashboard').then((d) => {
const s = d.store as Record<string, unknown>; const s = d.store as Record<string, unknown>;
setStore(s); setStore(s);
setOpen(String(s?.status) === 'OPEN'); const status = String(s?.status || '');
setPermanentlyClosed(status === 'CLOSED');
setOpen(status === 'OPEN');
if (s?.updatedAt) { if (s?.updatedAt) {
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN')); setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
} }
@@ -24,31 +39,33 @@ export default function StatusPage() {
}, []); }, []);
function requestToggle(next: boolean) { function requestToggle(next: boolean) {
if (permanentlyClosed) return;
if (next === open) return; if (next === open) return;
setPendingOpen(next); setPendingOpen(next);
setShowModal(true); setShowModal(true);
} }
async function confirmToggle() { async function confirmToggle() {
if (pendingOpen === null) return; if (pendingOpen === null || permanentlyClosed) return;
const next = pendingOpen ? 'OPEN' : 'PAUSED'; const next = pendingOpen ? 'OPEN' : 'PAUSED';
try { try {
setError('');
await request('SHOP_H5', '/shop/store/status', { await request('SHOP_H5', '/shop/store/status', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
setOpen(pendingOpen); setOpen(pendingOpen);
setLastUpdate(new Date().toLocaleString('zh-CN')); setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch { } catch (e) {
/* keep current state */ setError(e instanceof Error ? e.message : '状态切换失败');
} finally { } finally {
setShowModal(false); setShowModal(false);
setPendingOpen(null); setPendingOpen(null);
} }
} }
const openTime = String(store?.openTime || '09:30'); const hoursText = formatShopHours(store);
const closeTime = String(store?.closeTime || '22:00'); const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
return ( return (
<div className="shop-status-page"> <div className="shop-status-page">
@@ -70,46 +87,61 @@ export default function StatusPage() {
<div className="shop-status-content"> <div className="shop-status-content">
<section className="shop-status-card"> <section className="shop-status-card">
<div className="shop-status-icon-wrap"> <div className="shop-status-icon-wrap">
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}> <div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}> <div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">storefront</span> <span className="material-symbols-outlined">storefront</span>
</div> </div>
</div> </div>
<span className="shop-status-check"> <span className="shop-status-check">
<span className={`material-symbols-outlined shop-fill-icon${open ? '' : ''}`} style={{ fontSize: 14, color: open ? 'var(--color-success-green)' : 'var(--color-subtle-gray)' }}> <span
{open ? 'check_circle' : 'cancel'} className="material-symbols-outlined shop-fill-icon"
style={{
fontSize: 14,
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
}}
>
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
</span> </span>
</span> </span>
</div> </div>
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}> <h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
{open ? '营业中' : '临时闭店'} {statusLabel}
</h2> </h2>
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}> {permanentlyClosed ? (
<input <p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
type="checkbox"
checked={open} </p>
onChange={(e) => requestToggle(e.target.checked)} ) : (
aria-label={open ? '切换为临时闭店' : '切换为营业中'} <label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
/> <input
<span className="shop-status-switch-track" /> type="checkbox"
<span className="shop-status-switch-caption"> checked={open}
{open ? '点击可临时闭店' : '点击恢复营业'} onChange={(e) => requestToggle(e.target.checked)}
</span> aria-label={open ? '切换为临时闭店' : '切换为营业中'}
</label> />
<span className="shop-status-switch-track" />
<span className="shop-status-switch-caption">
{open ? '点击可临时闭店' : '点击恢复营业'}
</span>
</label>
)}
<p className="shop-status-hours-label"></p> <p className="shop-status-hours-label"></p>
<p className="shop-status-hours">{openTime} - {closeTime}</p> <p className="shop-status-hours">{hoursText}</p>
{lastUpdate && <p className="shop-status-updated"> {lastUpdate}</p>} {lastUpdate && <p className="shop-status-updated"> {lastUpdate}</p>}
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
</section> </section>
<div className={`shop-status-hint${open ? ' open' : ' closed'}`}> <div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">info</span> <span className="material-symbols-outlined">info</span>
<p> <p>
{open {permanentlyClosed
? '当前处于营业状态,用户可在您的门店核销餐券。' ? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'} : open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
</p> </p>
</div> </div>
</div> </div>
@@ -37,6 +37,8 @@ type Order = {
id: string; id: string;
orderNo: string; orderNo: string;
orderType?: string; orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
status: string; status: string;
originOrderId?: string | null; originOrderId?: string | null;
remark?: string | null; remark?: string | null;
@@ -138,6 +140,7 @@ export default function OrderDetailPage() {
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]); const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT'; const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() { async function loadTrack() {
if (!id) return; if (!id) return;
@@ -240,6 +243,11 @@ export default function OrderDetailPage() {
<h2>{banner.title}</h2> <h2>{banner.title}</h2>
</div> </div>
<p>{banner.subtitle}</p> <p>{banner.subtitle}</p>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
· {order.proxyPartnerName}
</p>
) : null}
</div> </div>
</section> </section>
+18 -4
View File
@@ -14,11 +14,14 @@ const TABS = [
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
PENDING_PAY: '待付款', PENDING_PAY: '待付款',
PENDING_SHIP: '已付款', PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已付款', OUT_WAREHOUSE: '已出库',
SHIPPING: '已付款', SHIPPING: '配送中',
PENDING_RECEIVE: '已付款', PENDING_RECEIVE: '待收货',
COMPLETED: '已完成', COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
}; };
function isReshipOrder(order: Record<string, unknown>) { function isReshipOrder(order: Record<string, unknown>) {
@@ -49,6 +52,7 @@ export default function OrderListPage() {
const items = (o.items as Array<Record<string, unknown>>) || []; const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0]; const item = items[0];
const isReship = isReshipOrder(o); const isReship = isReshipOrder(o);
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
const isPendingPay = String(o.status) === 'PENDING_PAY'; const isPendingPay = String(o.status) === 'PENDING_PAY';
return ( return (
<div key={String(o.id)} className="card"> <div key={String(o.id)} className="card">
@@ -60,9 +64,19 @@ export default function OrderListPage() {
</span> </span>
)} )}
{isProxy && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
</span> </span>
<span className="status-tag">{orderStatusLabel(o)}</span> <span className="status-tag">{orderStatusLabel(o)}</span>
</div> </div>
{isProxy && o.proxyPartnerName ? (
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
{String(o.proxyPartnerName)}
</div>
) : null}
{item && ( {item && (
<div className="card-row"> <div className="card-row">
<AppImage <AppImage
+10 -2
View File
@@ -24,6 +24,9 @@ type StoreDetail = {
status: string; status: string;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null; category?: { name: string } | null;
media?: StoreMedia[]; media?: StoreMedia[];
}; };
@@ -41,8 +44,10 @@ const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。'; '作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) { function formatHours(store: StoreDetail) {
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`; const parts: string[] = [];
return '09:30 - 22:00'; if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '09:30-22:00';
} }
function fullAddress(store: StoreDetail) { function fullAddress(store: StoreDetail) {
@@ -135,6 +140,9 @@ export default function StoreDetailPage() {
<div> <div>
<h2 className="store-detail-name">{store.name}</h2> <h2 className="store-detail-name">{store.name}</h2>
<p className="store-detail-hours">{formatHours(store)}</p> <p className="store-detail-hours">{formatHours(store)}</p>
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<p className="store-detail-hours"> ¥{Number(store.avgPrice).toFixed(0)}</p>
) : null}
{store.category?.name && ( {store.category?.name && (
<span className="store-detail-category">{store.category.name}</span> <span className="store-detail-category">{store.category.name}</span>
)} )}
+10 -4
View File
@@ -32,6 +32,9 @@ type StoreItem = {
status: string; status: string;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null; category?: { name: string } | null;
}; };
@@ -50,10 +53,10 @@ function storeCover(store: StoreItem, index: number) {
} }
function formatHours(store: StoreItem) { function formatHours(store: StoreItem) {
if (store.openTime && store.closeTime) { const parts: string[] = [];
return `营业时间: ${store.openTime}-${store.closeTime}`; if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
} if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return '营业时间: 10:00-22:00'; return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
} }
export default function StoreListPage() { export default function StoreListPage() {
@@ -238,6 +241,9 @@ export default function StoreListPage() {
<div className="store-card-meta"> <div className="store-card-meta">
<span className="status-open"></span> <span className="status-open"></span>
<span className="store-card-hours">{formatHours(s)}</span> <span className="store-card-hours">{formatHours(s)}</span>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<span className="store-card-hours">¥{Number(s.avgPrice).toFixed(0)}</span>
) : null}
</div> </div>
</div> </div>
<div className="store-card-bottom"> <div className="store-card-bottom">
+23
View File
@@ -0,0 +1,23 @@
/** 球面距离(米) */
export function haversineMeters(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
export function formatDistanceMeters(meters: number | null | undefined): string {
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
}
+1 -1
View File
@@ -105,7 +105,7 @@ export function normalizeRegionSelection(selection: RegionSelection): RegionSele
export const DEFAULT_REGION: RegionSelection = { export const DEFAULT_REGION: RegionSelection = {
province: '河南省', province: '河南省',
city: '郑州市', city: '郑州市',
district: '金水区', district: REGION_ALL,
}; };
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection { export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
+40 -3
View File
@@ -1,10 +1,11 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { getWechatLocationDetailed } from '@dukang/weixin-sdk'; import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { API_BASE, CLIENT_APP, getToken, request } from './api'; import { API_BASE, CLIENT_APP, getToken, request } from './api';
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data'; import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images'; import { FALLBACK_CITY_CODE } from './product-images';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
const USER_COORDS_KEY = 'dukang_user_coords';
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */ /** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
const LOCATION_DENIED_KEY = 'dukang_location_denied'; const LOCATION_DENIED_KEY = 'dukang_location_denied';
@@ -19,12 +20,14 @@ export type ResolvedUserCity = {
displayCity: string; displayCity: string;
}; };
export type UserCoords = { latitude: number; longitude: number };
type GpsCityCache = ResolvedUserCity & { timestamp: number }; type GpsCityCache = ResolvedUserCity & { timestamp: number };
const FALLBACK_CITY: ResolvedUserCity = { const FALLBACK_CITY: ResolvedUserCity = {
province: DEFAULT_REGION.province, province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city, city: DEFAULT_REGION.city,
district: DEFAULT_REGION.district, district: REGION_ALL,
cityCode: FALLBACK_CITY_CODE, cityCode: FALLBACK_CITY_CODE,
cityName: '郑州市', cityName: '郑州市',
openCity: true, openCity: true,
@@ -85,6 +88,39 @@ function writeCache(data: ResolvedUserCity) {
} }
} }
export function writeUserCoords(latitude: number, longitude: number) {
try {
Taro.setStorageSync(
USER_COORDS_KEY,
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
);
} catch {
/* ignore */
}
}
export function readCachedUserCoords(): UserCoords | null {
try {
const raw = Taro.getStorageSync(USER_COORDS_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
return { latitude: parsed.latitude, longitude: parsed.longitude };
} catch {
return null;
}
}
/** 门店列表默认用市级全市筛选 */
export function toCityWideRegion(region: RegionSelection): RegionSelection {
return {
province: region.province,
city: region.city,
district: REGION_ALL,
};
}
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */ /** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
function cacheFallbackAndMaybeDeny(denied: boolean) { function cacheFallbackAndMaybeDeny(denied: boolean) {
if (denied) markLocationDenied(); if (denied) markLocationDenied();
@@ -167,12 +203,12 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
status: 'fail', status: 'fail',
errMsg: outcome.errMsg, errMsg: outcome.errMsg,
}).catch(() => {}); }).catch(() => {});
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
cacheFallbackAndMaybeDeny(denied); cacheFallbackAndMaybeDeny(denied);
return null; return null;
} }
try { try {
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
const data = await reportLocationToServer({ const data = await reportLocationToServer({
latitude: outcome.location.latitude, latitude: outcome.location.latitude,
longitude: outcome.location.longitude, longitude: outcome.location.longitude,
@@ -213,6 +249,7 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
try { try {
const loc = await getMiniLocation(); const loc = await getMiniLocation();
writeUserCoords(loc.latitude, loc.longitude);
const data = await reportLocationToServer({ const data = await reportLocationToServer({
latitude: loc.latitude, latitude: loc.latitude,
longitude: loc.longitude, longitude: loc.longitude,
@@ -3,4 +3,6 @@ export default definePageConfig({
navigationBarTitleText: '好客权益', navigationBarTitleText: '好客权益',
enablePullDownRefresh: true, enablePullDownRefresh: true,
backgroundTextStyle: 'dark', backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
+29 -2
View File
@@ -1,11 +1,18 @@
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
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, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type BenefitSummary = { type BenefitSummary = {
totalBalance: number; totalBalance: number;
@@ -84,8 +91,25 @@ export default function BenefitPage() {
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID'); const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
const visible = tab === 'available' ? available : history; const visible = tab === 'available' ? available : history;
const sharePayload = useMemo(
() => ({
title: '好客权益 · 杜康好客',
desc: DEFAULT_SHARE_DESC,
path: '/pages/benefit/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
return ( return (
<PageShell variant="tab" className="benefit-page"> <PageShell variant="tab" className="benefit-page">
<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' ? ( {process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text> <Text className="benefit-header-title"></Text>
@@ -98,6 +122,9 @@ export default function BenefitPage() {
<View className="benefit-header-city-pin" /> <View className="benefit-header-city-pin" />
<Text></Text> <Text></Text>
</View> </View>
<View className="benefit-header__share page-nav-bar__right-slot">
<ShareNavButton payload={sharePayload} />
</View>
</View> </View>
</View> </View>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '杜康好客', navigationBarTitleText: '杜康好客',
enablePullDownRefresh: true, enablePullDownRefresh: true,
backgroundTextStyle: 'dark', backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
+26 -2
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'; import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } 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 CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
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';
@@ -11,6 +12,11 @@ import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Product = { type Product = {
id: string; id: string;
@@ -139,8 +145,26 @@ export default function HomePage() {
const banners = miniHome.banners; const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl; const footerUrl = miniHome.footerUrl;
const sharePayload = useMemo(
() => ({
title: DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: '/pages/home/index',
imgUrl: banners[0] || undefined,
}),
[banners],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
return ( return (
<PageShell variant="tab" className="home-page no-tab-header"> <PageShell variant="tab" className="home-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="杜康好客" /> <TabMainHeader title="杜康好客" />
{banners.length > 0 ? ( {banners.length > 0 ? (
@@ -150,7 +174,7 @@ export default function HomePage() {
indicatorDots={banners.length > 1} indicatorDots={banners.length > 1}
autoplay={banners.length > 1} autoplay={banners.length > 1}
circular={banners.length > 1} circular={banners.length > 1}
interval={4000} interval={2500}
> >
{banners.map((url) => ( {banners.map((url) => (
<SwiperItem key={url}> <SwiperItem key={url}>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '我的', navigationBarTitleText: '我的',
enablePullDownRefresh: true, enablePullDownRefresh: true,
backgroundTextStyle: 'dark', backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
+67 -7
View File
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Button, Input } from '@tarojs/components'; import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types'; import {
BRAND_LOGO_MARK_URL,
QUALIFICATION_DISCLOSURE_URL,
isWxAuthorizeEnabled,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth'; import { bindWechatForUser } from '../../lib/wechat-auth';
import { import {
@@ -18,6 +23,11 @@ import {
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin'; import { isWechatEnv } from '../../lib/weixin';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
const ORDER_SHORTCUTS = [ const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: '付', label: '待付款' }, { tab: 'pending_pay', icon: '付', label: '待付款' },
@@ -29,6 +39,7 @@ const SERVICES = [
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' }, { icon: '址', label: '地址管理', url: '/pages/addresses/index' },
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' }, { icon: '店', label: '可用门店', tab: '/pages/stores/index' },
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' }, { icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
{ icon: '资', label: '资质公示', action: 'qualification' as const },
{ icon: '关', label: '关于我们', action: 'about' as const }, { icon: '关', label: '关于我们', action: 'about' as const },
] as const; ] as const;
@@ -51,6 +62,7 @@ export default function MinePage() {
const [draftNickname, setDraftNickname] = useState(''); const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState(''); const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false);
function resetGuestState() { function resetGuestState() {
setProfile(null); setProfile(null);
@@ -126,6 +138,21 @@ export default function MinePage() {
.catch(() => setWxAuthorize(true)); .catch(() => setWxAuthorize(true));
}, []); }, []);
const sharePayload = useMemo(
() => ({
title: '杜康好客 · 我的',
desc: DEFAULT_SHARE_DESC,
path: '/pages/mine/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
async function ensureWechatBound(): Promise<boolean> { async function ensureWechatBound(): Promise<boolean> {
if (profile?.hasWechat) return true; if (profile?.hasWechat) return true;
if (!wxAuthorize) { if (!wxAuthorize) {
@@ -254,6 +281,10 @@ export default function MinePage() {
Taro.switchTab({ url: item.tab }); Taro.switchTab({ url: item.tab });
return; return;
} }
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') { if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化'); toast('杜康好客 · 传承千年酒文化');
} }
@@ -269,6 +300,7 @@ export default function MinePage() {
if (!authed) { if (!authed) {
return ( return (
<PageShell variant="tab" className="mine-page no-tab-header"> <PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
@@ -321,6 +353,7 @@ export default function MinePage() {
return ( return (
<PageShell variant="tab" className="mine-page no-tab-header"> <PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
@@ -507,14 +540,41 @@ export default function MinePage() {
</View> </View>
<View <View
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`} className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
onClick={savingProfile ? undefined : () => void saveWxProfile()} onClick={() => {
if (!savingProfile) void saveWxProfile();
}}
> >
<Text>{savingProfile ? '保存中...' : '保存'}</Text> <Text>{savingProfile ? '保存中' : '保存'}</Text>
</View> </View>
</View> </View>
</View> </View>
</View> </View>
) : null} ) : null}
{qualificationOpen ? (
<View
className="mine-qualification-mask"
onClick={() => setQualificationOpen(false)}
>
<ScrollView
scrollY
enableFlex
className="mine-qualification-scroll"
style={{ height: '100%' }}
enhanced
showScrollbar
>
<View className="mine-qualification-body">
<Image
className="mine-qualification-img"
src={QUALIFICATION_DISCLOSURE_URL}
mode="widthFix"
/>
</View>
</ScrollView>
<Text className="mine-qualification-hint"></Text>
</View>
) : null}
</PageShell> </PageShell>
); );
} }
@@ -35,7 +35,7 @@ type OrderPreview = {
export default function OrderConfirmPickupPage() { export default function OrderConfirmPickupPage() {
const router = useRouter(); const router = useRouter();
const productId = router.params.productId ?? ''; const productId = router.params.productId ?? '';
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 1))); const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
const [preview, setPreview] = useState<OrderPreview | null>(null); const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -70,12 +70,12 @@ export default function OrderConfirmPickupPage() {
}; };
}, [productId, quantity]); }, [productId, quantity]);
const minQty = preview?.minQty ?? 1; const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false; const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading; const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) { function updateQuantity(next: number) {
if (next < 1) return; if (next < minQty) return;
setQuantity(next); setQuantity(next);
} }
@@ -95,7 +95,7 @@ export default function OrderConfirmPickupPage() {
async function submit() { async function submit() {
if (!canSubmit) { if (!canSubmit) {
if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`); if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`);
return; return;
} }
@@ -185,8 +185,8 @@ export default function OrderConfirmPickupPage() {
<Text></Text> <Text></Text>
<View className="order-qty-controls"> <View className="order-qty-controls">
<View <View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`} className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)} onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
> >
<Text></Text> <Text></Text>
</View> </View>
@@ -196,6 +196,11 @@ export default function OrderConfirmPickupPage() {
</View> </View>
</View> </View>
</View> </View>
{!quantityOk ? (
<Text className="order-qty-hint">
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View> </View>
<View className="order-card"> <View className="order-card">
@@ -219,7 +224,7 @@ export default function OrderConfirmPickupPage() {
) : null} ) : null}
{msg ? ( {msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}> <Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{msg} {msg}
</Text> </Text>
) : null} ) : null}
@@ -39,6 +39,9 @@ type OrderDetail = {
receiverAddress?: string; receiverAddress?: string;
createdAt?: string; createdAt?: string;
originOrderId?: string | null; originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[]; items?: OrderItem[];
}; };
@@ -80,9 +83,10 @@ export default function OrderDetailPage() {
}, [orderId]); }, [orderId]);
const isReship = !!order?.originOrderId; const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive = const canConfirmReceive =
!!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); !!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
@@ -166,9 +170,15 @@ export default function OrderDetailPage() {
<> <>
<View className="order-card"> <View className="order-card">
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
<Text className="order-list-status"> <View className="order-status-row">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'} <Text className="order-list-status">
</Text> {STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
{isProxy && order.proxyPartnerName ? (
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null}
</View> </View>
<View className="order-card"> <View className="order-card">
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
+14 -12
View File
@@ -6,6 +6,8 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
const TABS = [ const TABS = [
{ key: 'all', label: '全部订单' }, { key: 'all', label: '全部订单' },
{ key: 'pending_pay', label: '待付款' }, { key: 'pending_pay', label: '待付款' },
@@ -13,22 +15,12 @@ const TABS = [
{ key: 'completed', label: '已完成' }, { key: 'completed', label: '已完成' },
] as const; ] as const;
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '已付款',
OUT_WAREHOUSE: '已付款',
SHIPPED: '已付款',
DELIVERED: '已付款',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
function orderStatusLabel(tab: string, status?: string): string { function orderStatusLabel(tab: string, status?: string): string {
if (tab !== 'all') { if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || ''; return TABS.find((t) => t.key === tab)?.label || status || '';
} }
if (!status) return ''; if (!status) return '';
return STATUS_LABELS[status] || status; return ORDER_STATUS_LABELS[status] || status;
} }
type OrderItem = { type OrderItem = {
@@ -47,6 +39,9 @@ type OrderRow = {
qty?: number; qty?: number;
quantity?: number; quantity?: number;
originOrderId?: string | null; originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[]; items?: OrderItem[];
}; };
@@ -110,6 +105,7 @@ export default function OrdersPage() {
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1; const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
const unitPrice = Number(item?.unitPrice ?? 0); const unitPrice = Number(item?.unitPrice ?? 0);
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId; const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
return ( return (
<View <View
@@ -118,11 +114,17 @@ export default function OrdersPage() {
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })} onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
> >
<View className="order-list-head"> <View className="order-list-head">
<Text className="order-list-no">{o.orderNo || o.id}</Text> <View className="order-list-head-left">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
<Text className="order-list-status"> <Text className="order-list-status">
{orderStatusLabel(tab, o.status)} {orderStatusLabel(tab, o.status)}
</Text> </Text>
</View> </View>
{isProxy && o.proxyPartnerName ? (
<Text className="order-proxy-hint"> {o.proxyPartnerName} </Text>
) : null}
<View className="order-list-body"> <View className="order-list-body">
<View className="order-list-thumb"> <View className="order-list-thumb">
{productImage ? ( {productImage ? (
+175 -20
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import Taro, { usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import Taro, { usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
@@ -13,19 +13,61 @@ import {
toWeappShareMessage, toWeappShareMessage,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
type StoreMedia = {
url?: string | null;
bizType?: string | null;
mediaType?: string | null;
};
type Store = { type Store = {
id: string; id: string;
name: string; name: string;
address?: string; address?: string;
province?: string;
cityName?: string;
city?: string;
district?: string; district?: string;
phone?: string; phone?: string;
intro?: string | null;
benefitUsageRule?: string | null;
coverUrl?: string | null; coverUrl?: string | null;
carouselUrls?: string[] | null; carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
latitude?: number | string | null;
longitude?: number | string | null;
category?: { name: string } | null; category?: { name: string } | null;
}; };
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = String(raw || '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
function envPhotoUrls(store: Store) {
return uniqueUrls(
(store.media || [])
.filter((m) => !m.bizType || m.bizType === 'ENV')
.map((m) => m.url),
);
}
function fullAddress(store: Store) {
const city = store.cityName || store.city || '';
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
}
export default function StoreDetailPage() { export default function StoreDetailPage() {
const router = useRouter(); const router = useRouter();
const storeId = router.params.id ?? ''; const storeId = router.params.id ?? '';
@@ -52,12 +94,15 @@ export default function StoreDetailPage() {
}, [storeId]); }, [storeId]);
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () => {
title: store?.name || DEFAULT_SHARE_TITLE, const envFirst = store ? envPhotoUrls(store)[0] : undefined;
desc: store?.address || DEFAULT_SHARE_DESC, return {
path: `/pages/store-detail/index?id=${storeId}`, title: store?.name || DEFAULT_SHARE_TITLE,
imgUrl: store?.coverUrl || store?.carouselUrls?.[0] || undefined, desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
}), path: `/pages/store-detail/index?id=${storeId}`,
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined,
};
},
[store, storeId], [store, storeId],
); );
@@ -74,6 +119,44 @@ export default function StoreDetailPage() {
else Taro.switchTab({ url: '/pages/stores/index' }); else Taro.switchTab({ url: '/pages/stores/index' });
} }
function callStore() {
if (!store?.phone) {
toast('暂无门店电话');
return;
}
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
}
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
const lng = store.longitude != null ? Number(store.longitude) : NaN;
const address = fullAddress(store) || store.address || store.name;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
Taro.openLocation({
latitude: lat,
longitude: lng,
name: store.name,
address,
scale: 16,
}).catch(() => {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
return;
}
toast('无法打开地图导航');
});
return;
}
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
return;
}
toast('门店位置待完善,暂无法导航');
}
if (!store) { if (!store) {
return ( return (
<PageShell variant="scroll" className="store-detail-page"> <PageShell variant="scroll" className="store-detail-page">
@@ -83,12 +166,25 @@ export default function StoreDetailPage() {
); );
} }
const images = const envPhotos = envPhotoUrls(store);
(store.carouselUrls && store.carouselUrls.length > 0 const images = uniqueUrls([
? store.carouselUrls store.coverUrl,
: store.coverUrl ...(store.carouselUrls || []),
? [store.coverUrl] ...envPhotos,
: []) as string[]; ]);
const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
function previewEnv(index: number) {
if (!envPhotos.length) return;
Taro.previewImage({
current: envPhotos[index],
urls: envPhotos,
}).catch(() => toast('无法预览图片'));
}
return ( return (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter> <PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
@@ -107,14 +203,39 @@ export default function StoreDetailPage() {
<View className="store-detail-info-card"> <View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text> <Text className="store-detail-name">{store.name}</Text>
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''}
{store.address || '地址待完善'}
</Text>
<Text className="store-detail-action" onClick={openMap}>
</Text>
</View>
<Text className="store-detail-meta"> <Text className="store-detail-meta">
{store.district ? `${store.district} · ` : ''} :{' '}
{store.address || '地址待完善'} {(() => {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '10:00-22:00';
})()}
</Text> </Text>
<Text className="store-detail-meta"> {store.avgPrice != null && Number(store.avgPrice) > 0 ? (
: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'} <Text className="store-detail-meta"> ¥{Number(store.avgPrice).toFixed(0)}</Text>
</Text> ) : null}
{store.phone ? <Text className="store-detail-meta">: {store.phone}</Text> : null}
{store.phone ? (
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">: {store.phone}</Text>
<Text className="store-detail-action" onClick={callStore}>
</Text>
</View>
) : null}
<View className="store-detail-tags"> <View className="store-detail-tags">
{store.category?.name ? ( {store.category?.name ? (
<Text className="store-detail-tag">{store.category.name}</Text> <Text className="store-detail-tag">{store.category.name}</Text>
@@ -124,8 +245,42 @@ export default function StoreDetailPage() {
</View> </View>
</View> </View>
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-intro">{intro}</Text>
</View>
) : null}
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{envPhotos.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<View className="store-detail-env-grid">
{envPhotos.map((url, index) => (
<View
key={`${url}-${index}`}
className="store-detail-env-item"
onClick={() => previewEnv(index)}
>
<Image className="store-detail-env-img" src={url} mode="aspectFill" />
</View>
))}
</View>
</View>
) : null}
<View className="store-detail-bar"> <View className="store-detail-bar">
<View className="u-btn u-btn--block" onClick={() => toast('核销请前往「好客权益」')}> <View
className="u-btn u-btn--block"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text></Text> <Text></Text>
</View> </View>
</View> </View>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '门店', navigationBarTitleText: '门店',
enablePullDownRefresh: true, enablePullDownRefresh: true,
backgroundTextStyle: 'dark', backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
+71 -16
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components'; import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import RegionPicker from '../../components/RegionPicker'; import RegionPicker from '../../components/RegionPicker';
import CategoryPicker, { import CategoryPicker, {
EMPTY_CATEGORY, EMPTY_CATEGORY,
@@ -17,9 +18,21 @@ import {
type RegionSelection, type RegionSelection,
} from '../../lib/region-data'; } from '../../lib/region-data';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import {
getCityCodeForCatalog,
readCachedUserCoords,
resolveUserCity,
toCityWideRegion,
type UserCoords,
} from '../../lib/user-location';
import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { formatDistanceMeters } from '../../lib/geo';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Store = { type Store = {
id: string; id: string;
@@ -31,13 +44,17 @@ type Store = {
coverUrl?: string | null; coverUrl?: string | null;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | 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 } | null;
latitude?: number | string | null;
longitude?: number | string | null;
distanceMeters?: number | null;
}; };
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
export default function StoresPage() { export default function StoresPage() {
const [stores, setStores] = useState<Store[]>([]); const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -49,6 +66,7 @@ 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 [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE); const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const [userCoords, setUserCoords] = useState<UserCoords | null>(() => readCachedUserCoords());
const regionLabel = formatRegionLabel(region); const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category); const categoryLabel = formatCategoryLabel(category);
@@ -66,8 +84,9 @@ export default function StoresPage() {
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(1); syncTabBarSelected(1);
void resolveUserCity().then((resolved) => { void resolveUserCity().then((resolved) => {
setRegion(resolved.region); setRegion(toCityWideRegion(resolved.region));
setCityCode(getCityCodeForCatalog(resolved)); setCityCode(getCityCodeForCatalog(resolved));
setUserCoords(readCachedUserCoords());
}); });
}); });
@@ -79,12 +98,19 @@ export default function StoresPage() {
const loadStores = useCallback(() => { const loadStores = useCallback(() => {
setLoading(true); setLoading(true);
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores'; const qs = new URLSearchParams();
if (cityCode) qs.set('cityCode', cityCode);
const coords = userCoords ?? readCachedUserCoords();
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
return request<Store[]>(path) return request<Store[]>(path)
.then((list) => setStores(Array.isArray(list) ? list : [])) .then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cityCode]); }, [cityCode, userCoords]);
useEffect(() => { useEffect(() => {
void loadStores(); void loadStores();
@@ -93,12 +119,20 @@ export default function StoresPage() {
usePullDownRefresh(() => { usePullDownRefresh(() => {
void (async () => { void (async () => {
try { try {
const resolved = await resolveUserCity(); const resolved = await resolveUserCity(true);
setRegion(resolved.region); setRegion(toCityWideRegion(resolved.region));
const nextCode = getCityCodeForCatalog(resolved); const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode); setCityCode(nextCode);
const coords = readCachedUserCoords();
setUserCoords(coords);
setLoading(true); setLoading(true);
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores'; const qs = new URLSearchParams();
if (nextCode) qs.set('cityCode', nextCode);
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
const list = await request<Store[]>(path); const list = await request<Store[]>(path);
setStores(Array.isArray(list) ? list : []); setStores(Array.isArray(list) ? list : []);
} catch (e) { } catch (e) {
@@ -142,14 +176,30 @@ export default function StoresPage() {
} }
function formatHours(store: Store) { function formatHours(store: Store) {
if (store.openTime && store.closeTime) { const parts: string[] = [];
return `营业时间: ${store.openTime}-${store.closeTime}`; if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
} if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return '营业时间: 10:00-22:00'; return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
} }
const sharePayload = useMemo(
() => ({
title: '杜康好客门店',
desc: DEFAULT_SHARE_DESC,
path: '/pages/stores/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
return ( return (
<PageShell variant="tab" className="store-page no-tab-header"> <PageShell variant="tab" className="store-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-filter"> <View className="store-filter">
@@ -186,7 +236,7 @@ export default function StoresPage() {
{loading ? <View className="u-empty"></View> : null} {loading ? <View className="u-empty"></View> : null}
{!loading && filtered.length === 0 ? <View className="u-empty"></View> : null} {!loading && filtered.length === 0 ? <View className="u-empty"></View> : null}
{!loading && {!loading &&
filtered.map((s, index) => ( filtered.map((s) => (
<View <View
key={s.id} key={s.id}
className="store-card" className="store-card"
@@ -204,8 +254,13 @@ export default function StoresPage() {
{s.address || '地址待完善'} {s.address || '地址待完善'}
</Text> </Text>
<Text className="store-card-meta">{formatHours(s)}</Text> <Text className="store-card-meta">{formatHours(s)}</Text>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<Text className="store-card-meta">¥{Number(s.avgPrice).toFixed(0)}</Text>
) : null}
<View className="store-card-footer"> <View className="store-card-footer">
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text> <Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)}
</Text>
<Text <Text
className="store-card-cta" className="store-card-cta"
onClick={(e) => { onClick={(e) => {
+8
View File
@@ -50,6 +50,14 @@
z-index: 2; 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 { .benefit-header-city-pin {
width: 8px; width: 8px;
height: 8px; height: 8px;
+2 -2
View File
@@ -205,8 +205,8 @@
justify-content: center; justify-content: center;
padding: 4px 10px; padding: 4px 10px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: #2e7d32; background: var(--color-aged-amber);
color: #fff; color: #5c4500;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 16px;
+55
View File
@@ -498,3 +498,58 @@
opacity: 0.65; opacity: 0.65;
pointer-events: none; pointer-events: none;
} }
.mine-qualification-mask {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
background: rgba(0, 0, 0, 0.55);
box-sizing: border-box;
}
.mine-qualification-scroll {
flex: 1;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
overflow: hidden;
background: transparent;
}
.mine-qualification-body {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 100%;
width: 100%;
padding: 24px 16px 56px;
}
.mine-qualification-img {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.mine-qualification-hint {
position: absolute;
left: 0;
right: 0;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
z-index: 1;
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
+30
View File
@@ -297,11 +297,41 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.order-list-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.order-list-no { .order-list-no {
font-size: 12px; font-size: 12px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
} }
.order-status-row {
display: flex;
align-items: center;
gap: 8px;
}
.order-proxy-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.08);
padding: 2px 6px;
border-radius: 4px;
}
.order-proxy-hint {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin: -4px 0 10px;
}
.order-list-status { .order-list-status {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -79,6 +79,38 @@
line-height: 1.5; line-height: 1.5;
} }
.store-detail-meta--flex {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.store-detail-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 6px;
}
.store-detail-action {
flex-shrink: 0;
padding: 2px 10px;
border-radius: 999px;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 600;
line-height: 1.6;
}
.store-detail-intro {
display: block;
font-size: 14px;
color: var(--color-on-surface);
line-height: 1.7;
white-space: pre-wrap;
}
.store-detail-tags { .store-detail-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -112,6 +144,27 @@
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.store-detail-env-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.store-detail-env-item {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: var(--radius-md, 8px);
overflow: hidden;
background: var(--color-surface-container);
}
.store-detail-env-img {
width: 100%;
height: 100%;
display: block;
}
.store-detail-bar { .store-detail-bar {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
+3 -2
View File
@@ -42,10 +42,11 @@ if [[ -z "$DEPLOY_HOST" ]]; then
fi fi
SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT") SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT")
[[ -n "$DEPLOY_SSH_KEY" ]] && SSH_OPTS+=(-i "$DEPLOY_SSH_KEY") SCP_OPTS=(-o "StrictHostKeyChecking=accept-new" -P "$DEPLOY_PORT")
[[ -n "$DEPLOY_SSH_KEY" ]] && SSH_OPTS+=(-i "$DEPLOY_SSH_KEY") && SCP_OPTS+=(-i "$DEPLOY_SSH_KEY")
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}" REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
echo "==> 同步 production 环境到 $REMOTE:$APP_ROOT/server/dukang-api/.env.production" echo "==> 同步 production 环境到 $REMOTE:$APP_ROOT/server/dukang-api/.env.production"
scp "${SSH_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/.env.production" scp "${SCP_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/.env.production"
ssh "${SSH_OPTS[@]}" "$REMOTE" "pm2 restart dukang-api && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:8090/api/v1/health" ssh "${SSH_OPTS[@]}" "$REMOTE" "pm2 restart dukang-api && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:8090/api/v1/health"
echo "==> 完成" echo "==> 完成"
+8
View File
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it('allows district partner without district codes', () => {
const result = validatePartnerCityBinding(
[],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: [] },
);
expect(result.ok).toBe(true);
});
it('allows valid district binding', () => { it('allows valid district binding', () => {
const result = validatePartnerCityBinding( const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }], [{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
+1 -6
View File
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
return { ok: true }; return { ok: true };
} }
const districts = normalizeDistrictCodes(input.districtCodes); // 区域合伙人所选区县可为空(录入时可稍后补全)
if (!districts.length) {
return { ok: false, message: '区域合伙人须至少选择一个区县' };
}
// 区县仅为标识,允许多个区域合伙人选择相同区县
return { ok: true }; return { ok: true };
} }
+86 -2
View File
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import { import {
calcBenefitAmount, calcBenefitAmount,
calcRedeemSettleAmount, calcRedeemSettleAmount,
calcLogisticsFeeByBottles,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase, validateMinPurchase,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
calcBenefitSummary, calcBenefitSummary,
@@ -33,9 +38,48 @@ describe('validateMinPurchase', () => {
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true); expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
}); });
it('on-site pickup requires at least 1 bottle', () => { it('on-site pickup requires at least local min (2 bottles)', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false); expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(true); expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
});
});
describe('validateBusinessHours', () => {
it('accepts one segment', () => {
expect(validateBusinessHours([{ open: '09:00', close: '22:00' }]).ok).toBe(true);
});
it('accepts two non-overlapping segments', () => {
expect(
validateBusinessHours([
{ open: '09:00', close: '14:00' },
{ open: '17:00', close: '21:00' },
]).ok,
).toBe(true);
});
it('rejects overlapping second segment', () => {
expect(
validateBusinessHours([
{ open: '09:00', close: '14:00' },
{ open: '13:00', close: '21:00' },
]).ok,
).toBe(false);
});
});
describe('formatBusinessHours', () => {
it('formats one or two segments', () => {
expect(formatBusinessHours({ openTime: '9:00', closeTime: '22:00' })).toBe('9:00-22:00');
expect(
formatBusinessHours({
openTime: '09:00',
closeTime: '14:00',
openTime2: '17:00',
closeTime2: '21:00',
}),
).toBe('09:00-14:0017:00-21:00');
}); });
}); });
@@ -67,6 +111,46 @@ describe('calcRedeemSettleAmount', () => {
}); });
}); });
describe('calcLogisticsFeeByBottles', () => {
const xfx = {
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
};
it('charges base for 1~2 bottles', () => {
expect(calcLogisticsFeeByBottles(1, xfx)).toBe(6);
expect(calcLogisticsFeeByBottles(2, xfx)).toBe(6);
});
it('adds extra bottle fee', () => {
expect(calcLogisticsFeeByBottles(3, xfx)).toBe(8);
expect(calcLogisticsFeeByBottles(5, xfx)).toBe(12);
});
it('uses box fee for full boxes', () => {
expect(calcLogisticsFeeByBottles(6, xfx)).toBe(14);
expect(calcLogisticsFeeByBottles(12, xfx)).toBe(28);
});
it('combines boxes with remainder ladder', () => {
expect(calcLogisticsFeeByBottles(7, xfx)).toBe(20);
expect(calcLogisticsFeeByBottles(8, xfx)).toBe(20);
expect(calcLogisticsFeeByBottles(9, xfx)).toBe(22);
});
});
describe('shouldHoldAutoCourierDispatch', () => {
it('holds at 10 full boxes (60 bottles)', () => {
expect(shouldHoldAutoCourierDispatch(59)).toBe(false);
expect(shouldHoldAutoCourierDispatch(60)).toBe(true);
expect(shouldHoldAutoCourierDispatch(66)).toBe(true);
expect(calcOrderBoxCount(60)).toBe(10);
});
});
describe('allocateBenefitCoupons', () => { describe('allocateBenefitCoupons', () => {
const coupons = [ const coupons = [
{ id: '1', balance: 300, createdAt: 1 }, { id: '1', balance: 300, createdAt: 1 },
+117 -2
View File
@@ -14,8 +14,9 @@ export function validateMinPurchase(
crossMinQty: number, crossMinQty: number,
): { ok: boolean; message?: string } { ): { ok: boolean; message?: string } {
if (deliveryType === 'ON_SITE_PICKUP') { if (deliveryType === 'ON_SITE_PICKUP') {
if (quantity < 1) { const min = localMinQty > 0 ? localMinQty : 2;
return { ok: false, message: '现场取货至少购买 1 瓶' }; if (quantity < min) {
return { ok: false, message: `现场提货至少购买 ${min}` };
} }
return { ok: true }; return { ok: true };
} }
@@ -95,6 +96,120 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
return Math.round(amount * settlementRate * 100) / 100; return Math.round(amount * settlementRate * 100) / 100;
} }
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
export type BusinessHoursSegment = { open: string; close: string };
export function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
/** 校验 12 段营业时间,例如 09:00-22:00 或 09:00-14:00 + 17:00-21:00 */
export function validateBusinessHours(
segments: BusinessHoursSegment[],
): { ok: boolean; message?: string } {
const cleaned = segments
.map((s) => ({ open: String(s.open || '').trim(), close: String(s.close || '').trim() }))
.filter((s) => s.open || s.close);
if (cleaned.length < 1) return { ok: false, message: '请设置营业时间' };
if (cleaned.length > 2) return { ok: false, message: '营业时间最多支持 2 段' };
for (let i = 0; i < cleaned.length; i++) {
const s = cleaned[i];
if (!TIME_HM_RE.test(s.open) || !TIME_HM_RE.test(s.close)) {
return { ok: false, message: `${i + 1} 段时间格式须为 HH:MM` };
}
if (timeToMinutes(s.open) >= timeToMinutes(s.close)) {
return { ok: false, message: `${i + 1} 段结束时间须晚于开始时间` };
}
}
if (cleaned.length === 2 && timeToMinutes(cleaned[0].close) >= timeToMinutes(cleaned[1].open)) {
return { ok: false, message: '第二段开始时间须晚于第一段结束时间' };
}
return { ok: true };
}
export function formatBusinessHours(store: {
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
}): string {
const parts: string[] = [];
if (store.openTime && store.closeTime) {
parts.push(`${store.openTime}-${store.closeTime}`);
}
if (store.openTime2 && store.closeTime2) {
parts.push(`${store.openTime2}-${store.closeTime2}`);
}
return parts.length ? parts.join('') : '10:00-22:00';
}
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
export const BOTTLES_PER_BOX = 6;
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
export const XFX_AUTO_DISPATCH_MAX_BOXES = 10;
/** 按箱规向上取整得到箱数 */
export function calcOrderBoxCount(quantity: number, bottlesPerBox = BOTTLES_PER_BOX): number {
const qty = Math.floor(Number(quantity) || 0);
if (qty <= 0 || !(bottlesPerBox > 0)) return 0;
return Math.ceil(qty / bottlesPerBox);
}
/** 是否因大单拦截自动推承运商(默认 ≥10 箱 = 60 瓶) */
export function shouldHoldAutoCourierDispatch(
quantity: number,
options?: { bottlesPerBox?: number; maxBoxes?: number },
): boolean {
const bottlesPerBox = options?.bottlesPerBox ?? BOTTLES_PER_BOX;
const maxBoxes = options?.maxBoxes ?? XFX_AUTO_DISPATCH_MAX_BOXES;
const qty = Math.floor(Number(quantity) || 0);
return qty >= bottlesPerBox * maxBoxes;
}
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
export type LogisticsPricingRule = {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
};
function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}
function calcLogisticsBottleLadder(quantity: number, rule: LogisticsPricingRule): number {
const qty = Math.floor(quantity);
if (qty <= 0) return 0;
if (qty <= rule.baseBottles) return rule.baseFee;
return rule.baseFee + (qty - rule.baseBottles) * rule.extraBottleFee;
}
/**
*
* boxFee/
*/
export function calcLogisticsFeeByBottles(quantity: number, rule: LogisticsPricingRule): number {
const qty = Math.floor(Number(quantity) || 0);
if (qty <= 0) return 0;
if (!(rule.baseBottles > 0) || !(rule.baseFee >= 0) || !(rule.extraBottleFee >= 0)) {
throw new Error('计价标准不完整');
}
const boxBottles = rule.boxBottles && rule.boxBottles > 0 ? rule.boxBottles : 0;
const boxFee = rule.boxFee != null && rule.boxFee >= 0 ? rule.boxFee : null;
if (boxBottles > 0 && boxFee != null) {
const boxes = Math.floor(qty / boxBottles);
const rem = qty % boxBottles;
return roundMoney(boxes * boxFee + calcLogisticsBottleLadder(rem, rule));
}
return roundMoney(calcLogisticsBottleLadder(qty, rule));
}
export function generateUserNo(): string { export function generateUserNo(): string {
const suffix = Math.floor(10000000 + Math.random() * 90000000); const suffix = Math.floor(10000000 + Math.random() * 90000000);
return `DK${suffix}`; return `DK${suffix}`;
+8 -1
View File
@@ -17,7 +17,7 @@ export interface AppConfig {
aliyunSmsTemplateCode: string; aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */ /** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */
aliyunSmsRedeemConfirmTemplateCode: string; aliyunSmsRedeemConfirmTemplateCode: string;
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER);env: ALIYUN_SMS_TEMPLATE_PROXY_ORDER */ /** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / PARTNER_PROXY_CUSTOMER);env: ALIYUN_SMS_TEMPLATE_PROXY_ORDER */
aliyunSmsProxyOrderTemplateCode: string; aliyunSmsProxyOrderTemplateCode: string;
aliyunSmsAccessKeyId: string; aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string; aliyunSmsAccessKeySecret: string;
@@ -42,6 +42,13 @@ export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
/** 仅图标 Logo(默认头像:未微信授权时) */ /** 仅图标 Logo(默认头像:未微信授权时) */
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`; export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
/** 小程序静态资源(资质公示等) */
export const MINI_USER_STATIC_OSS_BASE =
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/';
/** 「我的」页资质公示长图 */
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
/** 总部客服电话(C 端联系客服) */ /** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234'; export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
+35 -1
View File
@@ -22,6 +22,7 @@ export enum UserSourceType {
SHARE_LINK = 'SHARE_LINK', SHARE_LINK = 'SHARE_LINK',
FRIEND_REFERRAL = 'FRIEND_REFERRAL', FRIEND_REFERRAL = 'FRIEND_REFERRAL',
OFFLINE_EVENT = 'OFFLINE_EVENT', OFFLINE_EVENT = 'OFFLINE_EVENT',
PARTNER_PROXY = 'PARTNER_PROXY',
OTHER = 'OTHER', OTHER = 'OTHER',
} }
@@ -31,6 +32,7 @@ export const USER_SOURCE_TYPE_LABELS: Record<UserSourceType, string> = {
[UserSourceType.SHARE_LINK]: '分享链接', [UserSourceType.SHARE_LINK]: '分享链接',
[UserSourceType.FRIEND_REFERRAL]: '好友推荐', [UserSourceType.FRIEND_REFERRAL]: '好友推荐',
[UserSourceType.OFFLINE_EVENT]: '线下活动', [UserSourceType.OFFLINE_EVENT]: '线下活动',
[UserSourceType.PARTNER_PROXY]: '代下单',
[UserSourceType.OTHER]: '其他', [UserSourceType.OTHER]: '其他',
}; };
@@ -46,7 +48,9 @@ export enum SmsScene {
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP', REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */ /** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM', REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
/** 合伙人代下单:线下代发货确认验证码(发至户手机) */ /** 合伙人代下单:客户手机号归属验证码(发至户手机) */
PARTNER_PROXY_CUSTOMER = 'PARTNER_PROXY_CUSTOMER',
/** 合伙人代下单:线下确认验证码(发至合伙人手机) */
PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER', PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
/** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */ /** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */
PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN', PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN',
@@ -106,6 +110,25 @@ export enum StoreStatus {
CLOSED = 'CLOSED', CLOSED = 'CLOSED',
} }
export const STORE_STATUS_LABELS: Record<StoreStatus, string> = {
[StoreStatus.OPEN]: '营业中',
[StoreStatus.PAUSED]: '临时闭店',
[StoreStatus.CLOSED]: '永久关闭',
};
/** C 端订单列表展示用(细粒度中文) */
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
export enum StoreAuditStatus { export enum StoreAuditStatus {
PENDING = 'PENDING', PENDING = 'PENDING',
APPROVED = 'APPROVED', APPROVED = 'APPROVED',
@@ -179,6 +202,17 @@ export enum FulfillmentProviderStatus {
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
} }
/** 物流承运商结算方式:前期充值,后期挂账月结 */
export enum LogisticsSettlementMethod {
PREPAID = 'PREPAID',
MONTHLY_CREDIT = 'MONTHLY_CREDIT',
}
export const LOGISTICS_SETTLEMENT_METHOD_LABELS: Record<LogisticsSettlementMethod, string> = {
[LogisticsSettlementMethod.PREPAID]: '充值扣款',
[LogisticsSettlementMethod.MONTHLY_CREDIT]: '挂账月结',
};
export enum WarehouseFulfillmentMode { export enum WarehouseFulfillmentMode {
API_AUTO = 'API_AUTO', API_AUTO = 'API_AUTO',
MANUAL = 'MANUAL', MANUAL = 'MANUAL',
@@ -1,5 +1,11 @@
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
} from './enums';
import type { WarehouseFulfillmentMode } from './enums'; import type { WarehouseFulfillmentMode } from './enums';
import type { LogisticsPricingRuleDto } from './settlement';
import { DEFAULT_XFX_LOGISTICS_PRICING } from './settlement';
/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */ /** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */
export interface XiaofeixiaProviderConfig { export interface XiaofeixiaProviderConfig {
@@ -19,6 +25,13 @@ export interface XiaofeixiaProviderConfigPublic {
hasApiKey: boolean; hasApiKey: boolean;
} }
export interface FulfillmentProviderBankDto {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}
export interface FulfillmentProviderDto { export interface FulfillmentProviderDto {
id: string; id: string;
code: string; code: string;
@@ -34,6 +47,14 @@ export interface FulfillmentProviderDto {
hasConfig: boolean; hasConfig: boolean;
/** 小飞侠等承运商结构化配置(脱敏) */ /** 小飞侠等承运商结构化配置(脱敏) */
xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null; xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null;
/** 结算银行账户 */
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
prepaidBalance: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -47,6 +68,12 @@ export interface CreateFulfillmentProviderInput {
capabilitiesJson?: string; capabilitiesJson?: string;
/** 结构化小飞侠配置;有则覆盖写入 configJson */ /** 结构化小飞侠配置;有则覆盖写入 configJson */
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
} }
export interface UpdateFulfillmentProviderInput { export interface UpdateFulfillmentProviderInput {
@@ -56,8 +83,16 @@ export interface UpdateFulfillmentProviderInput {
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
} }
export { DEFAULT_XFX_LOGISTICS_PRICING };
export interface ManualShipOrderInput { export interface ManualShipOrderInput {
logisticsCompany: string; logisticsCompany: string;
trackingNo: string; trackingNo: string;
+19 -1
View File
@@ -19,6 +19,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'knowledge_bases', label: '知识库', group: '业务' }, { key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' }, { key: 'logs', label: '日志', group: '业务' },
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
{ key: 'orders_delete', label: '删除订单', group: '危险操作' },
{ key: 'cities_delete', label: '删除城市', group: '危险操作' },
{ key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_permissions', label: '权限分配', group: '管理' },
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' }, { key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' }, { key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
@@ -33,6 +36,21 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:角色默认与超管自动权限均不含,需在权限分配中显式勾选 */
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
'cities_delete',
] as const satisfies readonly HqPermissionKey[];
export function isHqDangerousPermission(key: string): boolean {
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function hqBasePermissionKeys(): HqPermissionKey[] {
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
}
/** 系统配置 registry group → 权限 key */ /** 系统配置 registry group → 权限 key */
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = { export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
feature: 'system_settings_feature', feature: 'system_settings_feature',
@@ -76,7 +94,7 @@ export const HQ_ADMIN_ROLES = [
] as const; ] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = { export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key), SUPER_ADMIN: hqBasePermissionKeys(),
OPS: [ OPS: [
'dashboard', 'dashboard',
'users', 'users',
+49
View File
@@ -90,6 +90,53 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
export const WINERY_SETTLEMENT_RATE = 0.3; export const WINERY_SETTLEMENT_RATE = 0.3;
export type { LogisticsSettlementMethod } from './enums';
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
import type { LogisticsSettlementMethod } from './enums';
/** 小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元 */
export const DEFAULT_XFX_LOGISTICS_PRICING = {
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
} as const;
export type LogisticsPricingRuleDto = {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
};
export interface LogisticsBillDto {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: LogisticsSettlementMethod;
status: FinancePayStatus;
paidAt?: string | null;
pricingSnapshot?: LogisticsPricingRuleDto | null;
}
export interface LogisticsBillItemDto {
id: string;
orderId: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
}
export type FinanceBillSummary = { export type FinanceBillSummary = {
count: number; count: number;
redeemAmount?: number; redeemAmount?: number;
@@ -98,6 +145,8 @@ export type FinanceBillSummary = {
orderCommission?: number; orderCommission?: number;
redeemCommission?: number; redeemCommission?: number;
wineryAmount?: number; wineryAmount?: number;
logisticsAmount?: number;
bottleCount?: number;
totalAmount: number; totalAmount: number;
}; };
+37 -6
View File
@@ -8,6 +8,10 @@ export interface OrderDto {
quantity: number; quantity: number;
productName: string; productName: string;
createdAt: string; createdAt: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
} }
export interface OrderPreviewRequest { export interface OrderPreviewRequest {
@@ -34,12 +38,21 @@ export interface DeliveryDto {
deliveredAt?: string; deliveredAt?: string;
} }
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
};
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
export type PartnerProxyOrderProductOption = { export type PartnerProxyOrderProductOption = {
id: string; id: string;
name: string; name: string;
spec: string; spec: string;
price: number; price: number;
benefitAmount: number | null; benefitAmount: number | null;
coverUrl?: string | null;
allowOnSitePickup?: boolean;
}; };
export type PartnerProxyOrderPromoOption = { export type PartnerProxyOrderPromoOption = {
@@ -48,14 +61,27 @@ export type PartnerProxyOrderPromoOption = {
name: string; name: string;
}; };
export type PartnerProxyOrderStoreOption = {
id: string;
name: string;
address?: string | null;
phone?: string | null;
province?: string | null;
cityName?: string | null;
district?: string | null;
};
export type PartnerProxyOrderOptions = { export type PartnerProxyOrderOptions = {
products: PartnerProxyOrderProductOption[]; products: PartnerProxyOrderProductOption[];
promoCodes: PartnerProxyOrderPromoOption[]; promoCodes: PartnerProxyOrderPromoOption[];
stores: PartnerProxyOrderStoreOption[];
}; };
export type PartnerProxyOrderPreviewRequest = { export type PartnerProxyOrderPreviewRequest = {
productId: string; productId: string;
quantity: number; quantity: number;
deliveryMode?: PartnerProxyDeliveryMode;
storeId?: string;
receiverCity?: string; receiverCity?: string;
receiverDistrict?: string; receiverDistrict?: string;
}; };
@@ -64,18 +90,23 @@ export type PartnerProxyOrderPreviewResult = {
productAmount: number; productAmount: number;
payAmount: number; payAmount: number;
benefitAmount: number; benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY'; deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
unitPrice: number; unitPrice: number;
}; };
export type PartnerProxyOrderCreateRequest = { export type PartnerProxyOrderCreateRequest = {
phone: string; phone: string;
smsCode: string; customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: PartnerProxyDeliveryMode;
/** 地址配送时必须为 true */
autoReceive?: boolean;
storeId?: string;
receiverName?: string; receiverName?: string;
province: string; province?: string;
city: string; city?: string;
district: string; district?: string;
addressDetail: string; addressDetail?: string;
productId: string; productId: string;
quantity: number; quantity: number;
promoCodeId?: string; promoCodeId?: string;
+2
View File
@@ -31,6 +31,8 @@ export type ClientRuntimeConfig = {
mockWechat?: boolean; mockWechat?: boolean;
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */ /** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
wxAuthorize?: boolean; wxAuthorize?: boolean;
/** 腾讯位置服务 Key(地图选点组件,可按域名限制) */
tencentLbsKey?: string;
/** 小程序首页轮播 / 底部图 */ /** 小程序首页轮播 / 底部图 */
miniHome?: { miniHome?: {
banners: string[]; banners: string[];
+3 -1
View File
@@ -59,7 +59,9 @@ WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建) # 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(地理编码,微信定位展示城市 # 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用
# 控制台须开启 WebServiceAPI;服务端调用建议 Key 不设域名白名单,或改用 IP 白名单
# (浏览器内嵌官方选点组件已弃用,避免 mapapi.qq.com / formatted_addresses 崩溃)
TENCENT_LBS_KEY= TENCENT_LBS_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错) # 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
+1
View File
@@ -12,6 +12,7 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate", "prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts", "prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
"prisma:seed-finance": "ts-node --transpile-only prisma/seed-finance-mock.ts",
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts", "prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts", "prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts", "prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
+103 -10
View File
@@ -226,6 +226,18 @@ enum FinancePayStatus {
PAID PAID
} }
// 物流承运商结算方式:前期充值,后期挂账月结
enum LogisticsSettlementMethod {
PREPAID
MONTHLY_CREDIT
}
enum LogisticsPrepaidLedgerType {
RECHARGE
DEDUCT
ADJUST
}
enum StoreStatus { enum StoreStatus {
OPEN OPEN
PAUSED PAUSED
@@ -244,6 +256,7 @@ enum UserSourceType {
SHARE_LINK SHARE_LINK
FRIEND_REFERRAL FRIEND_REFERRAL
OFFLINE_EVENT OFFLINE_EVENT
PARTNER_PROXY
OTHER OTHER
} }
@@ -674,22 +687,90 @@ model CommonCity {
} }
model FulfillmentProvider { model FulfillmentProvider {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32) code String @unique @db.VarChar(32)
name String @db.VarChar(128) name String @db.VarChar(128)
type FulfillmentProviderType type FulfillmentProviderType
status FulfillmentProviderStatus @default(ACTIVE) status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text capabilitiesJson String? @map("capabilities_json") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) bankAccountName String? @map("bank_account_name") @db.VarChar(64)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) bankName String? @map("bank_name") @db.VarChar(64)
bankBranch String? @map("bank_branch") @db.VarChar(128)
bankAccountNo String? @map("bank_account_no") @db.VarChar(64)
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
pricingRulesJson String? @map("pricing_rules_json") @db.Text
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
warehouses CityWarehouse[] warehouses CityWarehouse[]
deliveries OrderDelivery[] deliveries OrderDelivery[]
logisticsBills LogisticsBill[]
prepaidLedgers LogisticsPrepaidLedger[]
@@map("common_fulfillment_provider") @@map("common_fulfillment_provider")
} }
// 物流对账月账单(按承运商汇总)
model LogisticsBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCount Int @default(0) @map("order_count")
bottleCount Int @default(0) @map("bottle_count")
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(12, 2)
settlementMethod LogisticsSettlementMethod @map("settlement_method")
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
status FinancePayStatus @default(UNPAID)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
items LogisticsBillItem[]
prepaidLedgers LogisticsPrepaidLedger[]
@@unique([fulfillmentProviderId, periodStart])
@@index([status, periodStart])
@@map("logistics_bill")
}
model LogisticsBillItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
logisticsBillId BigInt @map("logistics_bill_id") @db.UnsignedBigInt
orderId BigInt @map("order_id") @db.UnsignedBigInt
orderNo String @map("order_no") @db.VarChar(32)
quantity Int
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(10, 2)
shippedAt DateTime @map("shipped_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
logisticsBill LogisticsBill @relation(fields: [logisticsBillId], references: [id], onDelete: Cascade)
@@unique([logisticsBillId, orderId])
@@index([logisticsBillId])
@@map("logistics_bill_item")
}
model LogisticsPrepaidLedger {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
type LogisticsPrepaidLedgerType
amount Decimal @db.Decimal(12, 2)
balanceAfter Decimal @map("balance_after") @db.Decimal(12, 2)
logisticsBillId BigInt? @map("logistics_bill_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
logisticsBill LogisticsBill? @relation(fields: [logisticsBillId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId, createdAt])
@@map("logistics_prepaid_ledger")
}
model CityWarehouse { model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt cityId BigInt @map("city_id") @db.UnsignedBigInt
@@ -945,6 +1026,8 @@ model Store {
latitude Decimal? @db.Decimal(10, 7) latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7)
intro String? @db.Text intro String? @db.Text
// 好客权益券使用规则(C 端门店详情展示)
benefitUsageRule String? @map("benefit_usage_rule") @db.Text
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2) avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
rating Decimal? @db.Decimal(3, 2) rating Decimal? @db.Decimal(3, 2)
@@ -955,6 +1038,8 @@ model Store {
auditedAt DateTime? @map("audited_at") @db.DateTime(3) auditedAt DateTime? @map("audited_at") @db.DateTime(3)
openTime String? @map("open_time") @db.VarChar(8) openTime String? @map("open_time") @db.VarChar(8)
closeTime String? @map("close_time") @db.VarChar(8) closeTime String? @map("close_time") @db.VarChar(8)
openTime2 String? @map("open_time_2") @db.VarChar(8)
closeTime2 String? @map("close_time_2") @db.VarChar(8)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4) settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1069,7 +1154,14 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3) payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4) orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
/// 代下单操作人(与佣金归属 partnerAccountIdAtPay 分离)
proxyPartnerAccountId BigInt? @map("proxy_partner_account_id") @db.UnsignedBigInt
proxyPartnerName String? @map("proxy_partner_name") @db.VarChar(64)
proxyPartnerPhone String? @map("proxy_partner_phone") @db.VarChar(20)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1094,6 +1186,7 @@ model Order {
@@index([ipCity]) @@index([ipCity])
@@index([gpsCity]) @@index([gpsCity])
@@index([fulfillmentWarehouseId]) @@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@map("user_order") @@map("user_order")
} }
@@ -0,0 +1,447 @@
/**
* Mock seed
* cd server/dukang-api && npx ts-node --transpile-only prisma/seed-finance-mock.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function billNo(prefix: string, suffix: string) {
return `${prefix}MOCK${suffix}`.slice(0, 32);
}
async function main() {
console.log('Seeding finance mock bills...');
const store = await prisma.store.findFirst({
where: { auditStatus: 'APPROVED' },
orderBy: { id: 'asc' },
});
if (!store) throw new Error('没有门店数据,请先 pnpm db:seed');
const partner = await prisma.partnerAccount.findFirst({
where: { isPrimary: 1, status: 'ACTIVE' },
orderBy: { id: 'asc' },
});
if (!partner) throw new Error('没有合伙人主账号,请先 pnpm db:seed');
let provider = await prisma.fulfillmentProvider.findFirst({
where: { code: { in: ['XFX', 'XIAOFEIXIA'] } },
});
if (!provider) {
provider = await prisma.fulfillmentProvider.findFirst({ orderBy: { id: 'asc' } });
}
if (!provider) throw new Error('没有仓配承运商,请先 pnpm db:seed 或在仓配管理注册');
// 确保小飞侠有对账配置,便于物流汇总页展示
if (!provider.pricingRulesJson || !provider.bankAccountName) {
provider = await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: {
settlementMethod: provider.settlementMethod ?? 'PREPAID',
prepaidBalance: Number(provider.prepaidBalance) > 0 ? provider.prepaidBalance : 5000,
bankAccountName: provider.bankAccountName || '郑州小飞侠物流有限公司',
bankName: provider.bankName || '招商银行',
bankBranch: provider.bankBranch || '郑州金水支行',
bankAccountNo: provider.bankAccountNo || '6225880000001234',
pricingRulesJson:
provider.pricingRulesJson ||
JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
},
});
}
const today = startOfDay(new Date());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dayBefore = new Date(today);
dayBefore.setDate(dayBefore.getDate() - 2);
const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
const prevMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const prevMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0, 23, 59, 59, 999);
// 清理旧 mock(按账单号前缀)
await prisma.logisticsBillItem.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsPrepaidLedger.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsBill.deleteMany({ where: { billNo: { startsWith: 'LBMOCK' } } });
await prisma.wineryBillItem.deleteMany({
where: { wineryBill: { billNo: { startsWith: 'WBMOCK' } } },
});
await prisma.wineryBill.deleteMany({ where: { billNo: { startsWith: 'WBMOCK' } } });
await prisma.storePayout.updateMany({
where: { storeBill: { billNo: { startsWith: 'SBMOCK' } } },
data: { storeBillId: null },
});
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(昨日未打款 + 前日已打款)───
const storeBillUnpaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id,
billDate: yesterday,
redeemCount: 5,
redeemAmount: 1500,
settlementRate: 0.6,
payoutAmount: 900,
status: 'UNPAID',
},
});
const storeBillPaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id,
billDate: dayBefore,
redeemCount: 3,
redeemAmount: 800,
settlementRate: 0.6,
payoutAmount: 480,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 2. 合伙人月账单(上月待确认 + 本月已打款样例)───
const partnerBillAwait = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
partnerAccountId: partner.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
},
update: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
paidAt: null,
rejectReason: null,
},
});
// 本月另起一条若冲突则跳过(unique partner+period)— 用上上月做已打款
const olderMonthStart = new Date(today.getFullYear(), today.getMonth() - 2, 1);
const olderMonthEnd = new Date(today.getFullYear(), today.getMonth() - 1, 0, 23, 59, 59, 999);
const partnerBillPaid = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: olderMonthStart,
},
},
create: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
partnerAccountId: partner.id,
periodStart: olderMonthStart,
periodEnd: olderMonthEnd,
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
sentAt: olderMonthEnd,
confirmedAt: thisMonthStart,
paidAt: thisMonthStart,
},
update: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
paidAt: thisMonthStart,
},
});
// ─── 3. 酒厂日账单 ───
const wineryUnpaid = await prisma.wineryBill.upsert({
where: { billDate: yesterday },
create: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
billDate: yesterday,
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
},
update: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
paidAt: null,
},
});
await prisma.wineryBillItem.deleteMany({ where: { wineryBillId: wineryUnpaid.id } });
// 用极大假 orderId,避免与真实订单冲突
const fakeOrderBase = BigInt('900000000000000001');
await prisma.wineryBillItem.createMany({
data: [
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase,
orderNo: 'DKMOCK-WB-001',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 1n,
orderNo: 'DKMOCK-WB-002',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 2n,
orderNo: 'DKMOCK-WB-003',
deliveryType: 'CROSS_CITY',
payAmount: 1797,
wineryAmount: 539.1,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 3n,
orderNo: 'DKMOCK-WB-004',
deliveryType: 'LOCAL',
payAmount: 599,
wineryAmount: 179.7,
paidAt: yesterday,
},
],
});
const wineryPaid = await prisma.wineryBill.upsert({
where: { billDate: dayBefore },
create: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
billDate: dayBefore,
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 4. 物流对账月账单(上月挂账未结 + 本月充值已扣)───
const logisticsUnpaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'UNPAID',
},
update: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
status: 'UNPAID',
paidAt: null,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsUnpaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 10n,
orderNo: 'DKMOCK-LB-001',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 3 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 11n,
orderNo: 'DKMOCK-LB-002',
quantity: 8,
logisticsAmount: 20, // 箱14 + 2瓶6
shippedAt: new Date(prevMonthStart.getTime() + 8 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 12n,
orderNo: 'DKMOCK-LB-003',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 15 * 86400000),
},
],
});
// 修正合计:14+20+14=48
await prisma.logisticsBill.update({
where: { id: logisticsUnpaid.id },
data: { bottleCount: 20, logisticsAmount: 48 },
});
const logisticsPaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
},
},
create: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
periodEnd: thisMonthEnd,
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
status: 'PAID',
paidAt: yesterday,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsPaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 20n,
orderNo: 'DKMOCK-LB-101',
quantity: 4,
logisticsAmount: 10,
shippedAt: new Date(thisMonthStart.getTime() + 2 * 86400000),
},
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 21n,
orderNo: 'DKMOCK-LB-102',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(thisMonthStart.getTime() + 5 * 86400000),
},
],
});
// 充值流水样例
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'RECHARGE',
amount: 5000,
balanceAfter: 5000,
remark: 'Mock 预充运费',
},
});
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'DEDUCT',
amount: 22,
balanceAfter: 4978,
logisticsBillId: logisticsPaid.id,
remark: 'Mock 本月物流账单扣款',
},
});
await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: { prepaidBalance: 4978, settlementMethod: 'PREPAID' },
});
console.log('Finance mock ready:', {
store: { id: store.id.toString(), name: store.name },
partner: { id: partner.id.toString(), phone: partner.phone },
provider: { id: provider.id.toString(), code: provider.code, name: provider.name },
storeBills: [storeBillUnpaid.billNo, storeBillPaid.billNo],
partnerBills: [partnerBillAwait.billNo, partnerBillPaid.billNo],
wineryBills: [wineryUnpaid.billNo, wineryPaid.billNo],
logisticsBills: [logisticsUnpaid.billNo, logisticsPaid.billNo],
});
console.log('打开 admin:财务 → 门店/合伙人/酒厂/物流对账');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+8
View File
@@ -183,6 +183,14 @@ async function main() {
name: '小飞侠', name: '小飞侠',
type: 'API', type: 'API',
status: 'ACTIVE', status: 'ACTIVE',
settlementMethod: 'PREPAID',
pricingRulesJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
capabilitiesJson: JSON.stringify({ capabilitiesJson: JSON.stringify({
createShipment: true, createShipment: true,
getTrack: true, getTrack: true,
@@ -0,0 +1,89 @@
/**
* 上传小程序资质公示静态图到 OSSstatic/mini-user/qualification-disclosure.png
* 用法 server/dukang-api:
* node scripts/upload-qualification-disclosure.mjs [本地 png 路径]
* 也可设环境变量 QUALIFICATION_DISCLOSURE_LOCAL
*/
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname, isAbsolute } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const OSS = require('ali-oss');
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiRoot = resolve(__dirname, '..');
function loadEnvFile(path) {
if (!existsSync(path)) return {};
const out = {};
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (!m) continue;
let v = m[2];
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
v = v.slice(1, -1);
}
out[m[1]] = v;
}
return out;
}
const env = {
...loadEnvFile(resolve(apiRoot, '.env.production')),
...loadEnvFile(resolve(apiRoot, '.env.development')),
...loadEnvFile(resolve(apiRoot, '.env')),
};
const accessKeyId = env.OSS_ACCESS_KEY_ID || '';
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET || '';
const bucket = env.OSS_BUCKET || '';
const region = env.OSS_REGION || 'oss-cn-beijing';
const cdnBase = (env.OSS_CDN_BASE || '').replace(/\/$/, '');
if (!accessKeyId || !accessKeySecret || !bucket) {
console.error('缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
process.exit(1);
}
const argPath = process.argv[2]?.trim() || env.QUALIFICATION_DISCLOSURE_LOCAL?.trim() || '';
if (!argPath) {
console.error(
'请传入本地 png 路径,例如:\n node scripts/upload-qualification-disclosure.mjs D:/tmp/qualification-disclosure.png',
);
process.exit(1);
}
const localFile = isAbsolute(argPath) ? argPath : resolve(process.cwd(), argPath);
if (!existsSync(localFile)) {
console.error('本地文件不存在:', localFile);
process.exit(1);
}
const ossKey = 'static/mini-user/qualification-disclosure.png';
const client = new OSS({
region,
accessKeyId,
accessKeySecret,
bucket,
});
const buffer = readFileSync(localFile);
await client.put(ossKey, buffer, {
mime: 'image/png',
headers: {
'Content-Disposition': 'inline',
'Cache-Control': 'public, max-age=31536000',
},
});
const url = cdnBase
? `${cdnBase}/${ossKey}`
: `https://${bucket}.${region}.aliyuncs.com/${ossKey}`;
console.log('uploaded:', ossKey);
console.log('url:', url);
@@ -22,10 +22,19 @@ export function mapOrderItemCompat(order: OrderLike) {
}; };
} }
export function mapOrderCompat<T extends OrderLike>(order: T) { export function mapOrderCompat<T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
}>(order: T) {
const payStatus = order.payStatus ?? 'UNPAID'; const payStatus = order.payStatus ?? 'UNPAID';
const isProxyOrder = order.orderType === 'PROXY';
return { return {
...order, ...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)], items: [mapOrderItemCompat(order)],
payment: { payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus, status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
@@ -7,10 +7,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { import {
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys, expandHqPermissionKeys,
hasAnySystemSettingsPermission, hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module'; import { PrismaService } from '../prisma/prisma.module';
@@ -35,30 +35,29 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') { if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用'); throw new ForbiddenException('HQ 账号不可用');
} }
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
});
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
return HQ_PERMISSION_CATALOG.map((p) => p.key); // 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选
return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]);
} }
const [roleRows, userRows] = await Promise.all([ const roleRows = await this.prisma.hqRolePermission.findMany({
this.prisma.hqRolePermission.findMany({ where: { adminRole: account.adminRole },
where: { adminRole: account.adminRole }, select: { permissionKey: true },
select: { permissionKey: true }, });
}),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
}),
]);
const roleKeys = const roleKeys =
roleRows.length > 0 roleRows.length > 0
? roleRows.map((r) => r.permissionKey) ? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; : [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([ return expandHqPermissionKeys([...roleKeys, ...userKeys]);
...roleKeys,
...userRows.map((r) => r.permissionKey),
]);
} }
} }
@@ -28,6 +28,7 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT', STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE', STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE', STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE', STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE', STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE', STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
@@ -67,6 +68,9 @@ export const HqOperationAction = {
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT', PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM', WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM', WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -124,6 +128,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_AUDIT]: '门店审核', [HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户', [HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户', [HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源', [HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源', [HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源', [HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
@@ -163,6 +168,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请', [HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款', [HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款', [HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -107,7 +107,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key(须开 WebServiceAPI;服务端地点搜索/地理编码)', group: G.app, type: 'password', secret: true, requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false }, { key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false }, { key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
@@ -184,16 +184,22 @@ export class SystemConfigService implements OnModuleInit {
async importFromProcessEnv(): Promise<{ imported: number }> { async importFromProcessEnv(): Promise<{ imported: number }> {
let imported = 0; let imported = 0;
const overlay: Record<string, string> = {};
for (const field of SYSTEM_CONFIG_FIELDS) { for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key]; const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue; if (envVal === undefined || envVal === '') continue;
const normalized = this.normalizeByMeta(field, envVal);
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } }); const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing) continue; if (existing?.value?.trim()) continue;
await this.prisma.systemConfig.create({ await this.prisma.systemConfig.upsert({
data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) }, where: { configKey: field.key },
create: { configKey: field.key, value: normalized },
update: { value: normalized },
}); });
overlay[field.key] = normalized;
imported += 1; imported += 1;
} }
if (imported) applyEnvOverlay(overlay);
await this.onModuleInit(); await this.onModuleInit();
return { imported }; return { imported };
} }
@@ -208,12 +214,15 @@ export class SystemConfigService implements OnModuleInit {
private async seedMissingFromProcessEnv() { private async seedMissingFromProcessEnv() {
for (const field of SYSTEM_CONFIG_FIELDS) { for (const field of SYSTEM_CONFIG_FIELDS) {
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing) continue;
const envVal = process.env[field.key]; const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue; if (envVal === undefined || envVal === '') continue;
await this.prisma.systemConfig.create({ const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) }, if (existing?.value?.trim()) continue;
const value = this.normalizeByMeta(field, envVal);
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value },
update: { value },
}); });
} }
} }
@@ -50,6 +50,14 @@ export class LlmChatClient {
Authorization: `Bearer ${params.apiKey}`, Authorization: `Bearer ${params.apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}).catch((err: unknown) => {
const cause =
err && typeof err === 'object' && 'cause' in err
? (err as { cause?: { code?: string; message?: string } }).cause
: undefined;
const detail = cause?.code || cause?.message || (err instanceof Error ? err.message : String(err));
this.logger.warn(`llm chat network error ${url}: ${detail}`);
throw new Error(`无法连接语言模型服务(${detail})。请检查 Base URL 是否可从服务器访问,或更换可达的模型网关`);
}); });
const text = await res.text(); const text = await res.text();
@@ -10,19 +10,134 @@ export type ReverseGeocodeResult = {
logId: bigint; logId: bigint;
}; };
export type GeocodeAddressResult = {
latitude: number;
longitude: number;
logId: bigint;
};
export type PlaceSuggestItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export type ReverseGeocodeDetailResult = {
latitude: number;
longitude: number;
address: string;
name?: string;
province: string;
city: string;
district: string;
logId: bigint;
};
function normalizeCityName(name: string) { function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim(); return name.replace(/市$/, '').trim();
} }
type TencentPlaceRow = {
id?: string;
title?: string;
address?: string;
city?: string;
location?: { lat?: number; lng?: number };
};
@Injectable() @Injectable()
export class TencentLbsProvider { export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name); private readonly logger = new Logger(TencentLbsProvider.name);
private readonly config = loadAppConfig();
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
isEnabled() { isEnabled() {
return !!this.config.tencentLbsKey; return !!this.getLbsKey();
}
/** 地址 → 坐标(正向地理编码) */
async geocodeAddress(
address: string,
actorRef?: WechatActorRef,
): Promise<GeocodeAddressResult | null> {
const trimmed = address.replace(/\s+/g, '').trim();
const baseLog = {
provider: 'WECHAT_MAP' as const,
scene: 'GEOCODE',
refType: actorRef?.refType,
refId: actorRef?.refId,
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: { address: trimmed.slice(0, 200) },
};
if (!trimmed) return null;
if (!this.isEnabled()) {
await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: 'TENCENT_LBS_KEY 未配置',
},
});
return null;
}
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('address', trimmed);
url.searchParams.set('key', this.getLbsKey());
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
result?: { location?: { lat?: number; lng?: number } };
};
const loc = data.result?.location;
const ok =
data.status === 0 &&
typeof loc?.lat === 'number' &&
typeof loc?.lng === 'number' &&
Number.isFinite(loc.lat) &&
Number.isFinite(loc.lng);
const log = await this.prisma.logThirdParty.create({
data: {
...baseLog,
responseBody: {
status: data.status,
message: data.message,
lat: loc?.lat,
lng: loc?.lng,
},
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.message ?? '地理编码失败',
},
});
if (!ok || !loc) return null;
return { latitude: loc.lat!, longitude: loc.lng!, logId: log.id };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS geocode failed: ${message}`);
await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: message.slice(0, 512),
},
});
return null;
}
} }
async reverseGeocode( async reverseGeocode(
@@ -57,7 +172,7 @@ export class TencentLbsProvider {
const location = `${latitude},${longitude}`; const location = `${latitude},${longitude}`;
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/'); const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', location); url.searchParams.set('location', location);
url.searchParams.set('key', this.config.tencentLbsKey); url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '0'); url.searchParams.set('get_poi', '0');
try { try {
@@ -116,4 +231,202 @@ export class TencentLbsProvider {
return null; return null;
} }
} }
private mapPlaceRows(rows: TencentPlaceRow[] | undefined): PlaceSuggestItem[] {
if (!rows?.length) return [];
const out: PlaceSuggestItem[] = [];
for (const row of rows) {
const lat = Number(row.location?.lat);
const lng = Number(row.location?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const title = (row.title || '').trim();
const address = (row.address || '').trim();
if (!title && !address) continue;
out.push({
id: String(row.id || `${lat},${lng}`),
title: title || address,
address: address || title,
latitude: lat,
longitude: lng,
city: row.city?.trim() || undefined,
});
}
return out;
}
/** 关键词输入提示(地点搜索) */
async suggestPlaces(
keyword: string,
options?: { region?: string; latitude?: number; longitude?: number },
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
const trimmed = keyword.trim();
if (!trimmed) return { items: [] };
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/place/v1/suggestion');
url.searchParams.set('keyword', trimmed.slice(0, 64));
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('policy', '1');
url.searchParams.set('page_index', '1');
url.searchParams.set('page_size', '20');
const region = options?.region?.trim();
if (region) url.searchParams.set('region', region);
if (
options?.latitude != null &&
options?.longitude != null &&
Number.isFinite(options.latitude) &&
Number.isFinite(options.longitude)
) {
url.searchParams.set('location', `${options.latitude},${options.longitude}`);
}
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS suggest failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '地点搜索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS suggest failed: ${message}`);
return { items: [], error: message };
}
}
/** 周边地点(打开选点时预填附近列表) */
async exploreNearby(
latitude: number,
longitude: number,
radiusMeters = 1000,
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { items: [], error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const radius = Math.min(5000, Math.max(200, Math.round(radiusMeters)));
const url = new URL('https://apis.map.qq.com/ws/place/v1/explore');
url.searchParams.set('boundary', `nearby(${latitude},${longitude},${radius})`);
url.searchParams.set('policy', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('key', this.getLbsKey());
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS explore failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '周边检索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS explore failed: ${message}`);
return { items: [], error: message };
}
}
/** 逆地理(含地址文案,供选点回填) */
async reverseGeocodeDetail(
latitude: number,
longitude: number,
): Promise<{ item: ReverseGeocodeDetailResult | null; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { item: null, error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { item: null, error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', `${latitude},${longitude}`);
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '1');
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
result?: {
address?: string;
formatted_addresses?: { recommend?: string; rough?: string };
address_component?: {
province?: string;
city?: string;
district?: string;
street?: string;
street_number?: string;
};
ad_info?: {
province?: string;
city?: string;
district?: string;
};
pois?: Array<{ title?: string; address?: string }>;
};
};
if (data.status !== 0 || !data.result) {
return { item: null, error: data.message || '逆地理编码失败' };
}
const result = data.result;
const ad = result.ad_info ?? result.address_component;
const province = ad?.province ?? '';
const city = normalizeCityName(ad?.city ?? '');
const district = ad?.district ?? '';
const recommend =
result.formatted_addresses?.recommend?.trim() ||
result.formatted_addresses?.rough?.trim() ||
result.address?.trim() ||
'';
const poiTitle = result.pois?.[0]?.title?.trim();
if (!recommend && !poiTitle) {
return { item: null, error: '未解析到地址' };
}
const log = await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_MAP',
scene: 'REVERSE_GEOCODE',
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: { latitude, longitude, detail: true },
responseBody: {
status: data.status,
address: recommend,
city,
},
status: 'SUCCESS',
},
});
return {
item: {
latitude,
longitude,
address: recommend || poiTitle || '',
name: poiTitle || recommend || undefined,
province,
city,
district,
logId: log.id,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS reverse detail failed: ${message}`);
return { item: null, error: message };
}
}
} }
@@ -66,7 +66,7 @@ export class SmsAliyunProvider implements ISmsProvider {
return this.config.aliyunSmsRedeemConfirmTemplateCode; return this.config.aliyunSmsRedeemConfirmTemplateCode;
} }
if ( if (
scene === 'PARTNER_PROXY_ORDER' && (scene === 'PARTNER_PROXY_ORDER' || scene === 'PARTNER_PROXY_CUSTOMER') &&
this.config.aliyunSmsProxyOrderTemplateCode this.config.aliyunSmsProxyOrderTemplateCode
) { ) {
return this.config.aliyunSmsProxyOrderTemplateCode; return this.config.aliyunSmsProxyOrderTemplateCode;
@@ -5,7 +5,7 @@ import { SettlementService } from '../modules/settlement/settlement.service';
/** /**
* Asia/Shanghai * Asia/Shanghai
* - 08:00 + 00:00 00:00 * - 08:00 + 00:00 00:00
* - 1 08:00 * - 1 08:00 +
*/ */
@Injectable() @Injectable()
export class SettlementScheduler { export class SettlementScheduler {
@@ -41,5 +41,13 @@ export class SettlementScheduler {
} catch (e) { } catch (e) {
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e); this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
} }
try {
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
this.logger.log(
`Logistics bills: total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
);
} catch (e) {
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
}
} }
} }
@@ -17,6 +17,8 @@ export class ClientConfigController {
mockSms: cfg.mockSms, mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat, mockWechat: cfg.mockWechat,
wxAuthorize: cfg.wxAuthorize, wxAuthorize: cfg.wxAuthorize,
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined,
miniHome: { miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null, footerUrl: footer || null,
@@ -14,6 +14,7 @@ import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller'; import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller'; import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller'; import { ClientConfigController } from './client-config.controller';
import { LbsController } from './lbs.controller';
import { WechatLocationService } from './wechat-location.service'; import { WechatLocationService } from './wechat-location.service';
@Module({ @Module({
@@ -25,6 +26,7 @@ import { WechatLocationService } from './wechat-location.service';
ThirdPartyLogController, ThirdPartyLogController,
WechatController, WechatController,
ClientConfigController, ClientConfigController,
LbsController,
], ],
providers: [ providers: [
ResourceService, ResourceService,
@@ -0,0 +1,91 @@
import {
BadRequestException,
Controller,
Get,
Query,
ServiceUnavailableException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
function parseCoord(raw: string | undefined, label: string): number | undefined {
if (raw == null || raw === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) throw new BadRequestException(`${label}无效`);
return n;
}
@Controller('common/lbs')
@UseGuards(JwtAuthGuard)
export class LbsController {
constructor(private readonly tencentLbs: TencentLbsProvider) {}
@Get('suggest')
async suggest(
@Query('keyword') keyword?: string,
@Query('region') region?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const q = (keyword ?? '').trim();
if (!q) return { items: [] };
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
const result = await this.tencentLbs.suggestPlaces(q, {
region: region?.trim() || undefined,
latitude,
longitude,
});
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('nearby')
async nearby(@Query('lat') lat?: string, @Query('lng') lng?: string, @Query('radius') radius?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const r = radius != null && radius !== '' ? Number(radius) : 1000;
const result = await this.tencentLbs.exploreNearby(latitude, longitude, Number.isFinite(r) ? r : 1000);
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('reverse')
async reverse(@Query('lat') lat?: string, @Query('lng') lng?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const result = await this.tencentLbs.reverseGeocodeDetail(latitude, longitude);
if (!result.item) {
throw new BadRequestException(result.error || '逆地理编码失败');
}
return {
latitude: result.item.latitude,
longitude: result.item.longitude,
address: result.item.address,
name: result.item.name,
province: result.item.province,
city: result.item.city,
district: result.item.district,
};
}
}
@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
import { import {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
Prisma,
} from '@prisma/client';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode, isXfxProviderCode,
type LogisticsPricingRuleDto,
type XiaofeixiaProviderConfig, type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic, type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -17,6 +24,12 @@ export type CreateFulfillmentProviderInput = {
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod | string;
pricingRules?: LogisticsPricingRuleDto | null;
}; };
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>; export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
@@ -106,6 +119,13 @@ export class FulfillmentProviderService {
this.assertXiaofeixiaConfigComplete(configJson, true); this.assertXiaofeixiaConfigComplete(configJson, true);
} }
const pricingRulesJson = this.resolvePricingRulesJson(
code,
input.pricingRules,
isXfxProviderCode(code) ? DEFAULT_XFX_LOGISTICS_PRICING : null,
);
const settlementMethod = this.parseSettlementMethod(input.settlementMethod) ?? 'PREPAID';
const row = await this.prisma.fulfillmentProvider.create({ const row = await this.prisma.fulfillmentProvider.create({
data: { data: {
code, code,
@@ -123,6 +143,12 @@ export class FulfillmentProviderService {
cancel: true, cancel: true,
}) })
: null), : null),
bankAccountName: this.normOptional(input.bankAccountName),
bankName: this.normOptional(input.bankName),
bankBranch: this.normOptional(input.bankBranch),
bankAccountNo: this.normOptional(input.bankAccountNo),
settlementMethod,
pricingRulesJson,
}, },
}); });
return this.toDto(row); return this.toDto(row);
@@ -142,6 +168,11 @@ export class FulfillmentProviderService {
this.assertXiaofeixiaConfigComplete(configJson, false); this.assertXiaofeixiaConfigComplete(configJson, false);
} }
const pricingRulesJson =
input.pricingRules !== undefined
? this.resolvePricingRulesJson(code, input.pricingRules, null)
: undefined;
const row = await this.prisma.fulfillmentProvider.update({ const row = await this.prisma.fulfillmentProvider.update({
where: { id }, where: { id },
data: { data: {
@@ -152,11 +183,87 @@ export class FulfillmentProviderService {
...(input.capabilitiesJson !== undefined ...(input.capabilitiesJson !== undefined
? { capabilitiesJson: input.capabilitiesJson?.trim() || null } ? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
: {}), : {}),
...(input.bankAccountName !== undefined
? { bankAccountName: this.normOptional(input.bankAccountName) }
: {}),
...(input.bankName !== undefined ? { bankName: this.normOptional(input.bankName) } : {}),
...(input.bankBranch !== undefined ? { bankBranch: this.normOptional(input.bankBranch) } : {}),
...(input.bankAccountNo !== undefined
? { bankAccountNo: this.normOptional(input.bankAccountNo) }
: {}),
...(input.settlementMethod !== undefined
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
: {}),
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
}, },
}); });
return this.toDto(row); return this.toDto(row);
} }
/** 充值(结算模块可复用) */
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
const rounded = Math.round(amount * 100) / 100;
const result = await this.prisma.$transaction(async (tx) => {
const row = await tx.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balanceAfter = Math.round((Number(row.prepaidBalance) + rounded) * 100) / 100;
const updated = await tx.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
const ledger = await tx.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'RECHARGE',
amount: rounded,
balanceAfter,
remark: remark?.trim() || '充值',
},
});
return { provider: updated, ledger };
});
return serializeBigInt({
provider: this.toDto(result.provider),
ledger: result.ledger,
});
}
/** 账单扣减充值余额;余额不足返回 false */
async deductPrepaid(
providerId: bigint,
amount: number,
logisticsBillId: bigint,
tx?: Prisma.TransactionClient,
): Promise<{ ok: true; balanceAfter: number } | { ok: false; balance: number }> {
const client = tx ?? this.prisma;
const rounded = Math.round(amount * 100) / 100;
const row = await client.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balance = Number(row.prepaidBalance);
if (balance + 1e-9 < rounded) {
return { ok: false, balance };
}
const balanceAfter = Math.round((balance - rounded) * 100) / 100;
await client.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
await client.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'DEDUCT',
amount: rounded,
balanceAfter,
logisticsBillId,
remark: '物流月账单扣款',
},
});
return { ok: true, balanceAfter };
}
parseCapabilities(raw: string | null): Capabilities | null { parseCapabilities(raw: string | null): Capabilities | null {
if (!raw) return null; if (!raw) return null;
try { try {
@@ -183,6 +290,63 @@ export class FulfillmentProviderService {
} }
} }
parsePricingRules(raw: string | null): LogisticsPricingRuleDto | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LogisticsPricingRuleDto>;
if (!parsed || typeof parsed !== 'object') return null;
const baseBottles = Number(parsed.baseBottles);
const baseFee = Number(parsed.baseFee);
const extraBottleFee = Number(parsed.extraBottleFee);
if (!(baseBottles > 0) || !(baseFee >= 0) || !(extraBottleFee >= 0)) return null;
const rule: LogisticsPricingRuleDto = { baseBottles, baseFee, extraBottleFee };
if (parsed.boxBottles != null && Number(parsed.boxBottles) > 0) {
rule.boxBottles = Number(parsed.boxBottles);
}
if (parsed.boxFee != null && Number(parsed.boxFee) >= 0) {
rule.boxFee = Number(parsed.boxFee);
}
return rule;
} catch {
return null;
}
}
private resolvePricingRulesJson(
code: string,
input: LogisticsPricingRuleDto | null | undefined,
fallback: LogisticsPricingRuleDto | null,
): string | null {
if (input === null) return null;
const rule = input ?? fallback;
if (!rule) return isXfxProviderCode(code) ? JSON.stringify(DEFAULT_XFX_LOGISTICS_PRICING) : null;
if (!(rule.baseBottles > 0)) throw new BadRequestException('计价标准:起送瓶数须大于 0');
if (!(rule.baseFee >= 0)) throw new BadRequestException('计价标准:起送费用无效');
if (!(rule.extraBottleFee >= 0)) throw new BadRequestException('计价标准:加瓶费用无效');
return JSON.stringify({
baseBottles: Number(rule.baseBottles),
baseFee: Number(rule.baseFee),
extraBottleFee: Number(rule.extraBottleFee),
...(rule.boxBottles != null ? { boxBottles: Number(rule.boxBottles) } : {}),
...(rule.boxFee != null ? { boxFee: Number(rule.boxFee) } : {}),
});
}
private parseSettlementMethod(
raw?: LogisticsSettlementMethod | string | null,
): LogisticsSettlementMethod | null {
if (raw == null || raw === '') return null;
if (raw === 'PREPAID' || raw === 'MONTHLY_CREDIT') return raw;
throw new BadRequestException('结算方式仅支持 PREPAID / MONTHLY_CREDIT');
}
private normOptional(v?: string | null) {
if (v === undefined) return undefined;
if (v == null) return null;
const t = String(v).trim();
return t || null;
}
private resolveConfigJsonForWrite( private resolveConfigJsonForWrite(
code: string, code: string,
existingRaw: string | null, existingRaw: string | null,
@@ -242,6 +406,13 @@ export class FulfillmentProviderService {
status: string; status: string;
configJson: string | null; configJson: string | null;
capabilitiesJson: string | null; capabilitiesJson: string | null;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: string;
pricingRulesJson?: string | null;
prepaidBalance?: Prisma.Decimal | number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -256,6 +427,13 @@ export class FulfillmentProviderService {
xiaofeixiaConfig: isXfxProviderCode(row.code) xiaofeixiaConfig: isXfxProviderCode(row.code)
? this.toPublicXiaofeixiaConfig(row.configJson) ? this.toPublicXiaofeixiaConfig(row.configJson)
: null, : null,
bankAccountName: row.bankAccountName ?? null,
bankName: row.bankName ?? null,
bankBranch: row.bankBranch ?? null,
bankAccountNo: row.bankAccountNo ?? null,
settlementMethod: row.settlementMethod ?? 'PREPAID',
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
prepaidBalance: Number(row.prepaidBalance ?? 0),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
@@ -1,6 +1,12 @@
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { isXfxProviderCode } from '@dukang/shared-types'; import { isXfxProviderCode } from '@dukang/shared-types';
import {
BOTTLES_PER_BOX,
XFX_AUTO_DISPATCH_MAX_BOXES,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service'; import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types'; import { CourierPayMode } from '../../integrations/courier/courier.types';
@@ -16,6 +22,8 @@ export type ManualShipInput = {
export type HqLogisticsShipInput = ManualShipInput; export type HqLogisticsShipInput = ManualShipInput;
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
@Injectable() @Injectable()
export class FulfillmentService { export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name); private readonly logger = new Logger(FulfillmentService.name);
@@ -64,10 +72,48 @@ export class FulfillmentService {
await this.ensureDeliveryRecord(orderId, 'MANUAL'); await this.ensureDeliveryRecord(orderId, 'MANUAL');
return; return;
} }
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
if (shouldHoldAutoCourierDispatch(order.quantity)) {
const boxes = calcOrderBoxCount(order.quantity);
this.logger.warn(
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
);
await this.prisma.order.update({
where: { id: orderId },
data: {
fulfillmentHold: true,
fulfillmentHoldReason: FULFILLMENT_HOLD_LARGE_ORDER,
},
});
await this.ensureDeliveryRecord(orderId, 'MANUAL', provider.id);
await this.prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH_HOLD',
refType: 'ORDER',
refId: order.id,
status: 'PENDING',
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
0,
512,
),
},
});
return;
}
await this.dispatchApiAuto(order, warehouse, provider); await this.dispatchApiAuto(order, warehouse, provider);
} }
} }
async clearFulfillmentHold(orderId: bigint) {
await this.prisma.order.update({
where: { id: orderId },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
}
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!isXfxProviderCode(provider.code)) { if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
@@ -129,6 +175,10 @@ export class FulfillmentService {
} else { } else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
} }
await tx.order.update({
where: { id: order.id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
await tx.logThirdParty.create({ await tx.logThirdParty.create({
data: { data: {
provider: 'XFX', provider: 'XFX',
@@ -260,6 +310,10 @@ export class FulfillmentService {
} else { } else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
} }
await tx.order.update({
where: { id: order.id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
}); });
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator); await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
@@ -97,6 +97,7 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_CONFIRM: case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5; return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER: case SmsScene.PARTNER_PROXY_ORDER:
case SmsScene.PARTNER_PROXY_CUSTOMER:
return ClientApp.PARTNER_H5; return ClientApp.PARTNER_H5;
default: default:
return ClientApp.USER_H5; return ClientApp.USER_H5;
@@ -155,13 +156,20 @@ export class AuthService {
}); });
return user ? { refType: 'USER', refId: user.id } : undefined; return user ? { refType: 'USER', refId: user.id } : undefined;
} }
case SmsScene.PARTNER_PROXY_ORDER: { case SmsScene.PARTNER_PROXY_CUSTOMER: {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 }, where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true }, select: { id: true },
}); });
return user ? { refType: 'USER', refId: user.id } : undefined; return user ? { refType: 'USER', refId: user.id } : undefined;
} }
case SmsScene.PARTNER_PROXY_ORDER: {
const partner = await this.prisma.partnerAccount.findUnique({
where: { phone },
select: { id: true },
});
return partner ? { refType: 'PARTNER', refId: partner.id } : undefined;
}
default: default:
return undefined; return undefined;
} }
@@ -334,7 +342,11 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销'); if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return; return;
} }
if (scene === SmsScene.PARTNER_PROXY_CUSTOMER) {
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) { if (scene === SmsScene.PARTNER_PROXY_ORDER) {
await this.assertPartnerAccountByPhone(phone);
return; return;
} }
if (scene === SmsScene.PARTNER_STORE_OPEN) { if (scene === SmsScene.PARTNER_STORE_OPEN) {
@@ -348,7 +360,14 @@ export class AuthService {
} }
/** 合伙人代下单:按手机号查找或创建已验证用户 */ /** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) { async findOrCreateUserByPhone(
phone: string,
source?: {
sourceType?: 'PARTNER_PROXY';
sourceRefId?: bigint;
sourceLabel?: string;
},
) {
const normalizedPhone = this.assertMobilePhone(phone); const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({ let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone }, where: { phone: normalizedPhone },
@@ -362,6 +381,9 @@ export class AuthService {
phoneVerifiedAt: new Date(), phoneVerifiedAt: new Date(),
userNo: generateUserNo(), userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`, nickname: `用户${normalizedPhone.slice(-4)}`,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
cityPreference: { cityPreference: {
create: { create: {
selectedCityCode: '410100', selectedCityCode: '410100',
@@ -1,11 +1,22 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsString, MinLength } from 'class-validator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminCitiesService } from './admin-cities.service'; import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto'; import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
class DeleteCityDto {
@IsString()
@MinLength(1)
confirmName!: string;
}
@Controller('admin/cities') @Controller('admin/cities')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard)
export class AdminCitiesController { export class AdminCitiesController {
@@ -16,6 +27,13 @@ export class AdminCitiesController {
return this.service.list(query); return this.service.list(query);
} }
@Get(':id/delete-preview')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
deletePreview(@Param('id') id: string) {
return this.service.deletePreview(BigInt(id));
}
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
return this.service.detail(BigInt(id)); return this.service.detail(BigInt(id));
@@ -37,4 +55,17 @@ export class AdminCitiesController {
update(@Param('id') id: string, @Body() dto: UpdateCityDto) { update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
return this.service.update(BigInt(id), dto); return this.service.update(BigInt(id), dto);
} }
@Delete(':id')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
@HqOperation({
action: HqOperationAction.CITY_DELETE,
refType: 'CITY',
refIdParam: 'id',
includeBody: true,
})
remove(@Param('id') id: string, @Body() dto: DeleteCityDto) {
return this.service.deleteCity(BigInt(id), dto.confirmName);
}
} }
@@ -144,4 +144,242 @@ export class AdminCitiesService {
}); });
return serializeBigInt(city); return serializeBigInt(city);
} }
/** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */
async deletePreview(id: bigint) {
const city = await this.prisma.commonCity.findUnique({
where: { id },
select: { id: true, code: true, name: true, province: true, status: true },
});
if (!city) throw new NotFoundException('开城城市不存在');
const [primaries, staff, stores, warehouses, orderCount] = await Promise.all([
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
bindingStatus: true,
scopeType: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
parentAccountId: true,
staffRole: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.store.findMany({
where: { cityId: id },
select: {
id: true,
name: true,
phone: true,
status: true,
auditStatus: true,
address: true,
partnerAccountId: true,
partnerAccount: { select: { companyName: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.findMany({
where: { cityId: id },
select: { id: true, name: true, status: true, address: true },
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({ where: { cityId: id } }),
]);
const storeIds = stores.map((s) => s.id);
const partnerIds = [...primaries, ...staff].map((p) => p.id);
const [redeemCount, partnerBillCount] = await Promise.all([
storeIds.length
? this.prisma.redeemRecord.count({ where: { storeId: { in: storeIds } } })
: Promise.resolve(0),
partnerIds.length
? this.prisma.partnerBill.count({ where: { partnerAccountId: { in: partnerIds } } })
: Promise.resolve(0),
]);
const warnings: string[] = [];
const blockers: string[] = [];
if (orderCount > 0) blockers.push(`该城市下已有 ${orderCount} 笔订单,无法删除`);
if (redeemCount > 0) warnings.push(`门店核销记录 ${redeemCount} 笔将删除,并回滚对应权益券余额`);
if (partnerBillCount > 0) warnings.push(`合伙人账单 ${partnerBillCount} 条将一并删除`);
if (stores.length) warnings.push(`将删除 ${stores.length} 家门店及其门店账号绑定`);
if (primaries.length || staff.length) {
warnings.push(`将删除 ${primaries.length} 个合伙人主账号、${staff.length} 个子账号`);
}
if (warehouses.length) warnings.push(`将删除 ${warehouses.length} 个城市仓库`);
return serializeBigInt({
city,
canDelete: blockers.length === 0,
blockers,
warnings,
summary: {
primaryPartnerCount: primaries.length,
staffCount: staff.length,
storeCount: stores.length,
warehouseCount: warehouses.length,
orderCount,
redeemCount,
partnerBillCount,
},
partners: primaries.map((p) => ({
...p,
staff: staff.filter((s) => s.parentAccountId === p.id),
})),
orphanStaff: staff.filter(
(s) => !s.parentAccountId || !primaries.some((p) => p.id === s.parentAccountId),
),
stores,
warehouses,
});
}
async deleteCity(id: bigint, confirmName: string) {
const preview = await this.deletePreview(id);
if (!preview.canDelete) {
throw new BadRequestException(preview.blockers.join('') || '当前城市不可删除');
}
const expected = String(preview.city.name || '').trim();
if (!confirmName?.trim() || confirmName.trim() !== expected) {
throw new BadRequestException(`请输入城市名称「${expected}」以确认删除`);
}
await this.prisma.$transaction(async (tx) => {
const storeIds = (preview.stores as Array<{ id: string | number | bigint }>).map((s) =>
BigInt(s.id),
);
const partnerIdSet = new Set<string>();
for (const p of preview.partners as Array<{
id: string | number | bigint;
staff?: Array<{ id: string | number | bigint }>;
}>) {
partnerIdSet.add(String(p.id));
for (const s of p.staff ?? []) partnerIdSet.add(String(s.id));
}
for (const s of preview.orphanStaff as Array<{ id: string | number | bigint }>) {
partnerIdSet.add(String(s.id));
}
const uniquePartnerIds = [...partnerIdSet].map(BigInt);
if (storeIds.length) {
await this.purgeStoresInTx(tx, storeIds);
}
if (uniquePartnerIds.length) {
await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: uniquePartnerIds } } });
await tx.$executeRaw`
DELETE FROM log_partner_analytics WHERE partner_account_id IN (${Prisma.join(uniquePartnerIds)})
`;
await tx.partnerAccount.updateMany({
where: { id: { in: uniquePartnerIds } },
data: { managedWarehouseId: null },
});
await tx.cityWarehouse.updateMany({
where: { cityId: id },
data: { partnerAccountId: null },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 0 },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 1 },
});
}
await tx.cityWarehouse.deleteMany({ where: { cityId: id } });
await tx.commonCity.delete({ where: { id } });
});
return { ok: true, id: id.toString(), name: preview.city.name };
}
/** 事务内清除门店及核销/结算/绑定(回滚权益券核销额) */
private async purgeStoresInTx(tx: Prisma.TransactionClient, storeIds: bigint[]) {
const redeems = await tx.redeemRecord.findMany({
where: { storeId: { in: storeIds } },
include: { allocations: true },
});
const restoreMap = new Map<string, Prisma.Decimal>();
for (const r of redeems) {
if (r.allocations.length) {
for (const a of r.allocations) {
const key = a.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(a.amount));
}
} else {
const key = r.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(r.amount));
}
}
for (const [couponId, amount] of restoreMap) {
const coupon = await tx.benefitCoupon.findUnique({ where: { id: BigInt(couponId) } });
if (!coupon) continue;
const used = new Prisma.Decimal(coupon.usedAmount).sub(amount);
const balance = new Prisma.Decimal(coupon.balance).add(amount);
const nextUsed = used.lt(0) ? new Prisma.Decimal(0) : used;
const nextBalance = Prisma.Decimal.min(balance, coupon.totalAmount);
await tx.benefitCoupon.update({
where: { id: BigInt(couponId) },
data: {
usedAmount: nextUsed,
balance: nextBalance,
status: nextBalance.gt(0) ? 'ACTIVE' : coupon.status,
version: { increment: 1 },
},
});
}
const redeemIds = redeems.map((r) => r.id);
await tx.storePayout.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.storeRating.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.redeemPendingRecord.deleteMany({ where: { storeId: { in: storeIds } } });
if (redeemIds.length) {
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.$executeRaw`DELETE FROM log_store_analytics WHERE store_id IN (${Prisma.join(storeIds)})`;
const bindings = await tx.storeAccountStore.findMany({
where: { storeId: { in: storeIds } },
select: { storeAccountId: true },
});
const accountIds = [...new Set(bindings.map((b) => b.storeAccountId.toString()))].map(BigInt);
await tx.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
const orphanAccountIds: bigint[] = [];
for (const aid of accountIds) {
const other = await tx.storeAccountStore.count({
where: { storeAccountId: aid, storeId: { notIn: storeIds } },
});
if (other === 0) orphanAccountIds.push(aid);
}
if (orphanAccountIds.length) {
await tx.storeAccount.deleteMany({ where: { parentAccountId: { in: orphanAccountIds } } });
await tx.storeAccount.deleteMany({ where: { id: { in: orphanAccountIds } } });
}
await tx.store.updateMany({ where: { id: { in: storeIds } }, data: { coverResourceId: null } });
await tx.store.deleteMany({ where: { id: { in: storeIds } } });
}
} }
@@ -1,5 +1,6 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminDashboardService } from './admin-dashboard.service'; import { AdminDashboardService } from './admin-dashboard.service';
@Controller('admin/dashboard') @Controller('admin/dashboard')
@@ -13,6 +14,7 @@ export class AdminDashboardController {
} }
@Get('version') @Get('version')
@UseGuards(SuperAdminGuard)
version() { version() {
return this.dashboardService.getLatestVersion(); return this.dashboardService.getLatestVersion();
} }
@@ -5,6 +5,7 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import { import {
CreateFulfillmentProviderDto, CreateFulfillmentProviderDto,
RechargeFulfillmentProviderDto,
UpdateFulfillmentProviderDto, UpdateFulfillmentProviderDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
@@ -40,6 +41,12 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig, xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
}); });
} }
@@ -63,6 +70,23 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig, xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
}); });
} }
@Post(':id/recharge')
@HqOperation({
action: HqOperationAction.LOGISTICS_PROVIDER_RECHARGE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
}
} }

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