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-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3手机号建用户;权益自动发 |
| ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
@@ -281,11 +281,11 @@ export default function CityPartnersPanel({
label: '主账号',
children: (
<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="phone" label="登录手机号" rules={[{ required: true }]}><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 }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
}
}}>
<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="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 }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
<Form.Item
name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</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> = {
OPEN: '营业中',
PAUSED: '暂停',
CLOSED: '关闭',
PAUSED: '临时闭店',
CLOSED: '永久关闭',
};
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) {
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_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
+45 -1
View File
@@ -11,7 +11,15 @@ export type StoreCreateForm = {
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
@@ -23,6 +31,12 @@ export type StoreCreateForm = {
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(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreCreateStep1(
form: Pick<
@@ -35,6 +49,12 @@ export function validateStoreCreateStep1(
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -45,9 +65,33 @@ export function validateStoreCreateStep1(
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
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()) {
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;
}
@@ -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 { Link } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -17,7 +18,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
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 { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -50,6 +51,40 @@ type Row = {
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 }));
export default function CitiesPage() {
@@ -82,6 +117,18 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = 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 res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,22 +176,85 @@ export default function CitiesPage() {
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> = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ 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: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</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);
void reload();
}}></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>
</>
),
@@ -407,6 +537,140 @@ export default function CitiesPage() {
</Form.Item>
</Form>
</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>
);
}
+24 -12
View File
@@ -301,7 +301,16 @@ export default function CityPartnersPage() {
ellipsis: true,
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: 'createdAt', width: 150, render: fmtTime },
{
@@ -444,7 +453,11 @@ export default function CityPartnersPage() {
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<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}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
@@ -460,8 +473,8 @@ export default function CityPartnersPage() {
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
@@ -472,8 +485,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话">
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
@@ -580,8 +593,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
@@ -589,8 +602,8 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
@@ -599,8 +612,7 @@ export default function CityPartnersPage() {
<Form.Item
name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
+22 -12
View File
@@ -52,8 +52,18 @@ export default function DashboardPage() {
request<DashboardStats>('/admin/dashboard/stats')
.then(setStats)
.finally(() => setLoading(false));
void loadVersion();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
request<HqProfile>('/admin/auth/me')
.then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]);
function handleDeploy() {
@@ -83,6 +93,7 @@ export default function DashboardPage() {
<div>
<Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card
title="系统版本"
loading={versionLoading}
@@ -92,16 +103,14 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button>
{isSuperAdmin && (
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
)}
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
</Space>
}
>
@@ -122,6 +131,7 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
+102 -42
View File
@@ -5,7 +5,6 @@ import {
Card,
Checkbox,
Col,
Divider,
Form,
Row,
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 ?? '其他'))];
function groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({
value,
onChange,
@@ -54,19 +60,39 @@ function PermissionChecklist({
>
{CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return (
<div key={group} style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
<div
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}
</Typography.Text>
<Row gutter={[8, 8]}>
{items.map((item) => (
<Col key={item.key} span={8}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
<Divider style={{ margin: '12px 0 0' }} />
{compact ? (
<Space size={[24, 8]} wrap>
{items.map((item) => (
<Checkbox key={item.key} value={item.key}>
{item.label}
</Checkbox>
))}
</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>
);
})}
@@ -88,6 +114,12 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = 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(
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys],
@@ -172,7 +204,8 @@ export default function HqPermissionsPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
=
//
//
</Typography.Paragraph>
@@ -198,7 +231,11 @@ export default function HqPermissionsPage() {
</Form.Item>
</Form>
{role === 'SUPER_ADMIN' ? (
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
<Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : (
<>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -227,12 +264,10 @@ export default function HqPermissionsPage() {
value={accountId}
onChange={setAccountId}
optionFilterProp="label"
options={accounts
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
options={accounts.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/>
</Form.Item>
</Form>
@@ -240,32 +275,57 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : (
<>
<Space wrap style={{ marginBottom: 12 }}>
<span></span>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
</Tag>
);
})}
</Space>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Tag color="blue"></Tag>
) : (
<Space wrap size={[4, 4]}>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<Space wrap style={{ marginTop: 12 }}>
<span></span>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
</Tag>
);
})}
</Space>
<div style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Space wrap size={[4, 4]}>
<Tag></Tag>
{accountKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key} color="orange">
{item?.label || key}
</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 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
+18 -10
View File
@@ -18,7 +18,7 @@ import {
message,
} from 'antd';
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 {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
@@ -154,6 +154,7 @@ export default function OrdersPage() {
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -174,12 +175,17 @@ export default function OrdersPage() {
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
[data?.items, selectedRowKeys],
);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
@@ -454,13 +460,15 @@ export default function OrdersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
@@ -504,10 +512,10 @@ export default function OrdersPage() {
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={{
rowSelection={canDeleteOrders ? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
} : undefined}
pagination={{
current: page,
pageSize,
+5 -6
View File
@@ -248,11 +248,11 @@ export default function PartnersPage() {
label: '基本信息',
children: (
<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="phone" label="登录手机号" rules={[{ required: true }]}><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 }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
@@ -348,10 +348,10 @@ export default function PartnersPage() {
}}
/>
</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="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 }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
@@ -359,8 +359,7 @@ export default function PartnersPage() {
<Form.Item
name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
+3 -1
View File
@@ -271,7 +271,9 @@ export default function ProductsPage() {
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', 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: '现场取货',
dataIndex: 'allowOnSitePickup',
+45 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
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';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
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> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
dataIndex: 'status',
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>
+622 -94
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
@@ -14,12 +14,13 @@ import {
Space,
Steps,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
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 {
ADMIN_OPTIONS_PAGE_SIZE,
@@ -37,6 +38,7 @@ import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
const CREATE_STEPS = [
{ title: '基本信息' },
@@ -44,6 +46,31 @@ const CREATE_STEPS = [
{ 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 = {
id?: string;
bizType?: string;
@@ -262,7 +289,20 @@ type StoreRow = {
createdAt: string;
cityRef?: { name: string; code: 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;
};
@@ -282,10 +322,18 @@ type CategoryNode = {
export default function StoresPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const [form] = Form.useForm();
const [editForm] = Form.useForm();
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>(
'/admin/stores',
() => {
@@ -294,10 +342,14 @@ export default function StoresPage() {
if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
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;
},
[filters],
);
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
@@ -306,15 +358,55 @@ export default function StoresPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
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 [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
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 selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
const categoryParentOptions = useMemo(
() =>
@@ -330,6 +422,140 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name }));
}, [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) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
if (!binding) {
@@ -356,7 +582,7 @@ export default function StoresPage() {
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() {
async function loadOptions(opts?: { quiet?: boolean }) {
setOptionsLoading(true);
try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
@@ -368,10 +594,12 @@ export default function StoresPage() {
setPartners(p.items);
setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
if (!opts?.quiet) {
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
}
}
} catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
@@ -392,6 +620,11 @@ export default function StoresPage() {
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
settlementRate: 60,
openTime: '10:00',
closeTime: '22:00',
openTime2: undefined,
closeTime2: undefined,
avgPrice: undefined,
});
setCreateStep(0);
setCreateError('');
@@ -447,7 +680,25 @@ export default function StoresPage() {
phone: values.phone.trim(),
district: values.district.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,
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,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined,
@@ -514,20 +765,7 @@ export default function StoresPage() {
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
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>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
),
},
];
@@ -544,6 +782,26 @@ export default function StoresPage() {
<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="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="营业状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
@@ -556,7 +814,20 @@ export default function StoresPage() {
/>
</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>
<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); } }} />
@@ -597,7 +868,7 @@ export default function StoresPage() {
</Button>
</>
) : 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 }))}
onChange={async (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 });
void reload();
}} />
<Button type="primary" onClick={async () => {
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>
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}></Button>
</Space>
)}>
{detail && (
<>
<StoreAuditMediaSection detail={detail} />
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="门店分类">
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
? String((detail.category as { name?: string }).name || '—')
: '—'}
</Descriptions.Item>
<Descriptions.Item label="审核状态">
<Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange'
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
}>
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
</Tag>
</Descriptions.Item>
{String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
) : null}
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销结算比例">
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
</Button>
</Descriptions.Item>
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
</Typography.Text>
))}
</Space>
</Descriptions.Item>
) : null}
</Descriptions>
<Form form={editForm} layout="vertical">
<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="coverUrl" label="封面图">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
</Form>
</>
<Form form={editForm} layout="vertical">
<Tabs
destroyInactiveTabPane={false}
items={[
{
key: 'basic',
label: '基本信息',
forceRender: true,
children: (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="审核状态">
<Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange'
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
}>
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
</Tag>
</Descriptions.Item>
{String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
) : null}
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
</Button>
</Descriptions.Item>
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
</Typography.Text>
))}
</Space>
</Descriptions.Item>
) : null}
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 0 }}></Typography.Title>
{phoneMismatch ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
/>
) : null}
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机号"
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>
<Modal
@@ -774,8 +1197,77 @@ export default function StoresPage() {
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</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="门店简介">
<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>
</div>
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
@@ -856,6 +1348,42 @@ export default function StoresPage() {
onChange={(e) => setRejectReason(e.target.value)}
/>
</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>
);
}
+25 -17
View File
@@ -18,8 +18,8 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = {
id: string;
@@ -66,6 +66,7 @@ export default function UsersPage() {
const navigate = useNavigate();
const location = useLocation();
const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -82,6 +83,11 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = 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 () => {
setLoading(true);
@@ -222,7 +228,7 @@ export default function UsersPage() {
title: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => v || '—',
render: (v) => maskPhone(v),
},
{
title: '验手机',
@@ -309,13 +315,15 @@ export default function UsersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
{canDeleteUsers ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号">
@@ -353,11 +361,11 @@ export default function UsersPage() {
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={{
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
} : undefined}
pagination={{
current: page,
pageSize,
@@ -375,9 +383,9 @@ export default function UsersPage() {
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={detail && (
extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}></Button>
)}
) : undefined}
>
{detail && (
<>
@@ -385,7 +393,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</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="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item>
@@ -419,7 +427,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至">
{detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -593,7 +601,7 @@ export default function UsersPage() {
columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ 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: '风险',
width: 200,
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v) => v || '—',
render: (v) => maskPhone(v),
},
{
title: '身份摘要',
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities
.map((i) => i.refLabel || i.name || i.phone)
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean)
.join(' · ')}
/>
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
render: (_, r) => (
<AdminCellLine
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 WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage';
@@ -33,6 +34,7 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<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;
name: string;
phone: string;
storeSmsCode: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string;
closeTime: string;
/** 是否启用第二段营业时间 */
dualHours: boolean;
openTime2: string;
closeTime2: string;
/** 人均费用(选填) */
avgPrice: string;
categoryParentId: string;
categoryId: string;
intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string;
envPhotoUrls: string[];
contractUrl: string;
@@ -39,13 +49,19 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '',
name: '',
phone: '',
storeSmsCode: '',
address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '',
categoryId: '',
intro: '',
benefitUsageRule: '',
coverUrl: '',
envPhotoUrls: ['', '', ''],
contractUrl: '',
@@ -54,39 +70,38 @@ export const defaultStoreForm = (): StoreDraftForm => ({
bankBranch: '',
});
export function normalizeStringArray(raw: unknown, length: number): string[] {
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
const items = raw.map((item) => String(item ?? ''));
while (items.length < length) items.push('');
return items.slice(0, length);
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const PHONE_RE = /^1[3-9]\d{9}$/;
const BANK_RE = /^\d{16,19}$/;
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 regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
? raw.regionCodes.map((code) => String(code))
: base.regionCodes;
if (!raw) return base;
return {
regionCodes,
...base,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
province: String(raw.province ?? base.province),
city: String(raw.city ?? base.city),
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),
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
categoryId: String(raw.categoryId ?? base.categoryId),
intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl),
dualHours: Boolean(raw.dualHours),
openTime2: String(raw.openTime2 ?? base.openTime2),
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
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;
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.form && typeof parsed.form === 'object') {
return {
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
form: normalizeForm(parsed.form as Record<string, unknown>),
};
}
return { step: 1, form: normalizeForm(parsed) };
const parsed = JSON.parse(raw) as StoreDraft;
return {
step: Number(parsed.step) || 0,
form: normalizeStoreDraftForm(parsed.form),
};
} catch {
return null;
}
@@ -129,15 +141,6 @@ export function clearAllStoreDrafts(accountId?: string) {
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(
form: Pick<
StoreDraftForm,
@@ -147,8 +150,13 @@ export function validateStoreStep1(
| 'address'
| 'openTime'
| 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId'
| 'intro'
| 'benefitUsageRule'
>,
): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
@@ -162,10 +170,29 @@ export function validateStoreStep1(
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
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.intro.trim()) {
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;
}
@@ -189,7 +216,7 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3(
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
@@ -198,7 +225,5 @@ export function validateStoreStep3(
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
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;
}
+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 };
+2 -2
View File
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '关闭';
if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '永久关闭';
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 { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast';
import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
@@ -17,19 +18,25 @@ function fmtMoney(n: number) {
export default function ProxyOrderPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [customerSmsCode, setCustomerSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2);
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 [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 [msg, setMsg] = useState('');
@@ -39,12 +46,26 @@ export default function ProxyOrderPage() {
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
setOptions({
...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 : '加载失败'))
.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(() => {
if (!productId || quantity < 1) {
@@ -58,8 +79,9 @@ export default function ProxyOrderPage() {
body: JSON.stringify({
productId,
quantity,
receiverCity: region.city || undefined,
receiverDistrict: region.district || undefined,
deliveryMode,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
silent: true,
})
@@ -68,67 +90,109 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false));
}, 300);
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('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
setMsg(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-customer-sms',
{
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
},
);
setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
startCooldown(setCustomerCooldown);
} catch (e) {
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() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
if (!smsCode.trim()) {
setMsg('请输入验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
if (!partnerSmsCode.trim()) {
setMsg('请输入合伙人确认验证码');
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
smsCode: smsCode.trim(),
customerSmsCode: customerSmsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: region.province,
city: region.city,
district: region.district,
addressDetail: addressDetail.trim(),
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId,
quantity,
promoCodeId: promoCodeId || undefined,
@@ -151,6 +215,12 @@ export default function ProxyOrderPage() {
}
const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<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' }}>
{loadingOptions ? (
<p className="label-md text-muted">广</p>
<p className="label-md text-muted"></p>
) : (
<>
<section className="partner-form-section">
@@ -175,70 +245,49 @@ export default function ProxyOrderPage() {
<button
type="button"
className="btn btn-secondary"
disabled={smsCooldown > 0}
onClick={() => void sendSms()}
disabled={customerCooldown > 0}
onClick={() => void sendCustomerSms()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</button>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="线下代发货确认码"
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="发至客户手机"
value={customerSmsCode}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
</section>
<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>}
</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)' }}
<label className="partner-form-label"></label>
<button
type="button"
className="partner-proxy-product-picker"
onClick={() =>
navigate(
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
)
}
>
{(options?.products ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.spec}¥{fmtMoney(p.price)}
</option>
))}
</select>
{selectedProduct ? (
<>
<span className="partner-proxy-product-name">{selectedProduct.name}</span>
<span className="label-md text-muted">
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
</span>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section>
<section className="partner-form-section">
@@ -261,7 +310,12 @@ export default function ProxyOrderPage() {
className="partner-input"
value={promoCodeId}
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>
{options!.promoCodes.map((p) => (
@@ -273,6 +327,73 @@ export default function ProxyOrderPage() {
</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">
<h3 className="headline-md"></h3>
{previewLoading ? (
@@ -288,8 +409,8 @@ export default function ProxyOrderPage() {
<span className="body-md">×{quantity}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
<span className="label-md text-muted"></span>
<span className="body-md">{deliveryLabel}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
@@ -302,25 +423,78 @@ export default function ProxyOrderPage() {
</>
) : (
<p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
</p>
)}
</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
type="button"
className="btn btn-primary btn-block"
disabled={submitting || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting ? '提交中…' : '验证码确认并代下单'}
</button>
{msg ? (
<p className="partner-form-error" role="alert">
{msg}
</p>
) : null}
{!confirmStep ? (
<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>
</>
)}
@@ -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 { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { checkStorePhoneAvailable } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
clearAllStoreDrafts,
@@ -51,7 +55,6 @@ type StoreCategoryNode = {
type FieldErrors = {
phone?: string;
storeSmsCode?: string;
};
@@ -66,7 +69,7 @@ function isPhoneConflictMessage(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 [smsCooldown, setSmsCooldown] = useState(0);
const [smsHint, setSmsHint] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) {
@@ -222,25 +225,13 @@ export default function StoreCreatePage() {
setSubmitError('');
let nextPatch = patch;
if ('phone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined }));
setSmsHint('');
nextPatch = { ...patch, storeSmsCode: '' };
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
}
if ('storeSmsCode' in patch) {
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
}
setForm((prev) => ({ ...prev, ...nextPatch }));
setForm((prev) => ({ ...prev, ...patch }));
}
@@ -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() {
if (step === 1) {
@@ -402,11 +327,7 @@ export default function StoreCreatePage() {
if (msg) {
if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
setFieldErrors({ phone: msg });
return;
}
reportFormError(msg);
@@ -502,19 +423,31 @@ export default function StoreCreatePage() {
phone: form.phone.trim(),
smsCode: form.storeSmsCode.trim(),
district: form.district.trim(),
address: form.address.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
openTime: form.openTime.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(),
intro: form.intro.trim() || undefined,
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined,
@@ -539,7 +472,6 @@ export default function StoreCreatePage() {
setForm(defaultStoreForm());
setFieldErrors({});
setSubmitError('');
setSmsHint('');
setParams({ step: '1' }, { replace: true });
toastSuccess('门店录入成功');
navigate(`/stores/${result.store.id}`);
@@ -549,11 +481,6 @@ export default function StoreCreatePage() {
setFieldErrors({ phone: message });
return;
}
if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message });
goStep(3);
return;
}
setSubmitError(message);
toastError(message);
} finally {
@@ -734,13 +661,54 @@ export default function StoreCreatePage() {
<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 className="partner-field">
<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
@@ -774,19 +742,97 @@ export default function StoreCreatePage() {
</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 }}>
10:0022:00
1 09:00-22:00 2 09:00-14:0017:00-21:00
</p>
</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">
<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 }}>
@@ -796,6 +842,26 @@ export default function StoreCreatePage() {
</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>
<div className="partner-info-banner">
@@ -1040,74 +1106,11 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</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>
</>
@@ -1150,6 +1153,23 @@ export default function StoreCreatePage() {
</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>
);
+110 -3
View File
@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -38,13 +40,23 @@ export default function StoreDetailPage() {
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
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 [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
@@ -55,6 +67,12 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''),
address: String(data.address || ''),
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);
setCoverUrl(String(data.coverUrl || ''));
@@ -149,6 +167,13 @@ export default function StoreDetailPage() {
phone: form.phone.trim(),
address: form.address.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);
@@ -319,14 +344,78 @@ export default function StoreDetailPage() {
<div className="partner-field">
<label></label>
<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 className="partner-field">
<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 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</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 className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -421,6 +510,24 @@ export default function StoreDetailPage() {
</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>
);
}
+139
View File
@@ -3315,6 +3315,145 @@ body {
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 {
display: flex;
flex-direction: column;
+11 -7
View File
@@ -10,13 +10,11 @@ function formatMoney(n: number) {
export default function HomePage() {
const navigate = useNavigate();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
})
.catch(() => {});
}, []);
@@ -44,8 +42,14 @@ export default function HomePage() {
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
const status = String(store?.status || '');
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 (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
@@ -91,12 +95,12 @@ export default function HomePage() {
</div>
<div>
<p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
<p className="shop-home-status-sub">: {openTime} - {closeTime}</p>
<p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {hoursText}</p>
</div>
</div>
<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" />
</label>
</section>
+5 -3
View File
@@ -58,8 +58,10 @@ export default function MinePage() {
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
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('') : '09:30 - 22:00';
const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
@@ -126,7 +128,7 @@ export default function MinePage() {
<div className="shop-mine-info-row">
<div>
<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>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
+2 -1
View File
@@ -63,7 +63,8 @@ export default function SelectStorePage() {
function statusLabel(status: string) {
if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '暂停营业';
if (status === 'PAUSED') return '临时闭店';
if (status === 'CLOSED') return '永久关闭';
return status || '门店';
}
+61 -29
View File
@@ -3,20 +3,35 @@ import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
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() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true);
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState('');
const [showModal, setShowModal] = useState(false);
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
const [error, setError] = useState('');
useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => {
const s = d.store as Record<string, unknown>;
setStore(s);
setOpen(String(s?.status) === 'OPEN');
const status = String(s?.status || '');
setPermanentlyClosed(status === 'CLOSED');
setOpen(status === 'OPEN');
if (s?.updatedAt) {
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
}
@@ -24,31 +39,33 @@ export default function StatusPage() {
}, []);
function requestToggle(next: boolean) {
if (permanentlyClosed) return;
if (next === open) return;
setPendingOpen(next);
setShowModal(true);
}
async function confirmToggle() {
if (pendingOpen === null) return;
if (pendingOpen === null || permanentlyClosed) return;
const next = pendingOpen ? 'OPEN' : 'PAUSED';
try {
setError('');
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
setOpen(pendingOpen);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch {
/* keep current state */
} catch (e) {
setError(e instanceof Error ? e.message : '状态切换失败');
} finally {
setShowModal(false);
setPendingOpen(null);
}
}
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
const hoursText = formatShopHours(store);
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
return (
<div className="shop-status-page">
@@ -70,46 +87,61 @@ export default function StatusPage() {
<div className="shop-status-content">
<section className="shop-status-card">
<div className="shop-status-icon-wrap">
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">storefront</span>
</div>
</div>
<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)' }}>
{open ? 'check_circle' : 'cancel'}
<span
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>
</div>
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
{open ? '营业中' : '临时闭店'}
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
{statusLabel}
</h2>
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<input
type="checkbox"
checked={open}
onChange={(e) => requestToggle(e.target.checked)}
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
/>
<span className="shop-status-switch-track" />
<span className="shop-status-switch-caption">
{open ? '点击可临时闭店' : '点击恢复营业'}
</span>
</label>
{permanentlyClosed ? (
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
</p>
) : (
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<input
type="checkbox"
checked={open}
onChange={(e) => requestToggle(e.target.checked)}
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
/>
<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">{openTime} - {closeTime}</p>
<p className="shop-status-hours">{hoursText}</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>
<div className={`shop-status-hint${open ? ' open' : ' closed'}`}>
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">info</span>
<p>
{open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
{permanentlyClosed
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
: open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
</p>
</div>
</div>
@@ -37,6 +37,8 @@ type Order = {
id: string;
orderNo: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
status: string;
originOrderId?: string | null;
remark?: string | null;
@@ -138,6 +140,7 @@ export default function OrderDetailPage() {
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() {
if (!id) return;
@@ -240,6 +243,11 @@ export default function OrderDetailPage() {
<h2>{banner.title}</h2>
</div>
<p>{banner.subtitle}</p>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
· {order.proxyPartnerName}
</p>
) : null}
</div>
</section>
+18 -4
View File
@@ -14,11 +14,14 @@ const TABS = [
const STATUS_LABEL: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '已付款',
OUT_WAREHOUSE: '已付款',
SHIPPING: '已付款',
PENDING_RECEIVE: '已付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
function isReshipOrder(order: Record<string, unknown>) {
@@ -49,6 +52,7 @@ export default function OrderListPage() {
const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0];
const isReship = isReshipOrder(o);
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
const isPendingPay = String(o.status) === 'PENDING_PAY';
return (
<div key={String(o.id)} className="card">
@@ -60,9 +64,19 @@ export default function OrderListPage() {
</span>
)}
{isProxy && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
</span>
<span className="status-tag">{orderStatusLabel(o)}</span>
</div>
{isProxy && o.proxyPartnerName ? (
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
{String(o.proxyPartnerName)}
</div>
) : null}
{item && (
<div className="card-row">
<AppImage
+10 -2
View File
@@ -24,6 +24,9 @@ type StoreDetail = {
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
media?: StoreMedia[];
};
@@ -41,8 +44,10 @@ const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) {
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
return '09:30 - 22:00';
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('') : '09:30-22:00';
}
function fullAddress(store: StoreDetail) {
@@ -135,6 +140,9 @@ export default function StoreDetailPage() {
<div>
<h2 className="store-detail-name">{store.name}</h2>
<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 && (
<span className="store-detail-category">{store.category.name}</span>
)}
+10 -4
View File
@@ -32,6 +32,9 @@ type StoreItem = {
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
};
@@ -50,10 +53,10 @@ function storeCover(store: StoreItem, index: number) {
}
function formatHours(store: StoreItem) {
if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`;
}
return '营业时间: 10:00-22:00';
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';
}
export default function StoreListPage() {
@@ -238,6 +241,9 @@ export default function StoreListPage() {
<div className="store-card-meta">
<span className="status-open"></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 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 = {
province: '河南省',
city: '郑州市',
district: '金水区',
district: REGION_ALL,
};
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
+40 -3
View File
@@ -1,10 +1,11 @@
import Taro from '@tarojs/taro';
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
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';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
const USER_COORDS_KEY = 'dukang_user_coords';
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
const LOCATION_DENIED_KEY = 'dukang_location_denied';
@@ -19,12 +20,14 @@ export type ResolvedUserCity = {
displayCity: string;
};
export type UserCoords = { latitude: number; longitude: number };
type GpsCityCache = ResolvedUserCity & { timestamp: number };
const FALLBACK_CITY: ResolvedUserCity = {
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: DEFAULT_REGION.district,
district: REGION_ALL,
cityCode: FALLBACK_CITY_CODE,
cityName: '郑州市',
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) {
if (denied) markLocationDenied();
@@ -167,12 +203,12 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
cacheFallbackAndMaybeDeny(denied);
return null;
}
try {
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
const data = await reportLocationToServer({
latitude: outcome.location.latitude,
longitude: outcome.location.longitude,
@@ -213,6 +249,7 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
try {
const loc = await getMiniLocation();
writeUserCoords(loc.latitude, loc.longitude);
const data = await reportLocationToServer({
latitude: loc.latitude,
longitude: loc.longitude,
@@ -3,4 +3,6 @@ export default definePageConfig({
navigationBarTitleText: '好客权益',
enablePullDownRefresh: true,
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 Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type BenefitSummary = {
totalBalance: number;
@@ -84,8 +91,25 @@ export default function BenefitPage() {
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
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 (
<PageShell variant="tab" className="benefit-page">
<WechatShareReady payload={sharePayload} />
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
{process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text>
@@ -98,6 +122,9 @@ export default function BenefitPage() {
<View className="benefit-header-city-pin" />
<Text></Text>
</View>
<View className="benefit-header__share page-nav-bar__right-slot">
<ShareNavButton payload={sharePayload} />
</View>
</View>
</View>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '杜康好客',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+26 -2
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
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 TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
@@ -11,6 +12,11 @@ import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Product = {
id: string;
@@ -139,8 +145,26 @@ export default function HomePage() {
const banners = miniHome.banners;
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 (
<PageShell variant="tab" className="home-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="杜康好客" />
{banners.length > 0 ? (
@@ -150,7 +174,7 @@ export default function HomePage() {
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={4000}
interval={2500}
>
{banners.map((url) => (
<SwiperItem key={url}>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '我的',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+67 -7
View File
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react';
import { View, Text, Image, Button, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import {
BRAND_LOGO_MARK_URL,
QUALIFICATION_DISCLOSURE_URL,
isWxAuthorizeEnabled,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import {
@@ -18,6 +23,11 @@ import {
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: '付', label: '待付款' },
@@ -29,6 +39,7 @@ const SERVICES = [
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
{ icon: '资', label: '资质公示', action: 'qualification' as const },
{ icon: '关', label: '关于我们', action: 'about' as const },
] as const;
@@ -51,6 +62,7 @@ export default function MinePage() {
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false);
function resetGuestState() {
setProfile(null);
@@ -126,6 +138,21 @@ export default function MinePage() {
.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> {
if (profile?.hasWechat) return true;
if (!wxAuthorize) {
@@ -254,6 +281,10 @@ export default function MinePage() {
Taro.switchTab({ url: item.tab });
return;
}
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
}
@@ -269,6 +300,7 @@ export default function MinePage() {
if (!authed) {
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -321,6 +353,7 @@ export default function MinePage() {
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -507,14 +540,41 @@ export default function MinePage() {
</View>
<View
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>
) : 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>
);
}
@@ -35,7 +35,7 @@ type OrderPreview = {
export default function OrderConfirmPickupPage() {
const router = useRouter();
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 [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
@@ -70,12 +70,12 @@ export default function OrderConfirmPickupPage() {
};
}, [productId, quantity]);
const minQty = preview?.minQty ?? 1;
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < 1) return;
if (next < minQty) return;
setQuantity(next);
}
@@ -95,7 +95,7 @@ export default function OrderConfirmPickupPage() {
async function submit() {
if (!canSubmit) {
if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`);
if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`);
return;
}
@@ -185,8 +185,8 @@ export default function OrderConfirmPickupPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
>
<Text></Text>
</View>
@@ -196,6 +196,11 @@ export default function OrderConfirmPickupPage() {
</View>
</View>
</View>
{!quantityOk ? (
<Text className="order-qty-hint">
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
@@ -219,7 +224,7 @@ export default function OrderConfirmPickupPage() {
) : null}
{msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{msg}
</Text>
) : null}
@@ -39,6 +39,9 @@ type OrderDetail = {
receiverAddress?: string;
createdAt?: string;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
};
@@ -80,9 +83,10 @@ export default function OrderDetailPage() {
}, [orderId]);
const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
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 productName = item?.productName || order?.productName || '杜康商品';
@@ -166,9 +170,15 @@ export default function OrderDetailPage() {
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
<View className="order-status-row">
<Text className="order-list-status">
{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 className="order-card">
<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 { buildPayUrl } from '../../lib/checkout-nav';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
const TABS = [
{ key: 'all', label: '全部订单' },
{ key: 'pending_pay', label: '待付款' },
@@ -13,22 +15,12 @@ const TABS = [
{ key: 'completed', label: '已完成' },
] 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 {
if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || '';
}
if (!status) return '';
return STATUS_LABELS[status] || status;
return ORDER_STATUS_LABELS[status] || status;
}
type OrderItem = {
@@ -47,6 +39,9 @@ type OrderRow = {
qty?: number;
quantity?: number;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
};
@@ -110,6 +105,7 @@ export default function OrdersPage() {
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
const unitPrice = Number(item?.unitPrice ?? 0);
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
return (
<View
@@ -118,11 +114,17 @@ export default function OrdersPage() {
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
>
<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">
{orderStatusLabel(tab, o.status)}
</Text>
</View>
{isProxy && o.proxyPartnerName ? (
<Text className="order-proxy-hint"> {o.proxyPartnerName} </Text>
) : null}
<View className="order-list-body">
<View className="order-list-thumb">
{productImage ? (
+175 -20
View File
@@ -1,5 +1,5 @@
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 PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
@@ -13,19 +13,61 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
type StoreMedia = {
url?: string | null;
bizType?: string | null;
mediaType?: string | null;
};
type Store = {
id: string;
name: string;
address?: string;
province?: string;
cityName?: string;
city?: string;
district?: string;
phone?: string;
intro?: string | null;
benefitUsageRule?: string | null;
coverUrl?: string | null;
carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
openTime?: 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;
};
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() {
const router = useRouter();
const storeId = router.params.id ?? '';
@@ -52,12 +94,15 @@ export default function StoreDetailPage() {
}, [storeId]);
const sharePayload = useMemo(
() => ({
title: store?.name || DEFAULT_SHARE_TITLE,
desc: store?.address || DEFAULT_SHARE_DESC,
path: `/pages/store-detail/index?id=${storeId}`,
imgUrl: store?.coverUrl || store?.carouselUrls?.[0] || undefined,
}),
() => {
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
return {
title: store?.name || DEFAULT_SHARE_TITLE,
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],
);
@@ -74,6 +119,44 @@ export default function StoreDetailPage() {
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) {
return (
<PageShell variant="scroll" className="store-detail-page">
@@ -83,12 +166,25 @@ export default function StoreDetailPage() {
);
}
const images =
(store.carouselUrls && store.carouselUrls.length > 0
? store.carouselUrls
: store.coverUrl
? [store.coverUrl]
: []) as string[];
const envPhotos = envPhotoUrls(store);
const images = uniqueUrls([
store.coverUrl,
...(store.carouselUrls || []),
...envPhotos,
]);
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 (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
@@ -107,14 +203,39 @@ export default function StoreDetailPage() {
<View className="store-detail-info-card">
<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">
{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 className="store-detail-meta">
: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
</Text>
{store.phone ? <Text className="store-detail-meta">: {store.phone}</Text> : null}
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<Text className="store-detail-meta"> ¥{Number(store.avgPrice).toFixed(0)}</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">
{store.category?.name ? (
<Text className="store-detail-tag">{store.category.name}</Text>
@@ -124,8 +245,42 @@ export default function StoreDetailPage() {
</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="u-btn u-btn--block" onClick={() => toast('核销请前往「好客权益」')}>
<View
className="u-btn u-btn--block"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text></Text>
</View>
</View>
@@ -2,4 +2,6 @@ export default definePageConfig({
navigationBarTitleText: '门店',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+71 -16
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
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 TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import RegionPicker from '../../components/RegionPicker';
import CategoryPicker, {
EMPTY_CATEGORY,
@@ -17,9 +18,21 @@ import {
type RegionSelection,
} from '../../lib/region-data';
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 { formatDistanceMeters } from '../../lib/geo';
import { request, toast } from '../../lib/api';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Store = {
id: string;
@@ -31,13 +44,17 @@ type Store = {
coverUrl?: string | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
status?: string;
categoryId?: string | 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() {
const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(true);
@@ -49,6 +66,7 @@ export default function StoresPage() {
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const [userCoords, setUserCoords] = useState<UserCoords | null>(() => readCachedUserCoords());
const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
@@ -66,8 +84,9 @@ export default function StoresPage() {
useDidShow(() => {
syncTabBarSelected(1);
void resolveUserCity().then((resolved) => {
setRegion(resolved.region);
setRegion(toCityWideRegion(resolved.region));
setCityCode(getCityCodeForCatalog(resolved));
setUserCoords(readCachedUserCoords());
});
});
@@ -79,12 +98,19 @@ export default function StoresPage() {
const loadStores = useCallback(() => {
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)
.then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [cityCode]);
}, [cityCode, userCoords]);
useEffect(() => {
void loadStores();
@@ -93,12 +119,20 @@ export default function StoresPage() {
usePullDownRefresh(() => {
void (async () => {
try {
const resolved = await resolveUserCity();
setRegion(resolved.region);
const resolved = await resolveUserCity(true);
setRegion(toCityWideRegion(resolved.region));
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
const coords = readCachedUserCoords();
setUserCoords(coords);
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);
setStores(Array.isArray(list) ? list : []);
} catch (e) {
@@ -142,14 +176,30 @@ export default function StoresPage() {
}
function formatHours(store: Store) {
if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`;
}
return '营业时间: 10:00-22:00';
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';
}
const sharePayload = useMemo(
() => ({
title: '杜康好客门店',
desc: DEFAULT_SHARE_DESC,
path: '/pages/stores/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
return (
<PageShell variant="tab" className="store-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="门店" />
<View className="store-filter">
@@ -186,7 +236,7 @@ export default function StoresPage() {
{loading ? <View className="u-empty"></View> : null}
{!loading && filtered.length === 0 ? <View className="u-empty"></View> : null}
{!loading &&
filtered.map((s, index) => (
filtered.map((s) => (
<View
key={s.id}
className="store-card"
@@ -204,8 +254,13 @@ export default function StoresPage() {
{s.address || '地址待完善'}
</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">
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
<Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)}
</Text>
<Text
className="store-card-cta"
onClick={(e) => {
+8
View File
@@ -50,6 +50,14 @@
z-index: 2;
}
.benefit-header__share .page-nav-bar__btn {
background: transparent;
}
.benefit-header__share .page-nav-bar__icon {
color: var(--color-heritage-red);
}
.benefit-header-city-pin {
width: 8px;
height: 8px;
+2 -2
View File
@@ -205,8 +205,8 @@
justify-content: center;
padding: 4px 10px;
border-radius: var(--radius-full);
background: #2e7d32;
color: #fff;
background: var(--color-aged-amber);
color: #5c4500;
font-size: 11px;
font-weight: 600;
line-height: 16px;
+55
View File
@@ -498,3 +498,58 @@
opacity: 0.65;
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;
}
.order-list-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.order-list-no {
font-size: 12px;
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 {
font-size: 13px;
font-weight: 600;
@@ -79,6 +79,38 @@
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 {
display: flex;
flex-wrap: wrap;
@@ -112,6 +144,27 @@
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 {
position: fixed;
bottom: 0;
+3 -2
View File
@@ -42,10 +42,11 @@ if [[ -z "$DEPLOY_HOST" ]]; then
fi
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}"
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"
echo "==> 完成"
+8
View File
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
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', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
+1 -6
View File
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
return { ok: true };
}
const districts = normalizeDistrictCodes(input.districtCodes);
if (!districts.length) {
return { ok: false, message: '区域合伙人须至少选择一个区县' };
}
// 区县仅为标识,允许多个区域合伙人选择相同区县
// 区域合伙人所选区县可为空(录入时可稍后补全)
return { ok: true };
}
+43 -2
View File
@@ -6,6 +6,8 @@ import {
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount,
allocateBenefitCoupons,
calcBenefitSummary,
@@ -36,9 +38,48 @@ describe('validateMinPurchase', () => {
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', 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,
): { ok: boolean; message?: string } {
if (deliveryType === 'ON_SITE_PICKUP') {
if (quantity < 1) {
return { ok: false, message: '现场取货至少购买 1 瓶' };
const min = localMinQty > 0 ? localMinQty : 2;
if (quantity < min) {
return { ok: false, message: `现场提货至少购买 ${min}` };
}
return { ok: true };
}
@@ -95,7 +96,56 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
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;
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
+8 -1
View File
@@ -17,7 +17,7 @@ export interface AppConfig {
aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */
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;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
@@ -42,6 +42,13 @@ export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
/** 仅图标 Logo(默认头像:未微信授权时) */
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 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
+24 -1
View File
@@ -22,6 +22,7 @@ export enum UserSourceType {
SHARE_LINK = 'SHARE_LINK',
FRIEND_REFERRAL = 'FRIEND_REFERRAL',
OFFLINE_EVENT = 'OFFLINE_EVENT',
PARTNER_PROXY = 'PARTNER_PROXY',
OTHER = 'OTHER',
}
@@ -31,6 +32,7 @@ export const USER_SOURCE_TYPE_LABELS: Record<UserSourceType, string> = {
[UserSourceType.SHARE_LINK]: '分享链接',
[UserSourceType.FRIEND_REFERRAL]: '好友推荐',
[UserSourceType.OFFLINE_EVENT]: '线下活动',
[UserSourceType.PARTNER_PROXY]: '代下单',
[UserSourceType.OTHER]: '其他',
};
@@ -46,7 +48,9 @@ export enum SmsScene {
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
/** 合伙人代下单:线下代发货确认验证码(发至户手机) */
/** 合伙人代下单:客户手机号归属验证码(发至户手机) */
PARTNER_PROXY_CUSTOMER = 'PARTNER_PROXY_CUSTOMER',
/** 合伙人代下单:线下确认验证码(发至合伙人手机) */
PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
/** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */
PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN',
@@ -106,6 +110,25 @@ export enum StoreStatus {
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 {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
+19 -1
View File
@@ -19,6 +19,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', 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_accounts', label: 'HQ 账户', 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 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 */
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
feature: 'system_settings_feature',
@@ -76,7 +94,7 @@ export const HQ_ADMIN_ROLES = [
] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key),
SUPER_ADMIN: hqBasePermissionKeys(),
OPS: [
'dashboard',
'users',
+32 -6
View File
@@ -8,6 +8,10 @@ export interface OrderDto {
quantity: number;
productName: string;
createdAt: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
}
export interface OrderPreviewRequest {
@@ -39,12 +43,16 @@ export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
};
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
export type PartnerProxyOrderProductOption = {
id: string;
name: string;
spec: string;
price: number;
benefitAmount: number | null;
coverUrl?: string | null;
allowOnSitePickup?: boolean;
};
export type PartnerProxyOrderPromoOption = {
@@ -53,14 +61,27 @@ export type PartnerProxyOrderPromoOption = {
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 = {
products: PartnerProxyOrderProductOption[];
promoCodes: PartnerProxyOrderPromoOption[];
stores: PartnerProxyOrderStoreOption[];
};
export type PartnerProxyOrderPreviewRequest = {
productId: string;
quantity: number;
deliveryMode?: PartnerProxyDeliveryMode;
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
};
@@ -69,18 +90,23 @@ export type PartnerProxyOrderPreviewResult = {
productAmount: number;
payAmount: number;
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
unitPrice: number;
};
export type PartnerProxyOrderCreateRequest = {
phone: string;
smsCode: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: PartnerProxyDeliveryMode;
/** 地址配送时必须为 true */
autoReceive?: boolean;
storeId?: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
+2
View File
@@ -31,6 +31,8 @@ export type ClientRuntimeConfig = {
mockWechat?: boolean;
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
wxAuthorize?: boolean;
/** 腾讯位置服务 Key(地图选点组件,可按域名限制) */
tencentLbsKey?: string;
/** 小程序首页轮播 / 底部图 */
miniHome?: {
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「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(地理编码,微信定位展示城市
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用
# 控制台须开启 WebServiceAPI;服务端调用建议 Key 不设域名白名单,或改用 IP 白名单
# (浏览器内嵌官方选点组件已弃用,避免 mapapi.qq.com / formatted_addresses 崩溃)
TENCENT_LBS_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
+10
View File
@@ -256,6 +256,7 @@ enum UserSourceType {
SHARE_LINK
FRIEND_REFERRAL
OFFLINE_EVENT
PARTNER_PROXY
OTHER
}
@@ -1025,6 +1026,8 @@ model Store {
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
intro String? @db.Text
// 好客权益券使用规则(C 端门店详情展示)
benefitUsageRule String? @map("benefit_usage_rule") @db.Text
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
rating Decimal? @db.Decimal(3, 2)
@@ -1035,6 +1038,8 @@ model Store {
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
openTime String? @map("open_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)
createdAt DateTime @default(now()) @map("created_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)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
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
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
@@ -1177,6 +1186,7 @@ model Order {
@@index([ipCity])
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@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 isProxyOrder = order.orderType === 'PROXY';
return {
...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)],
payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
@@ -7,10 +7,10 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
@@ -35,30 +35,29 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') {
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') {
return HQ_PERMISSION_CATALOG.map((p) => p.key);
// 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选
return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]);
}
const [roleRows, userRows] = await Promise.all([
this.prisma.hqRolePermission.findMany({
where: { adminRole: account.adminRole },
select: { permissionKey: true },
}),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
}),
]);
const roleRows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: account.adminRole },
select: { permissionKey: true },
});
const roleKeys =
roleRows.length > 0
? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([
...roleKeys,
...userRows.map((r) => r.permissionKey),
]);
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
}
}
@@ -28,6 +28,7 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
@@ -127,6 +128,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
[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: '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_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 }> {
let imported = 0;
const overlay: Record<string, string> = {};
for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue;
const normalized = this.normalizeByMeta(field, envVal);
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing) continue;
await this.prisma.systemConfig.create({
data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) },
if (existing?.value?.trim()) continue;
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value: normalized },
update: { value: normalized },
});
overlay[field.key] = normalized;
imported += 1;
}
if (imported) applyEnvOverlay(overlay);
await this.onModuleInit();
return { imported };
}
@@ -208,12 +214,15 @@ export class SystemConfigService implements OnModuleInit {
private async seedMissingFromProcessEnv() {
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];
if (envVal === undefined || envVal === '') continue;
await this.prisma.systemConfig.create({
data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) },
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
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}`,
},
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();
@@ -10,19 +10,134 @@ export type ReverseGeocodeResult = {
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) {
return name.replace(/市$/, '').trim();
}
type TencentPlaceRow = {
id?: string;
title?: string;
address?: string;
city?: string;
location?: { lat?: number; lng?: number };
};
@Injectable()
export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name);
private readonly config = loadAppConfig();
constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
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(
@@ -57,7 +172,7 @@ export class TencentLbsProvider {
const location = `${latitude},${longitude}`;
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', location);
url.searchParams.set('key', this.config.tencentLbsKey);
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '0');
try {
@@ -116,4 +231,202 @@ export class TencentLbsProvider {
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;
}
if (
scene === 'PARTNER_PROXY_ORDER' &&
(scene === 'PARTNER_PROXY_ORDER' || scene === 'PARTNER_PROXY_CUSTOMER') &&
this.config.aliyunSmsProxyOrderTemplateCode
) {
return this.config.aliyunSmsProxyOrderTemplateCode;
@@ -17,6 +17,8 @@ export class ClientConfigController {
mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat,
wxAuthorize: cfg.wxAuthorize,
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined,
miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null,
@@ -14,6 +14,7 @@ import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller';
import { LbsController } from './lbs.controller';
import { WechatLocationService } from './wechat-location.service';
@Module({
@@ -25,6 +26,7 @@ import { WechatLocationService } from './wechat-location.service';
ThirdPartyLogController,
WechatController,
ClientConfigController,
LbsController,
],
providers: [
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:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
case SmsScene.PARTNER_PROXY_CUSTOMER:
return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
@@ -155,13 +156,20 @@ export class AuthService {
});
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({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
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:
return undefined;
}
@@ -334,7 +342,11 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_CUSTOMER) {
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
await this.assertPartnerAccountByPhone(phone);
return;
}
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);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
@@ -362,6 +381,9 @@ export class AuthService {
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
cityPreference: {
create: {
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 {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
class DeleteCityDto {
@IsString()
@MinLength(1)
confirmName!: string;
}
@Controller('admin/cities')
@UseGuards(HqAuthGuard)
export class AdminCitiesController {
@@ -16,6 +27,13 @@ export class AdminCitiesController {
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')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
@@ -37,4 +55,17 @@ export class AdminCitiesController {
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
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);
}
/** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */
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 { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminDashboardService } from './admin-dashboard.service';
@Controller('admin/dashboard')
@@ -13,6 +14,7 @@ export class AdminDashboardController {
}
@Get('version')
@UseGuards(SuperAdminGuard)
version() {
return this.dashboardService.getLatestVersion();
}
@@ -4,6 +4,7 @@ import {
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -49,7 +50,7 @@ export class AdminHqPermissionsService {
async saveRolePermissions(role: string, permissionKeys: string[]) {
if (role === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
}
assertPermissionKeys(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 },
});
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') {
const rolePermissionKeys = hqBasePermissionKeys();
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
return serializeBigInt({
account,
permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
userPermissionKeys: [],
effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
permissionKeys: userPermissionKeys,
rolePermissionKeys,
userPermissionKeys,
effectivePermissionKeys,
});
}
const [rolePerms, userPerms] = await Promise.all([
this.getRolePermissions(account.adminRole),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
}),
]);
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
const rolePerms = await this.getRolePermissions(account.adminRole);
const effectivePermissionKeys = [
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -107,9 +112,6 @@ export class AdminHqPermissionsService {
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('HQ 账号不存在');
if (account.adminRole === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
}
assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys);
await this.prisma.$transaction([
@@ -1,6 +1,9 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminOrdersService } from './admin-orders.service';
@@ -18,7 +21,8 @@ export class AdminOrdersController {
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({
action: HqOperationAction.ORDER_BATCH_DELETE,
refType: 'ORDER',
@@ -191,8 +191,8 @@ export class AdminPartnersService {
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName.trim(),
address: dto.address.trim(),
companyName: dto.companyName?.trim() || null,
address: dto.address?.trim() || null,
contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName,
@@ -94,6 +94,16 @@ export class AdminStoreAccountsController {
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
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')
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -16,6 +17,14 @@ import type {
UpdateStoreStatusDto,
} 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()
export class AdminStoresService {
constructor(
@@ -105,6 +114,8 @@ export class AdminStoresService {
return serializeBigInt(mapStoreCompat({
...store,
account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
bindings: undefined,
media,
audits,
@@ -176,41 +187,183 @@ export class AdminStoresService {
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
const store = await this.prisma.store.update({
where: { id },
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 } : {}),
},
});
const current = await this.prisma.store.findUnique({ where: { id } });
if (!current) throw new NotFoundException('门店不存在');
if (dto.coverUrl) {
const current = await this.prisma.store.findUniqueOrThrow({ where: { id } });
if (current.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
const openTime2 =
dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
const closeTime2 =
dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
const hoursCheck = validateBusinessHours([
{ open: openTime || '10:00', close: closeTime || '22:00' },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
}
const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined;
const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined;
if (latitude !== undefined || longitude !== undefined) {
if (latitude == null || longitude == null) {
throw new BadRequestException('经纬度须同时提供');
}
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);
}
@@ -246,6 +399,37 @@ export class AdminStoresService {
const categoryId = BigInt(dto.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({
data: {
cityId: city.id,
@@ -258,9 +442,14 @@ export class AdminStoresService {
cityName: dto.city ?? city.name,
district: dto.district ?? '',
address: dto.address,
intro: dto.intro ?? null,
openTime: '10:00',
closeTime: '22:00',
intro,
benefitUsageRule,
avgPrice: dto.avgPrice ?? null,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
@@ -536,4 +725,26 @@ export class AdminStoresService {
});
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 { 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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminUsersService } from './admin-users.service';
@@ -18,13 +21,15 @@ export class AdminUsersController {
}
@Post('batch-delete/preview')
@UseGuards(SuperAdminGuard)
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({
action: HqOperationAction.USER_BATCH_DELETE,
refType: 'USER',
@@ -44,7 +49,8 @@ export class AdminUsersController {
}
@Delete(':id')
@UseGuards(SuperAdminGuard)
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({
action: HqOperationAction.USER_DELETE,
refType: 'USER',
@@ -57,10 +57,22 @@ export class CreateStoreDto {
@IsNotEmpty()
address: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsString()
intro?: string;
@IsOptional()
@IsString()
benefitUsageRule?: string;
@IsOptional()
@IsString()
coverUrl?: string;
@@ -94,6 +106,27 @@ export class CreateStoreDto {
@IsNumber()
@Min(0)
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 {
@@ -109,6 +142,10 @@ export class UpdateStoreDto {
@IsString()
intro?: string;
@IsOptional()
@IsString()
benefitUsageRule?: string;
@IsOptional()
@IsString()
coverUrl?: string;
@@ -117,14 +154,67 @@ export class UpdateStoreDto {
@IsString()
address?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsString()
province?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsNumber()
@Min(0)
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 {
@@ -168,13 +258,13 @@ export class CreatePartnerDto {
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
@IsNotEmpty()
companyName: string;
companyName?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
address: string;
address?: string;
@IsOptional()
@IsString()
@@ -14,8 +14,14 @@ export class PublicStoreController {
constructor(private readonly storeService: StoreService) {}
@Get()
list(@Query('cityCode') cityCode?: string) {
return this.storeService.listOpenStores(cityCode);
list(
@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')
@@ -3,6 +3,7 @@ import { IamModule } from '../iam/iam.module';
import { RedeemModule } from '../redeem/redeem.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { StoreService } from './store.service';
import { StoreCategoryService } from './store-category.service';
import {
@@ -17,7 +18,13 @@ import {
} from './store.controller';
@Module({
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
imports: [
IamModule,
AnalyticsModule,
CityScopeModule,
IntegrationsModule,
forwardRef(() => RedeemModule),
],
controllers: [
PublicStoreController,
PublicStoreCategoriesController,
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } 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 { serializeBigInt } from '../../common/decorators/current-user.decorator';
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 { AuthService } from '../iam/auth.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()
export class StoreService {
@@ -25,9 +55,48 @@ export class StoreService {
private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService,
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' };
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
@@ -38,7 +107,43 @@ export class StoreService {
include: { category: true, coverResource: true },
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) {
@@ -47,11 +152,19 @@ export class StoreService {
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
const coords = await this.ensureStoreCoordinates(store);
const media = await this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
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) {
@@ -155,9 +268,6 @@ export class StoreService {
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
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';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
@@ -180,6 +290,36 @@ export class StoreService {
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
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({
data: {
cityId: city.id,
@@ -191,9 +331,14 @@ export class StoreService {
cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
openTime: body.openTime ? String(body.openTime) : '10:00',
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
intro: introRaw || null,
benefitUsageRule: benefitUsageRuleRaw,
avgPrice: avgPriceRaw,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
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';
if (coverUrl) {
@@ -379,24 +528,50 @@ export class StoreService {
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
const address = body.address !== undefined ? String(body.address).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 (phone !== undefined && !/^1\d{10}$/.test(phone)) {
throw new BadRequestException('联系电话须为11位手机号');
}
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 10~500 字');
if (introRaw && (introRaw.length < 2 || introRaw.length > 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';
await this.prisma.store.update({
const hasCoordsUpdate = latitude != null && longitude != null;
const updated = await this.prisma.store.update({
where: { id: storeId },
data: {
...(name !== undefined ? { name } : {}),
...(phone !== undefined ? { phone } : {}),
...(address !== undefined ? { address } : {}),
...(address !== undefined
? hasCoordsUpdate
? { address }
: { address, latitude: null, longitude: null }
: {}),
...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(benefitUsageRuleRaw !== undefined
? { benefitUsageRule: benefitUsageRuleRaw }
: {}),
...(resubmitAudit
? {
auditStatus: 'PENDING' as const,
@@ -408,6 +583,10 @@ export class StoreService {
},
});
if (address !== undefined && !hasCoordsUpdate) {
await this.ensureStoreCoordinates(updated);
}
if (resubmitAudit) {
await this.prisma.commonEvent.create({
data: {
@@ -549,6 +728,9 @@ export class StoreService {
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
if (binding.store.status === 'CLOSED') {
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
}
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
throw new BadRequestException(
binding.store.auditStatus === 'REJECTED'
@@ -1,5 +1,16 @@
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 {
@IsString()
@@ -11,6 +22,14 @@ export class PartnerProxyOrderPreviewDto {
@Min(1)
quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
receiverCity?: string;
@@ -20,7 +39,7 @@ export class PartnerProxyOrderPreviewDto {
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
export class PartnerProxyOrderSendCustomerSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@@ -33,28 +52,47 @@ export class PartnerProxyOrderCreateDto {
@IsString()
@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()
@IsString()
@MaxLength(32)
receiverName?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
province: string;
province?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
city: string;
city?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
district: string;
district?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(256)
addressDetail: string;
addressDetail?: string;
@IsString()
@IsNotEmpty()
@@ -9,7 +9,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
PartnerProxyOrderSendCustomerSmsDto,
} from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
@@ -204,8 +204,8 @@ export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
options(@CurrentUser() user: AuthUser) {
return this.tradeService.getPartnerProxyOrderOptions(user.actorId);
}
@Post('preview')
@@ -213,9 +213,20 @@ export class PartnerProxyOrderController {
return this.tradeService.previewPartnerProxyOrder(dto);
}
/** @deprecated 兼容:转发客户短信 */
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
sendSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
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()
@@ -96,7 +96,7 @@ export class TradeService {
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
deliveryType === 'ON_SITE_PICKUP'
? 1
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
@@ -1023,10 +1023,25 @@ export class TradeService {
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const [products, promoCodes, stores] = await Promise.all([
this.catalogService.listProducts(),
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({
products: products.map((p) => ({
@@ -1035,14 +1050,27 @@ export class TradeService {
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
})),
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: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
}) {
@@ -1053,14 +1081,23 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
const deliveryMode = body.deliveryMode ?? 'ADDRESS';
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
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(
deliveryType,
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
@@ -1083,9 +1120,9 @@ export class TradeService {
};
}
async sendPartnerProxyOrderSms(phone: string) {
async sendPartnerProxyCustomerSms(phone: string) {
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,
});
const masked =
@@ -1095,16 +1132,41 @@ export class TradeService {
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(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
storeId?: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
@@ -1112,16 +1174,43 @@ export class TradeService {
req: Request,
) {
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(
normalizedPhone,
body.smsCode.trim(),
body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
partnerPhone,
body.partnerSmsCode.trim(),
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({
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
});
@@ -1130,8 +1219,32 @@ export class TradeService {
where: { id: BigInt(body.productId) },
});
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;
if (body.promoCodeId?.trim()) {
@@ -1139,11 +1252,8 @@ export class TradeService {
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 now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
@@ -1178,9 +1288,9 @@ export class TradeService {
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
@@ -1190,7 +1300,10 @@ export class TradeService {
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
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 },
});
@@ -1211,7 +1324,7 @@ export class TradeService {
fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED',
operator: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
remark: `合伙人线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`,
}),
});
@@ -1237,6 +1350,7 @@ export class TradeService {
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
promoCodeId: promoCodeId?.toString() ?? null,
},
});
+3 -2
View File
@@ -97,7 +97,7 @@
| SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% |
| SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 |
| SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 |
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3) |
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3);合伙人侧:双短信确认、线下完成发权益、订单记代下单人;总部本期对齐后续 |
| SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 |
| SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 |
@@ -403,7 +403,7 @@
| OPT | 波次 | 说明 |
|-----|------|------|
| OPT-002 代下单 | W3 | W2 前运营人工代下单 |
| OPT-002 代下单 | W3 | 合伙人主账号:客户验码→选品/履约(配送须勾选自动收货或现场提货选门店)→合伙人确认码;线下已收款直接完成发权益;新用户 sourceType=PARTNER_PROXY;总部代下单后续对齐 |
| OPT-006 弱网 | W3 | W1~2 重试+人工补核销 |
| OPT-010 未出账提现 | W2 | 含 FIN-001~003 |
| 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 |
---

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