feat: audit diff, mock SMS 999888, env photos, proxy pay lock, mini-user UX
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,7 +40,7 @@ node scripts/smoke-prev1.mjs
|
|||||||
pnpm lint && pnpm test
|
pnpm lint && pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
|
||||||
|
|
||||||
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
||||||
|
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||||
import { saveAuth, request } from '../lib/api';
|
import { saveAuth, request } from '../lib/api';
|
||||||
|
|
||||||
type LoginResult = { accessToken: string; refreshToken: string };
|
type LoginResult = { accessToken: string; refreshToken: string };
|
||||||
@@ -117,14 +118,14 @@ export default function LoginPage() {
|
|||||||
form={smsForm}
|
form={smsForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={onSmsFinish}
|
onFinish={onSmsFinish}
|
||||||
initialValues={{ phone: '13600000001', code: '123456' }}
|
initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
|
||||||
>
|
>
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
<Input placeholder="13600000001" maxLength={11} />
|
<Input placeholder="13600000001" maxLength={11} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="123456"
|
placeholder={MOCK_SMS_FIXED_CODE}
|
||||||
addonAfter={
|
addonAfter={
|
||||||
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
||||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type ApiResult = Record<string, unknown>;
|
type ApiResult = Record<string, unknown>;
|
||||||
@@ -255,7 +256,7 @@ export default function RedeemDebugPage() {
|
|||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
|
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Mock 默认 123456" />
|
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -335,7 +336,7 @@ export default function RedeemDebugPage() {
|
|||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
|
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Mock 默认 123456" />
|
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -1,11 +1,68 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { StorePackageChangeRequestDto } from '@dukang/shared-types';
|
import type {
|
||||||
|
StorePackageAuditDetailDto,
|
||||||
|
StorePackageChangeRequestDto,
|
||||||
|
StorePackageItemDto,
|
||||||
|
StorePackageViewDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
|
const name = String(pkg.name ?? '').trim();
|
||||||
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||||
|
const rows: Array<{
|
||||||
|
key: string;
|
||||||
|
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||||
|
live?: StorePackageViewDto;
|
||||||
|
proposed?: StorePackageItemDto;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const l = liveMap.get(key);
|
||||||
|
const p = proposedMap.get(key);
|
||||||
|
if (l && !p) {
|
||||||
|
rows.push({ key, change: 'removed', live: l });
|
||||||
|
} else if (!l && p) {
|
||||||
|
rows.push({ key, change: 'added', proposed: p });
|
||||||
|
} else if (l && p) {
|
||||||
|
const changed =
|
||||||
|
l.price !== p.price ||
|
||||||
|
l.dishes !== p.dishes ||
|
||||||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
|
||||||
|
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_LABELS = {
|
||||||
|
added: { text: '新增', color: 'green' },
|
||||||
|
removed: { text: '删除', color: 'red' },
|
||||||
|
changed: { text: '变更', color: 'orange' },
|
||||||
|
unchanged: { text: '未变', color: 'default' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default function StorePackageAuditsPage() {
|
export default function StorePackageAuditsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
@@ -15,6 +72,9 @@ export default function StorePackageAuditsPage() {
|
|||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||||
|
|
||||||
async function reload(nextPage = page, nextStatus = status) {
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -41,6 +101,21 @@ export default function StorePackageAuditsPage() {
|
|||||||
void reload(1, status);
|
void reload(1, status);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetailOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
try {
|
||||||
|
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`);
|
||||||
|
setDetail(data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||||
|
setDetailOpen(false);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/store-package-audits/${id}/audit`, {
|
await request(`/admin/store-package-audits/${id}/audit`, {
|
||||||
@@ -50,12 +125,63 @@ export default function StorePackageAuditsPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
|
setDetailOpen(false);
|
||||||
void reload(page, status);
|
void reload(page, status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||||
|
|
||||||
|
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
||||||
|
{
|
||||||
|
title: '变更',
|
||||||
|
dataIndex: 'change',
|
||||||
|
width: 72,
|
||||||
|
render: (v: keyof typeof CHANGE_LABELS) => {
|
||||||
|
const meta = CHANGE_LABELS[v];
|
||||||
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前线上',
|
||||||
|
render: (_, row) =>
|
||||||
|
row.live ? (
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
|
||||||
|
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
|
||||||
|
{row.live.usableTime ? (
|
||||||
|
<div><Typography.Text type="secondary">可用:{row.live.usableTime}</Typography.Text></div>
|
||||||
|
) : null}
|
||||||
|
{row.live.otherNotes ? (
|
||||||
|
<div><Typography.Text type="secondary">备注:{row.live.otherNotes}</Typography.Text></div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '申请变更',
|
||||||
|
render: (_, row) =>
|
||||||
|
row.proposed ? (
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
|
||||||
|
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
|
||||||
|
{row.proposed.usableTime ? (
|
||||||
|
<div><Typography.Text type="secondary">可用:{row.proposed.usableTime}</Typography.Text></div>
|
||||||
|
) : null}
|
||||||
|
{row.proposed.otherNotes ? (
|
||||||
|
<div><Typography.Text type="secondary">备注:{row.proposed.otherNotes}</Typography.Text></div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
{
|
{
|
||||||
@@ -76,9 +202,13 @@ export default function StorePackageAuditsPage() {
|
|||||||
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_, row) =>
|
render: (_, row) => (
|
||||||
row.status === 'PENDING' ? (
|
|
||||||
<Space>
|
<Space>
|
||||||
|
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||||
|
查看变更
|
||||||
|
</Button>
|
||||||
|
{row.status === 'PENDING' ? (
|
||||||
|
<>
|
||||||
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
@@ -93,9 +223,11 @@ export default function StorePackageAuditsPage() {
|
|||||||
>
|
>
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</>
|
||||||
) : (
|
) : (
|
||||||
row.rejectReason || '—'
|
row.rejectReason || null
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -122,6 +254,57 @@ export default function StorePackageAuditsPage() {
|
|||||||
onChange: (p) => void reload(p, status),
|
onChange: (p) => void reload(p, status),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||||
|
width={720}
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail?.status === 'PENDING' ? (
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setActiveId(detail.id);
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{detail.rejectReason ? (
|
||||||
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="key"
|
||||||
|
columns={diffColumns}
|
||||||
|
dataSource={diffRows}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="驳回套餐变更"
|
title="驳回套餐变更"
|
||||||
open={rejectOpen}
|
open={rejectOpen}
|
||||||
|
|||||||
@@ -1404,14 +1404,20 @@ export default function StoresPage() {
|
|||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Typography.Text strong>环境照片</Typography.Text>
|
<Typography.Text strong>环境照片</Typography.Text>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||||
|
至少 3 张,可继续添加
|
||||||
|
</Typography.Paragraph>
|
||||||
<Form.List name="envPhotoUrls">
|
<Form.List name="envPhotoUrls">
|
||||||
{(fields) => (
|
{(fields, { add }) => (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||||
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add('')} block>
|
||||||
|
添加环境照片
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
|
|||||||
@@ -82,12 +82,18 @@ function timeToMinutes(hhmm: string): number {
|
|||||||
return h * 60 + m;
|
return h * 60 + m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||||
|
|
||||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||||
while (arr.length < minLen) arr.push('');
|
while (arr.length < minLen) arr.push('');
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addEnvPhotoSlot(urls: string[]): string[] {
|
||||||
|
return [...urls, ''];
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
||||||
const base = defaultStoreForm();
|
const base = defaultStoreForm();
|
||||||
if (!raw) return base;
|
if (!raw) return base;
|
||||||
@@ -104,7 +110,7 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
openTime2: String(raw.openTime2 ?? base.openTime2),
|
openTime2: String(raw.openTime2 ?? base.openTime2),
|
||||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||||
packages: Array.isArray(raw.packages)
|
packages: Array.isArray(raw.packages)
|
||||||
? raw.packages.map((p, i) => ({
|
? raw.packages.map((p, i) => ({
|
||||||
name: String((p as { name?: string }).name ?? ''),
|
name: String((p as { name?: string }).name ?? ''),
|
||||||
@@ -215,13 +221,14 @@ export function validateStoreStep2(
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||||
if (envCount < 3) return '请上传至少 3 张环境照片';
|
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
|
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
|
||||||
const envPhotoUrls = normalizeStringArray(urls, 3);
|
const envPhotoUrls = [...urls];
|
||||||
|
while (envPhotoUrls.length <= index) envPhotoUrls.push('');
|
||||||
envPhotoUrls[index] = url;
|
envPhotoUrls[index] = url;
|
||||||
return envPhotoUrls;
|
return envPhotoUrls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default function ProxyOrderDetailPage() {
|
|||||||
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
|
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
|
||||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
const pollRef = useRef<number | null>(null);
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
function stopPoll() {
|
function stopPoll() {
|
||||||
@@ -77,7 +78,12 @@ export default function ProxyOrderDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
|
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
|
||||||
.then(setOrder)
|
.then((data) => {
|
||||||
|
setOrder(data);
|
||||||
|
if (data.proxyPayMethod) {
|
||||||
|
setPayMethod(data.proxyPayMethod);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -129,6 +135,15 @@ export default function ProxyOrderDetailPage() {
|
|||||||
|
|
||||||
async function startPay(method: ProxyPayMethod) {
|
async function startPay(method: ProxyPayMethod) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
const locked = order?.proxyPayMethod;
|
||||||
|
if (locked && locked !== method) {
|
||||||
|
setPayMsg(
|
||||||
|
locked === 'JSAPI'
|
||||||
|
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||||
|
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
setPayMsg('');
|
setPayMsg('');
|
||||||
setPayMethod(method);
|
setPayMethod(method);
|
||||||
@@ -166,12 +181,14 @@ export default function ProxyOrderDetailPage() {
|
|||||||
|
|
||||||
if (pay.mode === 'native' && pay.codeUrl) {
|
if (pay.mode === 'native' && pay.codeUrl) {
|
||||||
setCodeUrl(pay.codeUrl);
|
setCodeUrl(pay.codeUrl);
|
||||||
|
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
|
||||||
startPoll(id);
|
startPoll(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pay.mode === 'jsapi' && pay.prepay) {
|
if (pay.mode === 'jsapi' && pay.prepay) {
|
||||||
setCodeUrl(null);
|
setCodeUrl(null);
|
||||||
|
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
|
||||||
await invokeWechatPay(pay.prepay, {
|
await invokeWechatPay(pay.prepay, {
|
||||||
apiBase: '/api/v1',
|
apiBase: '/api/v1',
|
||||||
clientApp: 'PARTNER_H5',
|
clientApp: 'PARTNER_H5',
|
||||||
@@ -191,14 +208,27 @@ export default function ProxyOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoPayStartedRef = useRef(false);
|
async function cancelPay() {
|
||||||
useEffect(() => {
|
if (!id) return;
|
||||||
if (!id || !unpaid || autoPayStartedRef.current) return;
|
setCancelling(true);
|
||||||
autoPayStartedRef.current = true;
|
setPayMsg('');
|
||||||
void startPay('NATIVE');
|
stopPoll();
|
||||||
// 仅首次进入未支付详情时自动拉收款码
|
try {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
await request('PARTNER_H5', `/partner/proxy-orders/${id}/cancel-pay`, {
|
||||||
}, [id, unpaid]);
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
|
||||||
|
navigate('/center/proxy-orders');
|
||||||
|
} catch (e) {
|
||||||
|
setPayMsg(e instanceof Error ? e.message : '取消失败');
|
||||||
|
toastError(e instanceof Error ? e.message : '取消失败');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedPayMethod = order?.proxyPayMethod ?? null;
|
||||||
|
|
||||||
const img = order?.imageResource?.url || '';
|
const img = order?.imageResource?.url || '';
|
||||||
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
|
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
|
||||||
@@ -285,6 +315,7 @@ export default function ProxyOrderDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
||||||
|
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
|
||||||
onClick={() => void startPay('NATIVE')}
|
onClick={() => void startPay('NATIVE')}
|
||||||
>
|
>
|
||||||
收款码
|
收款码
|
||||||
@@ -292,11 +323,21 @@ export default function ProxyOrderDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
||||||
|
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
|
||||||
onClick={() => void startPay('JSAPI')}
|
onClick={() => void startPay('JSAPI')}
|
||||||
>
|
>
|
||||||
微信代付
|
微信代付
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{lockedPayMethod ? (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
请选择支付方式并点击下方按钮发起支付
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{payMethod === 'NATIVE' ? (
|
{payMethod === 'NATIVE' ? (
|
||||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
@@ -338,6 +379,16 @@ export default function ProxyOrderDetailPage() {
|
|||||||
{payMsg}
|
{payMsg}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-block"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
disabled={cancelling || paying}
|
||||||
|
onClick={() => void cancelPay()}
|
||||||
|
>
|
||||||
|
{cancelling ? '取消中…' : '取消支付'}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export default function ProxyOrderPage() {
|
|||||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [lockedPayMethod, setLockedPayMethod] = useState<ProxyPayMethod | null>(null);
|
||||||
const pollRef = useRef<number | null>(null);
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||||
@@ -234,8 +236,17 @@ export default function ProxyOrderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startPay(orderId: string, method: ProxyPayMethod) {
|
async function startPay(orderId: string, method: ProxyPayMethod) {
|
||||||
|
if (lockedPayMethod && lockedPayMethod !== method) {
|
||||||
|
setMsg(
|
||||||
|
lockedPayMethod === 'JSAPI'
|
||||||
|
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||||
|
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
setPayMethod(method);
|
||||||
try {
|
try {
|
||||||
if (method === 'JSAPI') {
|
if (method === 'JSAPI') {
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
@@ -266,11 +277,14 @@ export default function ProxyOrderPage() {
|
|||||||
|
|
||||||
if (pay.mode === 'native' && pay.codeUrl) {
|
if (pay.mode === 'native' && pay.codeUrl) {
|
||||||
setCodeUrl(pay.codeUrl);
|
setCodeUrl(pay.codeUrl);
|
||||||
|
setLockedPayMethod(method);
|
||||||
startPoll(orderId);
|
startPoll(orderId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pay.mode === 'jsapi' && pay.prepay) {
|
if (pay.mode === 'jsapi' && pay.prepay) {
|
||||||
|
setCodeUrl(null);
|
||||||
|
setLockedPayMethod(method);
|
||||||
await invokeWechatPay(pay.prepay, {
|
await invokeWechatPay(pay.prepay, {
|
||||||
apiBase: '/api/v1',
|
apiBase: '/api/v1',
|
||||||
clientApp: 'PARTNER_H5',
|
clientApp: 'PARTNER_H5',
|
||||||
@@ -289,6 +303,40 @@ export default function ProxyOrderPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelPay(orderId: string) {
|
||||||
|
setCancelling(true);
|
||||||
|
setMsg('');
|
||||||
|
stopPoll();
|
||||||
|
try {
|
||||||
|
await request('PARTNER_H5', `/partner/proxy-orders/${orderId}/cancel-pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
|
||||||
|
setStep('form');
|
||||||
|
setCreated(null);
|
||||||
|
setCodeUrl(null);
|
||||||
|
setLockedPayMethod(null);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '取消失败');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPayMethod(method: ProxyPayMethod) {
|
||||||
|
if (lockedPayMethod && lockedPayMethod !== method) {
|
||||||
|
setMsg(
|
||||||
|
lockedPayMethod === 'JSAPI'
|
||||||
|
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||||
|
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPayMethod(method);
|
||||||
|
setMsg('');
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setMsg('');
|
setMsg('');
|
||||||
const err = validateForm();
|
const err = validateForm();
|
||||||
@@ -322,6 +370,7 @@ export default function ProxyOrderPage() {
|
|||||||
setCreated(order);
|
setCreated(order);
|
||||||
setStep('pay');
|
setStep('pay');
|
||||||
setCodeUrl(null);
|
setCodeUrl(null);
|
||||||
|
setLockedPayMethod(null);
|
||||||
await startPay(order.id, payMethod);
|
await startPay(order.id, payMethod);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||||
@@ -359,24 +408,25 @@ export default function ProxyOrderPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
||||||
onClick={() => {
|
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
|
||||||
setPayMethod('NATIVE');
|
onClick={() => switchPayMethod('NATIVE')}
|
||||||
void startPay(created.id, 'NATIVE');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
收款码
|
收款码
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
||||||
onClick={() => {
|
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
|
||||||
setPayMethod('JSAPI');
|
onClick={() => switchPayMethod('JSAPI')}
|
||||||
void startPay(created.id, 'JSAPI');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
微信代付
|
微信代付
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{lockedPayMethod ? (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{payMethod === 'NATIVE' ? (
|
{payMethod === 'NATIVE' ? (
|
||||||
@@ -419,6 +469,16 @@ export default function ProxyOrderPage() {
|
|||||||
{msg}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-block"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
disabled={cancelling || paying}
|
||||||
|
onClick={() => void cancelPay(created.id)}
|
||||||
|
>
|
||||||
|
{cancelling ? '取消中…' : '取消支付'}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
) : loadingOptions ? (
|
) : loadingOptions ? (
|
||||||
<p className="label-md text-muted">加载商品…</p>
|
<p className="label-md text-muted">加载商品…</p>
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ import {
|
|||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
patchEnvPhotoAt,
|
patchEnvPhotoAt,
|
||||||
|
addEnvPhotoSlot,
|
||||||
|
MIN_ENV_PHOTO_COUNT,
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||||
@@ -956,7 +957,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 3 张,展示店内整洁环境</p>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境</p>
|
||||||
|
|
||||||
<div className="partner-upload-grid">
|
<div className="partner-upload-grid">
|
||||||
|
|
||||||
@@ -982,6 +983,17 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 12 }}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
添加环境照片
|
||||||
|
</button>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { request } from '../lib/api';
|
|||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||||
const ENV_SLOT_COUNT = 3;
|
|
||||||
|
|
||||||
function uniqueEnvUrls(urls: string[]): string[] {
|
function uniqueEnvUrls(urls: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -81,7 +80,7 @@ export default function StoreDetailPage() {
|
|||||||
.map((m) => String(m.url || '')),
|
.map((m) => String(m.url || '')),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
|
setEnvPhotoUrls(normalizeStringArray(envFromMedia, MIN_ENV_PHOTO_COUNT));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,8 +195,8 @@ export default function StoreDetailPage() {
|
|||||||
setActionError('请上传门头照');
|
setActionError('请上传门头照');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextEnv.length < ENV_SLOT_COUNT) {
|
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
||||||
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
|
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMediaSaving(true);
|
setMediaSaving(true);
|
||||||
@@ -400,7 +399,7 @@ export default function StoreDetailPage() {
|
|||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||||
<span className="label-md text-muted">
|
<span className="label-md text-muted">
|
||||||
{canMutate && !readOnly
|
{canMutate && !readOnly
|
||||||
? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||||||
: envPhotos.length
|
: envPhotos.length
|
||||||
? `已上传 ${envPhotos.length} 张`
|
? `已上传 ${envPhotos.length} 张`
|
||||||
: '暂无照片'}
|
: '暂无照片'}
|
||||||
@@ -420,6 +419,14 @@ export default function StoreDetailPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 12 }}
|
||||||
|
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
|
||||||
|
>
|
||||||
|
添加环境照片
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-outline"
|
className="partner-btn-outline"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
loginHqWithWechat,
|
loginHqWithWechat,
|
||||||
} from '../../lib/wechat';
|
} from '../../lib/wechat';
|
||||||
import { isWechatEnv } from '../../lib/weixin';
|
import { isWechatEnv } from '../../lib/weixin';
|
||||||
|
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||||
import { clearHqAccountCache } from '../../lib/session';
|
import { clearHqAccountCache } from '../../lib/session';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
@@ -41,7 +42,7 @@ function WechatIcon() {
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const remembered = loadRememberedPhone();
|
const remembered = loadRememberedPhone();
|
||||||
const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE);
|
const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE);
|
||||||
const [code, setCode] = useState('123456');
|
const [code, setCode] = useState(MOCK_SMS_FIXED_CODE);
|
||||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
@@ -103,7 +104,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { phone, scene: 'HQ_LOGIN' },
|
data: { phone, scene: 'HQ_LOGIN' },
|
||||||
});
|
});
|
||||||
toast('验证码已发送(Mock:123456)', 'success');
|
toast(`验证码已发送(Mock:${MOCK_SMS_FIXED_CODE})`, 'success');
|
||||||
setCooldown(60);
|
setCooldown(60);
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
setCooldown((s) => {
|
setCooldown((s) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
@@ -200,6 +200,18 @@ export default function HomePage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
|
const visibleAromaTabs = useMemo(
|
||||||
|
() => AROMA_TABS.filter((t) => productsByAroma[t.key].length > 0),
|
||||||
|
[productsByAroma],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || visibleAromaTabs.length === 0) return;
|
||||||
|
if (!visibleAromaTabs.some((t) => t.key === activeAroma)) {
|
||||||
|
setActiveAroma(visibleAromaTabs[0].key);
|
||||||
|
}
|
||||||
|
}, [loading, visibleAromaTabs, activeAroma]);
|
||||||
|
|
||||||
const banners = miniHome.banners;
|
const banners = miniHome.banners;
|
||||||
const footerUrl = miniHome.footerUrl;
|
const footerUrl = miniHome.footerUrl;
|
||||||
|
|
||||||
@@ -246,18 +258,18 @@ export default function HomePage() {
|
|||||||
if (now - lastScrollSyncAtRef.current < 80) return;
|
if (now - lastScrollSyncAtRef.current < 80) return;
|
||||||
lastScrollSyncAtRef.current = now;
|
lastScrollSyncAtRef.current = now;
|
||||||
const query = Taro.createSelectorQuery();
|
const query = Taro.createSelectorQuery();
|
||||||
AROMA_TABS.forEach((t) => {
|
visibleAromaTabs.forEach((t) => {
|
||||||
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
|
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
|
||||||
});
|
});
|
||||||
query.exec((rects) => {
|
query.exec((rects) => {
|
||||||
if (!Array.isArray(rects) || rects.length === 0) return;
|
if (!Array.isArray(rects) || rects.length === 0) return;
|
||||||
let next: AromaKey = AROMA_TABS[0].key;
|
let next: AromaKey = visibleAromaTabs[0]?.key ?? AROMA_TABS[0].key;
|
||||||
for (let i = 0; i < AROMA_TABS.length; i++) {
|
for (let i = 0; i < visibleAromaTabs.length; i++) {
|
||||||
const rect = rects[i] as { top?: number } | null;
|
const rect = rects[i] as { top?: number } | null;
|
||||||
if (!rect || rect.top == null) continue;
|
if (!rect || rect.top == null) continue;
|
||||||
// 区块顶进入导航下方一带时视为当前香型
|
// 区块顶进入导航下方一带时视为当前香型
|
||||||
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
|
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
|
||||||
next = AROMA_TABS[i].key;
|
next = visibleAromaTabs[i].key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveAroma((prev) => (prev === next ? prev : next));
|
setActiveAroma((prev) => (prev === next ? prev : next));
|
||||||
@@ -341,7 +353,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<View className="home-aroma-nav">
|
<View className="home-aroma-nav">
|
||||||
<View className="home-aroma-tabs">
|
<View className="home-aroma-tabs">
|
||||||
{AROMA_TABS.map((t) => (
|
{visibleAromaTabs.map((t) => (
|
||||||
<Text
|
<Text
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
|
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||||
@@ -361,16 +373,12 @@ export default function HomePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
products.length > 0 &&
|
products.length > 0 &&
|
||||||
AROMA_TABS.map((t) => {
|
visibleAromaTabs.map((t) => {
|
||||||
const list = productsByAroma[t.key];
|
const list = productsByAroma[t.key];
|
||||||
return (
|
return (
|
||||||
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
|
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
|
||||||
<Text className="home-aroma-section-title">{t.label}</Text>
|
<Text className="home-aroma-section-title">{t.label}</Text>
|
||||||
{list.length === 0 ? (
|
{list.map((p) => renderProductCard(p))}
|
||||||
<View className="home-empty home-empty--section">该香型暂未上线</View>
|
|
||||||
) : (
|
|
||||||
list.map((p) => renderProductCard(p))
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image, Textarea } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
useLoad,
|
useLoad,
|
||||||
@@ -152,9 +152,6 @@ export default function StoreDetailPage() {
|
|||||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [disputeOpen, setDisputeOpen] = useState(false);
|
|
||||||
const [disputeRemark, setDisputeRemark] = useState('');
|
|
||||||
const [disputeSubmitting, setDisputeSubmitting] = useState(false);
|
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
@@ -276,27 +273,6 @@ export default function StoreDetailPage() {
|
|||||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitPackageDispute() {
|
|
||||||
if (!storeId) return;
|
|
||||||
setDisputeSubmitting(true);
|
|
||||||
try {
|
|
||||||
await request('/trade/package-disputes', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
storeId,
|
|
||||||
remark: disputeRemark.trim() || '用户对门店套餐有异议',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
toast('已提交套餐异议');
|
|
||||||
setDisputeOpen(false);
|
|
||||||
setDisputeRemark('');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : '提交失败');
|
|
||||||
} finally {
|
|
||||||
setDisputeSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMap() {
|
function openMap() {
|
||||||
if (!store) return;
|
if (!store) return;
|
||||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||||
@@ -440,9 +416,6 @@ export default function StoreDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
<Text className="store-detail-package-dispute" onClick={() => setDisputeOpen(true)}>
|
|
||||||
对套餐有异议?提交反馈
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -477,33 +450,6 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{disputeOpen ? (
|
|
||||||
<View className="store-detail-dispute-mask" onClick={() => !disputeSubmitting && setDisputeOpen(false)}>
|
|
||||||
<View className="store-detail-dispute-panel" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Text className="store-detail-section-title">套餐异议</Text>
|
|
||||||
<Text className="store-detail-package-meta">请描述您对门店套餐内容的异议,客服将跟进处理。</Text>
|
|
||||||
<Textarea
|
|
||||||
className="store-detail-dispute-input"
|
|
||||||
value={disputeRemark}
|
|
||||||
maxlength={500}
|
|
||||||
placeholder="请填写异议说明(选填)"
|
|
||||||
onInput={(e) => setDisputeRemark(e.detail.value)}
|
|
||||||
/>
|
|
||||||
<View className="store-detail-dispute-actions">
|
|
||||||
<View className="u-btn u-btn--ghost" onClick={() => setDisputeOpen(false)}>
|
|
||||||
<Text>取消</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className="u-btn"
|
|
||||||
onClick={() => !disputeSubmitting && void submitPackageDispute()}
|
|
||||||
>
|
|
||||||
<Text>{disputeSubmitting ? '提交中…' : '提交'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View className="store-detail-bar">
|
<View className="store-detail-bar">
|
||||||
<View
|
<View
|
||||||
className="u-btn u-btn--block"
|
className="u-btn u-btn--block"
|
||||||
|
|||||||
@@ -141,3 +141,6 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
wechatPayEnabled: resolveWechatPayEnabled(base, e),
|
wechatPayEnabled: resolveWechatPayEnabled(base, e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mock 短信环境固定验证码 */
|
||||||
|
export const MOCK_SMS_FIXED_CODE = '999888';
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export interface StorePackageChangeRequestDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 套餐变更审核详情(含线上现行套餐,供对比) */
|
||||||
|
export interface StorePackageAuditDetailDto extends StorePackageChangeRequestDto {
|
||||||
|
livePackages: StorePackageViewDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorePackageAuditAction {
|
export interface StorePackageAuditAction {
|
||||||
action: 'APPROVE' | 'REJECT';
|
action: 'APPROVE' | 'REJECT';
|
||||||
rejectReason?: string;
|
rejectReason?: string;
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export type PartnerProxyOrderListItem = OrderDto & {
|
|||||||
deliveryType?: string | null;
|
deliveryType?: string | null;
|
||||||
productSpec?: string | null;
|
productSpec?: string | null;
|
||||||
imageResource?: { url?: string } | null;
|
imageResource?: { url?: string } | null;
|
||||||
|
/** 已锁定的支付方式(首次拉起支付后不可切换,须取消后重新下单) */
|
||||||
|
proxyPayMethod?: ProxyPayMethod | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderListResponse = {
|
export type PartnerProxyOrderListResponse = {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
|
import { MOCK_SMS_FIXED_CODE, SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
|
|
||||||
|
export { MOCK_SMS_FIXED_CODE };
|
||||||
|
|
||||||
const RATE_TTL_SECONDS = 60;
|
const RATE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
function codeKey(phone: string, scene: string) {
|
function codeKey(phone: string, scene: string) {
|
||||||
@@ -31,12 +33,15 @@ export class SmsCodeStore {
|
|||||||
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
|
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateAndStore(phone: string, scene: string): Promise<string> {
|
async storeCode(phone: string, scene: string, code: string): Promise<string> {
|
||||||
const code = randomSixDigitCode();
|
|
||||||
await this.redis.client.set(codeKey(phone, scene), code, 'EX', SMS_CODE_TTL_SECONDS);
|
await this.redis.client.set(codeKey(phone, scene), code, 'EX', SMS_CODE_TTL_SECONDS);
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateAndStore(phone: string, scene: string): Promise<string> {
|
||||||
|
return this.storeCode(phone, scene, randomSixDigitCode());
|
||||||
|
}
|
||||||
|
|
||||||
async verifyAndConsume(phone: string, scene: string, code: string) {
|
async verifyAndConsume(phone: string, scene: string, code: string) {
|
||||||
const key = codeKey(phone, scene);
|
const key = codeKey(phone, scene);
|
||||||
const stored = await this.redis.client.get(key);
|
const stored = await this.redis.client.get(key);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
|
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
|
||||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||||
import { SmsCodeStore } from './sms-code.store';
|
import { MOCK_SMS_FIXED_CODE, SmsCodeStore } from './sms-code.store';
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function maskPhone(phone: string) {
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
@@ -19,7 +19,7 @@ export class SmsMockProvider implements ISmsProvider {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
const code = await this.smsCodeStore.storeCode(phone, scene, MOCK_SMS_FIXED_CODE);
|
||||||
await this.mockSmsCodeService.record(phone, scene, code);
|
await this.mockSmsCodeService.record(phone, scene, code);
|
||||||
const masked = maskPhone(phone);
|
const masked = maskPhone(phone);
|
||||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export class AdminStorePackageAuditController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':requestId')
|
||||||
|
detail(@Param('requestId') requestId: string) {
|
||||||
|
return this.packages.adminGetAuditDetail(BigInt(requestId));
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':requestId/audit')
|
@Put(':requestId/audit')
|
||||||
audit(
|
audit(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
|
|||||||
@@ -221,6 +221,28 @@ export class StorePackageService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminGetAuditDetail(requestId: bigint) {
|
||||||
|
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
include: { store: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!req) throw new NotFoundException('审核记录不存在');
|
||||||
|
const livePackages = await this.listLivePackages(req.storeId);
|
||||||
|
return serializeBigInt({
|
||||||
|
id: req.id.toString(),
|
||||||
|
storeId: req.storeId.toString(),
|
||||||
|
storeName: req.store.name,
|
||||||
|
status: req.status,
|
||||||
|
packages: req.packagesJson as unknown as StorePackageItemDto[],
|
||||||
|
livePackages,
|
||||||
|
submitterType: req.submitterType,
|
||||||
|
submitterId: req.submitterId.toString(),
|
||||||
|
rejectReason: req.rejectReason,
|
||||||
|
reviewedAt: req.reviewedAt?.toISOString() ?? null,
|
||||||
|
createdAt: req.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
|
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ export class StoreService {
|
|||||||
return this.partnerGetStore(partnerAccountId, storeId);
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
|
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(至少 3 张,不设上限) */
|
||||||
async partnerUpdateStoreMedia(
|
async partnerUpdateStoreMedia(
|
||||||
partnerAccountId: bigint,
|
partnerAccountId: bigint,
|
||||||
storeId: bigint,
|
storeId: bigint,
|
||||||
@@ -1271,7 +1271,7 @@ export class StoreService {
|
|||||||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
|
private normalizeEnvPhotoUrls(raw: unknown): string[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
@@ -1280,7 +1280,6 @@ export class StoreService {
|
|||||||
if (!url || seen.has(url)) continue;
|
if (!url || seen.has(url)) continue;
|
||||||
seen.add(url);
|
seen.add(url);
|
||||||
urls.push(url);
|
urls.push(url);
|
||||||
if (urls.length >= max) break;
|
|
||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,11 @@ export class PartnerProxyOrderController {
|
|||||||
return this.tradeService.payPartnerProxyOrder(user.actorId, BigInt(id), dto.payMethod);
|
return this.tradeService.payPartnerProxyOrder(user.actorId, BigInt(id), dto.payMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel-pay')
|
||||||
|
cancelPay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.tradeService.cancelPartnerProxyOrder(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/pay/mock-confirm')
|
@Post(':id/pay/mock-confirm')
|
||||||
mockConfirmPay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
mockConfirmPay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
return this.tradeService.mockConfirmProxyPay(BigInt(id), {
|
return this.tradeService.mockConfirmProxyPay(BigInt(id), {
|
||||||
|
|||||||
@@ -1545,7 +1545,52 @@ export class TradeService {
|
|||||||
where: orderStatusLogWhere(orderId),
|
where: orderStatusLogWhere(orderId),
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
return serializeBigInt({
|
||||||
|
...mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }),
|
||||||
|
proxyPayMethod: this.parseProxyPayMethod(order.channelSource),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseProxyPayMethod(channelSource: string | null | undefined): 'NATIVE' | 'JSAPI' | null {
|
||||||
|
if (!channelSource) return null;
|
||||||
|
const m = channelSource.match(/^PROXY_ONLINE:(NATIVE|JSAPI)$/);
|
||||||
|
return m ? (m[1] as 'NATIVE' | 'JSAPI') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private proxyChannelWithMethod(method: 'NATIVE' | 'JSAPI'): string {
|
||||||
|
return `PROXY_ONLINE:${method}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合伙人取消代下单支付(关闭待支付订单,可重新下单并选择其他支付方式) */
|
||||||
|
async cancelPartnerProxyOrder(partnerAccountId: bigint, orderId: bigint) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const order = await this.prisma.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
orderType: 'PROXY',
|
||||||
|
proxyPartnerAccountId: primary.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('代下单不存在');
|
||||||
|
if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') {
|
||||||
|
throw new BadRequestException('仅待支付订单可取消');
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: { status: 'CANCELLED', cancelledAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.commonEvent.create({
|
||||||
|
data: buildOrderStatusEvent({
|
||||||
|
orderId,
|
||||||
|
fromStatus: 'PENDING_PAY',
|
||||||
|
toStatus: 'CANCELLED',
|
||||||
|
operator: 'PARTNER_PROXY',
|
||||||
|
remark: '合伙人取消代下单支付',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||||
@@ -1775,6 +1820,21 @@ export class TradeService {
|
|||||||
throw new BadRequestException('订单已超时未支付');
|
throw new BadRequestException('订单已超时未支付');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockedMethod = this.parseProxyPayMethod(order.channelSource);
|
||||||
|
if (lockedMethod && lockedMethod !== payMethod) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
lockedMethod === 'JSAPI'
|
||||||
|
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||||
|
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!lockedMethod) {
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: { channelSource: this.proxyChannelWithMethod(payMethod) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
|
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
userId: order.userId,
|
userId: order.userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user