Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cae426fbc2 | |||
| 8f25f823d5 | |||
| 253430b291 | |||
| 23ba639e9b | |||
| 4bdb09068c | |||
| a9c466930d | |||
| 192a401227 | |||
| 0e711be6c6 | |||
| 8a196c377e | |||
| 418e13a5e3 | |||
| 7fa69c2521 | |||
| b0874f07f6 | |||
| c26ebdde1b | |||
| b60b16a84f | |||
| b74dfd391a | |||
| ec9b7efcd6 | |||
| d04ca615ff | |||
| ebb34ad112 | |||
| d5994af431 | |||
| c29eb2c383 | |||
| 29e95bd5a3 | |||
| e5d7e468cc | |||
| 62d75a6869 | |||
| 8d70f0da72 | |||
| 92dfbf5722 | |||
| 42c1b65632 | |||
| de7bd34aa8 | |||
| 68a4b7f984 | |||
| e5fe8b903a | |||
| c6b01def4c | |||
| 63fcf33416 | |||
| 101b27c92c | |||
| 5ed9f79a5e | |||
| 6880b5daf4 |
@@ -102,7 +102,7 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
|||||||
| `/admin/users` | C 端用户 |
|
| `/admin/users` | C 端用户 |
|
||||||
| `/admin/orders` | 订单 |
|
| `/admin/orders` | 订单 |
|
||||||
| `/admin/stores` | 门店 |
|
| `/admin/stores` | 门店 |
|
||||||
| `/admin/store-accounts` | 门店账号 |
|
| `/admin/store-accounts` | 门店账号(含子账号 CRUD:`/:id/staff`) |
|
||||||
| `/admin/store-media` | 门店媒体 |
|
| `/admin/store-media` | 门店媒体 |
|
||||||
| `/admin/partners` | 合伙人 |
|
| `/admin/partners` | 合伙人 |
|
||||||
| `/admin/partner-accounts` | 合伙人账号 |
|
| `/admin/partner-accounts` | 合伙人账号 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/admin-web",
|
"name": "@dukang/admin-web",
|
||||||
"version": "0.1.0",
|
"version": "4.0.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
|||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||||
|
import BankAccountsPage from './pages/BankAccountsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||||
import InvoicesPage from './pages/InvoicesPage';
|
import InvoicesPage from './pages/InvoicesPage';
|
||||||
@@ -50,7 +51,9 @@ import HqPermissionsPage from './pages/HqPermissionsPage';
|
|||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
|
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
|
||||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
|
import WecomReportsPage from './pages/WecomReportsPage';
|
||||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||||
@@ -94,7 +97,9 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
|
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
|
||||||
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
||||||
|
<Route path="/wecom/plugins" element={<WecomApiPluginsPage />} />
|
||||||
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
||||||
|
<Route path="/wecom/reports" element={<WecomReportsPage />} />
|
||||||
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
||||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||||
@@ -129,6 +134,7 @@ export default function App() {
|
|||||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||||
|
<Route path="/finance/bank-accounts" element={<BankAccountsPage />} />
|
||||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Form, Input, Modal, message } from 'antd';
|
||||||
|
import type { UpdateMyHqCredentialsRequest } from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile } from '../lib/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
profile: HqProfile | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: (profile: HqProfile) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HqAccountSettingsModal({ open, profile, onClose, onUpdated }: Props) {
|
||||||
|
const [form] = Form.useForm<UpdateMyHqCredentialsRequest & { confirmPassword?: string }>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.setFieldsValue({
|
||||||
|
loginName: profile?.loginName ?? '',
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
}, [open, profile, form]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
if (values.newPassword && values.newPassword !== values.confirmPassword) {
|
||||||
|
message.error('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: UpdateMyHqCredentialsRequest = {};
|
||||||
|
const nextLogin = values.loginName?.trim();
|
||||||
|
if (nextLogin && nextLogin !== (profile?.loginName ?? '')) {
|
||||||
|
body.loginName = nextLogin;
|
||||||
|
}
|
||||||
|
if (values.newPassword?.trim()) {
|
||||||
|
body.newPassword = values.newPassword.trim();
|
||||||
|
if (values.oldPassword?.trim()) body.oldPassword = values.oldPassword.trim();
|
||||||
|
}
|
||||||
|
if (!body.loginName && !body.newPassword) {
|
||||||
|
message.warning('请填写要修改的内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/me/credentials', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const updated = await request<HqProfile>('/admin/auth/me');
|
||||||
|
message.success('账号信息已更新');
|
||||||
|
onUpdated(updated);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="账号设置"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="loginName" label="登录用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input autoComplete="username" placeholder="用于密码登录" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="oldPassword" label="当前密码" extra="已设置过密码时,修改密码必填">
|
||||||
|
<Input.Password autoComplete="current-password" placeholder="不修改密码请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="newPassword" label="新密码" rules={[{ min: 6, message: '至少 6 位' }]}>
|
||||||
|
<Input.Password autoComplete="new-password" placeholder="不修改请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="confirmPassword" label="确认新密码">
|
||||||
|
<Input.Password autoComplete="new-password" placeholder="不修改请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -140,6 +141,14 @@ export default function OrderTrackDrawer({
|
|||||||
</Space>
|
</Space>
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</div>
|
</div>
|
||||||
|
) : nodes.some((n) => n.statusName?.includes('签收') || n.trackInfo?.includes('签收')) ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="暂无签收照片"
|
||||||
|
description="小飞侠 100108 未返回图片(可能是本人签收未拍照,或照片已过期)。可在「小飞侠联调」用运单号复测。"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Typography, Upload, message } from 'antd';
|
||||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
@@ -13,6 +13,7 @@ type OssUploadProps = {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
uploadHint?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isImageUrl(url: string) {
|
function isImageUrl(url: string) {
|
||||||
@@ -23,6 +24,32 @@ function isPdfUrl(url: string) {
|
|||||||
return /\.pdf(\?|#|$)/i.test(url);
|
return /\.pdf(\?|#|$)/i.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUploadImageMessage(result: UploadFileResult): string {
|
||||||
|
const img = result.image;
|
||||||
|
if (!img) return '上传成功';
|
||||||
|
const sizeLabel = `${img.width}×${img.height}`;
|
||||||
|
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||||
|
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||||
|
if (original !== sizeLabel) {
|
||||||
|
return `上传成功(${sizeLabel},已由 ${original} 自动压缩)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `上传成功(${sizeLabel})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUploadImageHint(result: UploadFileResult): string | null {
|
||||||
|
const img = result.image;
|
||||||
|
if (!img) return null;
|
||||||
|
const sizeLabel = `${img.width}×${img.height}`;
|
||||||
|
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||||
|
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||||
|
if (original !== sizeLabel) {
|
||||||
|
return `${sizeLabel}(已由 ${original} 自动压缩)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sizeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -31,9 +58,11 @@ export default function OssUpload({
|
|||||||
mediaType = 'IMAGE',
|
mediaType = 'IMAGE',
|
||||||
accept,
|
accept,
|
||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
|
uploadHint,
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
const [imageMetaHint, setImageMetaHint] = useState<string | null>(null);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
@@ -45,7 +74,9 @@ export default function OssUpload({
|
|||||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||||
onChange?.(result.url);
|
onChange?.(result.url);
|
||||||
onUploaded?.(result);
|
onUploaded?.(result);
|
||||||
message.success('上传成功');
|
const successMessage = formatUploadImageMessage(result);
|
||||||
|
setImageMetaHint(formatUploadImageHint(result));
|
||||||
|
message.success(successMessage);
|
||||||
onSuccess?.(result);
|
onSuccess?.(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error('上传失败');
|
const err = e instanceof Error ? e : new Error('上传失败');
|
||||||
@@ -116,6 +147,16 @@ export default function OssUpload({
|
|||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
{filePreview}
|
{filePreview}
|
||||||
|
{uploadHint ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{uploadHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{imageMetaHint ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
当前底图:{imageMetaHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
import { request, type HqProfile, type Paginated } from '../lib/api';
|
import { request, requestDownload, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
||||||
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
||||||
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
||||||
|
const [downloadingQr, setDownloadingQr] = useState(false);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
||||||
@@ -78,6 +79,26 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadBareQrcode() {
|
||||||
|
if (!summary?.qrcodeUrl) {
|
||||||
|
message.warning('尚未生成关联码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDownloadingQr(true);
|
||||||
|
try {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/partners/${partnerId}/assoc/qrcode`,
|
||||||
|
{},
|
||||||
|
`partner-assoc-${partnerId}.png`,
|
||||||
|
);
|
||||||
|
message.success('已开始下载二维码');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载二维码失败');
|
||||||
|
} finally {
|
||||||
|
setDownloadingQr(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function unbind(userId: string) {
|
async function unbind(userId: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
||||||
@@ -137,12 +158,19 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
关联用户 {summary?.userCount ?? 0} 人
|
关联用户 {summary?.userCount ?? 0} 人
|
||||||
</Link>
|
</Link>
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Button loading={issuing} onClick={() => void reissue()}>
|
<Space wrap>
|
||||||
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
<Button loading={issuing} onClick={() => void reissue()}>
|
||||||
</Button>
|
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
||||||
{canDownloadPosters ? (
|
</Button>
|
||||||
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
{summary?.qrcodeUrl ? (
|
||||||
) : null}
|
<Button loading={downloadingQr} onClick={() => void downloadBareQrcode()}>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { clearAuth, request, type HqProfile } from '../lib/api';
|
|||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
||||||
|
import { HqAccountSettingsModal } from '../components/HqAccountSettingsModal';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||||
|
{ key: '/finance/bank-accounts', label: '全部银行账户' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,7 +129,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '企微机器人',
|
label: '企微机器人',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/wecom/bots', label: '智能机器人' },
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
|
{ key: '/wecom/plugins', label: 'API 插件' },
|
||||||
{ key: '/wecom/pushes', label: '消息推送' },
|
{ key: '/wecom/pushes', label: '消息推送' },
|
||||||
|
{ key: '/wecom/reports', label: '报告' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||||
@@ -184,7 +188,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/promo-codes': 'promo_codes',
|
'/promo-codes': 'promo_codes',
|
||||||
'/activity-posters': 'activity_posters',
|
'/activity-posters': 'activity_posters',
|
||||||
'/wecom/bots': 'wecom_bots',
|
'/wecom/bots': 'wecom_bots',
|
||||||
|
'/wecom/plugins': 'wecom_bots',
|
||||||
'/wecom/pushes': 'wecom_bots',
|
'/wecom/pushes': 'wecom_bots',
|
||||||
|
'/wecom/reports': 'wecom_bots',
|
||||||
'wecom-group': 'wecom_bots',
|
'wecom-group': 'wecom_bots',
|
||||||
'/llm-configs': 'llm_configs',
|
'/llm-configs': 'llm_configs',
|
||||||
'/knowledge-bases': 'knowledge_bases',
|
'/knowledge-bases': 'knowledge_bases',
|
||||||
@@ -206,6 +212,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/finance/partner-bills': 'finance',
|
'/finance/partner-bills': 'finance',
|
||||||
'/finance/winery-bills': 'finance',
|
'/finance/winery-bills': 'finance',
|
||||||
'/finance/logistics-bills': 'finance',
|
'/finance/logistics-bills': 'finance',
|
||||||
|
'/finance/bank-accounts': 'finance',
|
||||||
'benefit-group': 'benefit',
|
'benefit-group': 'benefit',
|
||||||
'/benefit/coupons': 'benefit',
|
'/benefit/coupons': 'benefit',
|
||||||
'/benefit/ledgers': 'benefit',
|
'/benefit/ledgers': 'benefit',
|
||||||
@@ -292,6 +299,7 @@ export default function AdminLayout() {
|
|||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
||||||
|
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||||
|
|
||||||
function refreshAuditPendingCount() {
|
function refreshAuditPendingCount() {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -419,11 +427,20 @@ export default function AdminLayout() {
|
|||||||
<span style={{ color: '#999' }}>
|
<span style={{ color: '#999' }}>
|
||||||
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
||||||
</span>
|
</span>
|
||||||
|
<Button type="text" icon={<UserOutlined />} onClick={() => setAccountSettingsOpen(true)}>
|
||||||
|
账号设置
|
||||||
|
</Button>
|
||||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
|
<HqAccountSettingsModal
|
||||||
|
open={accountSettingsOpen}
|
||||||
|
profile={profile}
|
||||||
|
onClose={() => setAccountSettingsOpen(false)}
|
||||||
|
onUpdated={setProfile}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="admin-layout"
|
className="admin-layout"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const CLIENT_APP = 'HQ_WEB';
|
|||||||
export type HqProfile = {
|
export type HqProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
loginName?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
adminRole: string;
|
adminRole: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -146,50 +147,12 @@ export type DashboardStats = {
|
|||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardAnalytics = {
|
export type {
|
||||||
summary: {
|
DashboardAnalytics,
|
||||||
users: number;
|
DashboardGranularity,
|
||||||
orders: number;
|
DashboardLineChart,
|
||||||
payingUsers: number;
|
DashboardLineHref,
|
||||||
partners: number;
|
} from '@dukang/shared-types';
|
||||||
stores: number;
|
|
||||||
redeems: number;
|
|
||||||
redeemAmount: number;
|
|
||||||
};
|
|
||||||
byDate: Array<{
|
|
||||||
date: string;
|
|
||||||
users: number;
|
|
||||||
orders: number;
|
|
||||||
partners: number;
|
|
||||||
stores: number;
|
|
||||||
redeems: number;
|
|
||||||
redeemAmount: number;
|
|
||||||
}>;
|
|
||||||
byCity: Array<{
|
|
||||||
cityId: string;
|
|
||||||
cityName: string;
|
|
||||||
users: number;
|
|
||||||
orders: number;
|
|
||||||
partners: number;
|
|
||||||
stores: number;
|
|
||||||
redeems: number;
|
|
||||||
redeemAmount: number;
|
|
||||||
}>;
|
|
||||||
byPromo: Array<{
|
|
||||||
promoCodeId: string | null;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
users: number;
|
|
||||||
orders: number;
|
|
||||||
}>;
|
|
||||||
byPartner: Array<{
|
|
||||||
partnerAccountId: string;
|
|
||||||
companyName: string;
|
|
||||||
stores: number;
|
|
||||||
redeems: number;
|
|
||||||
redeemAmount: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemVersion = {
|
export type SystemVersion = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ECharts } from 'echarts';
|
||||||
|
|
||||||
|
const TITLE_FONT = 'bold 32px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif';
|
||||||
|
const SUB_FONT = '22px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif';
|
||||||
|
|
||||||
|
type ChartShot = {
|
||||||
|
title: string;
|
||||||
|
instance: ECharts | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('图表截图失败'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function composePage(params: {
|
||||||
|
chartPng: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||||
|
const img = await loadImage(params.chartPng);
|
||||||
|
const header = 96;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height + header;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('无法生成 PDF 画布');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillStyle = '#1f1f1f';
|
||||||
|
ctx.font = TITLE_FONT;
|
||||||
|
ctx.fillText(params.title, 24, 16, canvas.width - 48);
|
||||||
|
ctx.fillStyle = '#8c8c8c';
|
||||||
|
ctx.font = SUB_FONT;
|
||||||
|
ctx.fillText(params.subtitle, 24, 56, canvas.width - 48);
|
||||||
|
ctx.drawImage(img, 0, header);
|
||||||
|
return {
|
||||||
|
dataUrl: canvas.toDataURL('image/jpeg', 0.92),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把当前已渲染折线图打成横向 A4 PDF(不含 KPI / 待办)。 */
|
||||||
|
export async function downloadDashboardChartsPdf(params: {
|
||||||
|
filename: string;
|
||||||
|
subtitle: string;
|
||||||
|
charts: ChartShot[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const ready = params.charts.filter((c): c is { title: string; instance: ECharts } => !!c.instance);
|
||||||
|
if (ready.length === 0) {
|
||||||
|
throw new Error('暂无可下载的图');
|
||||||
|
}
|
||||||
|
if (ready.length !== params.charts.length) {
|
||||||
|
throw new Error('图表尚未渲染完成,请稍后再试');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { jsPDF } = await import('jspdf');
|
||||||
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
const pageW = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageH = pdf.internal.pageSize.getHeight();
|
||||||
|
const margin = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < ready.length; i += 1) {
|
||||||
|
const chart = ready[i];
|
||||||
|
const png = chart.instance.getDataURL({
|
||||||
|
type: 'png',
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
});
|
||||||
|
const page = await composePage({
|
||||||
|
chartPng: png,
|
||||||
|
title: chart.title,
|
||||||
|
subtitle: params.subtitle,
|
||||||
|
});
|
||||||
|
const maxW = pageW - margin * 2;
|
||||||
|
const maxH = pageH - margin * 2;
|
||||||
|
const ratio = Math.min(maxW / page.width, maxH / page.height);
|
||||||
|
const w = page.width * ratio;
|
||||||
|
const h = page.height * ratio;
|
||||||
|
const x = (pageW - w) / 2;
|
||||||
|
const y = margin + (maxH - h) / 2;
|
||||||
|
if (i > 0) pdf.addPage();
|
||||||
|
pdf.addImage(page.dataUrl, 'JPEG', x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = params.filename.endsWith('.pdf') ? params.filename : `${params.filename}.pdf`;
|
||||||
|
pdf.save(filename);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** 将列表可见列 key 追加到导出 query */
|
||||||
|
export function appendExportColumns(qs: URLSearchParams, keys: string[]) {
|
||||||
|
if (keys.length) qs.set('columns', keys.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportColumnsBody(keys: string[]): { columns?: string[] } {
|
||||||
|
return keys.length ? { columns: keys } : {};
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||||
|
{ value: 'STORE_ACCOUNT_STAFF_CREATE', label: '新增门店子账号' },
|
||||||
|
{ value: 'STORE_ACCOUNT_STAFF_UPDATE', label: '编辑门店子账号' },
|
||||||
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
||||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||||
formatOssMaxSizeMb,
|
formatOssMaxSizeMb,
|
||||||
} from '@dukang/shared-ui/compressImage';
|
} from '@dukang/shared-ui/compressImage';
|
||||||
|
import type { OssUploadImageMeta } from '@dukang/shared-types';
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export type UploadFileResult = {
|
|||||||
ossKey: string;
|
ossKey: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
mock: boolean;
|
mock: boolean;
|
||||||
|
image?: OssUploadImageMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
applyColumnPrefs,
|
applyColumnPrefs,
|
||||||
columnKey,
|
columnKey,
|
||||||
settingItems,
|
settingItems,
|
||||||
|
visibleColumnKeys,
|
||||||
type ListColumnSettingItem,
|
type ListColumnSettingItem,
|
||||||
} from './list-column-prefs';
|
} from './list-column-prefs';
|
||||||
import { beginColumnResize, withResizeTitle } from './column-resize';
|
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||||
@@ -48,6 +49,16 @@ export function useAdminListColumns<T>(
|
|||||||
);
|
);
|
||||||
itemsRef.current = items;
|
itemsRef.current = items;
|
||||||
|
|
||||||
|
const exportColumnKeys = useMemo(() => {
|
||||||
|
const keyed = (allColumns as ColumnType<T>[]).map((col, i) => ({
|
||||||
|
col,
|
||||||
|
key: columnKey(col, i),
|
||||||
|
actions: col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions',
|
||||||
|
}));
|
||||||
|
const defaultKeys = keyed.filter((c) => !c.actions).map((c) => c.key);
|
||||||
|
return visibleColumnKeys(defaultKeys, pref);
|
||||||
|
}, [allColumns, pref]);
|
||||||
|
|
||||||
const serialCol: ColumnType<T> = useMemo(
|
const serialCol: ColumnType<T> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
key: SERIAL_COLUMN_KEY,
|
key: SERIAL_COLUMN_KEY,
|
||||||
@@ -171,5 +182,5 @@ export function useAdminListColumns<T>(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return { columns, settingsButton, settingsModal };
|
return { columns, settingsButton, settingsModal, exportColumnKeys };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_POSTER_STATUS_LABELS,
|
ACTIVITY_POSTER_STATUS_LABELS,
|
||||||
|
ACTIVITY_POSTER_UPLOAD_HINT,
|
||||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
type ActivityPosterItem,
|
type ActivityPosterItem,
|
||||||
type ActivityPosterQrSlot,
|
type ActivityPosterQrSlot,
|
||||||
@@ -280,7 +281,7 @@ export default function ActivityPostersPage() {
|
|||||||
<Input maxLength={128} />
|
<Input maxLength={128} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
||||||
<OssUpload bizType="ACTIVITY_POSTER" />
|
<OssUpload bizType="ACTIVITY_POSTER" uploadHint={ACTIVITY_POSTER_UPLOAD_HINT} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||||
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPES,
|
||||||
|
type FinanceBankAccountDto,
|
||||||
|
type FinanceBankAccountType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string };
|
||||||
|
|
||||||
|
type ExportResult = {
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OtherForm = {
|
||||||
|
name?: string;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<FinanceBankAccountType, string> = {
|
||||||
|
STORE: 'blue',
|
||||||
|
WINERY: 'gold',
|
||||||
|
PARTNER: 'purple',
|
||||||
|
LOGISTICS: 'cyan',
|
||||||
|
OTHER: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function otherNumericId(id: string) {
|
||||||
|
return id.startsWith('OTHER:') ? id.slice('OTHER:'.length) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [filterForm] = Form.useForm<{
|
||||||
|
type?: FinanceBankAccountType;
|
||||||
|
cityId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}>();
|
||||||
|
const [otherForm] = Form.useForm<OtherForm>();
|
||||||
|
const [remarkForm] = Form.useForm<{ remark?: string }>();
|
||||||
|
const [filters, setFilters] = useState({ type: '', cityId: '', keyword: '' });
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FinanceBankAccountDto | null>(null);
|
||||||
|
const [remarkTarget, setRemarkTarget] = useState<FinanceBankAccountDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState<'xlsx' | 'pdf' | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => setCities([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<FinanceBankAccountDto>(
|
||||||
|
'/admin/finance/bank-accounts',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.type) qs.set('type', filters.type);
|
||||||
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters.type, filters.cityId, filters.keyword],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
otherForm.resetFields();
|
||||||
|
setOtherOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FinanceBankAccountDto) {
|
||||||
|
setEditing(row);
|
||||||
|
otherForm.setFieldsValue({
|
||||||
|
name: row.ownerName === row.bankAccountName ? undefined : row.ownerName,
|
||||||
|
bankAccountName: row.bankAccountName,
|
||||||
|
bankAccountNo: row.bankAccountNo,
|
||||||
|
bankBranch: row.bankBranch ?? undefined,
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
});
|
||||||
|
setOtherOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemark(row: FinanceBankAccountDto) {
|
||||||
|
setRemarkTarget(row);
|
||||||
|
remarkForm.setFieldsValue({ remark: row.remark ?? undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOther() {
|
||||||
|
const values = await otherForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/finance/bank-accounts/other/${otherNumericId(editing.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/finance/bank-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已新增');
|
||||||
|
}
|
||||||
|
setOtherOpen(false);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemark() {
|
||||||
|
if (!remarkTarget) return;
|
||||||
|
const values = await remarkForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/finance/bank-accounts/${encodeURIComponent(remarkTarget.id)}/remark`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ remark: values.remark ?? '' }),
|
||||||
|
});
|
||||||
|
message.success('备注已保存');
|
||||||
|
setRemarkTarget(null);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOther(row: FinanceBankAccountDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/finance/bank-accounts/other/${otherNumericId(row.id)}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFile(format: 'xlsx' | 'pdf') {
|
||||||
|
setExporting(format);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('format', format);
|
||||||
|
if (filters.type) qs.set('type', filters.type);
|
||||||
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
const result = await request<ExportResult>(`/admin/finance/bank-accounts/export?${qs}`);
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = useMemo(
|
||||||
|
() => FINANCE_BANK_ACCOUNT_TYPES.map((value) => ({ value, label: FINANCE_BANK_ACCOUNT_TYPE_LABELS[value] })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<FinanceBankAccountDto> = [
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
width: 88,
|
||||||
|
render: (type: FinanceBankAccountType) => (
|
||||||
|
<Tag color={TYPE_COLORS[type]}>{FINANCE_BANK_ACCOUNT_TYPE_LABELS[type]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '归属',
|
||||||
|
dataIndex: 'ownerName',
|
||||||
|
width: 180,
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => {
|
||||||
|
if (row.type === 'STORE' && row.ownerId) {
|
||||||
|
navigate(`/stores?storeId=${encodeURIComponent(row.ownerId)}&tab=settlement`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.type === 'WINERY') {
|
||||||
|
navigate('/system-settings?group=winery_bank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.type === 'PARTNER' && row.ownerId) {
|
||||||
|
navigate(`/city-partners?partnerId=${encodeURIComponent(row.ownerId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.type === 'LOGISTICS' && row.ownerId) {
|
||||||
|
navigate(`/fulfillment-providers?id=${encodeURIComponent(row.ownerId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.type === 'OTHER') {
|
||||||
|
openEdit(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 100, render: (v?: string | null) => v || '—' },
|
||||||
|
{ title: '结算户名', dataIndex: 'bankAccountName', width: 140 },
|
||||||
|
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
|
||||||
|
{ title: '开户银行', dataIndex: 'bankBranch', width: 180, render: (v?: string | null) => v || '—' },
|
||||||
|
{ title: '备注', dataIndex: 'remark', width: 200, render: (v?: string | null) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openRemark(row)}>
|
||||||
|
备注
|
||||||
|
</Button>
|
||||||
|
{row.editable ? (
|
||||||
|
<>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除账户「${row.ownerName}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void removeOther(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="全部银行账户"
|
||||||
|
description="汇总门店结算资质、酒厂、合伙人、物流的有效银行账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新增账户
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters({
|
||||||
|
type: v.type || '',
|
||||||
|
cityId: v.cityId || '',
|
||||||
|
keyword: v.keyword?.trim() || '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="type" label="类型">
|
||||||
|
<Select allowClear style={{ width: 140 }} options={typeOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword" label="查找">
|
||||||
|
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户银行 / 归属 / 备注" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
filterForm.resetFields();
|
||||||
|
setFilters({ type: '', cityId: '', keyword: '' });
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button loading={exporting === 'xlsx'} onClick={() => void exportFile('xlsx')}>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button loading={exporting === 'pdf'} onClick={() => void exportFile('pdf')}>
|
||||||
|
导出 PDF
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑账户' : '新增账户'}
|
||||||
|
open={otherOpen}
|
||||||
|
onCancel={() => setOtherOpen(false)}
|
||||||
|
onOk={() => void saveOther()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={otherForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="归属名称">
|
||||||
|
<Input placeholder="选填,如供应商或内部账户名称" maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankAccountName" label="户名" rules={[{ required: true, message: '请填写户名' }]}>
|
||||||
|
<Input maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccountNo"
|
||||||
|
label="银行账号"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写银行账号' },
|
||||||
|
{ pattern: /^\d{8,32}$/, message: '银行账号须为 8~32 位数字' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={32} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankBranch" label="开户银行">
|
||||||
|
<Input maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={3} maxLength={256} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑备注"
|
||||||
|
open={!!remarkTarget}
|
||||||
|
onCancel={() => setRemarkTarget(null)}
|
||||||
|
onOk={() => void saveRemark()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={remarkForm} layout="vertical">
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={4} maxLength={256} placeholder="仅财务目录可见,不改写来源账户" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -129,12 +129,20 @@ function formatApiError(err: unknown): string | null {
|
|||||||
|
|
||||||
export default function CityPartnersPage() {
|
export default function CityPartnersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialCityId = searchParams.get('cityId')?.trim() || '';
|
||||||
|
const initialPartnerId = searchParams.get('partnerId')?.trim() || '';
|
||||||
|
const deepLinkPartnerOpenedRef = useRef(false);
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||||
|
const init: Record<string, string | boolean> = {};
|
||||||
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/partners',
|
'/admin/partners',
|
||||||
() => {
|
() => {
|
||||||
@@ -207,6 +215,14 @@ export default function CityPartnersPage() {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialPartnerId || deepLinkPartnerOpenedRef.current) return;
|
||||||
|
deepLinkPartnerOpenedRef.current = true;
|
||||||
|
void openPartner(initialPartnerId).catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '合伙人详情加载失败');
|
||||||
|
});
|
||||||
|
}, [initialPartnerId]);
|
||||||
|
|
||||||
async function savePartner() {
|
async function savePartner() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
try {
|
try {
|
||||||
@@ -484,6 +500,7 @@ export default function CityPartnersPage() {
|
|||||||
form={filterForm}
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
initialValues={initialCityId ? { cityId: initialCityId } : undefined}
|
||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
setFilters(v);
|
setFilters(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
|
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Segmented, Select, Space, Statistic, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CloudUploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import type { EChartsOption } from 'echarts';
|
import { getInstanceByDom, type EChartsOption } from 'echarts';
|
||||||
|
import {
|
||||||
|
addShanghaiDays,
|
||||||
|
defaultShanghaiRangeYmds,
|
||||||
|
shanghaiMonthRange,
|
||||||
|
shanghaiQuarterIndex,
|
||||||
|
shanghaiQuarterRange,
|
||||||
|
shanghaiWeekRange,
|
||||||
|
shanghaiYearMonth,
|
||||||
|
shanghaiYmd,
|
||||||
|
} from '@dukang/domain';
|
||||||
|
import type { DashboardGranularity, DashboardLineChart, DashboardLineHref } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
request,
|
request,
|
||||||
type DashboardAnalytics,
|
type DashboardAnalytics,
|
||||||
@@ -17,6 +28,7 @@ import {
|
|||||||
type SystemVersion,
|
type SystemVersion,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
import { downloadDashboardChartsPdf } from '../lib/dashboard-charts-pdf';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
@@ -36,26 +48,121 @@ const DEPLOYED_BY_LABELS: Record<string, string> = {
|
|||||||
admin: 'Admin 发布',
|
admin: 'Admin 发布',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GRAIN_OPTIONS: Array<{ label: string; value: DashboardGranularity }> = [
|
||||||
|
{ label: '日', value: 'day' },
|
||||||
|
{ label: '周', value: 'week' },
|
||||||
|
{ label: '月', value: 'month' },
|
||||||
|
{ label: '季', value: 'quarter' },
|
||||||
|
{ label: '年', value: 'year' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ChartMetric = 'total' | 'increment';
|
||||||
|
|
||||||
|
const METRIC_OPTIONS: Array<{ label: string; value: ChartMetric }> = [
|
||||||
|
{ label: '总量', value: 'total' },
|
||||||
|
{ label: '增量', value: 'increment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toDayjsRange(from: Date, toInclusive: Date): [Dayjs, Dayjs] {
|
||||||
|
return [dayjs(shanghaiYmd(from)), dayjs(shanghaiYmd(toInclusive))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整上一自然周 / 上月 / 上季(北京日历) */
|
||||||
|
function datePresetRange(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter'): [Dayjs, Dayjs] {
|
||||||
|
const today = new Date();
|
||||||
|
if (kind === 'lastWeek') {
|
||||||
|
const thisMonday = shanghaiWeekRange(today).start;
|
||||||
|
return toDayjsRange(addShanghaiDays(thisMonday, -7), addShanghaiDays(thisMonday, -1));
|
||||||
|
}
|
||||||
|
if (kind === 'lastMonth') {
|
||||||
|
const { year, month } = shanghaiYearMonth(today);
|
||||||
|
const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
|
||||||
|
const r = shanghaiMonthRange(prev.year, prev.month);
|
||||||
|
return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1));
|
||||||
|
}
|
||||||
|
const { year, quarter } = shanghaiQuarterIndex(today);
|
||||||
|
const prev = quarter === 1 ? { year: year - 1, quarter: 4 } : { year, quarter: quarter - 1 };
|
||||||
|
const r = shanghaiQuarterRange(prev.year, prev.quarter);
|
||||||
|
return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PRESETS: Array<{ key: 'lastWeek' | 'lastMonth' | 'lastQuarter'; label: string }> = [
|
||||||
|
{ key: 'lastWeek', label: '上周' },
|
||||||
|
{ key: 'lastMonth', label: '上月' },
|
||||||
|
{ key: 'lastQuarter', label: '上季度' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function chartIsMetric(key: string, metric: ChartMetric): boolean {
|
||||||
|
const isInc = key.includes('.increment');
|
||||||
|
return metric === 'increment' ? isInc : !isInc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HREF_PATH: Record<DashboardLineHref, string> = {
|
||||||
|
users: '/users',
|
||||||
|
partners: '/city-partners',
|
||||||
|
stores: '/stores',
|
||||||
|
orders: '/orders',
|
||||||
|
redeems: '/redeem-records',
|
||||||
|
};
|
||||||
|
|
||||||
type CityOption = { id: string; name: string; code: string };
|
type CityOption = { id: string; name: string; code: string };
|
||||||
type PromoOption = { id: string; code: string; name: string };
|
|
||||||
|
|
||||||
type AnalyticsFilters = {
|
type AnalyticsFilters = {
|
||||||
|
granularity: DashboardGranularity;
|
||||||
range: [Dayjs, Dayjs];
|
range: [Dayjs, Dayjs];
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
promoCodeId?: string;
|
|
||||||
partnerAccountId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function defaultRange(grain: DashboardGranularity): [Dayjs, Dayjs] {
|
||||||
|
const y = defaultShanghaiRangeYmds(grain);
|
||||||
|
return [dayjs(y.from), dayjs(y.to)];
|
||||||
|
}
|
||||||
|
|
||||||
function buildAnalyticsQs(f: AnalyticsFilters) {
|
function buildAnalyticsQs(f: AnalyticsFilters) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('granularity', f.granularity);
|
||||||
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
|
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
|
||||||
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
|
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
|
||||||
if (f.cityId) qs.set('cityId', f.cityId);
|
if (f.cityId) qs.set('cityId', f.cityId);
|
||||||
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
|
|
||||||
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
|
|
||||||
return qs.toString();
|
return qs.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listHref(kind: DashboardLineHref, f: AnalyticsFilters): string {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('createdFrom', f.range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('createdTo', f.range[1].format('YYYY-MM-DD'));
|
||||||
|
if (f.cityId && f.cityId !== 'none') qs.set('cityId', f.cityId);
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `${HREF_PATH[kind]}?${s}` : HREF_PATH[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOption(
|
||||||
|
periods: Array<{ key: string; label: string }>,
|
||||||
|
chart: DashboardLineChart,
|
||||||
|
): EChartsOption {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { type: 'scroll', top: 0 },
|
||||||
|
grid: { left: 56, right: 28, top: 48, bottom: periods.length > 14 ? 64 : 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: periods.map((p) => p.label),
|
||||||
|
axisLabel: { rotate: periods.length > 14 ? 45 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
minInterval: chart.unit === 'count' ? 1 : undefined,
|
||||||
|
},
|
||||||
|
series: chart.series.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line' as const,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: periods.length <= 24,
|
||||||
|
data: s.values,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [version, setVersion] = useState<SystemVersion | null>(null);
|
const [version, setVersion] = useState<SystemVersion | null>(null);
|
||||||
@@ -72,27 +179,27 @@ export default function DashboardPage() {
|
|||||||
const canStores = has('stores');
|
const canStores = has('stores');
|
||||||
const canStoreAudits = has('store_audits');
|
const canStoreAudits = has('store_audits');
|
||||||
const canPartners = has('partners');
|
const canPartners = has('partners');
|
||||||
const canPromo = has('promo_codes');
|
|
||||||
const canFinance = has('finance');
|
const canFinance = has('finance');
|
||||||
const canBenefit = has('benefit');
|
const canBenefit = has('benefit');
|
||||||
const canDeliveries = has('deliveries');
|
const canDeliveries = has('deliveries');
|
||||||
const canTickets = has('tickets');
|
const canTickets = has('tickets');
|
||||||
const permsReady = profile !== null;
|
const permsReady = profile !== null;
|
||||||
|
const hasAnalytics = canUsers || canOrders || canPartners || canStores || canBenefit;
|
||||||
|
|
||||||
const [filterForm] = Form.useForm<{
|
const [filterForm] = Form.useForm<{
|
||||||
range: [Dayjs, Dayjs];
|
range: [Dayjs, Dayjs];
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
promoCodeId?: string;
|
|
||||||
partnerAccountId?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const [filters, setFilters] = useState<AnalyticsFilters>({
|
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||||
range: [dayjs().subtract(29, 'day'), dayjs()],
|
granularity: 'day',
|
||||||
|
range: defaultRange('day'),
|
||||||
});
|
});
|
||||||
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||||||
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const [promos, setPromos] = useState<PromoOption[]>([]);
|
const [metric, setMetric] = useState<ChartMetric>('total');
|
||||||
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const chartsWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const loadVersion = useCallback(() => {
|
const loadVersion = useCallback(() => {
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
@@ -136,18 +243,6 @@ export default function DashboardPage() {
|
|||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.then((res) => setCities(res.items ?? []))
|
.then((res) => setCities(res.items ?? []))
|
||||||
.catch(() => setCities([]));
|
.catch(() => setCities([]));
|
||||||
if ((profile.permissionKeys ?? []).includes('promo_codes')) {
|
|
||||||
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
|
||||||
.then((res) => setPromos(res.items ?? []))
|
|
||||||
.catch(() => setPromos([]));
|
|
||||||
}
|
|
||||||
if ((profile.permissionKeys ?? []).includes('partners')) {
|
|
||||||
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
|
||||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
|
||||||
)
|
|
||||||
.then((res) => setPartners(res.items ?? []))
|
|
||||||
.catch(() => setPartners([]));
|
|
||||||
}
|
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,42 +252,22 @@ export default function DashboardPage() {
|
|||||||
function applyFilters(values: {
|
function applyFilters(values: {
|
||||||
range?: [Dayjs, Dayjs];
|
range?: [Dayjs, Dayjs];
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
promoCodeId?: string;
|
|
||||||
partnerAccountId?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const next: AnalyticsFilters = {
|
setFilters((prev) => ({
|
||||||
range: values.range ?? filters.range,
|
...prev,
|
||||||
|
range: values.range ?? prev.range,
|
||||||
cityId: values.cityId || undefined,
|
cityId: values.cityId || undefined,
|
||||||
promoCodeId: values.promoCodeId || undefined,
|
}));
|
||||||
partnerAccountId: values.partnerAccountId || undefined,
|
|
||||||
};
|
|
||||||
setFilters(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCityFilter(cityId: string) {
|
function setGranularity(granularity: DashboardGranularity) {
|
||||||
const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId };
|
setFilters((prev) => ({ ...prev, granularity }));
|
||||||
filterForm.setFieldsValue({ cityId: next.cityId });
|
|
||||||
setFilters(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPromoFilter(promoCodeId: string) {
|
function applyDatePreset(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter') {
|
||||||
const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId;
|
const range = datePresetRange(kind);
|
||||||
const next = {
|
filterForm.setFieldsValue({ range });
|
||||||
...filters,
|
setFilters((prev) => ({ ...prev, range }));
|
||||||
promoCodeId: key === filters.promoCodeId ? undefined : key,
|
|
||||||
};
|
|
||||||
filterForm.setFieldsValue({ promoCodeId: next.promoCodeId });
|
|
||||||
setFilters(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPartnerFilter(partnerAccountId: string) {
|
|
||||||
const next = {
|
|
||||||
...filters,
|
|
||||||
partnerAccountId:
|
|
||||||
partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId,
|
|
||||||
};
|
|
||||||
filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId });
|
|
||||||
setFilters(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeploy() {
|
function handleDeploy() {
|
||||||
@@ -217,224 +292,51 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
||||||
|
const periods = analytics?.periods ?? [];
|
||||||
|
const chartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(analytics?.charts ?? [])
|
||||||
|
.filter((chart) => chartIsMetric(chart.key, metric))
|
||||||
|
.map((chart) => ({
|
||||||
|
chart,
|
||||||
|
option: lineOption(periods, chart),
|
||||||
|
})),
|
||||||
|
[analytics, periods, metric],
|
||||||
|
);
|
||||||
|
|
||||||
const ordersDrillQs = useMemo(() => {
|
async function handleDownloadPdf() {
|
||||||
const qs = new URLSearchParams();
|
if (analyticsLoading || chartOptions.length === 0) {
|
||||||
qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD'));
|
message.warning('暂无可下载的图');
|
||||||
qs.set('createdTo', filters.range[1].format('YYYY-MM-DD'));
|
return;
|
||||||
if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId);
|
|
||||||
return qs.toString();
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const byDateOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byDate ?? [];
|
|
||||||
const legend: string[] = [];
|
|
||||||
const series: EChartsOption['series'] = [];
|
|
||||||
if (canUsers) {
|
|
||||||
legend.push('新增用户');
|
|
||||||
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
|
|
||||||
}
|
}
|
||||||
if (canOrders) {
|
const grainLabel = GRAIN_OPTIONS.find((o) => o.value === filters.granularity)?.label ?? filters.granularity;
|
||||||
legend.push('订单数');
|
const metricLabel = metric === 'increment' ? '增量' : '总量';
|
||||||
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
|
const cityLabel = !filters.cityId
|
||||||
}
|
? (cityScoped ? '全部负责城市' : '全部开城')
|
||||||
return {
|
: filters.cityId === 'none'
|
||||||
tooltip: { trigger: 'axis' },
|
? '未选城'
|
||||||
legend: { data: legend },
|
: (cities.find((c) => c.id === filters.cityId)?.name ?? '城市');
|
||||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
const from = filters.range[0].format('YYYY-MM-DD');
|
||||||
xAxis: {
|
const to = filters.range[1].format('YYYY-MM-DD');
|
||||||
type: 'category',
|
setPdfLoading(true);
|
||||||
data: rows.map((r) => r.date.slice(5)),
|
try {
|
||||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
const doms = [
|
||||||
},
|
...(chartsWrapRef.current?.querySelectorAll<HTMLElement>('[_echarts_instance_]') ?? []),
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
];
|
||||||
series,
|
await downloadDashboardChartsPdf({
|
||||||
};
|
filename: `数据概览-${metricLabel}-${grainLabel}-${from}_${to}.pdf`,
|
||||||
}, [analytics, canUsers, canOrders]);
|
subtitle: `${metricLabel} · ${grainLabel} · ${from} ~ ${to} · ${cityLabel}`,
|
||||||
|
charts: chartOptions.map(({ chart }, index) => ({
|
||||||
const byCityOption = useMemo<EChartsOption>(() => {
|
title: chart.title,
|
||||||
const rows = analytics?.byCity ?? [];
|
instance: doms[index] ? getInstanceByDom(doms[index]) : undefined,
|
||||||
const legend: string[] = [];
|
})),
|
||||||
const series: EChartsOption['series'] = [];
|
|
||||||
if (canUsers) {
|
|
||||||
legend.push('用户');
|
|
||||||
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
|
||||||
}
|
|
||||||
if (canOrders) {
|
|
||||||
legend.push('订单');
|
|
||||||
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: legend },
|
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.cityName),
|
|
||||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
}, [analytics, canUsers, canOrders]);
|
|
||||||
|
|
||||||
const byPromoOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byPromo ?? [];
|
|
||||||
const legend: string[] = [];
|
|
||||||
const series: EChartsOption['series'] = [];
|
|
||||||
if (canUsers) {
|
|
||||||
legend.push('用户');
|
|
||||||
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
|
||||||
}
|
|
||||||
if (canOrders) {
|
|
||||||
legend.push('订单');
|
|
||||||
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: legend },
|
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.name || r.code),
|
|
||||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
}, [analytics, canUsers, canOrders]);
|
|
||||||
|
|
||||||
const opsByDateOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byDate ?? [];
|
|
||||||
const legend: string[] = [];
|
|
||||||
const series: NonNullable<EChartsOption['series']> = [];
|
|
||||||
if (canPartners) {
|
|
||||||
legend.push('新增合伙人');
|
|
||||||
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
|
|
||||||
}
|
|
||||||
if (canStores) {
|
|
||||||
legend.push('新签门店');
|
|
||||||
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
|
|
||||||
}
|
|
||||||
if (canBenefit) {
|
|
||||||
legend.push('核销笔数', '核销金额');
|
|
||||||
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
|
|
||||||
series.push({
|
|
||||||
name: '核销金额',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: rows.map((r) => r.redeemAmount),
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载 PDF 失败');
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(false);
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: legend },
|
|
||||||
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.date.slice(5)),
|
|
||||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '数量', minInterval: 1 },
|
|
||||||
{ type: 'value', name: '金额', minInterval: 1 },
|
|
||||||
],
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
}, [analytics, canPartners, canStores, canBenefit]);
|
|
||||||
|
|
||||||
const opsByCityOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byCity ?? [];
|
|
||||||
const legend: string[] = [];
|
|
||||||
const series: EChartsOption['series'] = [];
|
|
||||||
if (canPartners) {
|
|
||||||
legend.push('合伙人');
|
|
||||||
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
|
|
||||||
}
|
|
||||||
if (canStores) {
|
|
||||||
legend.push('门店');
|
|
||||||
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
|
||||||
}
|
|
||||||
if (canBenefit) {
|
|
||||||
legend.push('核销笔数');
|
|
||||||
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: legend },
|
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.cityName),
|
|
||||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
}, [analytics, canPartners, canStores, canBenefit]);
|
|
||||||
|
|
||||||
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byPartner ?? [];
|
|
||||||
const legend: string[] = [];
|
|
||||||
const series: NonNullable<EChartsOption['series']> = [];
|
|
||||||
if (canStores) {
|
|
||||||
legend.push('门店');
|
|
||||||
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
|
||||||
}
|
|
||||||
if (canBenefit) {
|
|
||||||
legend.push('核销笔数', '核销金额');
|
|
||||||
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
|
||||||
series.push({
|
|
||||||
name: '核销金额',
|
|
||||||
type: 'line',
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: rows.map((r) => r.redeemAmount),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: legend },
|
|
||||||
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.companyName),
|
|
||||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '数量', minInterval: 1 },
|
|
||||||
{ type: 'value', name: '金额' },
|
|
||||||
],
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
}, [analytics, canStores, canBenefit]);
|
|
||||||
|
|
||||||
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
|
||||||
const rows = analytics?.byCity ?? [];
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: ['核销笔数', '核销金额'] },
|
|
||||||
grid: { left: 48, right: 48, top: 40, bottom: 48 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: rows.map((r) => r.cityName),
|
|
||||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '笔数', minInterval: 1 },
|
|
||||||
{ type: 'value', name: '金额' },
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 },
|
|
||||||
{
|
|
||||||
name: '核销金额',
|
|
||||||
type: 'line',
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: rows.map((r) => r.redeemAmount),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, [analytics]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -574,7 +476,7 @@ export default function DashboardPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="数据统计筛选"
|
title="全局筛选"
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
@@ -592,8 +494,24 @@ export default function DashboardPage() {
|
|||||||
initialValues={{ range: filters.range }}
|
initialValues={{ range: filters.range }}
|
||||||
onFinish={applyFilters}
|
onFinish={applyFilters}
|
||||||
>
|
>
|
||||||
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
<Form.Item label="粒度">
|
||||||
<DatePicker.RangePicker allowClear={false} />
|
<Segmented
|
||||||
|
options={GRAIN_OPTIONS}
|
||||||
|
value={filters.granularity}
|
||||||
|
onChange={(v) => setGranularity(v as DashboardGranularity)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="日期" required>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Form.Item name="range" noStyle rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker.RangePicker allowClear={false} />
|
||||||
|
</Form.Item>
|
||||||
|
{DATE_PRESETS.map((preset) => (
|
||||||
|
<Button key={preset.key} autoInsertSpace={false} onClick={() => applyDatePreset(preset.key)}>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="cityId" label="城市">
|
<Form.Item name="cityId" label="城市">
|
||||||
<Select
|
<Select
|
||||||
@@ -606,230 +524,49 @@ export default function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{canPromo ? (
|
<Form.Item label="指标">
|
||||||
<Form.Item name="promoCodeId" label="推广码">
|
<Segmented
|
||||||
<Select
|
options={METRIC_OPTIONS}
|
||||||
allowClear
|
value={metric}
|
||||||
placeholder="全部来源"
|
onChange={(v) => setMetric(v as ChartMetric)}
|
||||||
style={{ width: 200 }}
|
/>
|
||||||
options={[
|
</Form.Item>
|
||||||
{ value: 'none', label: '自然量 / 无推广码' },
|
|
||||||
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
{canPartners ? (
|
|
||||||
<Form.Item name="partnerAccountId" label="合伙人">
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="全部合伙人"
|
|
||||||
style={{ width: 200 }}
|
|
||||||
options={partners.map((p) => ({
|
|
||||||
value: p.id,
|
|
||||||
label: p.companyName || p.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
loading={pdfLoading}
|
||||||
|
disabled={!hasAnalytics || analyticsLoading || chartOptions.length === 0}
|
||||||
|
onClick={() => void handleDownloadPdf()}
|
||||||
|
>
|
||||||
|
下载PDF
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{canUsers || canOrders ? (
|
{permsReady && !hasAnalytics ? (
|
||||||
<Card
|
<Card style={{ marginBottom: 24 }}>
|
||||||
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
|
<Typography.Text type="secondary">当前账号无可视指标</Typography.Text>
|
||||||
style={{ marginBottom: 24 }}
|
</Card>
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}>查看订单</Link> : null}
|
|
||||||
{canUsers ? <Link to="/users">查看用户</Link> : null}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
{canUsers ? (
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canOrders ? (
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canOrders ? (
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col xs={24}>
|
|
||||||
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
|
||||||
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} lg={canPromo ? 12 : 24}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title="按城市(点击柱联动筛选)"
|
|
||||||
loading={analyticsLoading}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ReactECharts
|
|
||||||
option={byCityOption}
|
|
||||||
style={{ height: 300 }}
|
|
||||||
notMerge
|
|
||||||
onEvents={{
|
|
||||||
click: (params: { name?: string }) => {
|
|
||||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
|
||||||
if (row) setCityFilter(row.cityId);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
{canPromo ? (
|
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title="按推广码(点击柱联动筛选)"
|
|
||||||
loading={analyticsLoading}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ReactECharts
|
|
||||||
option={byPromoOption}
|
|
||||||
style={{ height: 300 }}
|
|
||||||
notMerge
|
|
||||||
onEvents={{
|
|
||||||
click: (params: { name?: string }) => {
|
|
||||||
const row = (analytics?.byPromo ?? []).find(
|
|
||||||
(r) => (r.name || r.code) === params.name,
|
|
||||||
);
|
|
||||||
if (row) setPromoFilter(row.promoCodeId ?? 'none');
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canPartners || canStores || canBenefit ? (
|
{hasAnalytics ? (
|
||||||
<Card
|
<div ref={chartsWrapRef} style={{ marginBottom: 24 }}>
|
||||||
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
|
{chartOptions.map(({ chart, option }) => (
|
||||||
style={{ marginBottom: 24 }}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
{canPartners ? <Link to="/city-partners">查看合伙人</Link> : null}
|
|
||||||
{canStores ? <Link to="/stores">查看门店</Link> : null}
|
|
||||||
{canBenefit ? <Link to="/redeem-records">查看核销</Link> : null}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
{canPartners ? (
|
|
||||||
<Col xs={24} sm={6}>
|
|
||||||
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canStores ? (
|
|
||||||
<Col xs={24} sm={6}>
|
|
||||||
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canBenefit ? (
|
|
||||||
<Col xs={24} sm={6}>
|
|
||||||
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canBenefit ? (
|
|
||||||
<Col xs={24} sm={6}>
|
|
||||||
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col xs={24}>
|
|
||||||
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
|
||||||
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} lg={canBenefit ? 12 : 24}>
|
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
key={chart.key}
|
||||||
title="按城市(点击柱联动筛选)"
|
title={chart.title}
|
||||||
|
extra={<Link to={listHref(chart.href, filters)}>查看</Link>}
|
||||||
loading={analyticsLoading}
|
loading={analyticsLoading}
|
||||||
size="small"
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
<ReactECharts
|
<ReactECharts option={option} style={{ height: 320, width: '100%' }} notMerge />
|
||||||
option={opsByCityOption}
|
|
||||||
style={{ height: 300 }}
|
|
||||||
notMerge
|
|
||||||
onEvents={{
|
|
||||||
click: (params: { name?: string }) => {
|
|
||||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
|
||||||
if (row) setCityFilter(row.cityId);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
))}
|
||||||
{canBenefit ? (
|
</div>
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title="按城市核销金额"
|
|
||||||
loading={analyticsLoading}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ReactECharts
|
|
||||||
option={redeemByCityOption}
|
|
||||||
style={{ height: 300 }}
|
|
||||||
notMerge
|
|
||||||
onEvents={{
|
|
||||||
click: (params: { name?: string }) => {
|
|
||||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
|
||||||
if (row) setCityFilter(row.cityId);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
{canPartners && (canStores || canBenefit) ? (
|
|
||||||
<Col xs={24}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title="按合伙人(门店 / 核销,点击联动筛选)"
|
|
||||||
loading={analyticsLoading}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ReactECharts
|
|
||||||
option={opsByPartnerOption}
|
|
||||||
style={{ height: 320 }}
|
|
||||||
notMerge
|
|
||||||
onEvents={{
|
|
||||||
click: (params: { name?: string }) => {
|
|
||||||
const row = (analytics?.byPartner ?? []).find(
|
|
||||||
(r) => r.companyName === params.name,
|
|
||||||
);
|
|
||||||
if (row) setPartnerFilter(row.partnerAccountId);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
) : null}
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canFinance || canTickets ? (
|
{canFinance || canTickets ? (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { downloadBase64File } from '../lib/exportExcel';
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
|
import { exportColumnsBody } from '../lib/export-columns';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -62,6 +63,8 @@ export default function DevPlanTasksPage() {
|
|||||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||||
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
||||||
const [batchSaving, setBatchSaving] = useState(false);
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
const [creatingVersion, setCreatingVersion] = useState(false);
|
||||||
|
const [newVersionNo, setNewVersionNo] = useState('');
|
||||||
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||||
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
||||||
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
||||||
@@ -172,9 +175,34 @@ export default function DevPlanTasksPage() {
|
|||||||
|
|
||||||
function openBatchEdit() {
|
function openBatchEdit() {
|
||||||
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
|
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
|
||||||
|
setNewVersionNo('');
|
||||||
setBatchEditOpen(true);
|
setBatchEditOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createVersionInBatch() {
|
||||||
|
const versionNo = newVersionNo.trim();
|
||||||
|
if (!versionNo) {
|
||||||
|
message.warning('请输入版本号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatingVersion(true);
|
||||||
|
try {
|
||||||
|
const created = await request<DevPlanVersionDto>('/admin/dev-plan/versions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ versionNo }),
|
||||||
|
});
|
||||||
|
const res = await request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100');
|
||||||
|
setVersions(res.items ?? []);
|
||||||
|
batchForm.setFieldsValue({ versionId: created.id });
|
||||||
|
setNewVersionNo('');
|
||||||
|
message.success(`已创建版本 ${versionNo}`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreatingVersion(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitBatchEdit() {
|
async function submitBatchEdit() {
|
||||||
const values = await batchForm.validateFields();
|
const values = await batchForm.validateFields();
|
||||||
if (!values.status && !values.versionId) {
|
if (!values.status && !values.versionId) {
|
||||||
@@ -224,34 +252,6 @@ export default function DevPlanTasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportTasks(scope: 'filter' | 'selected') {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const result = await request<{
|
|
||||||
filename: string;
|
|
||||||
mimeType: string;
|
|
||||||
contentBase64: string;
|
|
||||||
count: number;
|
|
||||||
}>('/admin/dev-plan/tasks/export', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
scope,
|
|
||||||
format: exportFormat,
|
|
||||||
ids: scope === 'selected' ? selectedRowKeys : undefined,
|
|
||||||
status: filters.status || undefined,
|
|
||||||
type: filters.type || undefined,
|
|
||||||
keyword: filters.keyword || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
|
||||||
message.success(`已导出 ${result.count} 条任务`);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '导出失败');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||||
{
|
{
|
||||||
title: '任务号',
|
title: '任务号',
|
||||||
@@ -285,6 +285,7 @@ export default function DevPlanTasksPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '附件',
|
title: '附件',
|
||||||
|
key: 'attachmentUrls',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.attachmentUrls?.length ? (
|
row.attachmentUrls?.length ? (
|
||||||
@@ -314,6 +315,7 @@ export default function DevPlanTasksPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '关联版本',
|
title: '关联版本',
|
||||||
|
key: 'versions',
|
||||||
dataIndex: 'versions',
|
dataIndex: 'versions',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
@@ -347,7 +349,36 @@ export default function DevPlanTasksPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
async function exportTasks(scope: 'filter' | 'selected') {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const result = await request<{
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
}>('/admin/dev-plan/tasks/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
scope,
|
||||||
|
format: exportFormat,
|
||||||
|
ids: scope === 'selected' ? selectedRowKeys : undefined,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
type: filters.type || undefined,
|
||||||
|
keyword: filters.keyword || undefined,
|
||||||
|
...exportColumnsBody(exportColumnKeys),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条任务`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -541,6 +572,19 @@ export default function DevPlanTasksPage() {
|
|||||||
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="创建新版本">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="版本号,如 v4.0.11"
|
||||||
|
value={newVersionNo}
|
||||||
|
onChange={(e) => setNewVersionNo(e.target.value)}
|
||||||
|
onPressEnter={() => void createVersionInBatch()}
|
||||||
|
/>
|
||||||
|
<Button loading={creatingVersion} onClick={() => void createVersionInBatch()}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function DevPlanVersionsPage() {
|
|||||||
useAdminList<DevPlanVersionDto>('/admin/dev-plan/versions', () => {
|
useAdminList<DevPlanVersionDto>('/admin/dev-plan/versions', () => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
return qs;
|
return qs;
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
@@ -190,6 +191,9 @@ export default function DevPlanVersionsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword" label="查找">
|
||||||
|
<Input allowClear placeholder="版本号或内容" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -52,8 +53,11 @@ const SIGN_OPTIONS = [
|
|||||||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||||
|
|
||||||
export default function FulfillmentProvidersPage() {
|
export default function FulfillmentProvidersPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialId = searchParams.get('id')?.trim() || '';
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -132,6 +136,13 @@ export default function FulfillmentProvidersPage() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialId || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const row = rows.find((r) => String(r.id) === initialId);
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
if (row) openEdit(row);
|
||||||
|
}, [initialId, loading, rows]);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const v = await form.validateFields();
|
const v = await form.validateFields();
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function HqPermissionsPage() {
|
|||||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
||||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单、物流对账与银行账户。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
|
import { appendExportColumns } from '../lib/export-columns';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -98,6 +100,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LogisticsBillsPage() {
|
export default function LogisticsBillsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [summaryForm] = Form.useForm();
|
const [summaryForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -202,6 +205,7 @@ export default function LogisticsBillsPage() {
|
|||||||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||||
if (filters.year) qs.set('year', filters.year);
|
if (filters.year) qs.set('year', filters.year);
|
||||||
if (filters.month) qs.set('month', filters.month);
|
if (filters.month) qs.set('month', filters.month);
|
||||||
|
appendExportColumns(qs, exportColumnKeys);
|
||||||
const result = await request<{ csv: string; count: number }>(
|
const result = await request<{ csv: string; count: number }>(
|
||||||
`/admin/logistics-bills/export?${qs}`,
|
`/admin/logistics-bills/export?${qs}`,
|
||||||
);
|
);
|
||||||
@@ -250,6 +254,7 @@ export default function LogisticsBillsPage() {
|
|||||||
const billColumns: ColumnsType<BillRow> = [
|
const billColumns: ColumnsType<BillRow> = [
|
||||||
{
|
{
|
||||||
title: '账单号',
|
title: '账单号',
|
||||||
|
key: '账单号',
|
||||||
dataIndex: 'billNo',
|
dataIndex: 'billNo',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
@@ -258,42 +263,49 @@ export default function LogisticsBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '承运商',
|
title: '承运商',
|
||||||
|
key: '承运商',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账期',
|
title: '账期',
|
||||||
|
key: '账期',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, r) =>
|
render: (_, r) =>
|
||||||
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结算方式',
|
title: '结算方式',
|
||||||
|
key: '结算方式',
|
||||||
dataIndex: 'settlementMethod',
|
dataIndex: 'settlementMethod',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v) =>
|
render: (v) =>
|
||||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: '订单数', key: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
{ title: '瓶数', key: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||||
{
|
{
|
||||||
title: '物流费',
|
title: '物流费',
|
||||||
|
key: '物流费',
|
||||||
dataIndex: 'logisticsAmount',
|
dataIndex: 'logisticsAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收款户名',
|
title: '收款户名',
|
||||||
|
key: '收款户名',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => providerBank(r)?.bankAccountName || '—',
|
render: (_, r) => providerBank(r)?.bankAccountName || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收款账号',
|
title: '收款账号',
|
||||||
|
key: '收款账号',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
|
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||||
@@ -398,7 +410,7 @@ export default function LogisticsBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
@@ -627,7 +639,20 @@ export default function LogisticsBillsPage() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={detail.items ?? []}
|
dataSource={detail.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'orderNo' },
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'orderNo',
|
||||||
|
render: (v: string) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
||||||
{
|
{
|
||||||
title: '物流费',
|
title: '物流费',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useAdminListColumns } from '../lib/useAdminListColumns';
|
|||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { downloadBase64File } from '../lib/exportExcel';
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
|
import { exportColumnsBody } from '../lib/export-columns';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
@@ -265,8 +266,9 @@ function buildExportPayload(
|
|||||||
format: OrderExportFormat,
|
format: OrderExportFormat,
|
||||||
filters: OrderExportFilters,
|
filters: OrderExportFilters,
|
||||||
selectedIds: string[],
|
selectedIds: string[],
|
||||||
|
columns?: string[],
|
||||||
) {
|
) {
|
||||||
const payload: Record<string, unknown> = { scope, format };
|
const payload: Record<string, unknown> = { scope, format, ...exportColumnsBody(columns ?? []) };
|
||||||
if (scope === 'selected') {
|
if (scope === 'selected') {
|
||||||
payload.ids = selectedIds;
|
payload.ids = selectedIds;
|
||||||
return payload;
|
return payload;
|
||||||
@@ -325,6 +327,9 @@ export default function OrdersPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
|
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
|
||||||
|
const initialCityId = searchParams.get('cityId')?.trim() || '';
|
||||||
|
const initialCreatedFrom = searchParams.get('createdFrom')?.trim() || '';
|
||||||
|
const initialCreatedTo = searchParams.get('createdTo')?.trim() || '';
|
||||||
const initialStatusParam = searchParams.getAll('status').join(',');
|
const initialStatusParam = searchParams.getAll('status').join(',');
|
||||||
const initialStatuses = useMemo(
|
const initialStatuses = useMemo(
|
||||||
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
|
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
|
||||||
@@ -387,6 +392,18 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
}, [form, initialOrderNo]);
|
}, [form, initialOrderNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const patch: { cityId?: string; dateRange?: [Dayjs, Dayjs] } = {};
|
||||||
|
if (initialCityId) patch.cityId = initialCityId;
|
||||||
|
if (
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(initialCreatedFrom) &&
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(initialCreatedTo)
|
||||||
|
) {
|
||||||
|
patch.dateRange = [dayjs(initialCreatedFrom), dayjs(initialCreatedTo)];
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length) form.setFieldsValue(patch);
|
||||||
|
}, [form, initialCityId, initialCreatedFrom, initialCreatedTo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialStatuses.length) {
|
if (initialStatuses.length) {
|
||||||
form.setFieldsValue({ status: initialStatuses });
|
form.setFieldsValue({ status: initialStatuses });
|
||||||
@@ -435,7 +452,7 @@ export default function OrdersPage() {
|
|||||||
const statusList = statuses.length ? statuses : initialStatuses;
|
const statusList = statuses.length ? statuses : initialStatuses;
|
||||||
for (const status of statusList) qs.append('status', status);
|
for (const status of statusList) qs.append('status', status);
|
||||||
if (values.orderType) qs.set('orderType', values.orderType);
|
if (values.orderType) qs.set('orderType', values.orderType);
|
||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId || initialCityId) qs.set('cityId', values.cityId || initialCityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
|
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
|
||||||
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
|
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
|
||||||
@@ -443,14 +460,16 @@ export default function OrdersPage() {
|
|||||||
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
||||||
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
|
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
|
||||||
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
|
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
|
||||||
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
const createdFrom = values.dateRange?.[0]?.format('YYYY-MM-DD') || initialCreatedFrom;
|
||||||
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
const createdTo = values.dateRange?.[1]?.format('YYYY-MM-DD') || initialCreatedTo;
|
||||||
|
if (createdFrom) qs.set('createdFrom', createdFrom);
|
||||||
|
if (createdTo) qs.set('createdTo', createdTo);
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
|
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses, initialCityId, initialCreatedFrom, initialCreatedTo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -671,6 +690,7 @@ export default function OrdersPage() {
|
|||||||
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
||||||
},
|
},
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
exportColumnKeys,
|
||||||
);
|
);
|
||||||
const result = await request<OrderExportResult>('/admin/orders/export', {
|
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -688,6 +708,7 @@ export default function OrdersPage() {
|
|||||||
const baseColumns: ColumnsType<AdminOrderRow> = [
|
const baseColumns: ColumnsType<AdminOrderRow> = [
|
||||||
{
|
{
|
||||||
title: '订单号',
|
title: '订单号',
|
||||||
|
key: '订单号',
|
||||||
dataIndex: 'orderNo',
|
dataIndex: 'orderNo',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
@@ -699,7 +720,7 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '用户',
|
title: '用户',
|
||||||
key: 'user',
|
key: '用户',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.user?.id ? (
|
row.user?.id ? (
|
||||||
@@ -712,12 +733,20 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品',
|
title: '商品',
|
||||||
key: 'productName',
|
key: '商品',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<span>{row.productName || '—'}</span>
|
<span>{row.productName || '—'}</span>
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
{row.fulfillmentHold ? (
|
||||||
|
<Tag color="orange">
|
||||||
|
{row.fulfillmentHoldReason === 'LARGE_ORDER_GE_10_BOXES'
|
||||||
|
? '大单'
|
||||||
|
: row.fulfillmentHoldReason === 'COURIER_OUT_OF_SERVICE'
|
||||||
|
? '超区'
|
||||||
|
: '推单失败'}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
<Tag color="purple">代下单</Tag>
|
<Tag color="purple">代下单</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -726,12 +755,14 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '规格',
|
title: '规格',
|
||||||
|
key: '规格',
|
||||||
dataIndex: 'productSpec',
|
dataIndex: 'productSpec',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v: string | undefined) => v || '—',
|
render: (v: string | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数量',
|
title: '数量',
|
||||||
|
key: '数量',
|
||||||
dataIndex: 'quantity',
|
dataIndex: 'quantity',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (v: number | undefined, row) =>
|
render: (v: number | undefined, row) =>
|
||||||
@@ -739,12 +770,14 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '配送方式',
|
title: '配送方式',
|
||||||
|
key: '配送方式',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'deliveryType',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s: string) => (
|
render: (s: string) => (
|
||||||
@@ -753,42 +786,47 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '实付',
|
title: '实付',
|
||||||
|
key: '实付',
|
||||||
dataIndex: 'payAmount',
|
dataIndex: 'payAmount',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: number) => `¥${v}`,
|
render: (v: number) => `¥${v}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '运费',
|
title: '运费',
|
||||||
key: 'logisticsFee',
|
key: '运费',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
|
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '好客权益',
|
title: '好客权益',
|
||||||
|
key: '好客权益',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, row) => formatBenefitBrief(row),
|
render: (_, row) => formatBenefitBrief(row),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收货人',
|
title: '收货人',
|
||||||
|
key: '收货人',
|
||||||
dataIndex: 'receiverName',
|
dataIndex: 'receiverName',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: string | undefined) => v || '—',
|
render: (v: string | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话',
|
title: '电话',
|
||||||
|
key: '电话',
|
||||||
dataIndex: 'receiverPhone',
|
dataIndex: 'receiverPhone',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string | undefined) => v || '—',
|
render: (v: string | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '地址',
|
title: '地址',
|
||||||
key: 'receiverAddress',
|
key: '地址',
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (_, row) => formatReceiverAddress(row) || '—',
|
render: (_, row) => formatReceiverAddress(row) || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '下单时间',
|
title: '下单时间',
|
||||||
|
key: '下单时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: fmtTime,
|
render: fmtTime,
|
||||||
@@ -823,7 +861,7 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('orders', baseColumns, {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
@@ -1010,7 +1048,7 @@ export default function OrdersPage() {
|
|||||||
) : <span />}
|
) : <span />}
|
||||||
<Space size={12} wrap>
|
<Space size={12} wrap>
|
||||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
<Checkbox>大单拦截</Checkbox>
|
<Checkbox>履约拦截</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1120,7 +1158,7 @@ export default function OrdersPage() {
|
|||||||
{detail.fulfillmentHold ? (
|
{detail.fulfillmentHold ? (
|
||||||
<Tag color="orange">
|
<Tag color="orange">
|
||||||
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
|
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
|
||||||
'大单待确认'}
|
'履约待确认'}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
|
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
|
||||||
@@ -1517,10 +1555,16 @@ export default function OrdersPage() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
message="大单已拦截自动推小飞侠"
|
message={
|
||||||
|
shipTarget.fulfillmentHoldReason === 'LARGE_ORDER_GE_10_BOXES'
|
||||||
|
? '大单已拦截自动推小飞侠'
|
||||||
|
: shipTarget.fulfillmentHoldReason === 'COURIER_OUT_OF_SERVICE'
|
||||||
|
? '小飞侠超出服务区'
|
||||||
|
: '自动推配送失败'
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
|
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
|
||||||
'≥10箱订单需总部确认:可选仓推小飞侠,或改用快递自配送。'
|
'请确认后:可选仓重推小飞侠,或改用快递自配送。'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -22,6 +24,7 @@ import dayjs, { type Dayjs } from 'dayjs';
|
|||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
|
import { appendExportColumns } from '../lib/export-columns';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -48,6 +51,7 @@ type Row = {
|
|||||||
redeemCommission: number;
|
redeemCommission: number;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
billDate: string;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
rejectReason?: string | null;
|
rejectReason?: string | null;
|
||||||
@@ -109,6 +113,7 @@ function weekMonday(d: Dayjs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PartnerBillsPage() {
|
export default function PartnerBillsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
@@ -263,6 +268,7 @@ export default function PartnerBillsPage() {
|
|||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||||
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
|
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
|
||||||
|
appendExportColumns(qs, exportColumnKeys);
|
||||||
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
|
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
|
||||||
const suffix = filters.weekStartYmd || 'all';
|
const suffix = filters.weekStartYmd || 'all';
|
||||||
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
|
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
|
||||||
@@ -285,6 +291,7 @@ export default function PartnerBillsPage() {
|
|||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '账单号',
|
title: '账单号',
|
||||||
|
key: '账单号',
|
||||||
dataIndex: 'billNo',
|
dataIndex: 'billNo',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
@@ -293,29 +300,45 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
|
key: '合伙人',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, r) => partnerName(r),
|
render: (_, r) => partnerName(r),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<Tooltip title="周账出账日为账期结束后的下周一;历史月账为次月 1 日">
|
||||||
|
出账日
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
key: '出账日',
|
||||||
|
dataIndex: 'billDate',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => String(v || '').slice(0, 10),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '账期',
|
title: '账期',
|
||||||
|
key: '账期',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, r) =>
|
render: (_, r) =>
|
||||||
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒单佣金',
|
title: '酒单佣金',
|
||||||
|
key: '酒单佣金',
|
||||||
dataIndex: 'orderCommission',
|
dataIndex: 'orderCommission',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '核销佣金',
|
title: '核销佣金',
|
||||||
|
key: '核销佣金',
|
||||||
dataIndex: 'redeemCommission',
|
dataIndex: 'redeemCommission',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '合计应付',
|
title: '合计应付',
|
||||||
|
key: '合计应付',
|
||||||
dataIndex: 'totalAmount',
|
dataIndex: 'totalAmount',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (v) =>
|
render: (v) =>
|
||||||
@@ -323,16 +346,19 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收款户名',
|
title: '收款户名',
|
||||||
|
key: '收款户名',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
|
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收款账号',
|
title: '收款账号',
|
||||||
|
key: '收款账号',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
|
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (s, row) => (
|
render: (s, row) => (
|
||||||
@@ -380,7 +406,7 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -572,6 +598,7 @@ export default function PartnerBillsPage() {
|
|||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
|
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="出账日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账期">
|
<Descriptions.Item label="账期">
|
||||||
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -610,7 +637,21 @@ export default function PartnerBillsPage() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={detail.orderItems ?? []}
|
dataSource={detail.orderItems ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'refNo', width: 160 },
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'refNo',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '商品', dataIndex: 'title' },
|
{ title: '商品', dataIndex: 'title' },
|
||||||
{ title: '数量', dataIndex: 'extra', width: 70 },
|
{ title: '数量', dataIndex: 'extra', width: 70 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function PromoCodesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '扫码',
|
title: <Tooltip title="按人次:每次扫码或带参进入记一次,未登录也计入;同一人多次进入记多次">扫码</Tooltip>,
|
||||||
dataIndex: 'scanCount',
|
dataIndex: 'scanCount',
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (v: number, row) => (
|
render: (v: number, row) => (
|
||||||
@@ -110,7 +110,7 @@ export default function PromoCodesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '转化率',
|
title: <Tooltip title="已完成订单 ÷ 扫码人次">转化率</Tooltip>,
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import {
|
|||||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
STORE_STAFF_DEFAULT_PERMISSIONS,
|
||||||
|
STORE_STAFF_PERMISSION_LABELS,
|
||||||
|
STORE_STAFF_ROLE_LABELS,
|
||||||
|
StoreStaffRole,
|
||||||
|
type AdminStoreStaffItem,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -10,7 +17,6 @@ import { useAdminListColumns } from '../lib/useAdminListColumns';
|
|||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -27,14 +33,22 @@ type Row = {
|
|||||||
bankBranch?: string | null;
|
bankBranch?: string | null;
|
||||||
store?: StoreBrief | null;
|
store?: StoreBrief | null;
|
||||||
stores?: StoreBrief[];
|
stores?: StoreBrief[];
|
||||||
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
|
staff?: AdminStoreStaffItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreOption = { id: string; name: string };
|
type StoreOption = { id: string; name: string };
|
||||||
|
|
||||||
|
const STAFF_ROLE_OPTIONS = Object.entries(STORE_STAFF_ROLE_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
const STAFF_PERMISSION_OPTIONS = STORE_STAFF_DEFAULT_PERMISSIONS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: STORE_STAFF_PERMISSION_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
export default function StoreAccountsPage() {
|
export default function StoreAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [staffForm] = Form.useForm();
|
||||||
|
const [staffEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-accounts',
|
'/admin/store-accounts',
|
||||||
@@ -50,9 +64,14 @@ export default function StoreAccountsPage() {
|
|||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [staffOpen, setStaffOpen] = useState(false);
|
||||||
|
const [staffEditOpen, setStaffEditOpen] = useState(false);
|
||||||
|
const [editingStaffId, setEditingStaffId] = useState<string | null>(null);
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const parentStoreOptions = (detail?.stores ?? []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
|
|
||||||
async function loadStores() {
|
async function loadStores() {
|
||||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
@@ -64,6 +83,34 @@ export default function StoreAccountsPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddStaff() {
|
||||||
|
if (!detail) return;
|
||||||
|
if (!detail.stores?.length) {
|
||||||
|
message.warning('请先为该主账号绑定门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
staffForm.resetFields();
|
||||||
|
staffForm.setFieldsValue({
|
||||||
|
staffRole: StoreStaffRole.CASHIER,
|
||||||
|
permissions: [...STORE_STAFF_DEFAULT_PERMISSIONS],
|
||||||
|
storeIds: detail.stores.map((s) => s.id),
|
||||||
|
});
|
||||||
|
setStaffOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditStaff(staff: AdminStoreStaffItem) {
|
||||||
|
setEditingStaffId(staff.id);
|
||||||
|
staffEditForm.setFieldsValue({
|
||||||
|
name: staff.name,
|
||||||
|
phone: staff.phone,
|
||||||
|
staffRole: staff.staffRole ?? StoreStaffRole.CASHIER,
|
||||||
|
status: staff.status,
|
||||||
|
permissions: staff.permissions?.length ? staff.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS],
|
||||||
|
storeIds: staff.storeIds?.length ? staff.storeIds : (staff.stores ?? []).map((s) => s.id),
|
||||||
|
});
|
||||||
|
setStaffEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteStaff(staffId: string) {
|
async function deleteStaff(staffId: string) {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setDeletingStaffId(staffId);
|
setDeletingStaffId(staffId);
|
||||||
@@ -151,13 +198,58 @@ export default function StoreAccountsPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
const staffColumns: ColumnsType<AdminStoreStaffItem> = [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 90 },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'staffRole',
|
||||||
|
width: 80,
|
||||||
|
render: (role) => STORE_STAFF_ROLE_LABELS[role as StoreStaffRole] || role || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
render: (_, staff) =>
|
||||||
|
staff.stores?.length ? staff.stores.map((s) => s.name).join('、') : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 70,
|
||||||
|
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_, staff) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openEditStaff(staff)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<AdminListHeader
|
||||||
title="门店账户"
|
title="门店账户"
|
||||||
settings={settingsButton}
|
settings={settingsButton}
|
||||||
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
description="主账号可绑定多家门店;收款信息挂在主账号;详情内可管理店员子账号"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -212,13 +304,13 @@ export default function StoreAccountsPage() {
|
|||||||
/>
|
/>
|
||||||
<Drawer
|
<Drawer
|
||||||
title="门店主账号"
|
title="门店主账号"
|
||||||
width={520}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
detail && (
|
detail && (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={detail.status}
|
value={detail.status}
|
||||||
style={{ width: 100 }}
|
style={{ width: 100 }}
|
||||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
onChange={async (status) => {
|
onChange={async (status) => {
|
||||||
@@ -227,7 +319,7 @@ export default function StoreAccountsPage() {
|
|||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
void reload();
|
await refreshDetail(detail.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -251,48 +343,22 @@ export default function StoreAccountsPage() {
|
|||||||
{!detail.stores?.length ? '—' : null}
|
{!detail.stores?.length ? '—' : null}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{detail.staff?.length ? (
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
|
||||||
<>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Typography.Title level={5} style={{ marginTop: 24 }}>子账号</Typography.Title>
|
子账号({detail.staff?.length ?? 0})· 仅主账号可添加,不可多级
|
||||||
<Table
|
</Typography.Text>
|
||||||
rowKey="id"
|
<Button size="small" type="primary" onClick={openAddStaff}>
|
||||||
size="small"
|
添加子账号
|
||||||
pagination={false}
|
</Button>
|
||||||
dataSource={detail.staff}
|
</div>
|
||||||
columns={[
|
<Table
|
||||||
{ title: '姓名', dataIndex: 'name' },
|
rowKey="id"
|
||||||
{ title: '手机', dataIndex: 'phone' },
|
size="small"
|
||||||
{
|
pagination={false}
|
||||||
title: '状态',
|
dataSource={detail.staff ?? []}
|
||||||
dataIndex: 'status',
|
columns={staffColumns}
|
||||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
locale={{ emptyText: '暂无子账号' }}
|
||||||
},
|
/>
|
||||||
{
|
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
|
|
||||||
暂无子账号
|
|
||||||
</Typography.Paragraph>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -321,6 +387,87 @@ export default function StoreAccountsPage() {
|
|||||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={detail ? `添加子账号 · ${detail.name}` : '添加子账号'}
|
||||||
|
open={staffOpen}
|
||||||
|
onCancel={() => setStaffOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!detail) return;
|
||||||
|
const v = await staffForm.validateFields();
|
||||||
|
await request(`/admin/store-accounts/${detail.id}/staff`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(v),
|
||||||
|
});
|
||||||
|
message.success('子账号已创建');
|
||||||
|
setStaffOpen(false);
|
||||||
|
await refreshDetail(detail.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={staffForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="登录手机"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
|
||||||
|
<Select mode="multiple" options={parentStoreOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title="编辑子账号"
|
||||||
|
open={staffEditOpen}
|
||||||
|
onCancel={() => setStaffEditOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!detail || !editingStaffId) return;
|
||||||
|
const v = await staffEditForm.validateFields();
|
||||||
|
await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(v),
|
||||||
|
});
|
||||||
|
message.success('子账号已更新');
|
||||||
|
setStaffEditOpen(false);
|
||||||
|
await refreshDetail(detail.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={staffEditForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="登录手机"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
|
||||||
|
<Select mode="multiple" options={parentStoreOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
|
import { appendExportColumns } from '../lib/export-columns';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
@@ -44,6 +45,8 @@ type Row = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
status: StoreSettlementStatus;
|
status: StoreSettlementStatus;
|
||||||
date: string;
|
date: string;
|
||||||
|
periodStart?: string | null;
|
||||||
|
periodEnd?: string | null;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
redeemCount?: number | null;
|
redeemCount?: number | null;
|
||||||
redeemAmount?: number | null;
|
redeemAmount?: number | null;
|
||||||
@@ -198,6 +201,7 @@ export default function StoreBillsPage() {
|
|||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
|
appendExportColumns(qs, exportColumnKeys);
|
||||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||||
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
||||||
@@ -218,6 +222,7 @@ export default function StoreBillsPage() {
|
|||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
|
key: '类型',
|
||||||
dataIndex: 'kind',
|
dataIndex: 'kind',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (k: StoreSettlementKind) => (
|
render: (k: StoreSettlementKind) => (
|
||||||
@@ -228,6 +233,7 @@ export default function StoreBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '单号',
|
title: '单号',
|
||||||
|
key: '账单号',
|
||||||
dataIndex: 'billNo',
|
dataIndex: 'billNo',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
@@ -243,39 +249,58 @@ export default function StoreBillsPage() {
|
|||||||
出账日
|
出账日
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
key: '出账日',
|
||||||
dataIndex: 'date',
|
dataIndex: 'date',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
{
|
||||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
title: (
|
||||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
<Tooltip title="T+1 账期为出账日前一日的核销窗口;手动提现无账期">
|
||||||
|
账期
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
key: '账期',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.kind === 'T1_BILL' && row.periodStart
|
||||||
|
? `${row.periodStart} ~ ${row.periodEnd}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{ title: '门店', key: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||||
|
{ title: '登录手机', key: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
|
{ title: '城市', key: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
{
|
{
|
||||||
title: '收款户名',
|
title: '收款户名',
|
||||||
|
key: '收款户名',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
|
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收款账号',
|
title: '收款账号',
|
||||||
|
key: '收款账号',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
|
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '笔数',
|
title: '笔数',
|
||||||
|
key: '笔数',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付金额',
|
title: '应付金额',
|
||||||
|
key: '应付金额',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s: string, row) => (
|
render: (s: string, row) => (
|
||||||
@@ -325,7 +350,7 @@ export default function StoreBillsPage() {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -495,6 +520,11 @@ export default function StoreBillsPage() {
|
|||||||
<Descriptions.Item label="出账日">
|
<Descriptions.Item label="出账日">
|
||||||
{String(detail.billDate || '').slice(0, 10)}
|
{String(detail.billDate || '').slice(0, 10)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账期">
|
||||||
|
{detail.periodStart
|
||||||
|
? `${String(detail.periodStart).slice(0, 10)} ~ ${String(detail.periodEnd || '').slice(0, 10)}`
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="应付">
|
<Descriptions.Item label="应付">
|
||||||
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
|
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export default function StoresPage() {
|
|||||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||||
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
||||||
const initialStoreId = searchParams.get('storeId') ?? '';
|
const initialStoreId = searchParams.get('storeId') ?? '';
|
||||||
|
const initialTab = searchParams.get('tab') ?? '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
@@ -476,13 +477,13 @@ export default function StoresPage() {
|
|||||||
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
||||||
if (row) {
|
if (row) {
|
||||||
deepLinkStoreOpenedRef.current = true;
|
deepLinkStoreOpenedRef.current = true;
|
||||||
void openStoreDetail(row);
|
void openStoreDetail(row, { tab: initialTab || undefined });
|
||||||
} else if (data && (data.items?.length ?? 0) >= 0) {
|
} else if (data && (data.items?.length ?? 0) >= 0) {
|
||||||
// 列表无该店时仍尝试直拉详情
|
// 列表无该店时仍尝试直拉详情
|
||||||
deepLinkStoreOpenedRef.current = true;
|
deepLinkStoreOpenedRef.current = true;
|
||||||
void openStoreDetail({ id: initialStoreId } as StoreRow);
|
void openStoreDetail({ id: initialStoreId } as StoreRow, { tab: initialTab || undefined });
|
||||||
}
|
}
|
||||||
}, [data, initialStoreId, loading]);
|
}, [data, initialStoreId, initialTab, loading]);
|
||||||
|
|
||||||
async function saveStoreDetail() {
|
async function saveStoreDetail() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -1276,14 +1277,14 @@ export default function StoresPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="bankAccountNo"
|
name="bankAccountNo"
|
||||||
label="银行卡号"
|
label="银行账号"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
validator: async (_, value) => {
|
validator: async (_, value) => {
|
||||||
const v = String(value || '').trim();
|
const v = String(value || '').trim();
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (!/^\d{16,19}$/.test(v)) {
|
if (!/^\d{16,19}$/.test(v)) {
|
||||||
throw new Error('银行卡号须为 16–19 位数字');
|
throw new Error('银行账号须为 16–19 位数字');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -159,6 +159,9 @@ function renderField(
|
|||||||
|
|
||||||
export default function SystemSettingsPage() {
|
export default function SystemSettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const groupKey = searchParams.get('group')?.trim() || '';
|
||||||
|
const [activeKeys, setActiveKeys] = useState<string[]>(() => (groupKey ? [groupKey] : []));
|
||||||
const [form] = Form.useForm<Record<string, string>>();
|
const [form] = Form.useForm<Record<string, string>>();
|
||||||
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
@@ -327,13 +330,30 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key: group.key,
|
key: group.key,
|
||||||
label: group.label,
|
label: <span id={`sys-setting-group-${group.key}`}>{group.label}</span>,
|
||||||
forceRender: true,
|
forceRender: true,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [meta, mockSmsEnabled, loading]);
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupKey) {
|
||||||
|
setActiveKeys((prev) => (prev.includes(groupKey) ? prev : [...prev, groupKey]));
|
||||||
|
}
|
||||||
|
}, [groupKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupKey || loading) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
document.getElementById(`sys-setting-group-${groupKey}`)?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}, 80);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [groupKey, loading]);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
await form.validateFields();
|
await form.validateFields();
|
||||||
const values = form.getFieldsValue(true);
|
const values = form.getFieldsValue(true);
|
||||||
@@ -432,7 +452,11 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
||||||
<Collapse defaultActiveKey={[]} items={collapseItems} />
|
<Collapse
|
||||||
|
activeKey={activeKeys}
|
||||||
|
onChange={(keys) => setActiveKeys(Array.isArray(keys) ? keys : [keys])}
|
||||||
|
items={collapseItems}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
{meta?.updatedAt ? (
|
{meta?.updatedAt ? (
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
WECOM_PLUGIN_API_KEY_HEADER,
|
||||||
|
WECOM_PLUGIN_PERMISSION_GROUPS,
|
||||||
|
WECOM_PLUGIN_PERMISSION_LABELS,
|
||||||
|
resolveWecomPluginPublicUrl,
|
||||||
|
type WecomApiPluginDto,
|
||||||
|
type WecomApiPluginSecretDto,
|
||||||
|
type WecomPluginPermission,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
apiKey?: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
remark?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WecomApiPluginsPage() {
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<WecomApiPluginDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rotatingId, setRotatingId] = useState<string | null>(null);
|
||||||
|
const [revealed, setRevealed] = useState<WecomApiPluginSecretDto | null>(null);
|
||||||
|
|
||||||
|
const pluginUrl = resolveWecomPluginPublicUrl(window.location.hostname);
|
||||||
|
const permissionsWatch = Form.useWatch('permissions', form) as WecomPluginPermission[] | undefined;
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||||
|
useAdminList<WecomApiPluginDto>(
|
||||||
|
'/admin/wecom-api-plugins',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
enabled: true,
|
||||||
|
permissions: [],
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: WecomApiPluginDto) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
apiKey: '',
|
||||||
|
permissions: [...row.permissions],
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: values.name,
|
||||||
|
permissions: values.permissions,
|
||||||
|
remark: values.remark?.trim() || null,
|
||||||
|
enabled: values.enabled,
|
||||||
|
sortOrder: values.sortOrder ?? 0,
|
||||||
|
apiKey: values.apiKey?.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/wecom-api-plugins/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
const created = await request<WecomApiPluginSecretDto>('/admin/wecom-api-plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setRevealed(created);
|
||||||
|
message.success('已创建,请立即复制密钥');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/wecom-api-plugins/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateKey(id: string) {
|
||||||
|
setRotatingId(id);
|
||||||
|
try {
|
||||||
|
const row = await request<WecomApiPluginSecretDto>(`/admin/wecom-api-plugins/${id}/rotate-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
setRevealed(row);
|
||||||
|
message.success('已轮换,请立即复制新密钥');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '轮换失败');
|
||||||
|
} finally {
|
||||||
|
setRotatingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<WecomApiPluginDto> = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '密钥', dataIndex: 'apiKeyMasked', width: 160 },
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
render: (perms: WecomPluginPermission[]) =>
|
||||||
|
perms.map((p) => (
|
||||||
|
<Tag key={p} style={{ marginBottom: 4 }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_LABELS[p]}
|
||||||
|
</Tag>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 72,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="轮换后旧 Key 立即失效,确认?"
|
||||||
|
onConfirm={() => void rotateKey(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" loading={rotatingId === row.id}>
|
||||||
|
轮换密钥
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns(
|
||||||
|
'wecom-api-plugins',
|
||||||
|
baseColumns,
|
||||||
|
{ page, pageSize },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
settings={settingsButton}
|
||||||
|
description="每个实例对应企微里一只 API 插件:共用 Base URL,用不同 X-Api-Key 区分,并按勾选权限放行工具。"
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建插件
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="填企微「添加 API 插件」第 1 步"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
插件 URL:
|
||||||
|
<Typography.Text copyable>{pluginUrl}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
OpenAPI(带该实例 Key):
|
||||||
|
<Typography.Text copyable>{`${pluginUrl}/openapi.json`}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER}
|
||||||
|
;第 2 步只添加本实例已勾选的工具(GET + Query)。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v as Record<string, string>);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="搜索" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: '启用' },
|
||||||
|
{ value: 'false', label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
filterForm.resetFields();
|
||||||
|
setFilters({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table<WecomApiPluginDto>
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑 API 插件' : '新建 API 插件'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void save()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" preserve={false}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="如:运营查询、客服只读" maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
{!editing ? (
|
||||||
|
<Form.Item
|
||||||
|
name="apiKey"
|
||||||
|
label="API Key"
|
||||||
|
extra="留空则服务端自动生成;创建后只回显一次"
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="留空自动生成" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="API Key">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{editing.apiKeyMasked}(列表不回显明文,需要时请轮换)
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="permissions"
|
||||||
|
label="工具权限"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, v: WecomPluginPermission[] | undefined) =>
|
||||||
|
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项')),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra={
|
||||||
|
permissionsWatch?.length ? (
|
||||||
|
<Typography.Text type="secondary">已选 {permissionsWatch.length} 项</Typography.Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox.Group style={{ width: '100%' }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_GROUPS.map((group) => (
|
||||||
|
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong>{group.label}</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{group.permissions.map((p) => (
|
||||||
|
<Checkbox key={p} value={p} style={{ marginInlineStart: 0, marginRight: 12 }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_LABELS[p]}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} placeholder="企微侧插件名等" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="请立即复制密钥"
|
||||||
|
open={!!revealed}
|
||||||
|
onCancel={() => setRevealed(null)}
|
||||||
|
footer={[
|
||||||
|
<Button key="ok" type="primary" onClick={() => setRevealed(null)}>
|
||||||
|
已复制
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{revealed ? (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
实例「{revealed.name}」的 {WECOM_PLUGIN_API_KEY_HEADER},关闭后不再展示。
|
||||||
|
</p>
|
||||||
|
<Typography.Text copyable>{revealed.apiKey}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -403,6 +405,20 @@ export default function WecomBotsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="企微 API 插件已迁入独立页"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
多实例、密钥与工具权限请到{' '}
|
||||||
|
<Link to="/wecom/plugins">企微机器人 → API 插件</Link>
|
||||||
|
{' '}管理,与上方长连接机器人独立。
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
TimePicker,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import {
|
||||||
|
WECOM_REPORT_KIND_LABELS,
|
||||||
|
WECOM_REPORT_KINDS,
|
||||||
|
WECOM_REPORT_WEEKDAY_OPTIONS,
|
||||||
|
type WecomReportKind,
|
||||||
|
type WecomReportPreviewDto,
|
||||||
|
type WecomReportPushDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
webhookUrl: string;
|
||||||
|
enabled: boolean;
|
||||||
|
mentionWecomUserId?: string;
|
||||||
|
sendTime: Dayjs;
|
||||||
|
sendWeekday: number;
|
||||||
|
sendMonthDay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReportKindPane({
|
||||||
|
kind,
|
||||||
|
row,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
kind: WecomReportKind;
|
||||||
|
row: WecomReportPushDto | undefined;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<WecomReportPreviewDto | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!row) return;
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
webhookUrl: row.webhookUrl,
|
||||||
|
enabled: row.enabled,
|
||||||
|
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
|
||||||
|
sendTime: dayjs().hour(row.sendHour).minute(row.sendMinute).second(0),
|
||||||
|
sendWeekday: row.sendWeekday,
|
||||||
|
sendMonthDay: row.sendMonthDay,
|
||||||
|
});
|
||||||
|
}, [form, row]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/wecom-reports/${kind}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
webhookUrl: values.webhookUrl,
|
||||||
|
enabled: values.enabled,
|
||||||
|
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
|
||||||
|
sendHour: values.sendTime.hour(),
|
||||||
|
sendMinute: values.sendTime.minute(),
|
||||||
|
sendWeekday: values.sendWeekday,
|
||||||
|
sendMonthDay: values.sendMonthDay,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview() {
|
||||||
|
setPreviewing(true);
|
||||||
|
try {
|
||||||
|
const data = await request<WecomReportPreviewDto>(`/admin/wecom-reports/${kind}/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
setPreview(data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '预览失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNow() {
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ message: string }>(`/admin/wecom-reports/${kind}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
message.success(res.message || '已发送');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '发送失败');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = WECOM_REPORT_KIND_LABELS[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form form={form} layout="vertical" style={{ maxWidth: 640 }}>
|
||||||
|
<Form.Item name="enabled" label="启用定时推送" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="webhookUrl"
|
||||||
|
label="群机器人 Webhook"
|
||||||
|
extra="企业微信群「群机器人」Webhook,与「消息推送」分开配置。"
|
||||||
|
rules={[{ required: true, message: '请填写 Webhook' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="mentionWecomUserId" label="@成员 userid">
|
||||||
|
<Input placeholder="可选,企业微信成员账号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="sendTime"
|
||||||
|
label="发送时刻"
|
||||||
|
extra={
|
||||||
|
kind === 'daily'
|
||||||
|
? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。'
|
||||||
|
: kind === 'weekly'
|
||||||
|
? '按北京时间该星期该时刻发送上一自然周;不含发送当天。'
|
||||||
|
: '按北京时间每月该日该时刻发送上一自然月;不含发送当天。'
|
||||||
|
}
|
||||||
|
rules={[{ required: true, message: '请选择时刻' }]}
|
||||||
|
>
|
||||||
|
<TimePicker format="HH:mm" minuteStep={1} allowClear={false} />
|
||||||
|
</Form.Item>
|
||||||
|
{kind === 'weekly' ? (
|
||||||
|
<Form.Item name="sendWeekday" label="发送星期" rules={[{ required: true }]}>
|
||||||
|
<Select options={WECOM_REPORT_WEEKDAY_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
{kind === 'monthly' ? (
|
||||||
|
<Form.Item name="sendMonthDay" label="每月几号" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={31} />
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{row?.lastSentAt
|
||||||
|
? `上次发送:${fmtTime(row.lastSentAt)}(${row.lastSentPeriod ?? '—'})`
|
||||||
|
: '尚未发送'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button loading={previewing} onClick={() => void openPreview()}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button loading={sending} onClick={() => void sendNow()}>
|
||||||
|
立即发送
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
<Modal
|
||||||
|
title={`预览${label}`}
|
||||||
|
open={!!preview}
|
||||||
|
onCancel={() => setPreview(null)}
|
||||||
|
footer={<Button onClick={() => setPreview(null)}>关闭</Button>}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
区间 {preview?.rangeLabel}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{preview?.markdown}</pre>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WecomReportsPage() {
|
||||||
|
const [rows, setRows] = useState<WecomReportPushDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
return request<WecomReportPushDto[]>('/admin/wecom-reports')
|
||||||
|
.then(setRows)
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const byKind = new Map(rows.map((r) => [r.kind, r]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>企微机器人 · 报告</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
日报 / 周报 / 月报走企微群机器人 Webhook,配置与「消息推送」相互独立。口径与概览一致:数量为期末存量,新增为区间内发生额;订单金额按已付
|
||||||
|
payAmount,核销按核销单。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Tabs
|
||||||
|
items={WECOM_REPORT_KINDS.map((kind) => ({
|
||||||
|
key: kind,
|
||||||
|
label: WECOM_REPORT_KIND_LABELS[kind],
|
||||||
|
children: <ReportKindPane kind={kind} row={byKind.get(kind)} onSaved={() => void load()} />,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
|
import { appendExportColumns } from '../lib/export-columns';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
@@ -37,6 +39,8 @@ type Row = {
|
|||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
billDate: string;
|
billDate: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
orderAmount: number;
|
orderAmount: number;
|
||||||
wineryRate: number;
|
wineryRate: number;
|
||||||
@@ -107,6 +111,7 @@ const WINERY_BANK_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function WineryBillsPage() {
|
export default function WineryBillsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [bankForm] = Form.useForm<Record<string, string>>();
|
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||||
const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>();
|
const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>();
|
||||||
@@ -196,6 +201,7 @@ export default function WineryBillsPage() {
|
|||||||
if (filters.month) qs.set('month', filters.month);
|
if (filters.month) qs.set('month', filters.month);
|
||||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
|
appendExportColumns(qs, exportColumnKeys);
|
||||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||||
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
||||||
@@ -275,6 +281,7 @@ export default function WineryBillsPage() {
|
|||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '账单号',
|
title: '账单号',
|
||||||
|
key: '账单号',
|
||||||
dataIndex: 'billNo',
|
dataIndex: 'billNo',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
@@ -284,34 +291,49 @@ export default function WineryBillsPage() {
|
|||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="出账当天的北京日历日;T+3 只决定纳入哪天完成的订单">
|
<Tooltip title="出账当天的北京日历日;T+3 只决定纳入哪天完成的订单">
|
||||||
账单日
|
出账日
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
key: '出账日',
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'billDate',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => String(v || '').slice(0, 10),
|
render: (v) => String(v || '').slice(0, 10),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
|
||||||
{
|
{
|
||||||
title: '酒单实付合计',
|
title: (
|
||||||
|
<Tooltip title="纳入本账单的订单完成日区间(含起止日)">
|
||||||
|
账期
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
key: '账期',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => `${row.periodStart} ~ ${row.periodEnd}`,
|
||||||
|
},
|
||||||
|
{ title: '订单数', key: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '订单总额',
|
||||||
|
key: '订单总额',
|
||||||
dataIndex: 'orderAmount',
|
dataIndex: 'orderAmount',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂比例',
|
title: '酒厂比例',
|
||||||
|
key: '酒厂比例',
|
||||||
dataIndex: 'wineryRate',
|
dataIndex: 'wineryRate',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付',
|
title: '应付',
|
||||||
|
key: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s, row) => {
|
render: (s, row) => {
|
||||||
@@ -338,7 +360,7 @@ export default function WineryBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -365,7 +387,7 @@ export default function WineryBillsPage() {
|
|||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="账单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="订单总额" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -460,7 +482,10 @@ export default function WineryBillsPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
<Descriptions.Item label="出账日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账期">
|
||||||
|
{detail.periodStart} ~ {detail.periodEnd}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||||||
@@ -500,7 +525,20 @@ export default function WineryBillsPage() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={detail.items ?? []}
|
dataSource={detail.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'orderNo' },
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'orderNo',
|
||||||
|
render: (v: string) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '配送',
|
title: '配送',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'deliveryType',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
Alert, Button, Card, Col, Descriptions, Form, Image, Input, InputNumber, Row, Select, Space,
|
||||||
Tabs, Tag, Typography, message,
|
Tabs, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -53,6 +53,22 @@ const CREATE_DEFAULTS = {
|
|||||||
|
|
||||||
function ResultPanel({ result }: { result: ApiResult | null }) {
|
function ResultPanel({ result }: { result: ApiResult | null }) {
|
||||||
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
const display = (() => {
|
||||||
|
if (!result.data || typeof result.data !== 'object') return result;
|
||||||
|
const data = result.data as { count?: number; data?: string[] };
|
||||||
|
if (!Array.isArray(data.data)) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
data: data.data.map((item) =>
|
||||||
|
typeof item === 'string' && item.length > 120
|
||||||
|
? `${item.slice(0, 80)}…(len=${item.length})`
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 8 }}>
|
<Space style={{ marginBottom: 8 }}>
|
||||||
@@ -64,7 +80,7 @@ function ResultPanel({ result }: { result: ApiResult | null }) {
|
|||||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
{JSON.stringify(result, null, 2)}
|
{JSON.stringify(display, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,7 +97,9 @@ export default function XiaofeixiaTestPage() {
|
|||||||
const [queryForm] = Form.useForm();
|
const [queryForm] = Form.useForm();
|
||||||
const [batchForm] = Form.useForm();
|
const [batchForm] = Form.useForm();
|
||||||
const [trackForm] = Form.useForm();
|
const [trackForm] = Form.useForm();
|
||||||
|
const [signPhotoForm] = Form.useForm();
|
||||||
const [cancelForm] = Form.useForm();
|
const [cancelForm] = Form.useForm();
|
||||||
|
const [signPhotoPreview, setSignPhotoPreview] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
||||||
@@ -99,12 +117,19 @@ export default function XiaofeixiaTestPage() {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
setResult(res);
|
setResult(res);
|
||||||
|
if (path.endsWith('/get-sign-photos')) {
|
||||||
|
const data = (res.data as { data?: string[] } | undefined)?.data;
|
||||||
|
setSignPhotoPreview(Array.isArray(data) ? data.filter((u) => typeof u === 'string') : []);
|
||||||
|
} else {
|
||||||
|
setSignPhotoPreview([]);
|
||||||
|
}
|
||||||
if (res.ok) message.success('调用成功');
|
if (res.ok) message.success('调用成功');
|
||||||
else message.warning(res.error || '调用失败');
|
else message.warning(res.error || '调用失败');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e.message : String(e);
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
message.error(err);
|
message.error(err);
|
||||||
setResult({ ok: false, elapsedMs: 0, error: err });
|
setResult({ ok: false, elapsedMs: 0, error: err });
|
||||||
|
setSignPhotoPreview([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -118,7 +143,7 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301,含签收图片 100108)。凭证请在
|
||||||
<Link to="/fulfillment-providers">仓配管理</Link>
|
<Link to="/fulfillment-providers">仓配管理</Link>
|
||||||
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -273,6 +298,25 @@ export default function XiaofeixiaTestPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'signPhotos',
|
||||||
|
label: '签收图片 (100108)',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={signPhotoForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-sign-photos', v)}
|
||||||
|
>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number">
|
||||||
|
<Input placeholder="小飞侠运单号,与商家单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber">
|
||||||
|
<Input placeholder="外部单号,与运单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'cancel',
|
key: 'cancel',
|
||||||
label: '取消订单 (100103)',
|
label: '取消订单 (100103)',
|
||||||
@@ -290,6 +334,24 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card title="响应结果" size="small">
|
<Card title="响应结果" size="small">
|
||||||
<ResultPanel result={result} />
|
<ResultPanel result={result} />
|
||||||
|
{signPhotoPreview.length > 0 ? (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text strong>签收照片预览({signPhotoPreview.length})</Typography.Text>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap style={{ marginTop: 8 }}>
|
||||||
|
{signPhotoPreview.map((url, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${index}-${url.slice(0, 32)}`}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -37,8 +37,33 @@ const descContentStyle: CSSProperties = {
|
|||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCAN_HINT =
|
||||||
|
'按人次累计:每次扫码或带参进入记一次,未登录也计入;同一用户多次进入记多次。同一次打开内的重复上报、以及登录后补归因不重复计。不是按人去重。';
|
||||||
|
|
||||||
const ATTRIBUTION_HINT =
|
const ATTRIBUTION_HINT =
|
||||||
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
'首次触达本推广码的用户,按人计:每人只归因一次、只归一个码。已登录扫码时,若还没有归因记录则记到本码;未登录先扫码、登录后再补记。之后再扫其他码不会改。未登录连续扫多个码时,登录后归到最后一次缓存的码。与「扫码注册」不同:已有分享等其他来源的用户仍可计入归因,但不计入扫码注册。';
|
||||||
|
|
||||||
|
const REGISTER_HINT =
|
||||||
|
'用户来源仍是自然量时,才标记为本推广码。已有分享等其他来源的用户不计入,因此通常小于或等于归因用户数。';
|
||||||
|
|
||||||
|
const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成订单。';
|
||||||
|
|
||||||
|
const CONVERSION_HINT =
|
||||||
|
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
|
||||||
|
|
||||||
|
function StatHint({ label, hint }: { label: string; hint: string }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
<Tooltip title={hint} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||||
|
<QuestionCircleOutlined
|
||||||
|
style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
async function downloadQrcode(url: string, filename: string) {
|
||||||
try {
|
try {
|
||||||
@@ -221,20 +246,16 @@ export default function PromoCodeDetailPage() {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||||
>
|
>
|
||||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
<Statistic
|
||||||
|
title={<StatHint label="扫码进入次数" hint={SCAN_HINT} />}
|
||||||
|
value={stats?.scanCount ?? detail.scanCount}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<StatHint label="归因用户数" hint={ATTRIBUTION_HINT} />}
|
||||||
<span>
|
|
||||||
归因用户数
|
|
||||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
|
||||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats?.attributionCount ?? 0}
|
value={stats?.attributionCount ?? 0}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -242,7 +263,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="扫码注册用户数"
|
title={<StatHint label="扫码注册用户数" hint={REGISTER_HINT} />}
|
||||||
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -255,14 +276,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<StatHint label="订单数" hint={ORDER_HINT} />}
|
||||||
<span>
|
|
||||||
订单数
|
|
||||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
|
||||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats?.orderCount ?? detail.orderCount}
|
value={stats?.orderCount ?? detail.orderCount}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -270,7 +284,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="转化率"
|
title={<StatHint label="转化率" hint={CONVERSION_HINT} />}
|
||||||
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
extra={
|
extra={
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
仅统计功能上线后的新事件
|
仅统计功能上线后的新事件。扫码进入按人次(多次进入记多次);归因 / 注册按人(每人一次)。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export default function PromoCodeUsersPage() {
|
|||||||
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '首次触达',
|
title: (
|
||||||
|
<Tooltip title="该用户首次归因到本码的时间。无值表示仅用户来源指向本码,归因记录在其他码。">
|
||||||
|
首次触达
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
dataIndex: 'firstTouchAt',
|
dataIndex: 'firstTouchAt',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v) => (v ? fmtTime(v) : '—'),
|
render: (v) => (v ? fmtTime(v) : '—'),
|
||||||
@@ -87,7 +91,10 @@ export default function PromoCodeUsersPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader settings={settingsButton} />
|
<AdminListHeader
|
||||||
|
settings={settingsButton}
|
||||||
|
description="含归因到本码、或用户来源标记为本码的用户。"
|
||||||
|
/>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/h5-partner",
|
"name": "@dukang/h5-partner",
|
||||||
"version": "0.1.0",
|
"version": "4.0.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ export default function AssocQrcodePage() {
|
|||||||
<section className="partner-bill-card" style={{ margin: 16 }}>
|
<section className="partner-bill-card" style={{ margin: 16 }}>
|
||||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
||||||
用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算
|
{summary?.isSubAccount
|
||||||
|
? '子账号专属二维码:用户扫码后锁定主账号(佣金归主账号),额外计入本子账号统计'
|
||||||
|
: '用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算'}
|
||||||
</p>
|
</p>
|
||||||
{summary?.qrcodeUrl ? (
|
{summary?.qrcodeUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -75,6 +77,7 @@ export default function AssocQrcodePage() {
|
|||||||
)}
|
)}
|
||||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
已关联 {summary?.userCount ?? 0} 人
|
已关联 {summary?.userCount ?? 0} 人
|
||||||
|
{typeof summary?.scanCount === 'number' ? ` · 已扫码 ${summary.scanCount} 次` : ''}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
||||||
下载二维码
|
下载二维码
|
||||||
|
|||||||
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
|||||||
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-leaderboard-stat">
|
<div className="partner-leaderboard-stat">
|
||||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
{entry.periodStores > 0 ? (
|
||||||
<p className="label-md text-muted">本月新增</p>
|
<>
|
||||||
|
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||||
|
<p className="label-md text-muted">本月新增</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
|
|||||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-leaderboard-stat">
|
<div className="partner-leaderboard-stat">
|
||||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
{entry.periodStores > 0 ? (
|
||||||
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
<>
|
||||||
|
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||||
|
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
||||||
|
import { validateShippingAddress } from '@dukang/domain';
|
||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
import { getToken, request } from '../lib/api';
|
import { getToken, request } from '../lib/api';
|
||||||
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||||
@@ -243,6 +244,13 @@ export default function ProxyOrderPage() {
|
|||||||
if (!allowOnline) return '该商品不支持线上购买';
|
if (!allowOnline) return '该商品不支持线上购买';
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
if (!addressDetail.trim()) return '请填写详细地址';
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
|
const shipping = validateShippingAddress({
|
||||||
|
province: region.province,
|
||||||
|
city: region.city,
|
||||||
|
district: region.district,
|
||||||
|
detail: addressDetail,
|
||||||
|
});
|
||||||
|
if (!shipping.ok) return shipping.message || '请完善收货地址';
|
||||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
} else if (!allowOnSite) {
|
} else if (!allowOnSite) {
|
||||||
return '该商品不支持现场提货';
|
return '该商品不支持现场提货';
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ export default function UsersManagePage() {
|
|||||||
|
|
||||||
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
||||||
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
||||||
用户扫码后首次锁定,后续购酒计入关联订单
|
{summary?.isSubAccount
|
||||||
|
? '子账号专属二维码:扫码锁定主账号(佣金归主账号),下方统计仅计本子账号'
|
||||||
|
: '用户扫码后首次锁定,后续购酒计入关联订单'}
|
||||||
</p>
|
</p>
|
||||||
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
|
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/h5-shop",
|
"name": "@dukang/h5-shop",
|
||||||
"version": "0.1.0",
|
"version": "4.0.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1923,6 +1923,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 休息中核销·开张确认弹窗 ─── */
|
/* ─── 休息中核销·开张确认弹窗 ─── */
|
||||||
.shop-redeem-modal {
|
.shop-redeem-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "4.0.10",
|
"version": "4.0.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ export default function OrderQtyControls({ value, unitLabel, onChange }: OrderQt
|
|||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onConfirm={commit}
|
onConfirm={commit}
|
||||||
/>
|
/>
|
||||||
<Text className="order-qty-unit">{unitLabel}</Text>
|
|
||||||
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Text className="order-qty-unit">{unitLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
getCitiesForPicker,
|
getCitiesForPicker,
|
||||||
getDistricts,
|
getDistricts,
|
||||||
getDistrictsForPicker,
|
getDistrictsForPicker,
|
||||||
|
getDistrictsForShippingPicker,
|
||||||
getProvincesForPicker,
|
getProvincesForPicker,
|
||||||
normalizeRegionSelection,
|
normalizeRegionSelection,
|
||||||
|
normalizeShippingRegionSelection,
|
||||||
toCityLevelRegion,
|
toCityLevelRegion,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../lib/region-data';
|
} from '../lib/region-data';
|
||||||
@@ -18,6 +20,8 @@ type RegionPickerProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (region: RegionSelection) => void;
|
onConfirm: (region: RegionSelection) => void;
|
||||||
levels?: 2 | 3;
|
levels?: 2 | 3;
|
||||||
|
/** filter:门店筛选可「全市」;shipping:收货地址禁止伪区县 */
|
||||||
|
mode?: 'filter' | 'shipping';
|
||||||
};
|
};
|
||||||
|
|
||||||
type PickerLevel = 'province' | 'city' | 'district';
|
type PickerLevel = 'province' | 'city' | 'district';
|
||||||
@@ -28,8 +32,13 @@ const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
|||||||
{ key: 'district', label: '区县' },
|
{ key: 'district', label: '区县' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
function initialTab(value: RegionSelection, levels: 2 | 3, mode: 'filter' | 'shipping'): PickerLevel {
|
||||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
const normalized =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(value)
|
||||||
|
: mode === 'shipping'
|
||||||
|
? normalizeShippingRegionSelection(value)
|
||||||
|
: normalizeRegionSelection(value);
|
||||||
if (levels === 2) {
|
if (levels === 2) {
|
||||||
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||||
}
|
}
|
||||||
@@ -54,24 +63,35 @@ export default function RegionPicker({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
levels = 3,
|
levels = 3,
|
||||||
|
mode = 'filter',
|
||||||
}: RegionPickerProps) {
|
}: RegionPickerProps) {
|
||||||
const [draft, setDraft] = useState<RegionSelection>(value);
|
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||||
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||||
|
|
||||||
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||||
|
const shipping = mode === 'shipping';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
const normalized =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(value)
|
||||||
|
: shipping
|
||||||
|
? normalizeShippingRegionSelection(value)
|
||||||
|
: normalizeRegionSelection(value);
|
||||||
setDraft(normalized);
|
setDraft(normalized);
|
||||||
setActiveTab(initialTab(value, levels));
|
setActiveTab(initialTab(value, levels, mode));
|
||||||
}, [open, value, levels]);
|
}, [open, value, levels, mode, shipping]);
|
||||||
|
|
||||||
const listItems = useMemo(() => {
|
const listItems = useMemo(() => {
|
||||||
if (activeTab === 'province') return getProvincesForPicker();
|
if (activeTab === 'province') return getProvincesForPicker();
|
||||||
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
if (activeTab === 'city') {
|
||||||
|
if (shipping) return getCities(draft.province);
|
||||||
|
return getCitiesForPicker(draft.province);
|
||||||
|
}
|
||||||
|
if (shipping) return getDistrictsForShippingPicker(draft.province, draft.city);
|
||||||
return getDistrictsForPicker(draft.province, draft.city);
|
return getDistrictsForPicker(draft.province, draft.city);
|
||||||
}, [activeTab, draft.province, draft.city]);
|
}, [activeTab, draft.province, draft.city, shipping]);
|
||||||
|
|
||||||
const selectedValue =
|
const selectedValue =
|
||||||
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||||
@@ -79,12 +99,19 @@ export default function RegionPicker({
|
|||||||
const canConfirm =
|
const canConfirm =
|
||||||
levels === 2
|
levels === 2
|
||||||
? Boolean(draft.province && draft.city)
|
? Boolean(draft.province && draft.city)
|
||||||
: Boolean(draft.province && draft.city && draft.district);
|
: shipping
|
||||||
|
? Boolean(
|
||||||
|
draft.province &&
|
||||||
|
draft.city &&
|
||||||
|
draft.district &&
|
||||||
|
draft.district !== REGION_ALL,
|
||||||
|
)
|
||||||
|
: Boolean(draft.province && draft.city && draft.district);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
function selectProvince(province: string) {
|
function selectProvince(province: string) {
|
||||||
if (province === REGION_ALL) {
|
if (!shipping && province === REGION_ALL) {
|
||||||
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||||
setActiveTab('city');
|
setActiveTab('city');
|
||||||
return;
|
return;
|
||||||
@@ -97,12 +124,16 @@ export default function RegionPicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextDistricts = getDistricts(province, city);
|
const nextDistricts = getDistricts(province, city);
|
||||||
setDraft({ province, city, district: nextDistricts[0] ?? '' });
|
setDraft({
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district: shipping ? '' : (nextDistricts[0] ?? ''),
|
||||||
|
});
|
||||||
setActiveTab('city');
|
setActiveTab('city');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCity(city: string) {
|
function selectCity(city: string) {
|
||||||
if (city === REGION_ALL) {
|
if (!shipping && city === REGION_ALL) {
|
||||||
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||||
if (levels === 3) setActiveTab('district');
|
if (levels === 3) setActiveTab('district');
|
||||||
return;
|
return;
|
||||||
@@ -112,11 +143,16 @@ export default function RegionPicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextDistricts = getDistricts(draft.province, city);
|
const nextDistricts = getDistricts(draft.province, city);
|
||||||
setDraft({ ...draft, city, district: nextDistricts[0] ?? '' });
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
city,
|
||||||
|
district: shipping ? '' : (nextDistricts[0] ?? ''),
|
||||||
|
});
|
||||||
setActiveTab('district');
|
setActiveTab('district');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDistrict(district: string) {
|
function selectDistrict(district: string) {
|
||||||
|
if (shipping && district === REGION_ALL) return;
|
||||||
setDraft({ ...draft, district });
|
setDraft({ ...draft, district });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +170,13 @@ export default function RegionPicker({
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
if (!canConfirm) return;
|
if (!canConfirm) return;
|
||||||
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
const next =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(draft)
|
||||||
|
: shipping
|
||||||
|
? normalizeShippingRegionSelection(draft)
|
||||||
|
: normalizeRegionSelection(draft);
|
||||||
|
if (shipping && (!next.district || next.district === REGION_ALL)) return;
|
||||||
onConfirm(next);
|
onConfirm(next);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||||||
|
import { formatRedeemRelativeTime } from '../lib/datetime';
|
||||||
|
|
||||||
export type StoreRedeemMarqueeItem = {
|
export type StoreRedeemMarqueeItem = {
|
||||||
userLabel: string;
|
userLabel: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
|
createdAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreRedeemMarqueeProps = {
|
type StoreRedeemMarqueeProps = {
|
||||||
items: StoreRedeemMarqueeItem[];
|
items: StoreRedeemMarqueeItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAY_MS = 20000;
|
const STAY_MS = 5000;
|
||||||
|
|
||||||
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
||||||
|
const timeLabel = formatRedeemRelativeTime(item.createdAt);
|
||||||
return (
|
return (
|
||||||
<View className="store-detail-marquee-inner">
|
<View className="store-detail-marquee-inner">
|
||||||
<View className="store-detail-marquee-dot" />
|
<View className="store-detail-marquee-dot" />
|
||||||
<Text className="store-detail-marquee-text">{item.userLabel} 刚刚到店核销</Text>
|
<Text className="store-detail-marquee-text">
|
||||||
|
{item.userLabel} {timeLabel}到店核销
|
||||||
|
</Text>
|
||||||
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
|
/** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
|
||||||
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||||
const list = useMemo(
|
const list = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
|||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
||||||
amount: String(row.amount || '').trim(),
|
amount: String(row.amount || '').trim(),
|
||||||
|
createdAt: row.createdAt ?? null,
|
||||||
}))
|
}))
|
||||||
.filter((row) => row.amount),
|
.filter((row) => row.amount),
|
||||||
[items],
|
[items],
|
||||||
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
|||||||
indicatorDots={false}
|
indicatorDots={false}
|
||||||
>
|
>
|
||||||
{list.map((row, index) => (
|
{list.map((row, index) => (
|
||||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
|
<SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
|
||||||
<MarqueeRow item={row} />
|
<MarqueeRow item={row} />
|
||||||
</SwiperItem>
|
</SwiperItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
|
const MS_MINUTE = 60_000;
|
||||||
|
const MS_HOUR = 60 * MS_MINUTE;
|
||||||
|
const MS_DAY = 24 * MS_HOUR;
|
||||||
|
|
||||||
|
function shanghaiParts(input: string | Date) {
|
||||||
|
const d = input instanceof Date ? input : new Date(input);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return {
|
||||||
|
year: sh.getUTCFullYear(),
|
||||||
|
month: p(sh.getUTCMonth() + 1),
|
||||||
|
day: p(sh.getUTCDate()),
|
||||||
|
hour: p(sh.getUTCHours()),
|
||||||
|
minute: p(sh.getUTCMinutes()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 核销走马灯时间:24 小时内相对时间,超过则显示具体时间 */
|
||||||
|
export function formatRedeemRelativeTime(input?: string | Date | null): string {
|
||||||
|
if (input == null || input === '') return '刚刚';
|
||||||
|
const at = input instanceof Date ? input : new Date(input);
|
||||||
|
if (Number.isNaN(at.getTime())) return '刚刚';
|
||||||
|
const diffMs = Date.now() - at.getTime();
|
||||||
|
if (diffMs < MS_MINUTE) return '刚刚';
|
||||||
|
if (diffMs < MS_HOUR) return `${Math.floor(diffMs / MS_MINUTE)}分钟前`;
|
||||||
|
if (diffMs < MS_DAY) return `${Math.floor(diffMs / MS_HOUR)}小时前`;
|
||||||
|
const parts = shanghaiParts(at);
|
||||||
|
if (!parts) return '刚刚';
|
||||||
|
const nowParts = shanghaiParts(new Date());
|
||||||
|
const datePrefix =
|
||||||
|
nowParts && parts.year === nowParts.year
|
||||||
|
? `${parts.month}-${parts.day}`
|
||||||
|
: `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
return `${datePrefix} ${parts.hour}:${parts.minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 格式化为 Asia/Shanghai:2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
|
/** 格式化为 Asia/Shanghai:2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
|
||||||
export function formatShanghaiDateTime(input?: string | Date | null): string {
|
export function formatShanghaiDateTime(input?: string | Date | null): string {
|
||||||
if (input == null || input === '') return '—';
|
if (input == null || input === '') return '—';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type EnterOptionsLike = {
|
|||||||
function normalizeAssocScene(raw: unknown): string | null {
|
function normalizeAssocScene(raw: unknown): string | null {
|
||||||
if (raw == null || raw === '') return null;
|
if (raw == null || raw === '') return null;
|
||||||
const s = safeDecode(String(raw)).trim();
|
const s = safeDecode(String(raw)).trim();
|
||||||
return /^pa_\d+$/.test(s) ? s : null;
|
return /^(pa|sa)_\d+$/.test(s) ? s : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function getDistrictsForPicker(province: string, city: string): string[]
|
|||||||
return [REGION_ALL, ...getDistricts(province, city)];
|
return [REGION_ALL, ...getDistricts(province, city)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 收货地址:不含「全市」 */
|
||||||
|
export function getDistrictsForShippingPicker(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||||
|
return [...getDistricts(province, city)];
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRegion(province: string, city: string, district: string): string {
|
export function formatRegion(province: string, city: string, district: string): string {
|
||||||
if (!province) return '';
|
if (!province) return '';
|
||||||
if (province === REGION_ALL) return REGION_ALL;
|
if (province === REGION_ALL) return REGION_ALL;
|
||||||
@@ -96,6 +102,35 @@ export const DEFAULT_REGION: RegionSelection = {
|
|||||||
district: REGION_ALL,
|
district: REGION_ALL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 收货地址默认:不预填伪区县,迫使用户选真实区县 */
|
||||||
|
export const DEFAULT_SHIPPING_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 收货场景:区县必须在省市区树内且非全市 */
|
||||||
|
export function normalizeShippingRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
|
const province = PROVINCES.includes(selection.province)
|
||||||
|
? selection.province
|
||||||
|
: DEFAULT_SHIPPING_REGION.province;
|
||||||
|
const cities = getCities(province);
|
||||||
|
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_SHIPPING_REGION.city);
|
||||||
|
const districts = getDistricts(province, city);
|
||||||
|
const district =
|
||||||
|
selection.district &&
|
||||||
|
selection.district !== REGION_ALL &&
|
||||||
|
districts.includes(selection.district)
|
||||||
|
? selection.district
|
||||||
|
: '';
|
||||||
|
return { province, city, district };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isShippingRegionComplete(selection: RegionSelection): boolean {
|
||||||
|
const n = normalizeShippingRegionSelection(selection);
|
||||||
|
return Boolean(n.province && n.city && n.district && n.district !== REGION_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
REGION_ALL,
|
||||||
|
isShippingRegionComplete,
|
||||||
|
normalizeShippingRegionSelection,
|
||||||
|
type RegionSelection,
|
||||||
|
} from './region-data';
|
||||||
|
|
||||||
|
export const SHIPPING_DETAIL_MIN_LEN = 8;
|
||||||
|
export const SHIPPING_REGION_REQUIRED_MSG = '请选择具体区县';
|
||||||
|
export const SHIPPING_DETAIL_REQUIRED_MSG = '请填写详细地址';
|
||||||
|
export const SHIPPING_DETAIL_TOO_SHORT_MSG = '请填写更详细的收货地址(含街道门牌)';
|
||||||
|
|
||||||
|
export type ShippingAddressLike = {
|
||||||
|
province?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isDirtyShippingAddress(addr: ShippingAddressLike): boolean {
|
||||||
|
return !validateClientShippingAddress(addr).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateClientShippingAddress(addr: ShippingAddressLike): {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
} {
|
||||||
|
const province = String(addr.province ?? '').trim();
|
||||||
|
const city = String(addr.city ?? '').trim();
|
||||||
|
const district = String(addr.district ?? '').trim();
|
||||||
|
const detail = String(addr.detail ?? '').trim();
|
||||||
|
|
||||||
|
if (!province || !city) return { ok: false, message: '请选择所在地区' };
|
||||||
|
if (!district || district === REGION_ALL || district === '全部') {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
const regionOk = isShippingRegionComplete({ province, city, district });
|
||||||
|
if (!regionOk) {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (!detail) return { ok: false, message: SHIPPING_DETAIL_REQUIRED_MSG };
|
||||||
|
if (detail.length < SHIPPING_DETAIL_MIN_LEN) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_TOO_SHORT_MSG };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shippingDetailCityWideHint(detail: string | null | undefined): string | null {
|
||||||
|
const d = String(detail ?? '').trim();
|
||||||
|
if (!d.includes('全市')) return null;
|
||||||
|
if (/路|街|巷|号|大厦|广场|小区|村|镇|乡/.test(d)) return null;
|
||||||
|
return '详细地址含「全市」,建议改为具体街道门牌,以免配送拒单';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toShippingRegion(addr: ShippingAddressLike): RegionSelection {
|
||||||
|
return normalizeShippingRegionSelection({
|
||||||
|
province: String(addr.province ?? ''),
|
||||||
|
city: String(addr.city ?? ''),
|
||||||
|
district: String(addr.district ?? ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,28 @@ export function storeStarCount(rating?: number | string | null): number {
|
|||||||
return Math.min(5, Math.max(1, Math.round(n)));
|
return Math.min(5, Math.max(1, Math.round(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StoreAddressParts = {
|
||||||
|
province?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function addressPart(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 省 + 市 + 区 + 详细地址原样拼接;详细地址已含省市区时不去重 */
|
||||||
|
export function fullStoreAddress(store: StoreAddressParts, fallback = '地址待完善'): string {
|
||||||
|
const province = addressPart(store.province);
|
||||||
|
const city = addressPart(store.cityName) || addressPart(store.city);
|
||||||
|
const district = addressPart(store.district);
|
||||||
|
const detail = addressPart(store.address);
|
||||||
|
const text = `${province}${city}${district}${detail}`;
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function storeCategoryTags(
|
export function storeCategoryTags(
|
||||||
store: {
|
store: {
|
||||||
tags?: unknown;
|
tags?: unknown;
|
||||||
@@ -153,3 +175,12 @@ export function storeLeafCategoryIds(store: {
|
|||||||
const id = String(store.categoryId || store.category?.id || '');
|
const id = String(store.categoryId || store.category?.id || '');
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端是否渲染「核销N次」;开关关闭或次数为 0 时不展示 */
|
||||||
|
export function shouldShowStoreRedeemCount(
|
||||||
|
enabled: boolean | undefined,
|
||||||
|
count?: number | null,
|
||||||
|
): boolean {
|
||||||
|
if (enabled === false) return false;
|
||||||
|
return Number(count) > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import RegionPicker from '../../components/RegionPicker';
|
import RegionPicker from '../../components/RegionPicker';
|
||||||
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGION,
|
DEFAULT_SHIPPING_REGION,
|
||||||
REGION_ALL,
|
REGION_ALL,
|
||||||
formatRegion,
|
formatRegion,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../../lib/region-data';
|
} from '../../lib/region-data';
|
||||||
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
||||||
|
import {
|
||||||
|
shippingDetailCityWideHint,
|
||||||
|
validateClientShippingAddress,
|
||||||
|
} from '../../lib/shipping-address';
|
||||||
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||||
import { resolveUserCity } from '../../lib/user-location';
|
import { resolveUserCity } from '../../lib/user-location';
|
||||||
import { request, toast, type UserProfile } from '../../lib/api';
|
import { request, toast, type UserProfile } from '../../lib/api';
|
||||||
@@ -36,12 +40,13 @@ export default function AddressEditPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [locating, setLocating] = useState(false);
|
const [locating, setLocating] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [hint, setHint] = useState('');
|
||||||
const [form, setForm] = useState<AddressForm>(() => ({
|
const [form, setForm] = useState<AddressForm>(() => ({
|
||||||
receiverName: '',
|
receiverName: '',
|
||||||
phone: id ? '' : getStoredUserPhone(),
|
phone: id ? '' : getStoredUserPhone(),
|
||||||
province: DEFAULT_REGION.province,
|
province: DEFAULT_SHIPPING_REGION.province,
|
||||||
city: DEFAULT_REGION.city,
|
city: DEFAULT_SHIPPING_REGION.city,
|
||||||
district: DEFAULT_REGION.district,
|
district: DEFAULT_SHIPPING_REGION.district,
|
||||||
detail: '',
|
detail: '',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
}));
|
}));
|
||||||
@@ -69,7 +74,7 @@ export default function AddressEditPage() {
|
|||||||
? resolved.region.district
|
? resolved.region.district
|
||||||
: resolved.district && resolved.district !== REGION_ALL
|
: resolved.district && resolved.district !== REGION_ALL
|
||||||
? resolved.district
|
? resolved.district
|
||||||
: DEFAULT_REGION.district;
|
: '';
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
province: resolved.region.province || prev.province,
|
province: resolved.region.province || prev.province,
|
||||||
@@ -91,12 +96,13 @@ export default function AddressEditPage() {
|
|||||||
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
||||||
const found = list.find((a) => String(a.id) === id);
|
const found = list.find((a) => String(a.id) === id);
|
||||||
if (found) {
|
if (found) {
|
||||||
|
const district = String(found.district ?? '');
|
||||||
setForm({
|
setForm({
|
||||||
receiverName: String(found.receiverName ?? ''),
|
receiverName: String(found.receiverName ?? ''),
|
||||||
phone: String(found.phone ?? ''),
|
phone: String(found.phone ?? ''),
|
||||||
province: String(found.province ?? DEFAULT_REGION.province),
|
province: String(found.province ?? DEFAULT_SHIPPING_REGION.province),
|
||||||
city: String(found.city ?? DEFAULT_REGION.city),
|
city: String(found.city ?? DEFAULT_SHIPPING_REGION.city),
|
||||||
district: String(found.district ?? DEFAULT_REGION.district),
|
district: district === REGION_ALL ? '' : district,
|
||||||
detail: String(found.detail ?? ''),
|
detail: String(found.detail ?? ''),
|
||||||
isDefault: found.isDefault === 1 || found.isDefault === true,
|
isDefault: found.isDefault === 1 || found.isDefault === true,
|
||||||
});
|
});
|
||||||
@@ -104,14 +110,21 @@ export default function AddressEditPage() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const regionText = formatRegion(form.province, form.city, form.district);
|
useEffect(() => {
|
||||||
|
setHint(shippingDetailCityWideHint(form.detail) ?? '');
|
||||||
|
}, [form.detail]);
|
||||||
|
|
||||||
|
const regionText =
|
||||||
|
form.province && form.city && form.district
|
||||||
|
? formatRegion(form.province, form.city, form.district)
|
||||||
|
: '';
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||||
const phoneCheck = validateMobilePhone(form.phone);
|
const phoneCheck = validateMobilePhone(form.phone);
|
||||||
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||||
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
const shipping = validateClientShippingAddress(form);
|
||||||
if (!form.detail.trim()) return '请输入详细地址';
|
if (!shipping.ok) return shipping.message ?? '请完善收货地址';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +214,7 @@ export default function AddressEditPage() {
|
|||||||
<Text>
|
<Text>
|
||||||
{locating && !isEdit
|
{locating && !isEdit
|
||||||
? '定位中…'
|
? '定位中…'
|
||||||
: regionText || '请选择省市区'}
|
: regionText || '请选择省 / 市 / 区县'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -228,6 +241,7 @@ export default function AddressEditPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{hint ? <Text className="address-form-hint">{hint}</Text> : null}
|
||||||
<View className="address-form-row">
|
<View className="address-form-row">
|
||||||
<Text>设为默认地址</Text>
|
<Text>设为默认地址</Text>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -248,6 +262,7 @@ export default function AddressEditPage() {
|
|||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
onConfirm={onRegionConfirm}
|
onConfirm={onRegionConfirm}
|
||||||
levels={3}
|
levels={3}
|
||||||
|
mode="shipping"
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildOrderConfirmUrl,
|
buildOrderConfirmUrl,
|
||||||
readCheckoutContext,
|
readCheckoutContext,
|
||||||
} from '../../lib/checkout-nav';
|
} from '../../lib/checkout-nav';
|
||||||
|
import { isDirtyShippingAddress } from '../../lib/shipping-address';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
@@ -51,6 +52,11 @@ export default function AddressesPage() {
|
|||||||
|
|
||||||
function selectAddress(addr: Address) {
|
function selectAddress(addr: Address) {
|
||||||
if (!selectMode) return;
|
if (!selectMode) return;
|
||||||
|
if (isDirtyShippingAddress(addr)) {
|
||||||
|
toast('该地址缺少具体区县,请先完善');
|
||||||
|
Taro.navigateTo({ url: buildAddressEditUrl(addr.id, checkoutCtx) }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
Taro.redirectTo({
|
Taro.redirectTo({
|
||||||
url: buildOrderConfirmUrl({
|
url: buildOrderConfirmUrl({
|
||||||
productId: checkoutCtx.productId,
|
productId: checkoutCtx.productId,
|
||||||
@@ -76,6 +82,12 @@ export default function AddressesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sorted = [...list].sort((a, b) => {
|
||||||
|
const da = isDirtyShippingAddress(a) ? 0 : 1;
|
||||||
|
const db = isDirtyShippingAddress(b) ? 0 : 1;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||||
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
||||||
@@ -84,46 +96,55 @@ export default function AddressesPage() {
|
|||||||
{!loading && list.length === 0 ? (
|
{!loading && list.length === 0 ? (
|
||||||
<View className="u-empty">暂无收货地址</View>
|
<View className="u-empty">暂无收货地址</View>
|
||||||
) : null}
|
) : null}
|
||||||
{list.map((a) => (
|
{sorted.map((a) => {
|
||||||
<View
|
const dirty = isDirtyShippingAddress(a);
|
||||||
key={a.id}
|
return (
|
||||||
className="address-item"
|
<View
|
||||||
onClick={() => selectAddress(a)}
|
key={a.id}
|
||||||
>
|
className={`address-item${dirty ? ' address-item--dirty' : ''}`}
|
||||||
<View className="address-item-head">
|
onClick={() => selectAddress(a)}
|
||||||
<Text className="address-item-name">{a.receiverName}</Text>
|
>
|
||||||
<Text className="address-item-phone">{a.phone}</Text>
|
<View className="address-item-head">
|
||||||
{a.isDefault === 1 || a.isDefault === true ? (
|
<Text className="address-item-name">{a.receiverName}</Text>
|
||||||
<Text className="address-default-tag">默认</Text>
|
<Text className="address-item-phone">{a.phone}</Text>
|
||||||
|
{dirty ? <Text className="address-need-fix-tag">需完善</Text> : null}
|
||||||
|
{a.isDefault === 1 || a.isDefault === true ? (
|
||||||
|
<Text className="address-default-tag">默认</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
||||||
|
{dirty ? (
|
||||||
|
<Text className="address-item-warn">缺少具体区县,同城配送可能失败,请编辑完善</Text>
|
||||||
|
) : null}
|
||||||
|
{!selectMode || dirty ? (
|
||||||
|
<View className="address-item-actions">
|
||||||
|
<Text
|
||||||
|
className="address-action"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
|
||||||
|
toast(err instanceof Error ? err.message : '无法打开编辑页');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dirty ? '去完善' : '编辑'}
|
||||||
|
</Text>
|
||||||
|
{!selectMode ? (
|
||||||
|
<Text
|
||||||
|
className="address-action"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void removeAddress(a.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
);
|
||||||
{!selectMode ? (
|
})}
|
||||||
<View className="address-item-actions">
|
|
||||||
<Text
|
|
||||||
className="address-action"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
|
|
||||||
toast(err instanceof Error ? err.message : '无法打开编辑页');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className="address-action"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void removeAddress(a.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className="address-fab"
|
className="address-fab"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
isHomeCatalogBootstrapped,
|
isHomeCatalogBootstrapped,
|
||||||
setHomeCatalogCache,
|
setHomeCatalogCache,
|
||||||
} from '../../lib/home-catalog-session';
|
} from '../../lib/home-catalog-session';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
|
||||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
import {
|
import {
|
||||||
@@ -196,14 +196,21 @@ export default function HomePage() {
|
|||||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goBuyOnline(productId: string) {
|
||||||
|
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
goLogin(returnPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Taro.navigateTo({ url: returnPath });
|
||||||
|
}
|
||||||
|
|
||||||
async function goOnSitePickup(productId: string) {
|
async function goOnSitePickup(productId: string) {
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ready = await ensurePayReady(returnPath);
|
|
||||||
if (!ready) return;
|
|
||||||
Taro.navigateTo({ url: returnPath });
|
Taro.navigateTo({ url: returnPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +355,7 @@ export default function HomePage() {
|
|||||||
className="home-buy-btn"
|
className="home-buy-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation?.();
|
e.stopPropagation?.();
|
||||||
openProductDetail(p.id);
|
void goBuyOnline(p.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
立即购买
|
立即购买
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function LoginPage() {
|
|||||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
const [showSmsForm, setShowSmsForm] = useState(true);
|
||||||
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +393,7 @@ export default function LoginPage() {
|
|||||||
? '绑定手机号'
|
? '绑定手机号'
|
||||||
: completeMode === 'wechat'
|
: completeMode === 'wechat'
|
||||||
? '授权登录'
|
? '授权登录'
|
||||||
: '手机号快捷登录';
|
: '验证码登录';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="plain" className="login-page">
|
<PageShell variant="plain" className="login-page">
|
||||||
@@ -451,32 +451,9 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||||
|
|
||||||
{showPhoneQuick ? (
|
|
||||||
<PhoneQuickLoginButton
|
|
||||||
loading={phoneQuickLoading}
|
|
||||||
agreed={agreed}
|
|
||||||
onRequireAgree={() => ensureAgreed()}
|
|
||||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
|
||||||
onFail={(message) => setMsg(message)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showPhoneQuick ? (
|
|
||||||
<View className="login-divider" style={{ marginTop: 20 }}>
|
|
||||||
<View className="login-divider-line" />
|
|
||||||
<Text
|
|
||||||
className="login-divider-text"
|
|
||||||
onClick={() => setShowSmsForm((v) => !v)}
|
|
||||||
>
|
|
||||||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
|
||||||
</Text>
|
|
||||||
<View className="login-divider-line" />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(showSmsForm || !showPhoneQuick) && (
|
{(showSmsForm || !showPhoneQuick) && (
|
||||||
<>
|
<>
|
||||||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
<View className="login-field">
|
||||||
<Text className="login-field-prefix">+86</Text>
|
<Text className="login-field-prefix">+86</Text>
|
||||||
<Input
|
<Input
|
||||||
className="login-field-input"
|
className="login-field-input"
|
||||||
@@ -526,6 +503,30 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPhoneQuick ? (
|
||||||
|
<>
|
||||||
|
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||||
|
<View className="login-divider-line" />
|
||||||
|
<Text className="login-divider-text">或者</Text>
|
||||||
|
<View className="login-divider-line" />
|
||||||
|
</View>
|
||||||
|
<PhoneQuickLoginButton
|
||||||
|
loading={phoneQuickLoading}
|
||||||
|
agreed={agreed}
|
||||||
|
onRequireAgree={() => ensureAgreed()}
|
||||||
|
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||||
|
onFail={(message) => setMsg(message)}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className="login-divider-text"
|
||||||
|
style={{ display: 'block', textAlign: 'center', marginTop: 12 }}
|
||||||
|
onClick={() => setShowSmsForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{displayMsg ? (
|
{displayMsg ? (
|
||||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||||
{displayMsg}
|
{displayMsg}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
|
|||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
|
||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
@@ -150,9 +149,6 @@ export default function OrderConfirmPickupPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await ensurePayReady(returnPath);
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const { confirm } = await Taro.showModal({
|
const { confirm } = await Taro.showModal({
|
||||||
title: '确认提交订单',
|
title: '确认提交订单',
|
||||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { goLogin } from '../../lib/auth-nav';
|
|||||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||||
import { tryGetClientGpsLocation } from '../../lib/client-location';
|
import { tryGetClientGpsLocation } from '../../lib/client-location';
|
||||||
import { maskPhone } from '../../lib/phone';
|
import { maskPhone } from '../../lib/phone';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
|
||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
|
import { isDirtyShippingAddress } from '../../lib/shipping-address';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
import OrderQtyControls from '../../components/OrderQtyControls';
|
import OrderQtyControls from '../../components/OrderQtyControls';
|
||||||
|
|
||||||
@@ -177,7 +177,12 @@ export default function OrderConfirmPage() {
|
|||||||
const isCross =
|
const isCross =
|
||||||
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
||||||
const crossBlocked = isCross && !allowCross;
|
const crossBlocked = isCross && !allowCross;
|
||||||
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
const dirtyAddress = !!selectedAddress && isDirtyShippingAddress(selectedAddress);
|
||||||
|
const addressOk = dirtyAddress
|
||||||
|
? false
|
||||||
|
: preview
|
||||||
|
? preview.addressOk !== false && !crossBlocked
|
||||||
|
: !crossBlocked;
|
||||||
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty =
|
const minQty =
|
||||||
preview?.minQty ??
|
preview?.minQty ??
|
||||||
@@ -186,9 +191,11 @@ export default function OrderConfirmPage() {
|
|||||||
const canSubmit =
|
const canSubmit =
|
||||||
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
|
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
|
||||||
|
|
||||||
const addressHint = !addressOk
|
const addressHint = dirtyAddress
|
||||||
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
|
? '请完善收货地址(需选择具体区县)'
|
||||||
: '';
|
: !addressOk
|
||||||
|
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
|
||||||
|
: '';
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
@@ -283,9 +290,6 @@ export default function OrderConfirmPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await ensurePayReady(returnPath);
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
|
|||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
import {
|
import {
|
||||||
getProductCarouselImages,
|
getProductCarouselImages,
|
||||||
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
|
|||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ready = await ensurePayReady(returnPath);
|
|
||||||
if (!ready) return;
|
|
||||||
Taro.navigateTo({ url: returnPath });
|
Taro.navigateTo({ url: returnPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
|
|||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ready = await ensurePayReady(returnPath);
|
|
||||||
if (!ready) return;
|
|
||||||
Taro.navigateTo({ url: returnPath });
|
Taro.navigateTo({ url: returnPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
|
|||||||
export default function RedeemSuccessPage() {
|
export default function RedeemSuccessPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [fromHistory, setFromHistory] = useState(false);
|
const [fromHistory, setFromHistory] = useState(false);
|
||||||
|
const [fromStore, setFromStore] = useState(false);
|
||||||
const [record, setRecord] = useState<RedeemRecord | null>(null);
|
const [record, setRecord] = useState<RedeemRecord | null>(null);
|
||||||
const [score, setScore] = useState(5);
|
const [serviceScore, setServiceScore] = useState(5);
|
||||||
|
const [envScore, setEnvScore] = useState(5);
|
||||||
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
|
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||||
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
|
|||||||
const rated = Boolean(record?.rating);
|
const rated = Boolean(record?.rating);
|
||||||
|
|
||||||
const applyRating = useCallback((rating: StoreRatingDto) => {
|
const applyRating = useCallback((rating: StoreRatingDto) => {
|
||||||
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
|
const nextService = Number(rating.serviceScore || 5);
|
||||||
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
|
const nextEnv = Number(rating.envScore || 5);
|
||||||
|
setServiceScore(
|
||||||
|
Number.isFinite(nextService) && nextService > 0 ? Math.min(5, Math.round(nextService)) : 5,
|
||||||
|
);
|
||||||
|
setEnvScore(Number.isFinite(nextEnv) && nextEnv > 0 ? Math.min(5, Math.round(nextEnv)) : 5);
|
||||||
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
||||||
setComment(String(rating.comment || ''));
|
setComment(String(rating.comment || ''));
|
||||||
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
|
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
|
||||||
@@ -105,6 +111,7 @@ export default function RedeemSuccessPage() {
|
|||||||
const id = String(options?.id || router.params.id || '').trim();
|
const id = String(options?.id || router.params.id || '').trim();
|
||||||
const from = String(options?.from || router.params.from || '');
|
const from = String(options?.from || router.params.from || '');
|
||||||
setFromHistory(from === 'history');
|
setFromHistory(from === 'history');
|
||||||
|
setFromStore(from === 'store');
|
||||||
if (id) {
|
if (id) {
|
||||||
void loadRecord(id);
|
void loadRecord(id);
|
||||||
return;
|
return;
|
||||||
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
|
|||||||
function leave() {
|
function leave() {
|
||||||
clearCache();
|
clearCache();
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
if (fromHistory && pages.length > 1) {
|
if ((fromHistory || fromStore) && pages.length > 1) {
|
||||||
Taro.navigateBack();
|
Taro.navigateBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
redeemRecordId: record.id,
|
redeemRecordId: record.id,
|
||||||
serviceScore: score,
|
serviceScore,
|
||||||
envScore: score,
|
envScore,
|
||||||
comment: comment.trim(),
|
comment: comment.trim(),
|
||||||
tags,
|
tags,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
|
|||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
rating: { serviceScore, envScore, comment, tags, imageUrls },
|
||||||
}
|
}
|
||||||
: prev,
|
: prev,
|
||||||
);
|
);
|
||||||
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="review-card">
|
<View className="review-card">
|
||||||
<Text className="review-card-title">本次到店体验</Text>
|
<Text className="review-card-title">服务评分</Text>
|
||||||
<View className="review-star-row">
|
<View className="review-star-row">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
<Text
|
<Text
|
||||||
key={value}
|
key={`service-${value}`}
|
||||||
className={`review-star${value <= score ? ' review-star--active' : ''}`}
|
className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!rated) setScore(value);
|
if (!rated) setServiceScore(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
★
|
★
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
|
<Text className="review-score-label">{SCORE_LABELS[serviceScore]}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="review-card">
|
||||||
|
<Text className="review-card-title">环境评分</Text>
|
||||||
|
<View className="review-star-row">
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<Text
|
||||||
|
key={`env-${value}`}
|
||||||
|
className={`review-star${value <= envScore ? ' review-star--active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!rated) setEnvScore(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="review-score-label">{SCORE_LABELS[envScore]}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="review-card">
|
<View className="review-card">
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ import ProductCarousel from '../../components/ProductCarousel';
|
|||||||
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
|
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
|
||||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast, isLoggedIn } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import { toMoneyNumber } from '../../lib/money';
|
import { toMoneyNumber } from '../../lib/money';
|
||||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import {
|
import {
|
||||||
|
fullStoreAddress,
|
||||||
|
shouldShowStoreRedeemCount,
|
||||||
storeCategoryTags,
|
storeCategoryTags,
|
||||||
storeStarCount,
|
storeStarCount,
|
||||||
type StoreCategoryTreeNode,
|
type StoreCategoryTreeNode,
|
||||||
@@ -115,8 +118,7 @@ function envPhotoUrls(store: Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fullAddress(store: Store) {
|
function fullAddress(store: Store) {
|
||||||
const city = store.cityName || store.city || '';
|
return fullStoreAddress(store, '');
|
||||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickStoreId(raw?: string | null) {
|
function pickStoreId(raw?: string | null) {
|
||||||
@@ -158,7 +160,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
|
|||||||
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
|
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
|
||||||
const amount = formatRedeemAmountYuan(row.amount);
|
const amount = formatRedeemAmountYuan(row.amount);
|
||||||
if (!amount) return null;
|
if (!amount) return null;
|
||||||
return { userLabel, amount };
|
return { userLabel, amount, createdAt: row.createdAt };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,8 @@ export default function StoreDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
|
|
||||||
@@ -196,6 +200,12 @@ export default function StoreDetailPage() {
|
|||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStore = useCallback(async (id: string) => {
|
const loadStore = useCallback(async (id: string) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -274,6 +284,13 @@ export default function StoreDetailPage() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
void loadStore(id);
|
void loadStore(id);
|
||||||
void loadRecentRedeems(id);
|
void loadRecentRedeems(id);
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
void request<{ id?: string | null }>(`/redeem/records/pending-rating?storeId=${id}`)
|
||||||
|
.then((data) => setPendingRatingId(data?.id ? String(data.id) : null))
|
||||||
|
.catch(() => setPendingRatingId(null));
|
||||||
|
} else {
|
||||||
|
setPendingRatingId(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharePayload = useMemo(() => {
|
const sharePayload = useMemo(() => {
|
||||||
@@ -432,15 +449,14 @@ export default function StoreDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{Number(store.redeemCount) > 0 ? (
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? (
|
||||||
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="store-detail-row">
|
<View className="store-detail-row">
|
||||||
<Text className="store-detail-meta store-detail-meta--flex">
|
<Text className="store-detail-meta store-detail-meta--flex">
|
||||||
{store.district ? `${store.district} · ` : ''}
|
{fullStoreAddress(store)}
|
||||||
{store.address || '地址待完善'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-detail-action" onClick={openMap}>
|
<Text className="store-detail-action" onClick={openMap}>
|
||||||
导航
|
导航
|
||||||
@@ -530,8 +546,20 @@ export default function StoreDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className="store-detail-bar">
|
<View className="store-detail-bar">
|
||||||
|
{pendingRatingId ? (
|
||||||
|
<View
|
||||||
|
className="store-detail-bar-btn store-detail-bar-btn--ghost"
|
||||||
|
onClick={() =>
|
||||||
|
Taro.navigateTo({
|
||||||
|
url: `/pages/redeem-success/index?id=${pendingRatingId}&from=store`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>评价门店</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
className="u-btn u-btn--block"
|
className={`store-detail-bar-btn store-detail-bar-btn--primary${pendingRatingId ? '' : ' store-detail-bar-btn--full'}`}
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||||
>
|
>
|
||||||
<Text>到店核销</Text>
|
<Text>到店核销</Text>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
navigationBarTitleText: '套餐详情',
|
navigationBarTitleText: '套餐详情',
|
||||||
});
|
enableShareAppMessage: true,
|
||||||
|
enableShareTimeline: true,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import '../../styles/store-detail.css';
|
import '../../styles/store-detail.css';
|
||||||
import '../../styles/product-detail.css';
|
import '../../styles/product-detail.css';
|
||||||
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import {
|
||||||
|
buildSceneSharePayload,
|
||||||
|
toWeappShareMessage,
|
||||||
|
toWeappShareTimeline,
|
||||||
|
} from '../../lib/wechat-share';
|
||||||
|
|
||||||
type StorePackage = {
|
type StorePackage = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
|
|||||||
export default function StorePackageDetailPage() {
|
export default function StorePackageDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
||||||
|
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
|
|||||||
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
||||||
const index = parsePackageIndex(options?.index ?? router.params.index);
|
const index = parsePackageIndex(options?.index ?? router.params.index);
|
||||||
setStoreId(sid);
|
setStoreId(sid);
|
||||||
|
setPackageIndex(index);
|
||||||
void loadPackage(sid, index);
|
void loadPackage(sid, index);
|
||||||
});
|
});
|
||||||
|
|
||||||
function goBack() {
|
const imageUrls = useMemo(
|
||||||
|
() => (pkg ? normalizeStorePackageImageUrls(pkg) : []),
|
||||||
|
[pkg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharePayload = useMemo(
|
||||||
|
() =>
|
||||||
|
buildSceneSharePayload('storeDetail', {
|
||||||
|
path: `/pages/store-package-detail/index?storeId=${storeId}&index=${packageIndex}`,
|
||||||
|
dynamicTitle: pkg
|
||||||
|
? storeName
|
||||||
|
? `${pkg.name} · ${storeName}`
|
||||||
|
: pkg.name
|
||||||
|
: undefined,
|
||||||
|
dynamicDesc: pkg?.dishes?.trim() || undefined,
|
||||||
|
dynamicImageUrl: imageUrls[0] || undefined,
|
||||||
|
}),
|
||||||
|
[imageUrls, pkg, packageIndex, storeId, storeName],
|
||||||
|
);
|
||||||
|
const sharePayloadRef = useRef(sharePayload);
|
||||||
|
sharePayloadRef.current = sharePayload;
|
||||||
|
const shareQuery = storeId ? `storeId=${storeId}&index=${packageIndex}` : '';
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
if (pages.length > 1) Taro.navigateBack();
|
if (pages.length > 1) Taro.navigateBack();
|
||||||
else if (storeId) {
|
else if (storeId) {
|
||||||
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
Taro.switchTab({ url: '/pages/stores/index' });
|
Taro.switchTab({ url: '/pages/stores/index' });
|
||||||
}
|
}
|
||||||
}
|
}, [storeId]);
|
||||||
|
|
||||||
if (loading) {
|
// 不用 useShare* Hook:Taro 编译会把它们折进 return,触发 React #310
|
||||||
return (
|
useEffect(() => {
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
const page = Taro.getCurrentInstance().page as
|
||||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
| {
|
||||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
|
||||||
</PageShell>
|
onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
|
||||||
);
|
}
|
||||||
}
|
| undefined;
|
||||||
|
if (!page) return;
|
||||||
|
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
|
||||||
|
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
|
||||||
|
}, [shareQuery]);
|
||||||
|
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
<PageShell variant="scroll" className="store-package-detail-page">
|
||||||
|
<WechatShareReady payload={sharePayload} />
|
||||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||||
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View>
|
<View className="page-with-nav-bar u-empty">
|
||||||
|
{loading ? '加载中…' : loadError || '套餐不存在'}
|
||||||
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
<PageShell variant="scroll" className="store-package-detail-page">
|
||||||
|
<WechatShareReady payload={sharePayload} />
|
||||||
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
||||||
|
|
||||||
<View className="store-package-detail-body">
|
<View className="store-package-detail-body">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||||
import { formatDistanceMeters } from '../../lib/geo';
|
import { formatDistanceMeters } from '../../lib/geo';
|
||||||
import { getToken, request, toast } from '../../lib/api';
|
import { getToken, request, toast } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import {
|
import {
|
||||||
getStoresListCache,
|
getStoresListCache,
|
||||||
isStoresSessionBootstrapped,
|
isStoresSessionBootstrapped,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||||
import { storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
||||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
@@ -123,13 +124,21 @@ export default function StoresPage() {
|
|||||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const regionRef = useRef(region);
|
const regionRef = useRef(region);
|
||||||
regionRef.current = region;
|
regionRef.current = region;
|
||||||
const regionLabel = formatRegionLabel(region);
|
const regionLabel = formatRegionLabel(region);
|
||||||
const categoryLabel = formatCategoryLabel(category);
|
const categoryLabel = formatCategoryLabel(category);
|
||||||
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
|
const sortOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
showStoreRedeemCount
|
||||||
|
? STORE_SORT_OPTIONS
|
||||||
|
: STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'),
|
||||||
|
[showStoreRedeemCount],
|
||||||
|
);
|
||||||
|
const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先';
|
||||||
const showBootLoading = loading && stores.length === 0;
|
const showBootLoading = loading && stores.length === 0;
|
||||||
|
|
||||||
const childIdsByParent = useMemo(() => {
|
const childIdsByParent = useMemo(() => {
|
||||||
@@ -143,6 +152,22 @@ export default function StoresPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [categoryTree]);
|
}, [categoryTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
const enabled = cfg.showStoreRedeemCount !== false;
|
||||||
|
setShowStoreRedeemCount(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev !== 'redeem') return prev;
|
||||||
|
patchStoresFilterCache({ sort: 'nearby' });
|
||||||
|
return 'nearby';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function fetchStores(
|
async function fetchStores(
|
||||||
nextCode: string,
|
nextCode: string,
|
||||||
coords: UserCoords | null,
|
coords: UserCoords | null,
|
||||||
@@ -317,14 +342,19 @@ export default function StoresPage() {
|
|||||||
if (!matchesCategory(s)) return false;
|
if (!matchesCategory(s)) return false;
|
||||||
if (!keyword.trim()) return true;
|
if (!keyword.trim()) return true;
|
||||||
const q = keyword.trim();
|
const q = keyword.trim();
|
||||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
return (
|
||||||
|
s.name.includes(q) ||
|
||||||
|
(s.address ?? '').includes(q) ||
|
||||||
|
(s.district ?? '').includes(q) ||
|
||||||
|
fullStoreAddress(s, '').includes(q)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const next = [...list];
|
const next = [...list];
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
if (sort === 'rating') {
|
if (sort === 'rating') {
|
||||||
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else if (sort === 'redeem') {
|
} else if (sort === 'redeem' && showStoreRedeemCount) {
|
||||||
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
}
|
}
|
||||||
@@ -333,7 +363,7 @@ export default function StoresPage() {
|
|||||||
return da - db;
|
return da - db;
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [stores, region, category, keyword, sort, childIdsByParent]);
|
}, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]);
|
||||||
|
|
||||||
function applySearch() {
|
function applySearch() {
|
||||||
const next = keywordInput.trim();
|
const next = keywordInput.trim();
|
||||||
@@ -492,11 +522,13 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text className="store-card-redeem">核销{s.redeemCount ?? 0}次</Text>
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? (
|
||||||
|
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className="store-card-row store-card-row--mid">
|
<View className="store-card-row store-card-row--mid">
|
||||||
<Text className="store-card-address" numberOfLines={2}>
|
<Text className="store-card-address" numberOfLines={2}>
|
||||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
{fullStoreAddress(s)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-card-distance">
|
<Text className="store-card-distance">
|
||||||
{formatDistanceMeters(s.distanceMeters)}
|
{formatDistanceMeters(s.distanceMeters)}
|
||||||
@@ -544,7 +576,7 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="region-picker-list">
|
<View className="region-picker-list">
|
||||||
{STORE_SORT_OPTIONS.map((opt) => (
|
{sortOptions.map((opt) => (
|
||||||
<View
|
<View
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
||||||
|
|||||||
@@ -38,6 +38,34 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-need-fix-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(200, 120, 20, 0.12);
|
||||||
|
color: #b36b00;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item--dirty {
|
||||||
|
border: 1px solid rgba(200, 120, 20, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item-warn {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b36b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-hint {
|
||||||
|
display: block;
|
||||||
|
margin: 0 var(--space-page) 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b36b00;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.address-item-detail {
|
.address-item-detail {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-qty-unit {
|
.order-qty-unit {
|
||||||
margin-right: 8px;
|
margin-left: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
|
|||||||
@@ -177,6 +177,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-row {
|
.store-detail-row {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 推送当前 dev_jacy 分支到远程 origin/dev_jacy
|
||||||
|
# 用法: deploy/git-push-dev-jacy.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(git branch --show-current)"
|
||||||
|
if [[ "$CURRENT" != "$DEV_BRANCH" ]]; then
|
||||||
|
echo "ERROR: 当前分支为 $CURRENT,请先 checkout $DEV_BRANCH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
echo "==> push $DEV_BRANCH -> $GIT_REMOTE/$DEV_BRANCH"
|
||||||
|
git push -u "$GIT_REMOTE" "$DEV_BRANCH"
|
||||||
|
|
||||||
|
echo "==> 完成: $(git rev-parse --short HEAD) 已推送到 $GIT_REMOTE/$DEV_BRANCH"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 将 dev_jacy 合并到 dev 并推送(staging 测试分支)
|
||||||
|
# 用法: deploy/git-sync-to-dev.sh [merge message]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
STAGING_BRANCH="${STAGING_BRANCH:-dev}"
|
||||||
|
|
||||||
|
MERGE_MSG="${1:-merge($DEV_BRANCH): sync to $STAGING_BRANCH}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ORIGINAL="$(git branch --show-current)"
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> push $DEV_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$DEV_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> checkout $STAGING_BRANCH"
|
||||||
|
git checkout "$STAGING_BRANCH"
|
||||||
|
git pull "$GIT_REMOTE" "$STAGING_BRANCH"
|
||||||
|
|
||||||
|
echo "==> merge $DEV_BRANCH -> $STAGING_BRANCH"
|
||||||
|
git merge "$DEV_BRANCH" -m "$MERGE_MSG"
|
||||||
|
|
||||||
|
echo "==> push $STAGING_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$STAGING_BRANCH"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> checkout $DEV_BRANCH"
|
||||||
|
git checkout "$DEV_BRANCH"
|
||||||
|
elif [[ "$ORIGINAL" != "$STAGING_BRANCH" ]]; then
|
||||||
|
echo "==> checkout $ORIGINAL"
|
||||||
|
git checkout "$ORIGINAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 完成: $STAGING_BRANCH = $(git rev-parse --short HEAD)"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 将 dev 合并到 main 并推送(生产分支,不含部署)
|
||||||
|
# 用法: deploy/git-sync-to-main.sh [merge message]
|
||||||
|
# 合并后发生产: deploy/deploy-prod.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
STAGING_BRANCH="${STAGING_BRANCH:-dev}"
|
||||||
|
PROD_BRANCH="${PROD_BRANCH:-main}"
|
||||||
|
|
||||||
|
MERGE_MSG="${1:-merge($STAGING_BRANCH): sync to $PROD_BRANCH}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ORIGINAL="$(git branch --show-current)"
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
echo "==> checkout $PROD_BRANCH"
|
||||||
|
git checkout "$PROD_BRANCH"
|
||||||
|
git pull "$GIT_REMOTE" "$PROD_BRANCH"
|
||||||
|
|
||||||
|
echo "==> merge $STAGING_BRANCH -> $PROD_BRANCH"
|
||||||
|
git merge "$STAGING_BRANCH" -m "$MERGE_MSG"
|
||||||
|
|
||||||
|
echo "==> push $PROD_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$PROD_BRANCH"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> checkout $DEV_BRANCH"
|
||||||
|
git checkout "$DEV_BRANCH"
|
||||||
|
elif [[ "$ORIGINAL" != "$PROD_BRANCH" ]]; then
|
||||||
|
echo "==> checkout $ORIGINAL"
|
||||||
|
git checkout "$ORIGINAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 完成: $PROD_BRANCH = $(git rev-parse --short HEAD)"
|
||||||
|
echo " 发生产请执行: bash deploy/deploy-prod.sh"
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# 企微 API 插件 · 配置手册
|
||||||
|
|
||||||
|
> 面向运营/技术:在企微后台「智能机器人 → 添加 API 插件」逐步填表。
|
||||||
|
> 后端前缀:`/api/v1/wecom/plugin` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。
|
||||||
|
> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 推荐流程(优先 OpenAPI 导入)
|
||||||
|
|
||||||
|
**不要 17 个工具全手填。** 推荐:
|
||||||
|
|
||||||
|
1. HQ → **企微机器人 → API 插件** → 新建实例,勾选工具权限,复制 **Key**(只显示一次)。
|
||||||
|
2. 企微 **第 1 步**:填插件 URL + Header Key(见 §2)。
|
||||||
|
3. 企微 **第 2 步**:**OpenAPI 导入**
|
||||||
|
- URL:`{Base URL}/openapi.json`
|
||||||
|
- **同样带 Header `X-Api-Key`**(与第 1 步相同 Key)
|
||||||
|
- 服务端按该 Key 的权限**只返回已授权路径**,与 HQ 勾选一致。
|
||||||
|
4. 导入后逐条核对 §3「全局规则」;类型不对时按 §5 手工修正。
|
||||||
|
|
||||||
|
手填仅适用于:导入失败,或只加 2~3 个高频工具。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 第 1 步:添加 API 插件
|
||||||
|
|
||||||
|
| 企微表单字段 | 测试环境 | 生产环境 |
|
||||||
|
|-------------|---------|---------|
|
||||||
|
| **插件 URL** | `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` | `https://api.dukanghaoke.com/api/v1/wecom/plugin` |
|
||||||
|
| **授权方式** | Service token / API key | 同左 |
|
||||||
|
| **位置** | Header | Header |
|
||||||
|
| **Parameter name** | `X-Api-Key` | `X-Api-Key` |
|
||||||
|
| **Service token** | HQ 该实例 Key | 同左 |
|
||||||
|
|
||||||
|
**自检(应返回 `{ code, message, data }`,不是 401):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Api-Key: <你的Key>" \
|
||||||
|
"https://api-test.dukanghaoke.com/api/v1/wecom/plugin/metrics?kind=today"
|
||||||
|
```
|
||||||
|
|
||||||
|
HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 全局规则(调试不过先看这里)
|
||||||
|
|
||||||
|
| 项 | 正确 | 错误 |
|
||||||
|
|----|------|------|
|
||||||
|
| HTTP 方法 | 全部 **GET** | POST、Body |
|
||||||
|
| 入参位置 | **Query** 或 **Path** | Body、Form |
|
||||||
|
| 响应顶层 | **`code`(Integer) + `message`(String) + `data`(Object)** | 只配 `data` 内字段 |
|
||||||
|
| 状态字段 | `status`、`payStatus`、`auditStatus` 等 → **String** | Integer |
|
||||||
|
| 模型可见 | 业务入参 `q`、`kind`、`from`… → **是** | 隐藏 `q` 模型不会传 |
|
||||||
|
| 分页 | `page` 默认 1;`pageSize` 默认 5,**最大 10** | 过大 pageSize |
|
||||||
|
| 鉴权 | 插件级 Header 已配 Key | Query 再传 apiKey |
|
||||||
|
|
||||||
|
**统一响应信封(`openapi.json` 除外):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
企微「输出参数」:**先配顶层 `code` / `message` / `data`,再在 `data` 下配业务字段**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HQ 权限与工具对照
|
||||||
|
|
||||||
|
只添加 HQ **已勾选**权限对应的工具;未授权返回 **403**。
|
||||||
|
|
||||||
|
| HQ 权限 | 路径 | 建议工具名 |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| `metrics.read` | `/metrics` | 查询经营指标 |
|
||||||
|
| `order.read` | `/orders` | 查询订单 |
|
||||||
|
| `user.read` | `/users` | 查询用户 |
|
||||||
|
| `store.read` | `/stores` | 查询门店 |
|
||||||
|
| `redeem.read` | `/redeems` | 查询核销 |
|
||||||
|
| `promo.read` | `/promo-codes`、`/promo-codes/{code}/stats` | 查询推广码、推广码统计 |
|
||||||
|
| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-info-audits/{id}`、`/store-package-audits`、`/store-package-audits/{id}` | 门店/信息/套餐审核 |
|
||||||
|
| `partner.read` | `/partners`、`/partners/{partnerId}/users|stores|orders` | 合伙人及关联数据 |
|
||||||
|
|
||||||
|
**实例建议:**
|
||||||
|
|
||||||
|
| 场景 | HQ 勾选 |
|
||||||
|
|------|---------|
|
||||||
|
| 日常运营 | metrics + order + user + store + promo + redeem |
|
||||||
|
| 审核值班 | 上表 + store.audit.read |
|
||||||
|
| 合伙人分析 | 上表 + partner.read |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 第 2 步:工具参数明细
|
||||||
|
|
||||||
|
以下均为 **GET**。Base URL 与 §2 相同,路径为相对路径。
|
||||||
|
|
||||||
|
### 5.1 通用分页(有列表的工具)
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|------|----------|------|
|
||||||
|
| page | Query | Integer | 否 | 1 | 否 | 页码 |
|
||||||
|
| pageSize | Query | Integer | 否 | 5 | 否 | 每页条数,最大 10 |
|
||||||
|
|
||||||
|
**列表类 `data` 结构:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| total | Integer | 总条数 |
|
||||||
|
| items | Array | 结果列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 查询经营指标
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 权限 | `metrics.read` |
|
||||||
|
| 路径 | `/metrics` |
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|------|----------|------|
|
||||||
|
| kind | Query | String | 否 | today | **是** | `today` / `daily` / `weekly` / `monthly` |
|
||||||
|
|
||||||
|
**输出 `data`:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| kind | String | 指标类型 |
|
||||||
|
| title | String | 标题 |
|
||||||
|
| rangeLabel | String | 统计区间 |
|
||||||
|
| incrementLabel | String | 如「今日新增」 |
|
||||||
|
| periodKey | String | 账期 key |
|
||||||
|
| stats | Object | 见下表 |
|
||||||
|
|
||||||
|
**`stats` 字段:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| usersTotal / usersIncrement | Integer | 用户存量/增量 |
|
||||||
|
| partnersTotal / partnersIncrement | Integer | 合伙人 |
|
||||||
|
| storesTotal / storesIncrement | Integer | 门店存量/增量 |
|
||||||
|
| **newStores** | Integer | **新增门店数**(与 storesIncrement 相同) |
|
||||||
|
| ordersTotal / ordersIncrement | Integer | 订单笔数(排除待支付 / 已取消 / 退款中) |
|
||||||
|
| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount,排除待支付 / 已取消 / 退款中) |
|
||||||
|
| redeemsTotal / redeemsIncrement | Integer | 核销笔数 |
|
||||||
|
| redeemAmountTotal / redeemAmountIncrement | Number | 核销金额 |
|
||||||
|
|
||||||
|
口径与 HQ 企微经营报告一致;`today` 期末为当前时刻。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 查询订单
|
||||||
|
|
||||||
|
| 权限 | `order.read` |
|
||||||
|
| 路径 | `/orders` |
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|----------|------|
|
||||||
|
| q | Query | String | **是** | **是** | 订单号,如 `DK20260903xxxx` |
|
||||||
|
|
||||||
|
**`items[]`:** orderNo, status(String), payStatus(String), deliveryType, productName, quantity, payAmount(Number), user(Object), receiverName, receiverPhone, receiverCity, trackingNo, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 查询用户
|
||||||
|
|
||||||
|
| 权限 | `user.read` |
|
||||||
|
| 路径 | `/users` |
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|----------|------|
|
||||||
|
| q | Query | String | **是** | **是** | 用户号或 11 位手机号 |
|
||||||
|
|
||||||
|
**`items[]`:** userNo, nickname, phone(脱敏), status, orderCount, benefitBalance, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 查询门店(含经营数据)
|
||||||
|
|
||||||
|
| 权限 | `store.read` |
|
||||||
|
| 路径 | `/stores` |
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|----------|------|
|
||||||
|
| q | Query | String | **是** | **是** | 门店名称关键词 |
|
||||||
|
|
||||||
|
**`items[]`:** id, name, status(String), auditStatus(String), cityName, district, address, contactPhone, rating(Number), redeemCount, totalRedeemedBenefitAmount, partnerName, partnerId, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 查询核销
|
||||||
|
|
||||||
|
| 权限 | `redeem.read` |
|
||||||
|
| 路径 | `/redeems` |
|
||||||
|
|
||||||
|
| 参数 | q=核销单号或门店名(Query,必填,模型可见)
|
||||||
|
|
||||||
|
**`items[]`:** redeemNo, amount, channel, storeName, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 查询推广码
|
||||||
|
|
||||||
|
| 权限 | `promo.read` |
|
||||||
|
| 路径 | `/promo-codes` |
|
||||||
|
|
||||||
|
| 参数 | q=推广码 code 或名称(Query,必填,模型可见)
|
||||||
|
|
||||||
|
**`items[]`:** code, name, scene, status(String), scanCount, orderCount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.8 推广码统计
|
||||||
|
|
||||||
|
| 权限 | `promo.read` |
|
||||||
|
| 路径 | `/promo-codes/{code}/stats` |
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 模型可见 |
|
||||||
|
|------|------|------|------|----------|
|
||||||
|
| code | **Path** | String | 是 | **是** |
|
||||||
|
|
||||||
|
**`data`:** code, name, status, stats(Object)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.9 门店入驻审核
|
||||||
|
|
||||||
|
| 权限 | `store.audit.read` |
|
||||||
|
| 路径 | `/store-audits` |
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 |
|
||||||
|
|------|------|------|------|------|----------|------|
|
||||||
|
| q | Query | String | 否 | — | 是 | 门店名称 |
|
||||||
|
| status | Query | String | 否 | PENDING | 是 | PENDING / APPROVED / REJECTED |
|
||||||
|
|
||||||
|
**`items[]`:** id, name, status, auditStatus, rejectReason, cityName, address, contactPhone, partnerName, partnerId, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.10 门店信息变更审核列表
|
||||||
|
|
||||||
|
| 权限 | `store.audit.read` |
|
||||||
|
| 路径 | `/store-info-audits` |
|
||||||
|
|
||||||
|
| 参数 | status(Query,默认 PENDING)+ 分页
|
||||||
|
|
||||||
|
**`items[]`:** id, storeId, storeName, status, changedFields, changedFieldLabels, submitterType, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.11 门店信息变更对比详情
|
||||||
|
|
||||||
|
| 权限 | `store.audit.read` |
|
||||||
|
| 路径 | `/store-info-audits/{id}` |
|
||||||
|
|
||||||
|
| 参数 | id → **Path**,String,必填,模型可见(从列表取 id)
|
||||||
|
|
||||||
|
**`data` 额外:** diffs[] → field, label, live, proposed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.12 门店套餐审核列表
|
||||||
|
|
||||||
|
| 权限 | `store.audit.read` |
|
||||||
|
| 路径 | `/store-package-audits` |
|
||||||
|
|
||||||
|
| 参数 | status + 分页
|
||||||
|
|
||||||
|
**`items[]`:** id, storeId, storeName, status, packageCount, createdAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.13 门店套餐对比详情
|
||||||
|
|
||||||
|
| 权限 | `store.audit.read` |
|
||||||
|
| 路径 | `/store-package-audits/{id}` |
|
||||||
|
|
||||||
|
| 参数 | id → Path
|
||||||
|
|
||||||
|
**`data`:** proposedPackages(Array), livePackages(Array) — 含 name, price, dishes, usableTime, otherNotes
|
||||||
|
|
||||||
|
> 插件为**只读**;审核通过/驳回仍在 HQ 操作,不在插件内配置写接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.14 查询合伙人
|
||||||
|
|
||||||
|
| 权限 | `partner.read` |
|
||||||
|
| 路径 | `/partners` |
|
||||||
|
|
||||||
|
| 参数 | q=姓名/公司名/手机号/ID(Query,必填,模型可见)
|
||||||
|
|
||||||
|
**`items[]`:** id, name, companyName, phone(脱敏), status, cityName, storeCount, userCount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.15 合伙人关联用户 / 门店 / 订单
|
||||||
|
|
||||||
|
先 **查询合伙人** 取得 `id`,再调下列接口。
|
||||||
|
|
||||||
|
| 工具名 | 路径 |
|
||||||
|
|--------|------|
|
||||||
|
| 合伙人关联用户 | `/partners/{partnerId}/users` |
|
||||||
|
| 合伙人名下门店 | `/partners/{partnerId}/stores` |
|
||||||
|
| 合伙人相关订单 | `/partners/{partnerId}/orders` |
|
||||||
|
|
||||||
|
**Path:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 模型可见 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| partnerId | String | 是 | **是** |
|
||||||
|
|
||||||
|
**Query(可选,建议模型可见):**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| from | String | 起始日期 `YYYY-MM-DD` 或 ISO |
|
||||||
|
| to | String | 结束日期(含当天) |
|
||||||
|
| page / pageSize | Integer | 分页 |
|
||||||
|
|
||||||
|
**`data`:** partnerId, partnerName, total, items[]
|
||||||
|
|
||||||
|
- **users**:关联用户(assocPartnerAccountId)
|
||||||
|
- **stores**:名下门店 + 经营数据
|
||||||
|
- **orders**:佣金归属订单(partnerAccountIdAtPay)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. OpenAPI 导入
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 导入 URL | `{Base URL}/openapi.json` |
|
||||||
|
| Header | `X-Api-Key: <同实例 Key>` |
|
||||||
|
| 格式 | OpenAPI 3.0(**无** `{code,message,data}` 信封,原样 JSON) |
|
||||||
|
| 过滤 | 仅含当前 Key 已授权 paths |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Api-Key: <Key>" \
|
||||||
|
"https://api-test.dukanghaoke.com/api/v1/wecom/plugin/openapi.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 调试检查清单
|
||||||
|
|
||||||
|
- [ ] 第 1 步 Key 与 HQ 实例一致,实例 **已启用**
|
||||||
|
- [ ] curl 带 Key 返回 `code: 0`
|
||||||
|
- [ ] 工具为 **GET**,入参在 **Query/Path**,非 Body
|
||||||
|
- [ ] 输出含 **code / message / data**
|
||||||
|
- [ ] status 等枚举字段类型为 **String**
|
||||||
|
- [ ] 未导入 HQ 未授权工具(否则 403)
|
||||||
|
- [ ] 企微白名单会话试问:「查一下今日经营指标」「订单 DK…」
|
||||||
|
- [ ] HQ **日志 → 智能机器人日志** 出现 `botKey=plugin:{id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 多实例与分工
|
||||||
|
|
||||||
|
| 企微插件 | HQ 实例 | 说明 |
|
||||||
|
|----------|---------|------|
|
||||||
|
| 运营查询 | 全权限 Key | metrics + 审核 + 合伙人等 |
|
||||||
|
| 客服只读 | 部分权限 Key | order + user + store |
|
||||||
|
| URL | **相同** Base URL | 仅 Key 不同 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 相关文档
|
||||||
|
|
||||||
|
- [杜康好客-v3.5.17-开发文档](./杜康好客-v3.5.17-开发文档.md) — 表结构、Admin API、权限目录
|
||||||
|
- [杜康好客-v3.5.16-开发文档](./杜康好客-v3.5.16-开发文档.md) — 插件初版与 curl 示例
|
||||||
|
- [杜康好客-v3编码手册](./杜康好客-v3编码手册.md) — 编码验收一句
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
|
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
|
||||||
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
|
||||||
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
||||||
|
| 3.5.15 | 09-02 | 企微机器人「报告」:日报/周报/月报;Webhook 与消息推送分开 | [`v3.5.15`](./杜康好客-v3.5.15-开发文档.md) |
|
||||||
|
|
||||||
**4.0 起**不再写入本表,见 [`v4-PRD`](./杜康好客-v4-PRD.md)。
|
**4.0 起**不再写入本表,见 [`v4-PRD`](./杜康好客-v4-PRD.md)。
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@
|
|||||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
||||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||||
|
- C 端小程序门店列表/详情的「核销N次」由 HQ「系统设置 → 功能开关 → C 端展示门店核销次数」(`SHOW_STORE_REDEEM_COUNT`)控制,默认开;关闭后接口不再返回次数。
|
||||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
||||||
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
||||||
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
||||||
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
| **admin-web** | 商品/开城/门店/门店账号含子账号 CRUD/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持/企微报告 | 完整 SOP 审核 UI、发票、热力图 |
|
||||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||||
|
|
||||||
## 3. 场景 SC-01~09
|
## 3. 场景 SC-01~09
|
||||||
@@ -68,9 +68,14 @@
|
|||||||
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
||||||
|
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
|
||||||
|
| 3.5.17 | [`企微 API 插件迁入 HQ 后台`](./杜康好客-v3.5.17-开发文档.md) | 🔶 开发完成 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-09-08 | HQ「门店账户」主账号详情可 CRUD 店员子账号(`/admin/store-accounts/:id/staff`) |
|
||||||
|
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
|
||||||
|
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
||||||
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
||||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
||||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 杜康好客 · v3.5.15 开发文档
|
||||||
|
|
||||||
|
> **2026-09-02** · ops / admin-web / domain / shared-types
|
||||||
|
> **主题**:企微机器人增加经营报告(日报 / 周报 / 月报)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 报告模块 | 需求 | HQ「企微机器人 → 报告」,与「消息推送」分开 |
|
||||||
|
| 2 | 三种报 | 需求 | 日报、周报、月报:存量 + 本期新增 |
|
||||||
|
| 3 | 推送 | 需求 | 企微群机器人 Webhook markdown;可预览 / 立即发送 / 定时 |
|
||||||
|
|
||||||
|
**不做**:并入消息推送条件、新权限键、城市范围裁剪、同比。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
配置在报告页,每种报一份 Webhook / 启用 / 发送时刻(周报另选星期,月报另选每月几号)。不占用「消息推送」的事件条件。
|
||||||
|
|
||||||
|
口径(北京日历,与概览折线一致):
|
||||||
|
|
||||||
|
| 指标 | 存量 | 新增 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户数量 | `status=1` 且未合并,`createdAt` < 期末 | 区间内创建 |
|
||||||
|
| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
|
||||||
|
| 门店数量 | 全部门店 | 区间内创建 |
|
||||||
|
| 订单数量 | 按下单 `createdAt`,排除待支付 / 已取消 / 退款中 | 区间内下单 |
|
||||||
|
| 订单金额 | 已付 `payAmount`(`paidAt`),排除待支付 / 已取消 / 退款中 | 区间内支付 |
|
||||||
|
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
||||||
|
|
||||||
|
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
|
||||||
|
- **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点。
|
||||||
|
- **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点。
|
||||||
|
|
||||||
|
三种报都不含发送当天发生额。
|
||||||
|
|
||||||
|
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
`GET /admin/wecom-reports`
|
||||||
|
`GET/PUT /admin/wecom-reports/:kind`(`daily` \| `weekly` \| `monthly`)
|
||||||
|
`POST /admin/wecom-reports/:kind/preview`
|
||||||
|
`POST /admin/wecom-reports/:kind/send`
|
||||||
|
|
||||||
|
权限:`wecom_bots`。
|
||||||
|
|
||||||
|
表 `wecom_report_push`(`kind` 唯一)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `wecom-report.ts` |
|
||||||
|
| shared-types | `wecom-report.ts` |
|
||||||
|
| Prisma | `WecomReportPush`;`prisma/migrate-wecom-report.ts` |
|
||||||
|
| API | `admin-wecom-reports.*` |
|
||||||
|
| HQ | `WecomReportsPage.tsx`;侧栏「报告」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] 侧栏企微机器人下有「报告」,与消息推送分开
|
||||||
|
- [ ] 日报/周报/月报可分别填 Webhook、启停、时刻并保存
|
||||||
|
- [ ] 预览文案含用户/合伙人/门店/订单数量与金额/核销数量与金额的存量+新增
|
||||||
|
- [ ] 立即发送走该报自己的 Webhook,不触发消息推送事件
|
||||||
|
- [ ] 启用后到达时刻只发一次该周期
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# 杜康好客 · v3.5.16 开发文档
|
||||||
|
|
||||||
|
> **2026-09-03** · integrations/wecom · admin-web · domain · shared-types
|
||||||
|
> **主题**:企微智能机器人 **API 插件**只读数据面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
企微后台「添加 API 插件」对接本系统:企微托管大模型 HTTP 调公网接口拉数。与 HQ「企微机器人」长连接 Bot **独立**,不改指令/审批/短信验身。
|
||||||
|
|
||||||
|
**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 接口暴露给企微。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 与长连接 Bot 的分工
|
||||||
|
|
||||||
|
| | 长连接智能机器人 | API 插件 |
|
||||||
|
|--|------------------|----------|
|
||||||
|
| 对话 | 我们收消息并回复 | 企微自带模型组织回复 |
|
||||||
|
| 入口 | HQ 企微机器人 + `@wecom/aibot-node-sdk` | 企微「添加 API 插件」 |
|
||||||
|
| 鉴权 | BotID / Secret | Header `X-Api-Key` |
|
||||||
|
| 能力 | 指令 + 工单/审批等 | 第一期只读查询 |
|
||||||
|
|
||||||
|
建议:客服/技术支持继续走长连接;另建一只「运营查询」机器人只挂本插件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 环境变量
|
||||||
|
|
||||||
|
```
|
||||||
|
WECOM_PLUGIN_ENABLED=true
|
||||||
|
WECOM_PLUGIN_API_KEY=<随机长密钥>
|
||||||
|
```
|
||||||
|
|
||||||
|
只放 `.env` / `.env.staging` / `.env.production`,不进 HQ 系统设置、不进 Git。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 接口
|
||||||
|
|
||||||
|
前缀:`/api/v1/wecom/plugin`
|
||||||
|
鉴权:Header `X-Api-Key`(未启用 / 无 Key / 错 Key 一律 401)
|
||||||
|
响应:`{ code, message, data }`(`GET .../openapi.json` **除外**,原样 OpenAPI 3.0)
|
||||||
|
列表 `pageSize` 默认 5、最大 10。手机号 `maskContactPhone`。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/` | 插件说明 |
|
||||||
|
| GET | `/openapi.json` | 供第 2 步导入工具 |
|
||||||
|
| GET | `/orders?q=` | 订单号 |
|
||||||
|
| GET | `/users?q=` | 用户号或 11 位手机 |
|
||||||
|
| GET | `/stores?q=` | 门店名 |
|
||||||
|
| GET | `/redeems?q=` | 核销单号或门店名 |
|
||||||
|
| GET | `/promo-codes?q=` | 推广码 / 名称 |
|
||||||
|
| GET | `/promo-codes/:code/stats` | 推广码统计 |
|
||||||
|
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
|
||||||
|
|
||||||
|
经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
|
||||||
|
|
||||||
|
审计:`log_wecom_bot.botKey=plugin`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 企微表单(上线后)
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 插件 URL | 生产 `https://api.dukanghaoke.com/api/v1/wecom/plugin`;测试 `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` |
|
||||||
|
| 授权 | Service token / API key |
|
||||||
|
| 位置 | Header |
|
||||||
|
| Parameter name | `X-Api-Key` |
|
||||||
|
| Service token | 与 `WECOM_PLUGIN_API_KEY` 相同 |
|
||||||
|
| 第 2 步导入 | `…/wecom/plugin/openapi.json`(同一把 Key) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 联调清单(staging curl)
|
||||||
|
|
||||||
|
先在 `api-test` 的 `.env.staging` 打开开关并写入 Key,重启 `dukang-api`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=https://api-test.dukanghaoke.com/api/v1/wecom/plugin
|
||||||
|
KEY='<WECOM_PLUGIN_API_KEY>'
|
||||||
|
|
||||||
|
# 无 Key → 401
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/metrics"
|
||||||
|
|
||||||
|
# 错 Key → 401
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: wrong" "$BASE/metrics"
|
||||||
|
|
||||||
|
# 经营指标 / OpenAPI / 订单 / 门店 / 推广码
|
||||||
|
curl -sS -H "X-Api-Key: $KEY" "$BASE/metrics?kind=today"
|
||||||
|
curl -sS -H "X-Api-Key: $KEY" "$BASE/openapi.json" | head -c 200
|
||||||
|
curl -sS -H "X-Api-Key: $KEY" "$BASE/orders?q=DK"
|
||||||
|
curl -sS -H "X-Api-Key: $KEY" "$BASE/stores?q=店"
|
||||||
|
curl -sS -H "X-Api-Key: $KEY" "$BASE/promo-codes?q=DK"
|
||||||
|
```
|
||||||
|
|
||||||
|
企微:导入 OpenAPI → 白名单会话用自然语言问订单/门店 → HQ「企微机器人 → 日志」出现 `plugin`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `wecom-plugin.ts` |
|
||||||
|
| shared-types | `wecom-plugin.ts` |
|
||||||
|
| API | `integrations/wecom/wecom-plugin.*`;`WecomModule` 注册 Controller |
|
||||||
|
| HQ | `WecomBotsPage.tsx` 插件 URL / Header 提示(不展示 Key) |
|
||||||
|
| env | `.env*.example`:`WECOM_PLUGIN_ENABLED` / `WECOM_PLUGIN_API_KEY` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收
|
||||||
|
|
||||||
|
- [ ] 无 Key / 错 Key → 401
|
||||||
|
- [ ] curl 带 Key 查订单 / 门店 / 推广码 / 今日指标,`{ code:0, data }` 且手机脱敏
|
||||||
|
- [ ] `openapi.json` 为 OpenAPI 文档(无信封)
|
||||||
|
- [ ] 企微第 2 步可导入工具;白名单会话能问到真实数据
|
||||||
|
- [ ] HQ 企微机器人日志可见 `plugin`
|
||||||
|
- [ ] 现有长连接 Bot 行为不变
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 杜康好客 · v3.5.17 开发文档
|
||||||
|
|
||||||
|
> **2026-09-06** · integrations/wecom · ops · admin-web · domain · shared-types
|
||||||
|
> **主题**:企微 API 插件迁入 HQ 后台(多实例 + 动态工具权限)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
把企微 API 插件从 `.env` 单 Key 迁到 HQ「企微机器人 → API 插件」:支持多实例,每实例一把 `X-Api-Key` 与一组工具权限。企微侧仍用同一 Base URL,各插件填不同 Key,第 2 步只配置已授权工具。
|
||||||
|
|
||||||
|
**不做**:改长连接 Bot / 消息推送 / 报告;新增 HQ 权限键(继续 `wecom_bots`);插件写操作;每实例独立 Base Path。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 映射约定
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 一条 HQ 实例 | 企微里一只「API 插件」 |
|
||||||
|
| Base URL | `/api/v1/wecom/plugin`(生产/测试域名不变) |
|
||||||
|
| 区分实例 | Header `X-Api-Key` |
|
||||||
|
| 未授权工具 | 403 |
|
||||||
|
| `GET /openapi.json` | 按当前 Key 的 permissions 过滤 paths |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 表 `wecom_api_plugin`
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| name | 展示名 |
|
||||||
|
| api_key | 明文存库(列表只返 `apiKeyConfigured` / `apiKeyMasked`) |
|
||||||
|
| permissions | JSON 字符串数组:`order.read` · `user.read` · `store.read` · `redeem.read` · `promo.read` · `metrics.read` |
|
||||||
|
| remark / enabled / sort_order | 备注、启停、排序 |
|
||||||
|
|
||||||
|
迁移:`pnpm --filter @dukang/api prisma:migrate-wecom-api-plugin`(或 `db push`)。
|
||||||
|
|
||||||
|
启动时若表为空且 env 仍有 `WECOM_PLUGIN_ENABLED=true` + `WECOM_PLUGIN_API_KEY`,一次性 upsert「迁移自 env」(全权限),避免断服。新部署不再依赖这两项 env。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 运行时
|
||||||
|
|
||||||
|
- Guard:读 `X-Api-Key`,在 `enabled=true` 实例中常量时间比对,命中则挂 `req.wecomPlugin`
|
||||||
|
- 无启用实例 / Key 未命中 → 401
|
||||||
|
- 各工具入口 `requirePerm`;无权限 → 403
|
||||||
|
- `GET /`:该实例 name + 已授权 tools
|
||||||
|
- 审计:`log_wecom_bot.botKey=plugin:{id}`,`action=plugin.order.read` 等
|
||||||
|
|
||||||
|
### 工具权限目录(v3.5.17 增补)
|
||||||
|
|
||||||
|
| 权限 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| `metrics.read` | `/metrics`(含 `newStores` / `storesIncrement` 新增门店) |
|
||||||
|
| `store.read` | `/stores`(名称搜索 + 评分/核销/累计核销/合伙人) |
|
||||||
|
| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-package-audits` 及 `/{id}` 对比详情 |
|
||||||
|
| `partner.read` | `/partners`、`/partners/{id}/users|stores|orders`(可选 `from`/`to` 时间筛选) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Admin API
|
||||||
|
|
||||||
|
权限键 `wecom_bots`。列表/详情永不回显完整 Key;创建与 `POST /:id/rotate-key` 一次性返回明文。
|
||||||
|
|
||||||
|
| 方法 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| GET/POST | `/admin/wecom-api-plugins` |
|
||||||
|
| GET/PUT/DELETE | `/admin/wecom-api-plugins/:id` |
|
||||||
|
| POST | `/admin/wecom-api-plugins/:id/rotate-key` |
|
||||||
|
|
||||||
|
创建时未填 `apiKey` 则服务端生成。更新时 `apiKey` 空=不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 企微侧怎么配
|
||||||
|
|
||||||
|
详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**(第 1 步插件、第 2 步工具/OpenAPI 导入、全接口参数表)。
|
||||||
|
|
||||||
|
1. 插件 URL = HQ 页展示的 Base URL(全实例相同)
|
||||||
|
2. Header `X-Api-Key` = 该实例密钥
|
||||||
|
3. 第 2 步只添加 HQ 已勾选的工具(GET + Query);可用带 Key 的 `openapi.json` 对照路径
|
||||||
|
|
||||||
|
权限示例:运营勾全量;客服只勾 `order.read` / `user.read` / `store.read`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收
|
||||||
|
|
||||||
|
- [ ] HQ 建两个插件不同权限 → 同 URL 不同 Key → 分别能/不能调 `/orders`
|
||||||
|
- [ ] 无 Key / 错 Key → 401;无权限工具 → 403
|
||||||
|
- [ ] 带 Key 的 `openapi.json` 只含已授权 paths(无信封)
|
||||||
|
- [ ] `/metrics` 返回 `newStores`;`/stores` 含经营字段;审核/合伙人工具按权限可用
|
||||||
|
- [ ] 创建/轮换后明文 Key 只回显一次;列表为掩码
|
||||||
|
- [ ] 企微调试通过;日志 `plugin:{id}`
|
||||||
|
- [ ] 长连接 Bot / 消息推送 / 报告行为不变
|
||||||
+11
-3
@@ -1,7 +1,7 @@
|
|||||||
# 杜康好客 · V3 编码手册(交付业务版)
|
# 杜康好客 · V3 编码手册(交付业务版)
|
||||||
|
|
||||||
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
||||||
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
||||||
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
||||||
|
|
||||||
## 1. 交付目标(六条)
|
## 1. 交付目标(六条)
|
||||||
@@ -44,13 +44,21 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
|
**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
|
||||||
|
|
||||||
|
**HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。
|
||||||
|
|
||||||
|
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
||||||
|
|
||||||
|
**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
|
||||||
|
|
||||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||||
|
|
||||||
|
**HQ 门店账号子账号**:`GET /admin/store-accounts/:id` 详情含 `staff`;`POST /admin/store-accounts/:id/staff` · `PUT /admin/store-accounts/:id/staff/:staffId` · `DELETE /admin/store-accounts/:id/staff/:staffId`。仅主账号可挂子账号(不可多级);`storeIds` 必须是主账号已绑定门店;创建默认 `ACTIVE`、角色默认收银员、权限默认核销+记录;改登录手机解绑微信。门店端自管仍走 `GET/POST/PUT/DELETE /shop/staff`。
|
||||||
|
|
||||||
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
||||||
|
|
||||||
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
||||||
|
|
||||||
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
|
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。C 端门店「核销N次」由系统设置功能开关 `SHOW_STORE_REDEEM_COUNT` 控制(默认开)。
|
||||||
|
|
||||||
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
||||||
|
|
||||||
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。
|
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
|
||||||
|
|
||||||
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||||
|
|
||||||
|
|||||||
+35
-7
@@ -1,8 +1,9 @@
|
|||||||
# 杜康好客 · V4 PRD
|
# 杜康好客 · V4 PRD
|
||||||
|
|
||||||
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理
|
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录
|
||||||
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算)。
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
||||||
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
||||||
|
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
|
|
||||||
## 0. 版本
|
## 0. 版本
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
| 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) |
|
| 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) |
|
||||||
| 4.0.7 | 09-01 | HQ 城市合伙人活动图快链;指定一张活动图为单个或勾选主合伙人合成下载(PNG / zip) | [`v4.0.7`](./杜康好客-v4.0.7-开发文档.md) |
|
| 4.0.7 | 09-01 | HQ 城市合伙人活动图快链;指定一张活动图为单个或勾选主合伙人合成下载(PNG / zip) | [`v4.0.7`](./杜康好客-v4.0.7-开发文档.md) |
|
||||||
| 4.0.9 | 09-02 | 子账号默认启用;关联码已扫码计数;零元账单不同步;主账号自填银行账号;周账周一 08:00 出账;预付款预估;子账号用户管理(无活动图) | [`v4.0.9`](./杜康好客-v4.0.9-开发文档.md) |
|
| 4.0.9 | 09-02 | 子账号默认启用;关联码已扫码计数;零元账单不同步;主账号自填银行账号;周账周一 08:00 出账;预付款预估;子账号用户管理(无活动图) | [`v4.0.9`](./杜康好客-v4.0.9-开发文档.md) |
|
||||||
|
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
|
||||||
|
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
||||||
|
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
|
||||||
|
|
||||||
## 1. 锚点(沿用 V3,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -28,11 +32,12 @@
|
|||||||
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
||||||
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
||||||
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
||||||
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。
|
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。HQ 合伙人账号/开城合伙人详情同样可下载**裸二维码**(「下载二维码」),与「下载活动图」合成海报分开。
|
||||||
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
||||||
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
||||||
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
||||||
- **子账号**也可进入用户管理:可看关联码、下载二维码、看已关联用户;**不能**看活动图入口与合成主图(只出纯关联码)。
|
- **子账号**也可进入用户管理:可看关联码、下载二维码、看已关联用户;**不能**看活动图入口与合成主图(只出纯关联码)。
|
||||||
|
- **子账号继承二维码**(v4.0.18):子账号有**自己的**小程序码(`getwxacodeunlimit`,scene=`sa_{subAccountId}`),与主账号 `pa_{partnerId}` 前缀、id 均不同,不冲突。扫码**仍锁定主账号**(佣金归主账号,first-lock 校验主账号),额外写入 `user_user.assoc_sub_account_id` 记录子账号归属,形成**新增一级子账号维度统计**。子账号在用户管理/关联码页展示并下载**自己的**码、看**自己维度**的已扫码/已关联/订单;主账号已扫码在读取时聚合 own+children,主账号行为不变。
|
||||||
- 用户管理页二维码下方展示「已扫码」与「已关联」人数;为 0 的段不显示。已扫码 = C 端带 `pa_` scene 进入时累加(未登录也计),与已关联人数独立。
|
- 用户管理页二维码下方展示「已扫码」与「已关联」人数;为 0 的段不显示。已扫码 = C 端带 `pa_` scene 进入时累加(未登录也计),与已关联人数独立。
|
||||||
- 主账号新建子账号默认 **ACTIVE**(可立即登录),列表中可再禁用。
|
- 主账号新建子账号默认 **ACTIVE**(可立即登录),列表中可再禁用。
|
||||||
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
||||||
@@ -86,7 +91,7 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
||||||
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
||||||
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
||||||
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。
|
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。合伙人详情关联码区另有「下载二维码」,直接下载裸关联码,不贴活动图。
|
||||||
|
|
||||||
## 7. 酒厂对账(v4.0.6)
|
## 7. 酒厂对账(v4.0.6)
|
||||||
|
|
||||||
@@ -95,6 +100,29 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
||||||
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
||||||
|
|
||||||
## 8. 不做
|
## 8. 门店打款账户(v4.0.18)
|
||||||
|
|
||||||
改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
- 打款/核销结算/提现/账单导出读取门店详情「结算资质」:结算户名、银行账号、开户银行(主账号 `StoreAccount.bank_account_*`)。
|
||||||
|
- **不**再维护独立「收款账户」列表或 `store_bank_account`;一门店一行。
|
||||||
|
- 改结算资质后无需重启,下次打款即时生效。
|
||||||
|
|
||||||
|
## 9. 财务全部银行账户(v4.0.18)
|
||||||
|
|
||||||
|
HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查阅、筛选、导出;**不替代**各业务源的维护入口,也不作为打款主数据。
|
||||||
|
|
||||||
|
| 类型 | 来源 | 有效 | 本页可写 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 门店 | 门店详情结算资质(主账号 `bank_account_*`) | 户名+账号非空;一店一行 | 仅备注 |
|
||||||
|
| 酒厂 | `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
|
||||||
|
| 合伙人 | 主账号 `bank_account_*` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||||
|
| 物流 | 承运商 `common_fulfillment_provider` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||||
|
| 其他 | `finance_bank_account` | ACTIVE | 增删改(不挂门店,不进入打款) |
|
||||||
|
|
||||||
|
- 列表字段:类型、归属(快链到源页面)、城市、结算户名、银行账号、开户银行、备注。
|
||||||
|
- 筛选:类型、城市、关键字(户名/账号/开户银行/归属/备注)。可导出 Excel 与 PDF。
|
||||||
|
- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
|
||||||
|
- 权限:`finance`。
|
||||||
|
|
||||||
|
## 10. 不做
|
||||||
|
|
||||||
|
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
+15
-3
@@ -3,14 +3,14 @@
|
|||||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
||||||
|
|
||||||
## 0. 总览(2026-09-02)
|
## 0. 总览(2026-09-07)
|
||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.9** 合伙人 H5 周结算、预付款、子账号用户管理 |
|
| 版本线 | **v4.0.18** 子账号继承二维码 + HQ 财务全部银行账户(门店行读结算资质;含 v4.0.15 HQ 概览折线图) |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
|
|
||||||
## 1. 版本交付
|
## 1. 版本交付
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 |
|
| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 |
|
| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.9 | [`合伙人 H5 周结算与用户管理`](./杜康好客-v4.0.9-开发文档.md) | ✅ 已实现 |
|
| 4.0.9 | [`合伙人 H5 周结算与用户管理`](./杜康好客-v4.0.9-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -31,3 +35,11 @@
|
|||||||
| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
||||||
| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) |
|
| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) |
|
||||||
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
||||||
|
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||||
|
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
||||||
|
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
|
||||||
|
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;子账号独立继承码 + 子账号维度统计 |
|
||||||
|
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
||||||
|
| 2026-09-08 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||||
|
| 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
|
||||||
|
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 |
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 杜康好客 · v4.0.13 开发文档
|
||||||
|
|
||||||
|
> **2026-09-02** · mini-user / iam / trade / fulfillment / admin-web / h5-partner / domain
|
||||||
|
> **主题**:收货地址严格把关(禁「全市」)+ 小飞侠拒单履约可感知
|
||||||
|
|
||||||
|
需求背景:订单 `DK2026090220803` 自动推小飞侠失败,原因 `超出服务区`;收货区县为门店筛选用伪值「全市」。
|
||||||
|
|
||||||
|
**不做(本版明确排除)**:仓坐标补全、收件 `chooseLocation` / `toCoordinate` 坐标链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 收货选区与门店筛选语义拆分 | 缺陷/体验 | `RegionPicker mode=shipping` 无「全市」 |
|
||||||
|
| 2 | 地址保存硬闸 | 缺陷 | 前后端拒伪区县;详细地址最短 8 字 |
|
||||||
|
| 3 | 下单/预览拦截脏地址 | 缺陷 | preview `addressOk=false`;确认页不可提交 |
|
||||||
|
| 4 | 地址簿存量提示 | 体验 | 「需完善」置顶;结账选址强制去编辑 |
|
||||||
|
| 5 | 推单失败可感知 | 缺陷 | `fulfillmentHold` + 原因码;HQ 标签/发货弹窗 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
### 2.1 收货地址
|
||||||
|
|
||||||
|
- 伪区县:`全市`、`全部`、空 —— **禁止**写入 `user_address` / 下单快照 / 合伙人代下单。
|
||||||
|
- 详细地址:必填且长度 ≥ 8;含「全市」且无街道路牌关键词时前端软提示(不单独硬失败)。
|
||||||
|
- 门店列表筛选仍可使用「全市」(`mode=filter`)。
|
||||||
|
- 现场取货地址快照(`现场/现场/取货`)不走本校验。
|
||||||
|
|
||||||
|
### 2.2 履约拦截(推单失败)
|
||||||
|
|
||||||
|
| `fulfillment_hold_reason` | 含义 | HQ 展示 |
|
||||||
|
|---------------------------|------|---------|
|
||||||
|
| `LARGE_ORDER_GE_10_BOXES` | 大单≥10箱 | 大单 |
|
||||||
|
| `COURIER_OUT_OF_SERVICE` | 小飞侠返回超出服务区 | 超区 |
|
||||||
|
| `COURIER_DISPATCH_FAILED` | 其它自动推单失败 | 推单失败 |
|
||||||
|
|
||||||
|
- 失败后仍写 `MANUAL` 配送记录(待发货),**同时**挂 `fulfillmentHold`,避免静默像「没推过」。
|
||||||
|
- HQ 手动推小飞侠或填快递成功后仍清 hold(既有逻辑)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `packages/domain/src/shipping-address.ts` |
|
||||||
|
| shared-types | `FULFILLMENT_HOLD_REASON_LABELS` + 常量 |
|
||||||
|
| API | `UserAddressService`;`TradeService` preview/create/代下单;`FulfillmentService.markCourierDispatchHold` |
|
||||||
|
| C 端 | `RegionPicker`、`address-edit`、`addresses`、`order-confirm`、`shipping-address.ts` |
|
||||||
|
| 合伙人 | `ProxyOrderPage` 校验 |
|
||||||
|
| HQ | `OrdersPage` 拦截筛选文案与标签 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 行为变化
|
||||||
|
|
||||||
|
| 接口 | 变化 |
|
||||||
|
|------|------|
|
||||||
|
| `POST/PUT /user/addresses` | 伪区县 / 详情过短 → `400` |
|
||||||
|
| `POST /trade/orders/preview` | 脏地址 → `addressOk=false` + message |
|
||||||
|
| `POST /trade/orders` | 脏地址 → `400` |
|
||||||
|
| 合伙人代下单 | 同上校验 |
|
||||||
|
| 支付后自动推单 | 失败写 hold reason(库字段,无新 path) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] 新增/编辑地址:区县列表无「全市」;选真实区县 + 足够详细地址可保存
|
||||||
|
- [ ] 保存「全市」或过短详情被拒(前端 toast + 后端 400)
|
||||||
|
- [ ] 历史脏地址在地址簿标「需完善」;结账选择时跳转编辑
|
||||||
|
- [ ] 确认订单页脏地址不可提交;preview 提示完善区县
|
||||||
|
- [ ] 合伙人代下单伪区县/过短详情被拒
|
||||||
|
- [ ] 模拟小飞侠「超出服务区」后:订单 `fulfillment_hold=1`、`reason=COURIER_OUT_OF_SERVICE`;HQ 列表「超区」、发货弹窗有说明
|
||||||
|
- [ ] 门店列表筛选「全市」行为不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 存量建议(运维,非代码)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 生产排查脏地址(只读)
|
||||||
|
SELECT id, user_id, province, city, district, detail, updated_at
|
||||||
|
FROM user_address
|
||||||
|
WHERE district IN ('全市','全部','') OR district IS NULL
|
||||||
|
ORDER BY id DESC LIMIT 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
可选:运营通知用户进「地址管理」完善;本版不自动改写历史行。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 杜康好客 · v4.0.14 开发文档
|
||||||
|
|
||||||
|
> **2026-09-02** · ops / admin-web / domain / shared-types
|
||||||
|
> **主题**:HQ 概览日/周/月/季/年分桶、环比、板块筛、订单/核销金额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 时间粒度 | 体验 | `day/week/month/quarter/year` + 自选日期 |
|
||||||
|
| 2 | 环比 | 需求 | 整段按粒度回退一档;上期 0 且本期 0 为 `—`,本期 > 0 为 `+100%` |
|
||||||
|
| 3 | 筛选分层 | 体验 | 全局只筛城市 + 时间;五板块各自筛选 |
|
||||||
|
| 4 | 金额 | 需求 | 订单/核销同时出笔数与金额 |
|
||||||
|
| 5 | 权限 | 安全 | 无权限模块后端不算不返回;城市范围只看负责城 |
|
||||||
|
|
||||||
|
**不做**:同比、测试单开关、新权限键、Prisma 迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
### 2.1 筛选分层
|
||||||
|
|
||||||
|
**全局**:粒度 + 日期 + 城市。环比窗口仍按全局日期、按粒度回退一档。
|
||||||
|
|
||||||
|
**板块筛**(只影响本板块 KPI / 趋势该系列 / 城市该系列):
|
||||||
|
|
||||||
|
| 板块 | 筛选 | 口径 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户 | 推广码、关联合伙人、活动 | 推广码=`user_promo_attribution`;关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前所选活动图(`partner_account.activity_poster_id`) |
|
||||||
|
| 合伙人 | 无 | 仅全局 |
|
||||||
|
| 门店 | 合伙人 | 门店归属主合伙人 `store.partner_account_id` |
|
||||||
|
| 订单 | 推广码、关联合伙人、活动、商品 | 推广码=`order.promo_code_id`;关联合伙人/活动同用户(下单用户);商品=`order.product_id`。笔数=`createdAt`;金额=已付 `payAmount`(`paidAt`) |
|
||||||
|
| 核销单 | 门店、关联合伙人 | 门店=`redeem.store_id`;关联合伙人=核销用户 `assoc_partner_account_id`。笔数与 `RedeemRecord.amount` |
|
||||||
|
|
||||||
|
下拉:合伙人「企业-个人」;活动=活动图标题(需 `activity_posters`)。缺对应权限则忽略该筛。
|
||||||
|
|
||||||
|
### 2.2 模块口径
|
||||||
|
|
||||||
|
| 模块 | 权限键 | 数量 | 新增 | 金额 |
|
||||||
|
|------|--------|------|------|------|
|
||||||
|
| 用户 | `users` | 区间末日存量 | `createdAt` 落入区间 | 有 `orders` 时交叉付费用户 / 客单价(跟用户筛,不跟订单筛) |
|
||||||
|
| 合伙人 | `partners` | 主账号期末存量 | 区间新签 | 交叉:支付时归属 GMV / 名下门店核销额(仅全局) |
|
||||||
|
| 门店 | `stores` | 期末存量 | 区间新签 | 交叉:本板块门店筛下的核销额(需 `benefit`) |
|
||||||
|
| 订单 | `orders` | 区间下单笔数 | 同左 | 已付 `payAmount` |
|
||||||
|
| 核销单 | `benefit` | 区间核销笔数 | 同左 | `RedeemRecord.amount` |
|
||||||
|
|
||||||
|
时间一律北京日历。周=周一~周日;季=自然季;年=自然年。
|
||||||
|
|
||||||
|
默认窗口:日=昨天~今天;周=上周一~今天;月=上月 1 日~今天;季=上季首日~今天;年=去年 1 月 1 日~今天。
|
||||||
|
|
||||||
|
### 2.3 权限与城市
|
||||||
|
|
||||||
|
- 进页仍需 `dashboard`。
|
||||||
|
- 无权限模块:后端不算、响应省略。
|
||||||
|
- 城市范围:只统计负责城市;筛选项无「未选城」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
`GET /admin/dashboard/analytics`
|
||||||
|
|
||||||
|
全局:`granularity`、`dateFrom`、`dateTo`、`cityId`(`none`=用户未选城)。
|
||||||
|
|
||||||
|
板块:`usersPromoCodeId`(`none`=无归因)、`usersPartnerAccountId`、`usersActivityPosterId`;`storesPartnerAccountId`;`ordersPromoCodeId`(`none`=无推广码)、`ordersPartnerAccountId`、`ordersActivityPosterId`、`ordersProductId`;`redeemsStoreId`、`redeemsPartnerAccountId`。
|
||||||
|
|
||||||
|
响应:`granularity`、`range`、`prevRange`、`modules`、`byPeriod`、`byCity`、`byProduct`、`byPartner`。
|
||||||
|
|
||||||
|
`GET /admin/dashboard/stats`、`/version` 不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `dashboard-period.ts`(分桶 / 上期 / 环比) |
|
||||||
|
| shared-types | `ops.ts` 概览 DTO |
|
||||||
|
| API | `admin-dashboard.service.ts`、`admin-dashboard-analytics.ts`、`admin-query.dto.ts` |
|
||||||
|
| HQ | `DashboardPage.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] 全局只有城市与时间;五板块筛互不影响
|
||||||
|
- [ ] 日默认昨天~今天,环比为前天~昨天;周月季年按粒度回退一档
|
||||||
|
- [ ] 订单/核销同时展示笔数与金额
|
||||||
|
- [ ] 用户筛关联合伙人后只计 `assoc_partner_account_id`
|
||||||
|
- [ ] 客服(仅 users/orders):无合伙人/门店/核销卡
|
||||||
|
- [ ] 待办卡与超管发版区行为不变
|
||||||
|
- [ ] domain 周期/环比单测通过
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 杜康好客 · v4.0.15 开发文档
|
||||||
|
|
||||||
|
> **2026-09-02** · ops / admin-web / domain / shared-types
|
||||||
|
> **主题**:HQ 概览改为分指标折线图;全局筛选不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 横轴 | 体验 | 粒度 = 时间分桶(日/周/月/季/年) |
|
||||||
|
| 2 | 纵轴 | 需求 | 总量 / 增量;订单与核销再拆笔数与金额 |
|
||||||
|
| 3 | 线条 | 需求 | 用户:推广码 / 关联合伙人 / 活动;门店:关联合伙人;订单:用户 / 关联合伙人 / 商品;核销:门店 / 关联合伙人;合伙人无维度单线 |
|
||||||
|
| 4 | 布局 | 体验 | 一图一行、宽度拉满;改全局筛选全部重绘 |
|
||||||
|
| 5 | 快链 | 体验 | 「查看」只带全局城市 + 日期,不带线条维度 |
|
||||||
|
|
||||||
|
**不做**:同比、板块筛、环比卡、Prisma 迁移、新权限键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
**全局筛选**:粒度 + 日期 + 城市;日期旁快捷「上周 / 上月 / 上季度」;查询左侧总量 / 增量单选(互斥,只渲染对应折线图);查询右侧「下载PDF」只导出当前指标下的折线图(不含 KPI / 待办 / 版本)。改粒度**不**改日期区间。
|
||||||
|
|
||||||
|
**总量** = 该桶结束时的累计存量(含区间前基线)。**增量** = 落入该桶的新增/发生额。订单笔数按 `createdAt`,订单金额按已付 `payAmount`(`paidAt`);核销按 `RedeemRecord`。
|
||||||
|
|
||||||
|
**线条口径**(与 v4.0.14 归因一致):
|
||||||
|
|
||||||
|
| 板块 | 维度 | 字段 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户 | 推广码 | `user_promo_attribution`;空=自然量 |
|
||||||
|
| 用户 | 关联合伙人 | `assoc_partner_account_id`;空=未关联 |
|
||||||
|
| 用户 | 活动 | 关联合伙人当前 `activity_poster_id`;空=无活动 |
|
||||||
|
| 门店 | 关联合伙人 | `store.partner_account_id` |
|
||||||
|
| 订单 | 用户 | `order.user_id` |
|
||||||
|
| 订单 | 关联合伙人 | 下单用户 `assoc_partner_account_id` |
|
||||||
|
| 订单 | 商品 | `order.product_id` |
|
||||||
|
| 核销 | 门店 | `redeem.store_id` |
|
||||||
|
| 核销 | 关联合伙人 | 核销用户 `assoc_partner_account_id` |
|
||||||
|
|
||||||
|
高基数维度:按期末存量 Top 10,长尾并入「其他」;`none` 有数据则始终保留。
|
||||||
|
|
||||||
|
无对应权限则不返回该图;无任何维度权限时用户/门店/核销退回单线。城市范围只统计负责城。时间北京日历。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
`GET /admin/dashboard/analytics?granularity&dateFrom&dateTo&cityId`
|
||||||
|
|
||||||
|
响应:`granularity`、`range`、`periods[]`、`charts[]`(`key/title/unit/href/series`)。不再返回 `modules` / `byPeriod` / `byCity` / `byProduct` / `byPartner` / 板块筛参数。
|
||||||
|
|
||||||
|
`GET /admin/dashboard/stats`、`/version` 不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `dashboard-series.ts`(累计 / TopN) |
|
||||||
|
| shared-types | `ops.ts` 折线 DTO |
|
||||||
|
| API | `admin-dashboard-lines.ts`、`admin-dashboard.service.ts`、`admin-query.dto.ts` |
|
||||||
|
| HQ | `DashboardPage.tsx`;订单/合伙人列表消费快链城市与日期 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] 全局:粒度、日期、城市;日期快捷上周/上月/上季度;查询左侧总量/增量单选只出对应图
|
||||||
|
- [ ] 查询右侧下载 PDF:仅当前总量或增量折线图;不含 KPI / 待办 / 版本
|
||||||
|
- [ ] 改粒度不改日期区间;改全局筛选(除指标单选)全部折线重绘
|
||||||
|
- [ ] 每个指标×维度一张全宽折线图;合伙人仅单线
|
||||||
|
- [ ] 日粒度横轴为日期区间按日划分
|
||||||
|
- [ ] 「查看」只带 `cityId` + `createdFrom/createdTo`,不带推广码/合伙人/商品等
|
||||||
|
- [ ] 客服无合伙人/门店/核销图
|
||||||
|
- [ ] 待办卡与超管发版区不变
|
||||||
|
- [ ] domain 折线单测通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 活动图上传压缩(DPT-20260902-849)
|
||||||
|
|
||||||
|
### 6.1 目标
|
||||||
|
|
||||||
|
HQ 上传活动底图时,后端用 Sharp 自动等比缩放至 v4.0.7 底图限制(最长边 ≤ 2500px、宽×高 ≤ 400 万),并在响应与表单中提示尺寸。
|
||||||
|
|
||||||
|
### 6.2 规则
|
||||||
|
|
||||||
|
- 入口:`POST /common/resources/upload`,`bizType=ACTIVITY_POSTER`、`mediaType=IMAGE`
|
||||||
|
- 尺寸合规:原样存 OSS,响应 `image.compressed=false`
|
||||||
|
- 尺寸超限:Sharp 等比缩放后存 OSS,响应含 `width/height/originalWidth/originalHeight/compressed`
|
||||||
|
- 有 alpha 通道保留 PNG;否则按原 mime 或 JPEG quality 85 输出
|
||||||
|
- 压缩后仍超过 `OSS_MAX_UPLOAD_BYTES` → 4xx,文案含当前尺寸
|
||||||
|
- **手动粘贴 URL** 仍不经上传压缩;合成时沿用 v4.0.7 校验
|
||||||
|
|
||||||
|
### 6.3 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| shared-types | `activity-poster.ts`:`activityPosterResizeTarget`、`OssUploadImageMeta`、`ACTIVITY_POSTER_UPLOAD_HINT` |
|
||||||
|
| API | `common/image/activity-poster-upload.util.ts`、`resource.service.ts` |
|
||||||
|
| HQ | `OssUpload.tsx`、`ActivityPostersPage.tsx`、`lib/upload.ts` |
|
||||||
|
|
||||||
|
无 Prisma 迁移。
|
||||||
|
|
||||||
|
### 6.4 验收
|
||||||
|
|
||||||
|
- [ ] 上传 1080×1920:成功,提示尺寸,未标记压缩
|
||||||
|
- [ ] 上传 4000×3000:成功,提示压缩后尺寸与原尺寸
|
||||||
|
- [ ] 保存后「为该合伙人下载」/ zip 导出不再报底图过大
|
||||||
|
- [ ] 非图片 / 损坏文件:明确 4xx
|
||||||
|
- [ ] AVATAR 等其他 bizType 行为不变
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 杜康好客 · v4.0.18 开发文档
|
||||||
|
|
||||||
|
> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types / domain
|
||||||
|
> **主题**:C 端门店列表省市区县地址;子账号独立继承二维码;HQ 财务全部银行账户目录
|
||||||
|
> **2026-09-08 修订**:门店列表地址按「省+市+区+详细地址」原样拼接,详细地址已含省市区时**不去重**
|
||||||
|
> **2026-09-08 追加**:HQ 财务「全部银行账户」目录(聚合门店结算资质/酒厂/合伙人/物流 + 不挂门店的「其他」账户)
|
||||||
|
> **2026-09-08 再修订**:撤销门店多收款账户(`store_bank_account`);打款与财务门店行均读门店详情「结算资质」主账号 `bank_account_*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | C 端门店列表地址 | 需求 | 「省 + 市 + 区 + 详细地址」原样拼接展示(不去重);搜索匹配完整地址 |
|
||||||
|
| 2 | 门店打款账户 | 需求 | 打款读门店详情「结算资质」(结算户名 / 银行账号 / 开户银行);**不**再维护独立收款账户列表 |
|
||||||
|
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
|
||||||
|
| 4 | HQ 财务全部银行账户 | 需求 | 财务菜单「全部银行账户」列出有效账户;门店行来自结算资质;可新增不挂门店的「其他」账户;类型/备注/筛选/Excel+PDF 导出 |
|
||||||
|
|
||||||
|
**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户;「其他」账户接入打款;本页改写门店/合伙人/酒厂/物流源字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
### 2.1 门店列表地址
|
||||||
|
|
||||||
|
C 端门店列表卡片「地址」一栏(门店详情页同规则)= **下拉省 + 下拉市 + 下拉区 + 详细地址 input 原文**,四段按字段原样拼接,中间不加分隔符。
|
||||||
|
|
||||||
|
| 字段 | 来源 | 存库 |
|
||||||
|
|------|------|------|
|
||||||
|
| 省 | 录店/改店省市区下拉 | `store.province` |
|
||||||
|
| 市 | 同上 | `store.cityName` |
|
||||||
|
| 区 | 同上 | `store.district` |
|
||||||
|
| 详细地址 | 用户自由输入(地图选点若回填也写入此字段) | `store.address` |
|
||||||
|
|
||||||
|
**禁止去重**:不得因详细地址已包含省/市/区而省略前缀。助手 `formatStoreDisplayAddress`(`packages/domain`)/ `fullStoreAddress`(mini-user)只做 trim 后拼接;空则回退「地址待完善」。
|
||||||
|
|
||||||
|
| 下拉省市区 | 详细地址 | 列表展示 |
|
||||||
|
|------------|----------|----------|
|
||||||
|
| 河南省 / 郑州市 / 金水区 | 金水东路333号 | `河南省郑州市金水区金水东路333号` |
|
||||||
|
| 河南省 / 郑州市 / 金水区 | 河南省郑州市金水区金水东路333号 | `河南省郑州市金水区河南省郑州市金水区金水东路333号` |
|
||||||
|
|
||||||
|
搜索关键字同时匹配 `address` / `district` / 完整拼接结果。规则实现:`formatStoreDisplayAddress`(domain,后端地理编码同用)与 mini-user `fullStoreAddress`(列表/详情展示)。
|
||||||
|
|
||||||
|
### 2.2 门店打款账户(结算资质)
|
||||||
|
|
||||||
|
- **撤销**独立表 `store_bank_account` 与门店端/HQ「收款账户」维护页。一门店一行,取绑定主账号(`is_primary=1`)的 `bank_account_name` / `bank_account_no` / `bank_branch`;无主账号则取最早绑定账号。
|
||||||
|
- 对应 HQ 门店详情页签「结算资质」:结算户名、银行账号、开户银行。
|
||||||
|
- 打款/结算/提现/账单导出统一读上述字段(`loadStoreDefaultBank`),实时查库,改后无需重启。
|
||||||
|
- 门店端不再提供收款账户入口;银行信息由总部在结算资质维护。
|
||||||
|
|
||||||
|
### 2.3 子账号继承二维码
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
scanC[C端扫码 sa_subId] --> touch[touchScan 子账号 assocScanCount+1]
|
||||||
|
scanC --> bind[bindUser]
|
||||||
|
bind -->|first-lock| setAssoc[user.assocPartnerAccountId=主账号, assocSubAccountId=子账号]
|
||||||
|
setAssoc --> main[主账号聚合统计: 关联用户/订单]
|
||||||
|
setAssoc --> sub[子账号统计: 已扫码/已关联/订单]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 复用 `partner_account.assoc_qrcode_id / assoc_qrcode_resource_id / assoc_scan_count`:子账号行存自己的 `sa_{subId}` 码(`assoc_qrcode_id` 为 `@unique`,`sa_` 与 `pa_` 前缀、id 均不同,不冲突)。
|
||||||
|
- 新增 `user_user.assoc_sub_account_id`:记录带来该用户的子账号(主账号码扫码则为空)。
|
||||||
|
- `touchScan`:`sa_` 场景对**子账号行** `assocScanCount` 自增;主账号已扫码在读取时聚合 own+children。
|
||||||
|
- `bindUser`:`sa_` 场景解析子账号 → 主账号;first-lock 仍校验主账号,写入 `assocPartnerAccountId=主账号` + `assocSubAccountId=子账号`。
|
||||||
|
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
|
||||||
|
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
|
||||||
|
|
||||||
|
### 2.4 HQ 财务全部银行账户
|
||||||
|
|
||||||
|
财务查阅与手工登记目录,**不改打款主数据**。打款仍读各业务源表(门店结算资质、合伙人主账号字段、承运商字段、酒厂 `WINERY_BANK_*`)。菜单名:**全部银行账户**。
|
||||||
|
|
||||||
|
**有效账户**(同时满足才入列):结算户名、银行账号均非空;来源状态为有效。
|
||||||
|
|
||||||
|
| 类型 | 数据源 | 有效条件 | 本页可写 |
|
||||||
|
|------|--------|----------|----------|
|
||||||
|
| 门店 | 门店详情结算资质(主账号 `StoreAccount.bank_account_*`) | 户名+账号已填;一店一行 | 仅备注 |
|
||||||
|
| 酒厂 | `system_config` `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
|
||||||
|
| 合伙人 | `partner_account` | `status=ACTIVE`、主账号、户名+账号已填 | 仅备注 |
|
||||||
|
| 物流 | `common_fulfillment_provider` | `status=ACTIVE`、户名+账号已填(仓无银行字段) | 仅备注 |
|
||||||
|
| 其他 | `finance_bank_account` | `status=ACTIVE` | 增删改 |
|
||||||
|
|
||||||
|
- 列表:类型、归属(快链)、城市、结算户名、银行账号、开户银行、备注。归属快链:门店→详情「结算资质」;酒厂→系统设置「酒厂银行账户」;合伙人→城市合伙人详情;物流→仓配管理并打开该承运商;其他→本页编辑弹窗。
|
||||||
|
- **新增不关联门店** = 类型「其他」,不进入提现/账单打款/酒厂/物流对账。
|
||||||
|
- 来源账户字段仍在原处改;本页只读这些字段。备注:其他写本表;来源写 overlay 表 `finance_bank_account_note`(门店 `sourceId` = `storeId`)。
|
||||||
|
- 筛选:类型、城市、关键字(户名/账号/开户银行/归属名/备注)。导出按当前筛选全量 Excel / PDF。
|
||||||
|
- 权限:HQ `finance`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
### 3.1 关联码(store 模块)
|
||||||
|
|
||||||
|
- `POST /user/partner-assoc/touch`:scene 支持 `pa_{id}` 与 `sa_{subId}`;`sa_` 对子账号行计数,返回 `{ partnerId, subAccountId, scanCounted, scanCount }`。
|
||||||
|
- `POST /user/partner-assoc/bind`:scene 支持 `sa_`;返回 `{ bound, alreadyBound, partnerId, subAccountId, partnerName }`。
|
||||||
|
- `GET /partner/assoc`(summary):子账号返回 `{ partnerId, primaryAccountId, isSubAccount, qrcodeUrl, userCount, scanCount }`。
|
||||||
|
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
|
||||||
|
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
|
||||||
|
|
||||||
|
### 3.2 财务全部银行账户(settlement 模块)
|
||||||
|
|
||||||
|
| 方法 | 路径 | Guard | 说明 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| GET | `/admin/finance/bank-accounts` | HQ `finance` | 聚合列表;`type` / `cityId` / `keyword` / `page` / `pageSize` |
|
||||||
|
| GET | `/admin/finance/bank-accounts/export` | HQ `finance` | `format=xlsx\|pdf`,同筛选全量 |
|
||||||
|
| POST | `/admin/finance/bank-accounts` | HQ `finance` | 新增「其他」 |
|
||||||
|
| PUT | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 编辑「其他」 |
|
||||||
|
| DELETE | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 删除「其他」 |
|
||||||
|
| PUT | `/admin/finance/bank-accounts/:id/remark` | HQ `finance` | 任意类型写备注;`id` 为复合键 |
|
||||||
|
|
||||||
|
行 `id`:`STORE:{storeId}` / `PARTNER:{partnerId}` / `LOGISTICS:{providerId}` / `WINERY:winery` / `OTHER:{id}`。`bankAccountNo` 校验 `^\d{8,32}$`(「其他」入参)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| Prisma | `schema.prisma`(`FinanceBankAccount`、`FinanceBankAccountNote`;`User.assocSubAccountId`;**已删除** `StoreBankAccount`) |
|
||||||
|
| 迁移 | `migrate-user-assoc-sub-account-v4018.sql`、`migrate-finance-bank-account-v4018.sql`、`migrate-drop-store-bank-account-v4018.sql`(历史 `migrate-store-bank-account-v4018.sql` 仅作曾上线回填) |
|
||||||
|
| domain | `store-address.ts`(`formatStoreDisplayAddress`:省+市+区+详细地址原样拼接,不去重) |
|
||||||
|
| shared-types | `settlement.ts`(`FinanceBankAccountDto`);`hq-list-columns.ts`(`finance-bank-accounts`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
|
||||||
|
| API store | `store-bank.util.ts`(`loadStoreDefaultBank` 读结算资质主账号);`partner-assoc.service.ts`(`sa_` 解析/生成/touch/bind/统计);`store.service.ts`(地理编码地址与 C 端同规则拼接) |
|
||||||
|
| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 读结算资质);`finance-bank-account.service.ts`、`finance-bank-export.util.ts`(财务全部银行账户) |
|
||||||
|
| API ops | `admin-redeem.service.ts`(结算资质银行字段) |
|
||||||
|
| mini-user | `stores/index.tsx`、`store-detail/index.tsx`、`lib/store-display.ts`(`fullStoreAddress`)、`lib/promo.ts`(放行 `sa_`) |
|
||||||
|
| h5-shop | 已删除收款账户页;`MinePage.tsx` 去掉入口 |
|
||||||
|
| admin-web | `StoresPage.tsx` 结算资质;`BankAccountsPage.tsx`(财务全部银行账户);已删除收款账户页签 |
|
||||||
|
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] C 端门店列表地址 = 省+市+区+详细地址原文;详细地址已含省市区时仍重复拼接(例:金水东路333号 → `河南省郑州市金水区金水东路333号`;详细地址写全称 → `河南省郑州市金水区河南省郑州市金水区金水东路333号`);空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
|
||||||
|
- [ ] 门店无独立收款账户页;打款/提现/账单导出读结算资质(结算户名、银行账号、开户银行);改后无需重启即时生效
|
||||||
|
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
|
||||||
|
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
|
||||||
|
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
|
||||||
|
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
|
||||||
|
- [ ] HQ 财务菜单「全部银行账户」:门店行来自结算资质(一店一行)+ 有效酒厂/主合伙人/承运商账户;可新增不挂门店账户;类型/城市/关键字筛选;Excel 与 PDF 导出与筛选一致;备注可写;结算资质改后刷新即更新
|
||||||
|
- [ ] shared-types 构建通过;相关 lint/单测过
|
||||||
@@ -150,6 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|
|||||||
| 能力 | 入口 |
|
| 能力 | 入口 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
||||||
|
| API 插件 | HQ `/wecom/plugins` 多实例;`/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
|
||||||
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
||||||
| 日志 | `/logs/wecom-bots` |
|
| 日志 | `/logs/wecom-bots` |
|
||||||
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
defaultShanghaiRange,
|
||||||
|
eachShanghaiBuckets,
|
||||||
|
periodMomRatio,
|
||||||
|
previousShanghaiRangeByGrain,
|
||||||
|
shanghaiBucketKey,
|
||||||
|
shanghaiQuarterIndex,
|
||||||
|
shanghaiQuarterRange,
|
||||||
|
} from './dashboard-period';
|
||||||
|
import { shanghaiYmd } from './shanghai-date';
|
||||||
|
|
||||||
|
describe('shanghaiBucketKey', () => {
|
||||||
|
it('周跨年落到周一 2025-12-29', () => {
|
||||||
|
expect(shanghaiBucketKey(new Date('2026-01-02T04:00:00+08:00'), 'week')).toBe('2025-12-29');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Q4 → Q1 分属两年', () => {
|
||||||
|
expect(shanghaiBucketKey(new Date('2025-12-15T00:00:00+08:00'), 'quarter')).toBe('2025-Q4');
|
||||||
|
expect(shanghaiBucketKey(new Date('2026-01-02T00:00:00+08:00'), 'quarter')).toBe('2026-Q1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eachShanghaiBuckets', () => {
|
||||||
|
it('周跨年包含 2025-12-29 与 2026-01-05', () => {
|
||||||
|
const keys = eachShanghaiBuckets(
|
||||||
|
new Date('2025-12-31T00:00:00+08:00'),
|
||||||
|
new Date('2026-01-06T00:00:00+08:00'),
|
||||||
|
'week',
|
||||||
|
).map((b) => b.key);
|
||||||
|
expect(keys).toEqual(['2025-12-29', '2026-01-05']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('季从 Q4 跨到 Q1', () => {
|
||||||
|
const keys = eachShanghaiBuckets(
|
||||||
|
new Date('2025-11-01T00:00:00+08:00'),
|
||||||
|
new Date('2026-02-01T00:00:00+08:00'),
|
||||||
|
'quarter',
|
||||||
|
).map((b) => b.key);
|
||||||
|
expect(keys).toEqual(['2025-Q4', '2026-Q1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('previousShanghaiRangeByGrain', () => {
|
||||||
|
it('日:昨天~今天 的环比是 前天~昨天', () => {
|
||||||
|
const prev = previousShanghaiRangeByGrain(
|
||||||
|
new Date('2026-09-01T00:00:00+08:00'),
|
||||||
|
new Date('2026-09-02T00:00:00+08:00'),
|
||||||
|
'day',
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(prev.from)).toBe('2026-08-31');
|
||||||
|
expect(shanghaiYmd(prev.to)).toBe('2026-09-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('周:整段回退 7 天', () => {
|
||||||
|
const prev = previousShanghaiRangeByGrain(
|
||||||
|
new Date('2026-08-24T00:00:00+08:00'),
|
||||||
|
new Date('2026-09-02T00:00:00+08:00'),
|
||||||
|
'week',
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(prev.from)).toBe('2026-08-17');
|
||||||
|
expect(shanghaiYmd(prev.to)).toBe('2026-08-26');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('月:整段回退 1 个月', () => {
|
||||||
|
const prev = previousShanghaiRangeByGrain(
|
||||||
|
new Date('2026-08-01T00:00:00+08:00'),
|
||||||
|
new Date('2026-09-02T00:00:00+08:00'),
|
||||||
|
'month',
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(prev.from)).toBe('2026-07-01');
|
||||||
|
expect(shanghaiYmd(prev.to)).toBe('2026-08-02');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('periodMomRatio', () => {
|
||||||
|
it('上期为 0:本期 0 为 null,本期 > 0 为 1', () => {
|
||||||
|
expect(periodMomRatio(0, 0)).toBeNull();
|
||||||
|
expect(periodMomRatio(8, 0)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('常规环比', () => {
|
||||||
|
expect(periodMomRatio(12, 10)).toBeCloseTo(0.2);
|
||||||
|
expect(periodMomRatio(8, 10)).toBeCloseTo(-0.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shanghaiQuarterRange', () => {
|
||||||
|
it('Q1 为 1/1~4/1(不含)', () => {
|
||||||
|
const { start, endExclusive } = shanghaiQuarterRange(2026, 1);
|
||||||
|
expect(shanghaiYmd(start)).toBe('2026-01-01');
|
||||||
|
expect(shanghaiYmd(endExclusive)).toBe('2026-04-01');
|
||||||
|
expect(shanghaiQuarterIndex(start)).toEqual({ year: 2026, quarter: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultShanghaiRange', () => {
|
||||||
|
it('日:昨天~今天', () => {
|
||||||
|
const r = defaultShanghaiRange('day', new Date('2026-09-02T12:00:00+08:00'));
|
||||||
|
expect(shanghaiYmd(r.from)).toBe('2026-09-01');
|
||||||
|
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('周:上周一~今天', () => {
|
||||||
|
const r = defaultShanghaiRange('week', new Date('2026-09-02T12:00:00+08:00'));
|
||||||
|
expect(shanghaiYmd(r.from)).toBe('2026-08-24');
|
||||||
|
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('月:上月1日~今天', () => {
|
||||||
|
const r = defaultShanghaiRange('month', new Date('2026-09-02T12:00:00+08:00'));
|
||||||
|
expect(shanghaiYmd(r.from)).toBe('2026-08-01');
|
||||||
|
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('季:上季首日~今天', () => {
|
||||||
|
const r = defaultShanghaiRange('quarter', new Date('2026-09-02T12:00:00+08:00'));
|
||||||
|
expect(shanghaiYmd(r.from)).toBe('2026-04-01');
|
||||||
|
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('年:去年1月1日~今天', () => {
|
||||||
|
const r = defaultShanghaiRange('year', new Date('2026-09-02T12:00:00+08:00'));
|
||||||
|
expect(shanghaiYmd(r.from)).toBe('2025-01-01');
|
||||||
|
expect(shanghaiYmd(r.to)).toBe('2026-09-02');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
addShanghaiDays,
|
||||||
|
parseShanghaiYmd,
|
||||||
|
shanghaiMonthRange,
|
||||||
|
shanghaiWeekRange,
|
||||||
|
shanghaiYearMonth,
|
||||||
|
shanghaiYmd,
|
||||||
|
startOfShanghaiDay,
|
||||||
|
} from './shanghai-date';
|
||||||
|
|
||||||
|
export const DASHBOARD_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const;
|
||||||
|
export type DashboardGranularity = (typeof DASHBOARD_GRANULARITIES)[number];
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiQuarterIndex(d: Date): { year: number; quarter: number } {
|
||||||
|
const [y, m] = shanghaiYmd(d).split('-').map(Number);
|
||||||
|
return { year: y, quarter: Math.ceil(m / 3) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiQuarterRange(
|
||||||
|
year: number,
|
||||||
|
quarter: number,
|
||||||
|
): { start: Date; endExclusive: Date } {
|
||||||
|
const startMonth = (quarter - 1) * 3 + 1;
|
||||||
|
const endMonth = startMonth + 3;
|
||||||
|
const endYear = endMonth > 12 ? year + 1 : year;
|
||||||
|
const em = endMonth > 12 ? endMonth - 12 : endMonth;
|
||||||
|
return {
|
||||||
|
start: new Date(`${year}-${pad2(startMonth)}-01T00:00:00+08:00`),
|
||||||
|
endExclusive: new Date(`${endYear}-${pad2(em)}-01T00:00:00+08:00`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiBucketKey(d: Date, grain: DashboardGranularity): string {
|
||||||
|
const ymd = shanghaiYmd(d);
|
||||||
|
const [y, m] = ymd.split('-').map(Number);
|
||||||
|
switch (grain) {
|
||||||
|
case 'day':
|
||||||
|
return ymd;
|
||||||
|
case 'week':
|
||||||
|
return shanghaiYmd(shanghaiWeekRange(d).start);
|
||||||
|
case 'month':
|
||||||
|
return `${y}-${pad2(m)}`;
|
||||||
|
case 'quarter':
|
||||||
|
return `${y}-Q${Math.ceil(m / 3)}`;
|
||||||
|
case 'year':
|
||||||
|
return String(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiBucketLabel(key: string, grain: DashboardGranularity): string {
|
||||||
|
switch (grain) {
|
||||||
|
case 'day':
|
||||||
|
return key.slice(5);
|
||||||
|
case 'week':
|
||||||
|
return `${key.slice(5)}周`;
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShanghaiBucket = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
start: Date;
|
||||||
|
endExclusive: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function eachShanghaiBuckets(
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
grain: DashboardGranularity,
|
||||||
|
): ShanghaiBucket[] {
|
||||||
|
const startDay = startOfShanghaiDay(from);
|
||||||
|
const endDay = startOfShanghaiDay(to);
|
||||||
|
const out: ShanghaiBucket[] = [];
|
||||||
|
|
||||||
|
if (grain === 'day') {
|
||||||
|
for (let d = startDay; d.getTime() <= endDay.getTime(); d = addShanghaiDays(d, 1)) {
|
||||||
|
const key = shanghaiYmd(d);
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
label: shanghaiBucketLabel(key, grain),
|
||||||
|
start: d,
|
||||||
|
endExclusive: addShanghaiDays(d, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grain === 'week') {
|
||||||
|
let { start } = shanghaiWeekRange(startDay);
|
||||||
|
const lastStart = shanghaiWeekRange(endDay).start;
|
||||||
|
while (start.getTime() <= lastStart.getTime()) {
|
||||||
|
const key = shanghaiYmd(start);
|
||||||
|
const endExclusive = addShanghaiDays(start, 7);
|
||||||
|
out.push({ key, label: shanghaiBucketLabel(key, grain), start, endExclusive });
|
||||||
|
start = endExclusive;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grain === 'month') {
|
||||||
|
let { year, month } = shanghaiYearMonth(startDay);
|
||||||
|
const end = shanghaiYearMonth(endDay);
|
||||||
|
while (year < end.year || (year === end.year && month <= end.month)) {
|
||||||
|
const range = shanghaiMonthRange(year, month);
|
||||||
|
const key = `${year}-${pad2(month)}`;
|
||||||
|
out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive });
|
||||||
|
month += 1;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grain === 'quarter') {
|
||||||
|
let { year, quarter } = shanghaiQuarterIndex(startDay);
|
||||||
|
const end = shanghaiQuarterIndex(endDay);
|
||||||
|
while (year < end.year || (year === end.year && quarter <= end.quarter)) {
|
||||||
|
const range = shanghaiQuarterRange(year, quarter);
|
||||||
|
const key = `${year}-Q${quarter}`;
|
||||||
|
out.push({ key, label: key, start: range.start, endExclusive: range.endExclusive });
|
||||||
|
quarter += 1;
|
||||||
|
if (quarter > 4) {
|
||||||
|
quarter = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let year = Number(shanghaiYmd(startDay).slice(0, 4));
|
||||||
|
const endYear = Number(shanghaiYmd(endDay).slice(0, 4));
|
||||||
|
while (year <= endYear) {
|
||||||
|
out.push({
|
||||||
|
key: String(year),
|
||||||
|
label: String(year),
|
||||||
|
start: new Date(`${year}-01-01T00:00:00+08:00`),
|
||||||
|
endExclusive: new Date(`${year + 1}-01-01T00:00:00+08:00`),
|
||||||
|
});
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按粒度把日期往前挪一档(日/周/月/季/年),用于环比窗口 */
|
||||||
|
export function addShanghaiGrain(d: Date, grain: DashboardGranularity, delta: number): Date {
|
||||||
|
const start = startOfShanghaiDay(d);
|
||||||
|
switch (grain) {
|
||||||
|
case 'day':
|
||||||
|
return addShanghaiDays(start, delta);
|
||||||
|
case 'week':
|
||||||
|
return addShanghaiDays(start, delta * 7);
|
||||||
|
case 'month':
|
||||||
|
return addShanghaiMonths(start, delta);
|
||||||
|
case 'quarter':
|
||||||
|
return addShanghaiMonths(start, delta * 3);
|
||||||
|
case 'year':
|
||||||
|
return addShanghaiMonths(start, delta * 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShanghaiMonths(d: Date, delta: number): Date {
|
||||||
|
const [y, m, day] = shanghaiYmd(d).split('-').map(Number);
|
||||||
|
const utc = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||||
|
const ty = utc.getUTCFullYear();
|
||||||
|
const tm = utc.getUTCMonth() + 1;
|
||||||
|
const last = new Date(Date.UTC(ty, tm, 0)).getUTCDate();
|
||||||
|
return parseShanghaiYmd(`${ty}-${pad2(tm)}-${pad2(Math.min(day, last))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整段起止按粒度回退一档:日窗口昨天~今天 → 前天~昨天 */
|
||||||
|
export function previousShanghaiRangeByGrain(
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
grain: DashboardGranularity,
|
||||||
|
): { from: Date; to: Date } {
|
||||||
|
return {
|
||||||
|
from: addShanghaiGrain(from, grain, -1),
|
||||||
|
to: addShanghaiGrain(to, grain, -1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 环比:(本期 − 上期) / 上期;上期 0 且本期 0 → null;上期 0 且本期 > 0 → 1 */
|
||||||
|
export function periodMomRatio(curr: number, prev: number): number | null {
|
||||||
|
if (prev === 0) return curr === 0 ? null : 1;
|
||||||
|
return (curr - prev) / prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认窗口 = 上一档起点 ~ 今天(日=昨天~今天,周=上周一~今天,以此类推) */
|
||||||
|
export function defaultShanghaiRange(
|
||||||
|
grain: DashboardGranularity,
|
||||||
|
anchor = new Date(),
|
||||||
|
): { from: Date; to: Date } {
|
||||||
|
const to = startOfShanghaiDay(anchor);
|
||||||
|
switch (grain) {
|
||||||
|
case 'day':
|
||||||
|
return { from: addShanghaiDays(to, -1), to };
|
||||||
|
case 'week': {
|
||||||
|
const thisMonday = shanghaiWeekRange(to).start;
|
||||||
|
return { from: addShanghaiDays(thisMonday, -7), to };
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const { year, month } = shanghaiYearMonth(to);
|
||||||
|
const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
|
||||||
|
return { from: shanghaiMonthRange(prev.year, prev.month).start, to };
|
||||||
|
}
|
||||||
|
case 'quarter': {
|
||||||
|
const { year, quarter } = shanghaiQuarterIndex(to);
|
||||||
|
let y = year;
|
||||||
|
let q = quarter - 1;
|
||||||
|
if (q <= 0) {
|
||||||
|
q = 4;
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
return { from: shanghaiQuarterRange(y, q).start, to };
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
const y = Number(shanghaiYmd(to).slice(0, 4));
|
||||||
|
return { from: new Date(`${y - 1}-01-01T00:00:00+08:00`), to };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultShanghaiRangeYmds(
|
||||||
|
grain: DashboardGranularity,
|
||||||
|
anchor = new Date(),
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const r = defaultShanghaiRange(grain, anchor);
|
||||||
|
return { from: shanghaiYmd(r.from), to: shanghaiYmd(r.to) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DASHBOARD_SERIES_ALL,
|
||||||
|
DASHBOARD_SERIES_NONE,
|
||||||
|
DASHBOARD_SERIES_OTHER,
|
||||||
|
buildDimensionLines,
|
||||||
|
buildSingleLine,
|
||||||
|
cumulativeValues,
|
||||||
|
normalizeSeriesId,
|
||||||
|
pickTopSeriesIds,
|
||||||
|
} from './dashboard-series';
|
||||||
|
|
||||||
|
describe('normalizeSeriesId', () => {
|
||||||
|
it('空值归 none', () => {
|
||||||
|
expect(normalizeSeriesId(null)).toBe(DASHBOARD_SERIES_NONE);
|
||||||
|
expect(normalizeSeriesId('')).toBe(DASHBOARD_SERIES_NONE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cumulativeValues', () => {
|
||||||
|
it('带期初存量按桶累加', () => {
|
||||||
|
expect(cumulativeValues([1, 2, 3], 10)).toEqual([11, 13, 16]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickTopSeriesIds', () => {
|
||||||
|
it('保留 none,其余按权重取 TopN', () => {
|
||||||
|
const { keep, rest } = pickTopSeriesIds(
|
||||||
|
[
|
||||||
|
{ id: 'none', weight: 1 },
|
||||||
|
{ id: 'a', weight: 9 },
|
||||||
|
{ id: 'b', weight: 8 },
|
||||||
|
{ id: 'c', weight: 1 },
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(keep).toEqual(['none', 'a', 'b']);
|
||||||
|
expect(rest).toEqual(['c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDimensionLines', () => {
|
||||||
|
it('总量为期初+增量累计,长尾并入其他', () => {
|
||||||
|
const { total, increment } = buildDimensionLines({
|
||||||
|
periodKeys: ['d1', 'd2'],
|
||||||
|
points: [
|
||||||
|
{ seriesId: 'a', period: 'd1', value: 2 },
|
||||||
|
{ seriesId: 'a', period: 'd2', value: 1 },
|
||||||
|
{ seriesId: 'b', period: 'd1', value: 4 },
|
||||||
|
{ seriesId: 'c', period: 'd2', value: 1 },
|
||||||
|
{ seriesId: 'none', period: 'd1', value: 3 },
|
||||||
|
],
|
||||||
|
baselines: [
|
||||||
|
{ seriesId: 'a', value: 10 },
|
||||||
|
{ seriesId: 'b', value: 1 },
|
||||||
|
],
|
||||||
|
names: new Map([
|
||||||
|
['a', '码A'],
|
||||||
|
['b', '码B'],
|
||||||
|
['c', '码C'],
|
||||||
|
]),
|
||||||
|
noneLabel: '自然量',
|
||||||
|
topN: 1,
|
||||||
|
});
|
||||||
|
expect(increment.map((s) => s.id)).toEqual(['none', 'a', DASHBOARD_SERIES_OTHER]);
|
||||||
|
expect(increment.find((s) => s.id === 'a')?.values).toEqual([2, 1]);
|
||||||
|
expect(total.find((s) => s.id === 'a')?.values).toEqual([12, 13]);
|
||||||
|
expect(increment.find((s) => s.id === DASHBOARD_SERIES_OTHER)?.values).toEqual([4, 1]);
|
||||||
|
expect(total.find((s) => s.id === DASHBOARD_SERIES_OTHER)?.values).toEqual([5, 6]);
|
||||||
|
expect(increment.find((s) => s.id === 'none')?.name).toBe('自然量');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSingleLine', () => {
|
||||||
|
it('无维度时只有一条 all', () => {
|
||||||
|
const { increment, total } = buildSingleLine({
|
||||||
|
periodKeys: ['d1', 'd2'],
|
||||||
|
points: [
|
||||||
|
{ seriesId: 'x', period: 'd1', value: 2 },
|
||||||
|
{ seriesId: 'y', period: 'd2', value: 3 },
|
||||||
|
],
|
||||||
|
baseline: 5,
|
||||||
|
name: '合伙人',
|
||||||
|
});
|
||||||
|
expect(increment).toEqual({ id: DASHBOARD_SERIES_ALL, name: '合伙人', values: [2, 3] });
|
||||||
|
expect(total.values).toEqual([7, 10]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
export const DASHBOARD_LINE_TOP_N = 10;
|
||||||
|
export const DASHBOARD_SERIES_NONE = 'none';
|
||||||
|
export const DASHBOARD_SERIES_OTHER = 'other';
|
||||||
|
export const DASHBOARD_SERIES_ALL = 'all';
|
||||||
|
|
||||||
|
export type DashboardSeriesPoint = {
|
||||||
|
seriesId: string;
|
||||||
|
period: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardNamedSeries = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
values: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeSeriesId(raw: string | number | bigint | null | undefined): string {
|
||||||
|
if (raw == null) return DASHBOARD_SERIES_NONE;
|
||||||
|
const s = String(raw).trim();
|
||||||
|
return s ? s : DASHBOARD_SERIES_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodValueMap(
|
||||||
|
periodKeys: string[],
|
||||||
|
points: DashboardSeriesPoint[],
|
||||||
|
): Map<string, number[]> {
|
||||||
|
const index = new Map(periodKeys.map((k, i) => [k, i]));
|
||||||
|
const map = new Map<string, number[]>();
|
||||||
|
const ensure = (id: string) => {
|
||||||
|
let arr = map.get(id);
|
||||||
|
if (!arr) {
|
||||||
|
arr = periodKeys.map(() => 0);
|
||||||
|
map.set(id, arr);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
for (const p of points) {
|
||||||
|
const i = index.get(p.period);
|
||||||
|
if (i === undefined) continue;
|
||||||
|
ensure(p.seriesId)[i] += p.value;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cumulativeValues(increments: number[], baseline = 0): number[] {
|
||||||
|
let acc = baseline;
|
||||||
|
return increments.map((n) => {
|
||||||
|
acc += n;
|
||||||
|
return acc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seriesEndWeight(increments: number[], baseline = 0): number {
|
||||||
|
return baseline + increments.reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTopSeriesIds(
|
||||||
|
weights: Array<{ id: string; weight: number }>,
|
||||||
|
topN = DASHBOARD_LINE_TOP_N,
|
||||||
|
reserved: string[] = [DASHBOARD_SERIES_NONE],
|
||||||
|
): { keep: string[]; rest: string[] } {
|
||||||
|
const reservedSet = new Set(reserved);
|
||||||
|
const reservedIds = weights
|
||||||
|
.filter((w) => reservedSet.has(w.id) && w.weight !== 0)
|
||||||
|
.map((w) => w.id);
|
||||||
|
const ranked = weights
|
||||||
|
.filter((w) => !reservedSet.has(w.id))
|
||||||
|
.sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));
|
||||||
|
return {
|
||||||
|
keep: [...reservedIds, ...ranked.slice(0, topN).map((w) => w.id)],
|
||||||
|
rest: ranked.slice(topN).map((w) => w.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumSeries(
|
||||||
|
incrementMap: Map<string, number[]>,
|
||||||
|
baselineMap: Map<string, number>,
|
||||||
|
ids: string[],
|
||||||
|
periodLen: number,
|
||||||
|
): { increments: number[]; baseline: number } {
|
||||||
|
const increments = Array.from({ length: periodLen }, () => 0);
|
||||||
|
let baseline = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
const arr = incrementMap.get(id);
|
||||||
|
if (arr) {
|
||||||
|
for (let i = 0; i < periodLen; i += 1) increments[i] += arr[i] ?? 0;
|
||||||
|
}
|
||||||
|
baseline += baselineMap.get(id) ?? 0;
|
||||||
|
}
|
||||||
|
return { increments, baseline };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRound(values: number[], round?: (n: number) => number): number[] {
|
||||||
|
return round ? values.map(round) : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDimensionLines(opts: {
|
||||||
|
periodKeys: string[];
|
||||||
|
points: DashboardSeriesPoint[];
|
||||||
|
baselines?: Array<{ seriesId: string; value: number }>;
|
||||||
|
names?: Map<string, string>;
|
||||||
|
noneLabel: string;
|
||||||
|
otherLabel?: string;
|
||||||
|
topN?: number;
|
||||||
|
round?: (n: number) => number;
|
||||||
|
}): { total: DashboardNamedSeries[]; increment: DashboardNamedSeries[] } {
|
||||||
|
const periodLen = opts.periodKeys.length;
|
||||||
|
const incrementMap = periodValueMap(opts.periodKeys, opts.points);
|
||||||
|
const baselineMap = new Map<string, number>();
|
||||||
|
for (const row of opts.baselines ?? []) {
|
||||||
|
const id = normalizeSeriesId(row.seriesId);
|
||||||
|
baselineMap.set(id, (baselineMap.get(id) ?? 0) + row.value);
|
||||||
|
if (!incrementMap.has(id)) incrementMap.set(id, opts.periodKeys.map(() => 0));
|
||||||
|
}
|
||||||
|
for (const id of incrementMap.keys()) {
|
||||||
|
if (!baselineMap.has(id)) baselineMap.set(id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weights = [...incrementMap.keys()].map((id) => ({
|
||||||
|
id,
|
||||||
|
weight: seriesEndWeight(incrementMap.get(id) ?? [], baselineMap.get(id) ?? 0),
|
||||||
|
}));
|
||||||
|
const { keep, rest } = pickTopSeriesIds(weights, opts.topN ?? DASHBOARD_LINE_TOP_N);
|
||||||
|
const nameOf = (id: string) => {
|
||||||
|
if (id === DASHBOARD_SERIES_NONE) return opts.noneLabel;
|
||||||
|
if (id === DASHBOARD_SERIES_OTHER) return opts.otherLabel ?? '其他';
|
||||||
|
return opts.names?.get(id) || `#${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedIds = [...keep];
|
||||||
|
if (rest.length) orderedIds.push(DASHBOARD_SERIES_OTHER);
|
||||||
|
|
||||||
|
const total: DashboardNamedSeries[] = [];
|
||||||
|
const increment: DashboardNamedSeries[] = [];
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
const packed =
|
||||||
|
id === DASHBOARD_SERIES_OTHER
|
||||||
|
? sumSeries(incrementMap, baselineMap, rest, periodLen)
|
||||||
|
: {
|
||||||
|
increments: incrementMap.get(id) ?? opts.periodKeys.map(() => 0),
|
||||||
|
baseline: baselineMap.get(id) ?? 0,
|
||||||
|
};
|
||||||
|
increment.push({
|
||||||
|
id,
|
||||||
|
name: nameOf(id),
|
||||||
|
values: applyRound(packed.increments, opts.round),
|
||||||
|
});
|
||||||
|
total.push({
|
||||||
|
id,
|
||||||
|
name: nameOf(id),
|
||||||
|
values: applyRound(cumulativeValues(packed.increments, packed.baseline), opts.round),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { total, increment };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSingleLine(opts: {
|
||||||
|
periodKeys: string[];
|
||||||
|
points: DashboardSeriesPoint[];
|
||||||
|
baseline?: number;
|
||||||
|
name: string;
|
||||||
|
round?: (n: number) => number;
|
||||||
|
}): { total: DashboardNamedSeries; increment: DashboardNamedSeries } {
|
||||||
|
const merged: DashboardSeriesPoint[] = opts.points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
seriesId: DASHBOARD_SERIES_ALL,
|
||||||
|
}));
|
||||||
|
const incrementMap = periodValueMap(opts.periodKeys, merged);
|
||||||
|
const increments = incrementMap.get(DASHBOARD_SERIES_ALL) ?? opts.periodKeys.map(() => 0);
|
||||||
|
const baseline = opts.baseline ?? 0;
|
||||||
|
return {
|
||||||
|
increment: {
|
||||||
|
id: DASHBOARD_SERIES_ALL,
|
||||||
|
name: opts.name,
|
||||||
|
values: applyRound(increments, opts.round),
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
id: DASHBOARD_SERIES_ALL,
|
||||||
|
name: opts.name,
|
||||||
|
values: applyRound(cumulativeValues(increments, baseline), opts.round),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -410,3 +410,9 @@ export * from './dev-plan';
|
|||||||
export * from './support-ticket';
|
export * from './support-ticket';
|
||||||
export * from './phone';
|
export * from './phone';
|
||||||
export * from './shanghai-date';
|
export * from './shanghai-date';
|
||||||
|
export * from './dashboard-period';
|
||||||
|
export * from './dashboard-series';
|
||||||
|
export * from './wecom-report';
|
||||||
|
export * from './wecom-plugin';
|
||||||
|
export * from './shipping-address';
|
||||||
|
export * from './store-address';
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isConcreteShippingDistrict,
|
||||||
|
isPseudoShippingDistrict,
|
||||||
|
shippingDetailCityWideHint,
|
||||||
|
validateShippingAddress,
|
||||||
|
} from './shipping-address';
|
||||||
|
|
||||||
|
describe('shipping address', () => {
|
||||||
|
it('rejects pseudo districts', () => {
|
||||||
|
expect(isPseudoShippingDistrict('全市')).toBe(true);
|
||||||
|
expect(isPseudoShippingDistrict('全部')).toBe(true);
|
||||||
|
expect(isPseudoShippingDistrict('')).toBe(true);
|
||||||
|
expect(isConcreteShippingDistrict('金水区')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires concrete district and detail length', () => {
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '全市',
|
||||||
|
detail: '上馆子信阳菜尚购生活广场店',
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
detail: '短',
|
||||||
|
}).message,
|
||||||
|
).toMatch(/详细/);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
detail: '沙口路8号院尚购生活广场',
|
||||||
|
}).ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-hints bare 全市 in detail', () => {
|
||||||
|
expect(shippingDetailCityWideHint('河南省郑州市全市上馆子')).toBeTruthy();
|
||||||
|
expect(shippingDetailCityWideHint('金水区沙口路8号')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/** 门店筛选用伪区县,禁止写入收货地址 */
|
||||||
|
export const PSEUDO_SHIPPING_DISTRICTS = ['全市', '全部'] as const;
|
||||||
|
|
||||||
|
export const SHIPPING_DETAIL_MIN_LEN = 8;
|
||||||
|
|
||||||
|
export const SHIPPING_REGION_REQUIRED_MSG = '请选择具体区县';
|
||||||
|
export const SHIPPING_DETAIL_REQUIRED_MSG = '请填写详细地址';
|
||||||
|
export const SHIPPING_DETAIL_TOO_SHORT_MSG = '请填写更详细的收货地址(含街道门牌)';
|
||||||
|
|
||||||
|
export type ShippingAddressFields = {
|
||||||
|
province?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShippingAddressValidation = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trim(v: string | null | undefined): string {
|
||||||
|
return String(v ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPseudoShippingDistrict(district: string | null | undefined): boolean {
|
||||||
|
const d = trim(district);
|
||||||
|
if (!d) return true;
|
||||||
|
return (PSEUDO_SHIPPING_DISTRICTS as readonly string[]).includes(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区县是否可作为收货地址(非空且非全市/全部) */
|
||||||
|
export function isConcreteShippingDistrict(district: string | null | undefined): boolean {
|
||||||
|
return !isPseudoShippingDistrict(district);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验收货省市区 + 详细地址。
|
||||||
|
* - 现场取货等特殊值请勿调用本函数
|
||||||
|
* - 不依赖完整省市区树;前端另做树内白名单
|
||||||
|
*/
|
||||||
|
export function validateShippingAddress(
|
||||||
|
input: ShippingAddressFields,
|
||||||
|
options?: { requireDetail?: boolean },
|
||||||
|
): ShippingAddressValidation {
|
||||||
|
const province = trim(input.province);
|
||||||
|
const city = trim(input.city);
|
||||||
|
const district = trim(input.district);
|
||||||
|
const detail = trim(input.detail);
|
||||||
|
const requireDetail = options?.requireDetail !== false;
|
||||||
|
|
||||||
|
if (!province || !city) {
|
||||||
|
return { ok: false, message: '请选择所在地区' };
|
||||||
|
}
|
||||||
|
if (!isConcreteShippingDistrict(district)) {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (requireDetail) {
|
||||||
|
if (!detail) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (detail.length < SHIPPING_DETAIL_MIN_LEN) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_TOO_SHORT_MSG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详细地址含「全市」时的软提示(不单独作为硬失败条件) */
|
||||||
|
export function shippingDetailCityWideHint(detail: string | null | undefined): string | null {
|
||||||
|
const d = trim(detail);
|
||||||
|
if (!d.includes('全市')) return null;
|
||||||
|
if (/路|街|巷|号|大厦|广场|小区|村|镇|乡/.test(d)) return null;
|
||||||
|
return '详细地址含「全市」,建议改为具体街道门牌,以免配送拒单';
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatStoreDisplayAddress } from './store-address';
|
||||||
|
|
||||||
|
describe('formatStoreDisplayAddress', () => {
|
||||||
|
it('concatenates dropdown region with street-only detail', () => {
|
||||||
|
expect(
|
||||||
|
formatStoreDisplayAddress({
|
||||||
|
province: '河南省',
|
||||||
|
cityName: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
address: '金水东路333号',
|
||||||
|
}),
|
||||||
|
).toBe('河南省郑州市金水区金水东路333号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip region prefix already present in detail', () => {
|
||||||
|
expect(
|
||||||
|
formatStoreDisplayAddress({
|
||||||
|
province: '河南省',
|
||||||
|
cityName: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
address: '河南省郑州市金水区金水东路333号',
|
||||||
|
}),
|
||||||
|
).toBe('河南省郑州市金水区河南省郑州市金水区金水东路333号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallback when all parts are empty', () => {
|
||||||
|
expect(formatStoreDisplayAddress({})).toBe('地址待完善');
|
||||||
|
expect(formatStoreDisplayAddress({}, '')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const STORE_ADDRESS_FALLBACK = '地址待完善';
|
||||||
|
|
||||||
|
export type StoreAddressParts = {
|
||||||
|
province?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function textPart(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店展示地址:省 + 市 + 区 + 详细地址,按字段原样拼接。
|
||||||
|
* 省/市/区来自下拉选择,详细地址来自自由输入;即使详细地址已含省市区也不得去重。
|
||||||
|
*/
|
||||||
|
export function formatStoreDisplayAddress(
|
||||||
|
store: StoreAddressParts,
|
||||||
|
fallback = STORE_ADDRESS_FALLBACK,
|
||||||
|
): string {
|
||||||
|
const province = textPart(store.province);
|
||||||
|
const city = textPart(store.cityName) || textPart(store.city);
|
||||||
|
const district = textPart(store.district);
|
||||||
|
const detail = textPart(store.address);
|
||||||
|
const text = `${province}${city}${district}${detail}`;
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { maskContactPhone } from './phone';
|
||||||
|
import {
|
||||||
|
clampWecomPluginPageSize,
|
||||||
|
isWecomPluginEnabled,
|
||||||
|
matchWecomPluginByApiKey,
|
||||||
|
parseWecomPluginDateRange,
|
||||||
|
parseWecomPluginMetricsKind,
|
||||||
|
toWecomPluginMetricsView,
|
||||||
|
toWecomPluginUserView,
|
||||||
|
verifyWecomPluginApiKey,
|
||||||
|
wecomPluginMetricsPeriod,
|
||||||
|
} from './wecom-plugin';
|
||||||
|
|
||||||
|
describe('verifyWecomPluginApiKey', () => {
|
||||||
|
it('accepts an exact match', () => {
|
||||||
|
expect(verifyWecomPluginApiKey('secret-token', 'secret-token')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong, empty, or missing keys', () => {
|
||||||
|
expect(verifyWecomPluginApiKey('secret-token', 'other-token')).toBe(false);
|
||||||
|
expect(verifyWecomPluginApiKey('secret-token', 'secret-toke')).toBe(false);
|
||||||
|
expect(verifyWecomPluginApiKey('', 'secret-token')).toBe(false);
|
||||||
|
expect(verifyWecomPluginApiKey('secret-token', '')).toBe(false);
|
||||||
|
expect(verifyWecomPluginApiKey('secret-token', null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchWecomPluginByApiKey', () => {
|
||||||
|
it('returns the matching enabled instance', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: '1', apiKey: 'alpha-key-aaaa' },
|
||||||
|
{ id: '2', apiKey: 'beta-key-bbbbb' },
|
||||||
|
];
|
||||||
|
expect(matchWecomPluginByApiKey('beta-key-bbbbb', rows)?.id).toBe('2');
|
||||||
|
expect(matchWecomPluginByApiKey('missing', rows)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWecomPluginEnabled', () => {
|
||||||
|
it('requires both the switch and a non-empty key', () => {
|
||||||
|
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: '' })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'false', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => {
|
||||||
|
it('defaults and caps pageSize at 10', () => {
|
||||||
|
expect(clampWecomPluginPageSize(undefined)).toBe(5);
|
||||||
|
expect(clampWecomPluginPageSize('3')).toBe(3);
|
||||||
|
expect(clampWecomPluginPageSize(99)).toBe(10);
|
||||||
|
expect(clampWecomPluginPageSize(0)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses metrics kind, defaulting empty to today', () => {
|
||||||
|
expect(parseWecomPluginMetricsKind(undefined)).toBe('today');
|
||||||
|
expect(parseWecomPluginMetricsKind('weekly')).toBe('weekly');
|
||||||
|
expect(parseWecomPluginMetricsKind('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseWecomPluginDateRange / toWecomPluginMetricsView', () => {
|
||||||
|
it('parses YYYY-MM-DD range with inclusive end day', () => {
|
||||||
|
const range = parseWecomPluginDateRange('2026-09-01', '2026-09-03');
|
||||||
|
expect(range?.gte?.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
|
||||||
|
expect(range?.lt?.toISOString()).toBe(new Date('2026-09-04T00:00:00+08:00').toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes newStores alias on metrics stats', () => {
|
||||||
|
const view = toWecomPluginMetricsView({
|
||||||
|
usersTotal: 1,
|
||||||
|
usersIncrement: 0,
|
||||||
|
partnersTotal: 1,
|
||||||
|
partnersIncrement: 0,
|
||||||
|
storesTotal: 10,
|
||||||
|
storesIncrement: 2,
|
||||||
|
ordersTotal: 0,
|
||||||
|
ordersIncrement: 0,
|
||||||
|
orderAmountTotal: 0,
|
||||||
|
orderAmountIncrement: 0,
|
||||||
|
redeemsTotal: 0,
|
||||||
|
redeemsIncrement: 0,
|
||||||
|
redeemAmountTotal: 0,
|
||||||
|
redeemAmountIncrement: 0,
|
||||||
|
});
|
||||||
|
expect(view.newStores).toBe(2);
|
||||||
|
expect(view.storesIncrement).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toWecomPluginUserView', () => {
|
||||||
|
it('masks phone and fills empty nickname', () => {
|
||||||
|
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
|
||||||
|
userNo: 'DK1',
|
||||||
|
nickname: '—',
|
||||||
|
phone: '138****8000',
|
||||||
|
});
|
||||||
|
expect(maskContactPhone('13800138000')).toBe('138****8000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wecomPluginMetricsPeriod', () => {
|
||||||
|
it('today starts at Shanghai midnight and ends at now', () => {
|
||||||
|
const now = new Date('2026-09-03T21:15:00+08:00');
|
||||||
|
const p = wecomPluginMetricsPeriod('today', now);
|
||||||
|
expect(p.kind).toBe('today');
|
||||||
|
expect(p.periodKey).toBe('2026-09-03');
|
||||||
|
expect(p.start.toISOString()).toBe(new Date('2026-09-03T00:00:00+08:00').toISOString());
|
||||||
|
expect(p.endExclusive.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { maskContactPhone } from './phone';
|
||||||
|
import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
|
||||||
|
import {
|
||||||
|
wecomReportPeriod,
|
||||||
|
type WecomReportKind,
|
||||||
|
type WecomReportPeriod,
|
||||||
|
type WecomReportStats,
|
||||||
|
} from './wecom-report';
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
|
||||||
|
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const;
|
||||||
|
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
|
||||||
|
|
||||||
|
export type WecomPluginMetricsPeriod = Omit<WecomReportPeriod, 'kind'> & {
|
||||||
|
kind: WecomPluginMetricsKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isWecomPluginMetricsKind(v: string): v is WecomPluginMetricsKind {
|
||||||
|
return (WECOM_PLUGIN_METRICS_KINDS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWecomPluginMetricsKind(raw?: string | null): WecomPluginMetricsKind | null {
|
||||||
|
const v = String(raw ?? '').trim().toLowerCase();
|
||||||
|
if (!v) return 'today';
|
||||||
|
return isWecomPluginMetricsKind(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampWecomPluginPageSize(raw?: string | number | null): number {
|
||||||
|
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return WECOM_PLUGIN_PAGE_SIZE_DEFAULT;
|
||||||
|
return Math.min(WECOM_PLUGIN_PAGE_SIZE_MAX, Math.max(1, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampWecomPluginPage(raw?: string | number | null): number {
|
||||||
|
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 1;
|
||||||
|
return Math.min(100, Math.floor(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长度必须相同后再逐字符 XOR,避免短密码走快速失败路径时的明显差异(仍非密码学级) */
|
||||||
|
export function verifyWecomPluginApiKey(provided: string, expected?: string | null): boolean {
|
||||||
|
const exp = String(expected ?? '');
|
||||||
|
const got = String(provided ?? '');
|
||||||
|
if (!exp || !got || exp.length !== got.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < exp.length; i++) {
|
||||||
|
diff |= exp.charCodeAt(i) ^ got.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWecomPluginEnabled(env: {
|
||||||
|
WECOM_PLUGIN_ENABLED?: string;
|
||||||
|
WECOM_PLUGIN_API_KEY?: string;
|
||||||
|
}): boolean {
|
||||||
|
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在少量启用实例中做常量时间匹配(长度不等仍会先失败) */
|
||||||
|
export function matchWecomPluginByApiKey<T extends { apiKey: string }>(
|
||||||
|
provided: string,
|
||||||
|
candidates: T[],
|
||||||
|
): T | null {
|
||||||
|
const got = String(provided ?? '').trim();
|
||||||
|
if (!got) return null;
|
||||||
|
let hit: T | null = null;
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (verifyWecomPluginApiKey(got, row.apiKey)) hit = row;
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPluginMetricsPeriod(
|
||||||
|
kind: WecomPluginMetricsKind,
|
||||||
|
now = new Date(),
|
||||||
|
): WecomPluginMetricsPeriod {
|
||||||
|
if (kind === 'today') {
|
||||||
|
const start = startOfShanghaiDay(now);
|
||||||
|
const ymd = shanghaiYmd(start);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
start,
|
||||||
|
endExclusive: now,
|
||||||
|
periodKey: ymd,
|
||||||
|
title: `今日(${ymd})`,
|
||||||
|
rangeLabel: ymd,
|
||||||
|
incrementLabel: '今日新增',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const period = wecomReportPeriod(kind as WecomReportKind, now);
|
||||||
|
return { ...period, kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WecomPluginDateRange = { gte?: Date; lt?: Date };
|
||||||
|
|
||||||
|
/** 插件时间筛选:from/to 支持 YYYY-MM-DD 或 ISO;日期含当天全天 */
|
||||||
|
export function parseWecomPluginDateRange(
|
||||||
|
fromRaw?: string | null,
|
||||||
|
toRaw?: string | null,
|
||||||
|
): WecomPluginDateRange | undefined {
|
||||||
|
const from = parseWecomPluginDate(fromRaw);
|
||||||
|
const to = parseWecomPluginDate(toRaw);
|
||||||
|
if (!from && !to) return undefined;
|
||||||
|
const range: WecomPluginDateRange = {};
|
||||||
|
if (from) range.gte = from;
|
||||||
|
if (to) {
|
||||||
|
range.lt = isWecomPluginDateOnly(toRaw) ? addShanghaiDays(startOfShanghaiDay(to), 1) : to;
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWecomPluginDateOnly(raw?: string | null): boolean {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(String(raw ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWecomPluginDate(raw?: string | null): Date | null {
|
||||||
|
const v = String(raw ?? '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||||
|
return startOfShanghaiDay(new Date(`${v}T00:00:00+08:00`));
|
||||||
|
}
|
||||||
|
const d = new Date(v);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toWecomPluginMetricsView(stats: WecomReportStats) {
|
||||||
|
return {
|
||||||
|
...stats,
|
||||||
|
newStores: stats.storesIncrement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toWecomPluginUserView(user: {
|
||||||
|
userNo: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
}): { userNo: string; nickname: string; phone: string } {
|
||||||
|
return {
|
||||||
|
userNo: user.userNo,
|
||||||
|
nickname: user.nickname?.trim() || '—',
|
||||||
|
phone: maskContactPhone(user.phone),
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user