Compare commits

...

31 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
101 changed files with 5270 additions and 858 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 日内对齐 |
@@ -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>
);
}
+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}>
+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()}>
+18 -10
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,
@@ -154,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);
@@ -174,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);
@@ -454,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="订单号">
@@ -504,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,
+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 };
} }
+43 -2
View File
@@ -6,6 +6,8 @@ import {
calcOrderBoxCount, calcOrderBoxCount,
shouldHoldAutoCourierDispatch, shouldHoldAutoCourierDispatch,
validateMinPurchase, validateMinPurchase,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
calcBenefitSummary, calcBenefitSummary,
@@ -36,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');
}); });
}); });
+53 -3
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,7 +96,56 @@ 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 BOTTLES_PER_BOX = 6;
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */ /** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
+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';
+24 -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',
+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',
+32 -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 {
@@ -39,12 +43,16 @@ export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送', 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 = {
@@ -53,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;
}; };
@@ -69,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;凭证齐全时直传,缺失则服务端报错)
+10
View File
@@ -256,6 +256,7 @@ enum UserSourceType {
SHARE_LINK SHARE_LINK
FRIEND_REFERRAL FRIEND_REFERRAL
OFFLINE_EVENT OFFLINE_EVENT
PARTNER_PROXY
OTHER OTHER
} }
@@ -1025,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)
@@ -1035,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)
@@ -1149,6 +1154,10 @@ 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") fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
@@ -1177,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,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',
@@ -127,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]: '删除门店资源',
@@ -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;
@@ -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,
};
}
}
@@ -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();
} }
@@ -4,6 +4,7 @@ import {
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY, LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys, expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
@@ -49,7 +50,7 @@ export class AdminHqPermissionsService {
async saveRolePermissions(role: string, permissionKeys: string[]) { async saveRolePermissions(role: string, permissionKeys: string[]) {
if (role === 'SUPER_ADMIN') { if (role === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置'); throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
} }
assertPermissionKeys(permissionKeys); assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys);
@@ -73,24 +74,28 @@ export class AdminHqPermissionsService {
select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true }, select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true },
}); });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
const userPerms = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
});
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = hqBasePermissionKeys();
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
return serializeBigInt({ return serializeBigInt({
account, account,
permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), permissionKeys: userPermissionKeys,
rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), rolePermissionKeys,
userPermissionKeys: [], userPermissionKeys,
effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), effectivePermissionKeys,
}); });
} }
const [rolePerms, userPerms] = await Promise.all([ const rolePerms = await this.getRolePermissions(account.adminRole);
this.getRolePermissions(account.adminRole),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
}),
]);
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
const effectivePermissionKeys = [ const effectivePermissionKeys = [
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[]; ] as HqPermissionKey[];
@@ -107,9 +112,6 @@ export class AdminHqPermissionsService {
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
if (account.adminRole === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
}
assertPermissionKeys(permissionKeys); assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys);
await this.prisma.$transaction([ await this.prisma.$transaction([
@@ -1,6 +1,9 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Put, Query, 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 {
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 { AdminOrdersService } from './admin-orders.service'; import { AdminOrdersService } from './admin-orders.service';
@@ -18,7 +21,8 @@ export class AdminOrdersController {
} }
@Post('batch-delete') @Post('batch-delete')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.ORDER_BATCH_DELETE, action: HqOperationAction.ORDER_BATCH_DELETE,
refType: 'ORDER', refType: 'ORDER',
@@ -191,8 +191,8 @@ export class AdminPartnersService {
orderCommissionRate: orderCommissionRate, orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate, redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus, bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName.trim(), companyName: dto.companyName?.trim() || null,
address: dto.address.trim(), address: dto.address?.trim() || null,
contactPhone: dto.contactPhone?.trim() ?? phone, contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo, contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName, bankAccountName: dto.bankAccountName,
@@ -94,6 +94,16 @@ export class AdminStoreAccountsController {
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) { update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
return this.service.updateStoreAccount(BigInt(id), dto); return this.service.updateStoreAccount(BigInt(id), dto);
} }
@Delete(':id/staff/:staffId')
@HqOperation({
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
refType: 'STORE_ACCOUNT',
refIdParam: 'staffId',
})
deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) {
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId));
}
} }
@Controller('admin/store-media') @Controller('admin/store-media')
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat'; import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -16,6 +17,14 @@ import type {
UpdateStoreStatusDto, UpdateStoreStatusDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
@Injectable() @Injectable()
export class AdminStoresService { export class AdminStoresService {
constructor( constructor(
@@ -105,6 +114,8 @@ export class AdminStoresService {
return serializeBigInt(mapStoreCompat({ return serializeBigInt(mapStoreCompat({
...store, ...store,
account: store.bindings[0]?.storeAccount ?? null, account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
bindings: undefined, bindings: undefined,
media, media,
audits, audits,
@@ -176,41 +187,183 @@ export class AdminStoresService {
} }
async updateStore(id: bigint, dto: UpdateStoreDto) { async updateStore(id: bigint, dto: UpdateStoreDto) {
const store = await this.prisma.store.update({ const current = await this.prisma.store.findUnique({ where: { id } });
where: { id }, if (!current) throw new NotFoundException('门店不存在');
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
},
});
if (dto.coverUrl) { const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
const current = await this.prisma.store.findUniqueOrThrow({ where: { id } }); const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
if (current.coverResourceId) { const openTime2 =
await this.prisma.commonResource.update({ dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
where: { id: current.coverResourceId }, const closeTime2 =
data: { url: dto.coverUrl, ossKey: dto.coverUrl }, dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
}); if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
} else { const hoursCheck = validateBusinessHours([
const cover = await this.prisma.commonResource.create({ { open: openTime || '10:00', close: closeTime || '22:00' },
data: { ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
ownerType: 'STORE', ]);
ownerId: id, if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
bizType: 'COVER', }
mediaType: 'IMAGE',
ossBucket: 'legacy', const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined;
ossKey: dto.coverUrl, const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined;
url: dto.coverUrl, if (latitude !== undefined || longitude !== undefined) {
}, if (latitude == null || longitude == null) {
}); throw new BadRequestException('经纬度须同时提供');
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } }); }
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new BadRequestException('纬度无效');
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
throw new BadRequestException('经度无效');
} }
} }
let categoryId: bigint | undefined;
if (dto.categoryId !== undefined) {
if (!dto.categoryId?.trim()) {
throw new BadRequestException('请选择门店分类');
}
categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
}
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号码');
}
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
const bankTouched =
dto.bankAccountName !== undefined ||
dto.bankAccountNo !== undefined ||
dto.bankBranch !== undefined;
const needAccountSync =
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
let primaryBinding: {
storeAccount: { id: bigint; phone: string; name: string } | null;
} | null = null;
if (needAccountSync) {
primaryBinding = await this.prisma.storeAccountStore.findFirst({
where: { storeId: id, storeAccount: { isPrimary: 1 } },
include: {
storeAccount: { select: { id: true, phone: true, name: true } },
},
});
if (!primaryBinding) {
primaryBinding = await this.prisma.storeAccountStore.findFirst({
where: { storeId: id },
include: {
storeAccount: { select: { id: true, phone: true, name: true } },
},
orderBy: { storeAccountId: 'asc' },
});
}
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
}
const primaryAccount = primaryBinding?.storeAccount;
if (
normalizedPhone !== undefined &&
primaryAccount &&
normalizedPhone !== primaryAccount.phone
) {
const occupied = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (occupied && occupied.id !== primaryAccount.id) {
throw new BadRequestException('该手机号已被其他门店账号使用');
}
}
if (dto.bankAccountNo !== undefined) {
const no = dto.bankAccountNo?.trim() || null;
if (no && !/^\d{16,19}$/.test(no)) {
throw new BadRequestException('银行卡号须为 1619 位数字');
}
}
}
await this.prisma.$transaction(async (tx) => {
await tx.store.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
: {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
...(categoryId !== undefined ? { categoryId } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} = {};
if (dto.name !== undefined) accountData.name = dto.name.trim();
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
accountData.phone = normalizedPhone;
}
if (dto.bankAccountName !== undefined) {
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
}
if (dto.bankAccountNo !== undefined) {
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
}
if (dto.bankBranch !== undefined) {
accountData.bankBranch = dto.bankBranch?.trim() || null;
}
if (Object.keys(accountData).length) {
await tx.storeAccount.update({
where: { id: account.id },
data: accountData,
});
}
}
});
return this.detailStore(id); return this.detailStore(id);
} }
@@ -246,6 +399,37 @@ export class AdminStoresService {
const categoryId = BigInt(dto.categoryId); const categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId); await this.storeCategoryService.assertLeafCategoryId(categoryId);
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
if (
latitude != null &&
(!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90)
) {
throw new BadRequestException('经纬度无效');
}
const openTime = dto.openTime?.trim() || '10:00';
const closeTime = dto.closeTime?.trim() || '22:00';
const openTime2 = dto.openTime2?.trim() || '';
const closeTime2 = dto.closeTime2?.trim() || '';
const hoursCheck = validateBusinessHours([
{ open: openTime, close: closeTime },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
const intro = dto.intro?.trim() || null;
if (intro && (intro.length < 2 || intro.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
if (benefitUsageRule && benefitUsageRule.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
@@ -258,9 +442,14 @@ export class AdminStoresService {
cityName: dto.city ?? city.name, cityName: dto.city ?? city.name,
district: dto.district ?? '', district: dto.district ?? '',
address: dto.address, address: dto.address,
intro: dto.intro ?? null, intro,
openTime: '10:00', benefitUsageRule,
closeTime: '22:00', avgPrice: dto.avgPrice ?? null,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN', status: 'OPEN',
auditStatus: 'APPROVED', auditStatus: 'APPROVED',
auditedAt: new Date(), auditedAt: new Date(),
@@ -536,4 +725,26 @@ export class AdminStoresService {
}); });
return serializeBigInt(account); return serializeBigInt(account);
} }
/** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在');
}
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId, isPrimary: 0 },
});
if (!staff) throw new NotFoundException('子账号不存在');
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: staffId },
});
if (pending > 0) {
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
}
await this.prisma.storeAccount.delete({ where: { id: staffId } });
return { ok: true };
}
} }
@@ -1,6 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query, 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 {
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 { AdminUsersService } from './admin-users.service'; import { AdminUsersService } from './admin-users.service';
@@ -18,13 +21,15 @@ export class AdminUsersController {
} }
@Post('batch-delete/preview') @Post('batch-delete/preview')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) { previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id))); return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
} }
@Post('batch-delete') @Post('batch-delete')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.USER_BATCH_DELETE, action: HqOperationAction.USER_BATCH_DELETE,
refType: 'USER', refType: 'USER',
@@ -44,7 +49,8 @@ export class AdminUsersController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.USER_DELETE, action: HqOperationAction.USER_DELETE,
refType: 'USER', refType: 'USER',
@@ -57,10 +57,22 @@ export class CreateStoreDto {
@IsNotEmpty() @IsNotEmpty()
address: string; address: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional() @IsOptional()
@IsString() @IsString()
intro?: string; intro?: string;
@IsOptional()
@IsString()
benefitUsageRule?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
coverUrl?: string; coverUrl?: string;
@@ -94,6 +106,27 @@ export class CreateStoreDto {
@IsNumber() @IsNumber()
@Min(0) @Min(0)
settlementRate?: number; settlementRate?: number;
@IsOptional()
@IsString()
openTime?: string;
@IsOptional()
@IsString()
closeTime?: string;
@IsOptional()
@IsString()
openTime2?: string;
@IsOptional()
@IsString()
closeTime2?: string;
@IsOptional()
@IsNumber()
@Min(0)
avgPrice?: number;
} }
export class UpdateStoreDto { export class UpdateStoreDto {
@@ -109,6 +142,10 @@ export class UpdateStoreDto {
@IsString() @IsString()
intro?: string; intro?: string;
@IsOptional()
@IsString()
benefitUsageRule?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
coverUrl?: string; coverUrl?: string;
@@ -117,14 +154,67 @@ export class UpdateStoreDto {
@IsString() @IsString()
address?: string; address?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsString()
province?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
district?: string; district?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
settlementRate?: number; settlementRate?: number;
@IsOptional()
@IsString()
openTime?: string;
@IsOptional()
@IsString()
closeTime?: string;
@IsOptional()
@IsString()
openTime2?: string | null;
@IsOptional()
@IsString()
closeTime2?: string | null;
@IsOptional()
@IsNumber()
@Min(0)
avgPrice?: number | null;
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
} }
export class CreateStoreAccountDto { export class CreateStoreAccountDto {
@@ -168,13 +258,13 @@ export class CreatePartnerDto {
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() companyName?: string;
companyName: string;
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() address?: string;
address: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -14,8 +14,14 @@ export class PublicStoreController {
constructor(private readonly storeService: StoreService) {} constructor(private readonly storeService: StoreService) {}
@Get() @Get()
list(@Query('cityCode') cityCode?: string) { list(
return this.storeService.listOpenStores(cityCode); @Query('cityCode') cityCode?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
) {
const userLat = lat != null && lat !== '' ? Number(lat) : undefined;
const userLng = lng != null && lng !== '' ? Number(lng) : undefined;
return this.storeService.listOpenStores(cityCode, userLat, userLng);
} }
@Get(':id') @Get(':id')
@@ -3,6 +3,7 @@ import { IamModule } from '../iam/iam.module';
import { RedeemModule } from '../redeem/redeem.module'; import { RedeemModule } from '../redeem/redeem.module';
import { AnalyticsModule } from '../analytics/analytics.module'; import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module'; import { CityScopeModule } from '../city-scope/city-scope.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { StoreService } from './store.service'; import { StoreService } from './store.service';
import { StoreCategoryService } from './store-category.service'; import { StoreCategoryService } from './store-category.service';
import { import {
@@ -17,7 +18,13 @@ import {
} from './store.controller'; } from './store.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)], imports: [
IamModule,
AnalyticsModule,
CityScopeModule,
IntegrationsModule,
forwardRef(() => RedeemModule),
],
controllers: [ controllers: [
PublicStoreController, PublicStoreController,
PublicStoreCategoriesController, PublicStoreCategoriesController,
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat'; import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -14,6 +15,35 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service'; import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service'; import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
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)));
}
/** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return null;
if (kind === 'lat' && (n < -90 || n > 90)) return null;
if (kind === 'lng' && (n < -180 || n > 180)) return null;
return n;
}
@Injectable() @Injectable()
export class StoreService { export class StoreService {
@@ -25,9 +55,48 @@ export class StoreService {
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService, private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
) {} ) {}
async listOpenStores(cityCode?: string) { private storeAddressText(store: {
province?: string | null;
cityName?: string | null;
district?: string | null;
address?: string | null;
}) {
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
}
/** 缺坐标时用地址正向地理编码并回写 */
private async ensureStoreCoordinates(store: {
id: bigint;
latitude?: unknown;
longitude?: unknown;
province?: string | null;
cityName?: string | null;
district?: string | null;
address?: string | null;
}): Promise<{ latitude: number; longitude: number } | null> {
const lat = store.latitude != null ? Number(store.latitude) : NaN;
const lng = store.longitude != null ? Number(store.longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { latitude: lat, longitude: lng };
}
const address = this.storeAddressText(store);
if (!address) return null;
const geo = await this.tencentLbs.geocodeAddress(address, {
refType: 'STORE',
refId: store.id,
});
if (!geo) return null;
await this.prisma.store.update({
where: { id: store.id },
data: { latitude: geo.latitude, longitude: geo.longitude },
});
return { latitude: geo.latitude, longitude: geo.longitude };
}
async listOpenStores(cityCode?: string, userLat?: number, userLng?: number) {
const where: Record<string, unknown> = { status: 'OPEN' }; const where: Record<string, unknown> = { status: 'OPEN' };
if (cityCode) { if (cityCode) {
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } }); const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
@@ -38,7 +107,43 @@ export class StoreService {
include: { category: true, coverResource: true }, include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return serializeBigInt(stores.map(mapStoreCompat));
const hasUser =
userLat != null &&
userLng != null &&
Number.isFinite(userLat) &&
Number.isFinite(userLng);
type StoreListItem = ReturnType<typeof mapStoreCompat> & {
distanceMeters: number | null;
latitude?: unknown;
longitude?: unknown;
};
const items: StoreListItem[] = [];
for (const store of stores) {
const coords = await this.ensureStoreCoordinates(store);
const mapped = mapStoreCompat({
...store,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
});
const distanceMeters =
hasUser && coords
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
: null;
items.push({ ...mapped, distanceMeters });
}
if (hasUser) {
items.sort((a, b) => {
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
});
}
return serializeBigInt(items);
} }
async getStore(id: bigint) { async getStore(id: bigint) {
@@ -47,11 +152,19 @@ export class StoreService {
include: { category: true, coverResource: true }, include: { category: true, coverResource: true },
}); });
if (!store) throw new NotFoundException('门店不存在'); if (!store) throw new NotFoundException('门店不存在');
const coords = await this.ensureStoreCoordinates(store);
const media = await this.prisma.commonResource.findMany({ const media = await this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}); });
return serializeBigInt(mapStoreCompat({ ...store, media })); return serializeBigInt(
mapStoreCompat({
...store,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
}),
);
} }
private async resolvePartnerScope(actorAccountId: bigint) { private async resolvePartnerScope(actorAccountId: bigint) {
@@ -155,9 +268,6 @@ export class StoreService {
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) { async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const normalizedPhone = String(body.phone).trim(); const normalizedPhone = String(body.phone).trim();
const smsCode = body.smsCode ? String(body.smsCode).trim() : '';
if (!smsCode) throw new BadRequestException('请输入门店手机号验证码');
await this.authService.verifySmsCode(normalizedPhone, smsCode, SmsScene.PARTNER_STORE_OPEN);
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true'; const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting); await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
@@ -180,6 +290,36 @@ export class StoreService {
const categoryId = parseBigIntParam(body.categoryId, '分类ID'); const categoryId = parseBigIntParam(body.categoryId, '分类ID');
await this.storeCategoryService.assertLeafCategoryId(categoryId); await this.storeCategoryService.assertLeafCategoryId(categoryId);
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
const openTime2 = body.openTime2 ? String(body.openTime2).trim() : '';
const closeTime2 = body.closeTime2 ? String(body.closeTime2).trim() : '';
const hoursCheck = validateBusinessHours([
{ open: openTime, close: closeTime },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
const avgPriceRaw = body.avgPrice != null && body.avgPrice !== '' ? Number(body.avgPrice) : null;
if (avgPriceRaw != null && (Number.isNaN(avgPriceRaw) || avgPriceRaw < 0)) {
throw new BadRequestException('人均费用须为非负数字');
}
const introRaw = body.intro != null ? String(body.intro).trim() : '';
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const latitude = parseOptionalCoord(body.latitude, 'lat');
const longitude = parseOptionalCoord(body.longitude, 'lng');
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
@@ -191,9 +331,14 @@ export class StoreService {
cityName: String(body.city ?? city.name ?? '郑州市'), cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''), district: String(body.district ?? ''),
address: String(body.address), address: String(body.address),
intro: body.intro ? String(body.intro) : null, intro: introRaw || null,
openTime: body.openTime ? String(body.openTime) : '10:00', benefitUsageRule: benefitUsageRuleRaw,
closeTime: body.closeTime ? String(body.closeTime) : '22:00', avgPrice: avgPriceRaw,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED', status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING', auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
auditedAt: this.config.autoApproveStore ? new Date() : null, auditedAt: this.config.autoApproveStore ? new Date() : null,
@@ -201,6 +346,10 @@ export class StoreService {
}, },
}); });
if (latitude == null || longitude == null) {
await this.ensureStoreCoordinates(store);
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy'; const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl) { if (coverUrl) {
@@ -379,24 +528,50 @@ export class StoreService {
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined; const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
const address = body.address !== undefined ? String(body.address).trim() : undefined; const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined; const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const benefitUsageRuleRaw =
body.benefitUsageRule !== undefined
? normalizeOptionalTextField(body.benefitUsageRule)
: undefined;
const latitude =
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
const longitude =
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称'); if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
if (phone !== undefined && !/^1\d{10}$/.test(phone)) { if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
throw new BadRequestException('联系电话须为11位手机号'); throw new BadRequestException('联系电话须为11位手机号');
} }
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址'); if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) { if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 10~500 字'); throw new BadRequestException('门店简介须为 2~500 字');
}
if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
if (
(latitude !== undefined || longitude !== undefined) &&
(latitude == null || longitude == null)
) {
throw new BadRequestException('经纬度须同时提供');
} }
const resubmitAudit = store.auditStatus === 'REJECTED'; const resubmitAudit = store.auditStatus === 'REJECTED';
await this.prisma.store.update({ const hasCoordsUpdate = latitude != null && longitude != null;
const updated = await this.prisma.store.update({
where: { id: storeId }, where: { id: storeId },
data: { data: {
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(phone !== undefined ? { phone } : {}), ...(phone !== undefined ? { phone } : {}),
...(address !== undefined ? { address } : {}), ...(address !== undefined
? hasCoordsUpdate
? { address }
: { address, latitude: null, longitude: null }
: {}),
...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}), ...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(benefitUsageRuleRaw !== undefined
? { benefitUsageRule: benefitUsageRuleRaw }
: {}),
...(resubmitAudit ...(resubmitAudit
? { ? {
auditStatus: 'PENDING' as const, auditStatus: 'PENDING' as const,
@@ -408,6 +583,10 @@ export class StoreService {
}, },
}); });
if (address !== undefined && !hasCoordsUpdate) {
await this.ensureStoreCoordinates(updated);
}
if (resubmitAudit) { if (resubmitAudit) {
await this.prisma.commonEvent.create({ await this.prisma.commonEvent.create({
data: { data: {
@@ -549,6 +728,9 @@ export class StoreService {
where: { storeAccountId_storeId: { storeAccountId, storeId } }, where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true }, include: { store: true },
}); });
if (binding.store.status === 'CLOSED') {
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
}
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') { if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
throw new BadRequestException( throw new BadRequestException(
binding.store.auditStatus === 'REJECTED' binding.store.auditStatus === 'REJECTED'
@@ -1,5 +1,16 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator'; import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
export class PartnerProxyOrderPreviewDto { export class PartnerProxyOrderPreviewDto {
@IsString() @IsString()
@@ -11,6 +22,14 @@ export class PartnerProxyOrderPreviewDto {
@Min(1) @Min(1)
quantity: number; quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
storeId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
receiverCity?: string; receiverCity?: string;
@@ -20,7 +39,7 @@ export class PartnerProxyOrderPreviewDto {
receiverDistrict?: string; receiverDistrict?: string;
} }
export class PartnerProxyOrderSendSmsDto { export class PartnerProxyOrderSendCustomerSmsDto {
@IsString() @IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' }) @Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string; phone: string;
@@ -33,28 +52,47 @@ export class PartnerProxyOrderCreateDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
smsCode: string; customerSmsCode: string;
@IsString()
@IsNotEmpty()
partnerSmsCode: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsBoolean()
autoReceive?: boolean;
@IsOptional()
@IsString()
storeId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
receiverName?: string; receiverName?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
province: string; province?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
city: string; city?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
district: string; district?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString() @IsString()
@MaxLength(256) @MaxLength(256)
addressDetail: string; addressDetail?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -9,7 +9,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { import {
PartnerProxyOrderCreateDto, PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto, PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto, PartnerProxyOrderSendCustomerSmsDto,
} from './dto/partner-proxy-order.dto'; } from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto'; import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto'; import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
@@ -204,8 +204,8 @@ export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {} constructor(private readonly tradeService: TradeService) {}
@Get('options') @Get('options')
options() { options(@CurrentUser() user: AuthUser) {
return this.tradeService.getPartnerProxyOrderOptions(); return this.tradeService.getPartnerProxyOrderOptions(user.actorId);
} }
@Post('preview') @Post('preview')
@@ -213,9 +213,20 @@ export class PartnerProxyOrderController {
return this.tradeService.previewPartnerProxyOrder(dto); return this.tradeService.previewPartnerProxyOrder(dto);
} }
/** @deprecated 兼容:转发客户短信 */
@Post('send-sms') @Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) { sendSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone); return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-customer-sms')
sendCustomerSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-partner-sms')
sendPartnerSms(@CurrentUser() user: AuthUser) {
return this.tradeService.sendPartnerProxyPartnerSms(user.actorId);
} }
@Post() @Post()
@@ -96,7 +96,7 @@ export class TradeService {
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty = const minQty =
deliveryType === 'ON_SITE_PICKUP' deliveryType === 'ON_SITE_PICKUP'
? 1 ? city.localMinQty
: deliveryType === 'LOCAL' : deliveryType === 'LOCAL'
? city.localMinQty ? city.localMinQty
: city.crossMinQty; : city.crossMinQty;
@@ -1023,10 +1023,25 @@ export class TradeService {
}); });
} }
async getPartnerProxyOrderOptions() { async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
const [products, promoCodes] = await Promise.all([ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const [products, promoCodes, stores] = await Promise.all([
this.catalogService.listProducts(), this.catalogService.listProducts(),
this.promoCodeService.listActiveOptions(), this.promoCodeService.listActiveOptions(),
this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
address: true,
phone: true,
province: true,
cityName: true,
district: true,
status: true,
},
}),
]); ]);
return serializeBigInt({ return serializeBigInt({
products: products.map((p) => ({ products: products.map((p) => ({
@@ -1035,14 +1050,27 @@ export class TradeService {
spec: p.spec, spec: p.spec,
price: Number(p.price), price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null, benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
})), })),
promoCodes, promoCodes,
stores: stores.map((s) => ({
id: s.id,
name: s.name,
address: s.address,
phone: s.phone,
province: s.province,
cityName: s.cityName,
district: s.district,
})),
}); });
} }
async previewPartnerProxyOrder(body: { async previewPartnerProxyOrder(body: {
productId: string; productId: string;
quantity: number; quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string; receiverCity?: string;
receiverDistrict?: string; receiverDistrict?: string;
}) { }) {
@@ -1053,14 +1081,23 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市'); if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL'; const deliveryMode = body.deliveryMode ?? 'ADDRESS';
const receiverCity = body.receiverCity?.trim(); let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY'; if (deliveryMode === 'ON_SITE_PICKUP') {
if (!product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场提货');
}
deliveryType = 'ON_SITE_PICKUP';
} else {
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
} }
const check = validateMinPurchase( const check = validateMinPurchase(
deliveryType, deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
body.quantity, body.quantity,
city.localMinQty, city.localMinQty,
city.crossMinQty, city.crossMinQty,
@@ -1083,9 +1120,9 @@ export class TradeService {
}; };
} }
async sendPartnerProxyOrderSms(phone: string) { async sendPartnerProxyCustomerSms(phone: string) {
const normalizedPhone = phone.trim(); const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, { await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
clientApp: ClientApp.PARTNER_H5, clientApp: ClientApp.PARTNER_H5,
}); });
const masked = const masked =
@@ -1095,16 +1132,41 @@ export class TradeService {
return { ok: true, maskedPhone: masked }; return { ok: true, maskedPhone: masked };
} }
/** @deprecated 兼容旧前端:转发为客户短信 */
async sendPartnerProxyOrderSms(phone: string) {
return this.sendPartnerProxyCustomerSms(phone);
}
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效,无法发送确认验证码');
}
await this.authService.sendSms(partnerPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder( async createPartnerProxyOrder(
partnerAccountId: bigint, partnerAccountId: bigint,
body: { body: {
phone: string; phone: string;
smsCode: string; customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
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;
@@ -1112,16 +1174,43 @@ export class TradeService {
req: Request, req: Request,
) { ) {
const normalizedPhone = body.phone.trim(); const normalizedPhone = body.phone.trim();
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效');
}
await this.authService.verifySmsCode( await this.authService.verifySmsCode(
normalizedPhone, normalizedPhone,
body.smsCode.trim(), body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
partnerPhone,
body.partnerSmsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER, SmsScene.PARTNER_PROXY_ORDER,
); );
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone); if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
const maskedPartnerPhone =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
sourceType: 'PARTNER_PROXY',
sourceRefId: primary.id,
sourceLabel: `代下单·${maskedPartnerPhone}`,
});
const preview = await this.previewPartnerProxyOrder({ const preview = await this.previewPartnerProxyOrder({
productId: body.productId, productId: body.productId,
quantity: body.quantity, quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city, receiverCity: body.city,
receiverDistrict: body.district, receiverDistrict: body.district,
}); });
@@ -1130,8 +1219,32 @@ export class TradeService {
where: { id: BigInt(body.productId) }, where: { id: BigInt(body.productId) },
}); });
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
let receiverProvince = body.province?.trim() || '';
let receiverCity = body.city?.trim() || '';
let receiverDistrict = body.district?.trim() || '';
let receiverAddress = '';
let commissionDistrict = receiverDistrict;
if (body.deliveryMode === 'ON_SITE_PICKUP') {
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
receiverProvince = '现场';
receiverCity = '现场';
receiverDistrict = '取货';
receiverAddress = '现场提货';
commissionDistrict = '';
} else {
if (!receiverProvince || !receiverCity || !receiverDistrict) {
throw new BadRequestException('请选择省市区');
}
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
let promoCodeId: bigint | undefined; let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) { if (body.promoCodeId?.trim()) {
@@ -1139,11 +1252,8 @@ export class TradeService {
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId); await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
} }
const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
const orderNo = generateOrderNo(); const orderNo = generateOrderNo();
const now = new Date(); const now = new Date();
const location = buildOrderClientLocationSnapshot( const location = buildOrderClientLocationSnapshot(
req, req,
this.ipGeoService.resolve(extractClientIp(req)), this.ipGeoService.resolve(extractClientIp(req)),
@@ -1178,9 +1288,9 @@ export class TradeService {
receiverName, receiverName,
receiverPhone: normalizedPhone, receiverPhone: normalizedPhone,
receiverAddress, receiverAddress,
receiverProvince: body.province, receiverProvince,
receiverCity: body.city, receiverCity,
receiverDistrict: body.district, receiverDistrict,
clientIp: location.clientIp, clientIp: location.clientIp,
ipProvince: location.ipProvince, ipProvince: location.ipProvince,
ipCity: location.ipCity, ipCity: location.ipCity,
@@ -1190,7 +1300,10 @@ export class TradeService {
completedAt: now, completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`, proxyPartnerAccountId: primary.id,
proxyPartnerName: primary.name,
proxyPartnerPhone: partnerPhone,
remark: `合伙人代下单 partnerAccountId=${primary.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
}, },
include: { product: true, imageResource: true }, include: { product: true, imageResource: true },
}); });
@@ -1211,7 +1324,7 @@ export class TradeService {
fromStatus: 'PENDING_PAY', fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED', toStatus: 'COMPLETED',
operator: 'PARTNER_PROXY', operator: 'PARTNER_PROXY',
remark: '合伙人线下代下单', remark: `合伙人线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`,
}), }),
}); });
@@ -1237,6 +1350,7 @@ export class TradeService {
userId: user.id.toString(), userId: user.id.toString(),
productId: body.productId, productId: body.productId,
quantity: body.quantity, quantity: body.quantity,
deliveryMode: body.deliveryMode,
promoCodeId: promoCodeId?.toString() ?? null, promoCodeId: promoCodeId?.toString() ?? null,
}, },
}); });
+3 -2
View File
@@ -97,7 +97,7 @@
| SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% | | SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% |
| SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 | | SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 |
| SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 | | SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 |
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3) | | SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3);合伙人侧:双短信确认、线下完成发权益、订单记代下单人;总部本期对齐后续 |
| SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 | | SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 |
| SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 | | SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 |
@@ -403,7 +403,7 @@
| OPT | 波次 | 说明 | | OPT | 波次 | 说明 |
|-----|------|------| |-----|------|------|
| OPT-002 代下单 | W3 | W2 前运营人工代下单 | | OPT-002 代下单 | W3 | 合伙人主账号:客户验码→选品/履约(配送须勾选自动收货或现场提货选门店)→合伙人确认码;线下已收款直接完成发权益;新用户 sourceType=PARTNER_PROXY;总部代下单后续对齐 |
| OPT-006 弱网 | W3 | W1~2 重试+人工补核销 | | OPT-006 弱网 | W3 | W1~2 重试+人工补核销 |
| OPT-010 未出账提现 | W2 | 含 FIN-001~003 | | OPT-010 未出账提现 | W2 | 含 FIN-001~003 |
| OPT-005 现场提货 | W2 | — | | OPT-005 现场提货 | W2 | — |
@@ -432,6 +432,7 @@
| 版本 | 日期 | 说明 | | 版本 | 日期 | 说明 |
|------|------|------| |------|------|------|
| v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 |
| v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent | | v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |
--- ---

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