Compare commits

...

25 Commits

Author SHA1 Message Date
jacy cae426fbc2 v4.0.18版本号修改 2026-09-08 15:21:13 +08:00
jacy 8f25f823d5 删除门店的多银行账户列表 2026-09-08 14:23:00 +08:00
jacy 253430b291 v4.0.18版本更新 2026-09-08 13:47:42 +08:00
jacy 23ba639e9b v4.0.18版本提交 2026-09-08 10:07:34 +08:00
jacy 4bdb09068c feat(store): v4.0.18 门店多收款账户与子账号继承二维码
C 端门店列表拼接省市区县地址;门店多银行账户与默认打款账户;子账号独立 sa_ 关联码及统计维度;同步 v4.0.18 开发文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:56:16 +08:00
jacy a9c466930d 插件和文档 2026-09-06 14:18:08 +08:00
jacy 192a401227 v4.0.17企业微信API插件优化 2026-09-06 09:36:49 +08:00
jacy 0e711be6c6 v4.0.17 小程序版本优化
CI / verify (pull_request) Has been cancelled
2026-09-03 23:28:37 +08:00
jacy 8a196c377e Merge commit '7fa69c2521eefbfc1b78cca0e8a45264291c967d' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-09-03 21:29:29 +08:00
jacy 418e13a5e3 企业微信智能机器人API插件 2026-09-03 21:29:10 +08:00
developer_liu 7fa69c2521 merge dev into main
CI / verify (push) Has been cancelled
2026-09-03 16:42:58 +08:00
developer_liu b0874f07f6 merge dev-v4.0.15 into dev 2026-09-03 16:42:41 +08:00
developer_liu c26ebdde1b v4.0.15上传图片自动压缩 2026-09-03 16:04:10 +08:00
jacy b60b16a84f Merge pull request 'Dev' (#69) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/69
2026-09-03 13:26:35 +08:00
jacy b74dfd391a Merge pull request '推广码模块,在后端增加文案表达' (#68) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/68
2026-09-03 13:26:23 +08:00
jacy ec9b7efcd6 推广码模块,在后端增加文案表达
CI / verify (pull_request) Has been cancelled
2026-09-03 13:25:22 +08:00
jacy d04ca615ff merge(dev): 修复企微报告表零日期
CI / verify (push) Has been cancelled
2026-09-02 22:19:50 +08:00
jacy ebb34ad112 merge(dev_jacy): 修复企微报告表零日期 2026-09-02 22:19:44 +08:00
jacy d5994af431 fix(ops): 修复企微报告表零日期导致无法保存
早期 INSERT 未写 updated_at,MySQL 落成 0000-00-00,Prisma 读行失败。
2026-09-02 22:19:40 +08:00
jacy c29eb2c383 merge(dev): 企微报告保存改走 Prisma Client
CI / verify (push) Has been cancelled
2026-09-02 22:15:33 +08:00
jacy 29e95bd5a3 merge(dev_jacy): 企微报告保存改走 Prisma Client 2026-09-02 22:15:27 +08:00
jacy e5d7e468cc fix(ops): 企微报告保存改走 Prisma Client
空 @成员 会把 null 插进 tagged raw SQL,MySQL 下 Prisma 报 Code N/A。
2026-09-02 22:15:07 +08:00
jacy 62d75a6869 merge(dev): 企微报告截账至发送日前一天24点
CI / verify (push) Has been cancelled
2026-09-02 22:05:55 +08:00
jacy 8d70f0da72 merge(dev_jacy): 企微报告截账至发送日前一天24点 2026-09-02 22:05:48 +08:00
jacy 92dfbf5722 fix(wecom): 经营报告截账至发送日前一天24点
日报、周报、月报均不含发送当天发生额,避免盘中数据未闭合。
2026-09-02 22:05:03 +08:00
128 changed files with 7087 additions and 512 deletions
@@ -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 -1
View File
@@ -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": {
+4
View File
@@ -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,6 +51,7 @@ 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 WecomReportsPage from './pages/WecomReportsPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -95,6 +97,7 @@ 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="/wecom/reports" element={<WecomReportsPage />} />
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} /> <Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
@@ -131,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
+44 -3
View File
@@ -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>
<Space wrap>
<Button loading={issuing} onClick={() => void reissue()}> <Button loading={issuing} onClick={() => void reissue()}>
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'} {summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
</Button> </Button>
{summary?.qrcodeUrl ? (
<Button loading={downloadingQr} onClick={() => void downloadBareQrcode()}>
</Button>
) : null}
{canDownloadPosters ? ( {canDownloadPosters ? (
<Button onClick={() => setPosterDownloadOpen(true)}></Button> <Button onClick={() => setPosterDownloadOpen(true)}></Button>
) : null} ) : null}
</Space>
</div> </div>
</Space> </Space>
<Table <Table
@@ -86,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: '全部银行账户' },
], ],
}, },
{ {
@@ -128,6 +129,7 @@ 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: '/wecom/reports', label: '报告' },
], ],
@@ -186,6 +188,7 @@ 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/reports': 'wecom_bots',
'wecom-group': 'wecom_bots', 'wecom-group': 'wecom_bots',
@@ -209,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',
+2
View File
@@ -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: '编辑门店分类' },
+2
View File
@@ -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> {
@@ -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>
);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -131,6 +131,8 @@ export default function CityPartnersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId')?.trim() || ''; 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();
@@ -213,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 {
@@ -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
+2 -2
View File
@@ -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),
}, },
+190 -43
View File
@@ -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,49 +343,23 @@ 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}·
</Typography.Text>
<Button size="small" type="primary" onClick={openAddStaff}>
</Button>
</div>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
pagination={false} pagination={false}
dataSource={detail.staff} dataSource={detail.staff ?? []}
columns={[ columns={staffColumns}
{ title: '姓名', dataIndex: 'name' }, locale={{ emptyText: '暂无子账号' }}
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]}
/> />
</> </>
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</>
)} )}
</Drawer> </Drawer>
<Modal <Modal
@@ -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>
); );
} }
+6 -5
View File
@@ -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('银行号须为 1619 位数字'); throw new Error('银行号须为 1619 位数字');
} }
}, },
}, },
@@ -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 keyHeader {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"
@@ -148,10 +148,10 @@ function ReportKindPane({
label="发送时刻" label="发送时刻"
extra={ extra={
kind === 'daily' kind === 'daily'
? '按北京时间当天该时刻发送当日数据。' ? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。'
: kind === 'weekly' : kind === 'weekly'
? '按北京时间该星期该时刻发送上一自然周。' ? '按北京时间该星期该时刻发送上一自然周;不含发送当天。'
: '按北京时间每月该日该时刻发送上一自然月。' : '按北京时间每月该日该时刻发送上一自然月;不含发送当天。'
} }
rules={[{ required: true, message: '请选择时刻' }]} rules={[{ required: true, message: '请选择时刻' }]}
> >
@@ -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 -1
View File
@@ -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()}>
+4
View File
@@ -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">
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p> <p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></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">
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p> <p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted">{periodSubLabel(period)}</p> <p className="label-md text-muted">{periodSubLabel(period)}</p>
</>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -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 -1
View File
@@ -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": {
+12
View File
@@ -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 -1
View File
@@ -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": {
@@ -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>
))} ))}
+37
View File
@@ -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/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */ /** 格式化为 Asia/Shanghai2026-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 '—';
+1 -1
View File
@@ -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 {
+31
View File
@@ -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;
}
+1 -5
View File
@@ -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 {
@@ -202,8 +202,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -213,8 +211,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
+27 -26
View File
@@ -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,7 +8,6 @@ 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';
@@ -291,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 <View
className="u-btn u-btn--block" 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
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* HookTaro 编译会把它们折进 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">
+40 -8
View File
@@ -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' : ''}`}
@@ -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 {
+31
View File
@@ -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"
+50
View File
@@ -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)"
+48
View File
@@ -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"
+380
View File
@@ -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` |
| 参数 | statusQuery,默认 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) — 编码验收一句
+1
View File
@@ -152,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)。
+6 -1
View File
@@ -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 项重排、系统设置置底 |
+7 -5
View File
@@ -28,13 +28,15 @@
| 用户数量 | `status=1` 且未合并,`createdAt` &lt; 期末 | 区间内创建 | | 用户数量 | `status=1` 且未合并,`createdAt` &lt; 期末 | 区间内创建 |
| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 | | 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
| 门店数量 | 全部门店 | 区间内创建 | | 门店数量 | 全部门店 | 区间内创建 |
| 订单数量 | 按下单 `createdAt` | 区间内下单 | | 订单数量 | 按下单 `createdAt`,排除待支付 / 已取消 / 退款中 | 区间内下单 |
| 订单金额 | 已付 `payAmount``paidAt` | 区间内支付 | | 订单金额 | 已付 `payAmount``paidAt`,排除待支付 / 已取消 / 退款中 | 区间内支付 |
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 | | 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
- **日报**当天;默认 20:00 发送;新增文案「当日新增」。 - **日报**发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
- **周报**:上一自然周(周一~周日);默认周一 09:00。 - **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点
- **月报**:上一自然月;默认每月 1 日 09:00。 - **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点
三种报都不含发送当天发生额。
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。 错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
+123
View File
@@ -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 行为不变
+96
View File
@@ -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 / 消息推送 / 报告行为不变
+7 -3
View File
@@ -46,15 +46,19 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 概览(v4.0.14 / v4.0.15**`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。 **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。日报=当天存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord` **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`
@@ -62,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 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
+33 -7
View File
@@ -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.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图 > **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**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览)。 > 未改规则仍见 [`杜康好客-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-现状对照`](./杜康好客-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. 版本
@@ -15,6 +16,7 @@
| 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.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.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,佣金归属改写)
@@ -30,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_*`)。
@@ -88,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
@@ -97,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 端/门店端活动图;预生成每人缓存图。
+10 -4
View File
@@ -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.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条 | | 版本线 | **v4.0.18** 子账号继承二维码 + HQ 财务全部银行账户(门店行读结算资质;含 v4.0.15 HQ 概览折线图) |
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) | | 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 | | 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
## 1. 版本交付 ## 1. 版本交付
@@ -24,6 +24,7 @@
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 | | 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 | | 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 | | 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
@@ -36,4 +37,9 @@
| 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.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
| 2026-09-02 | v4.0.14HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 | | 2026-09-02 | v4.0.14HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
| 2026-09-02 | v4.0.15HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期 | | 2026-09-02 | v4.0.15HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
| 2026-09-07 | v4.0.18C 端门店列表省市区县地址;子账号独立继承码 + 子账号维度统计 |
| 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 端门店核销次数由系统设置开关控制 |
+35
View File
@@ -77,3 +77,38 @@
- [ ] 客服无合伙人/门店/核销图 - [ ] 客服无合伙人/门店/核销图
- [ ] 待办卡与超管发版区不变 - [ ] 待办卡与超管发版区不变
- [ ] domain 折线单测通过 - [ ] 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 行为不变
+145
View File
@@ -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/单测过
+1
View File
@@ -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`;小程序须已关联该企业微信客服 |
+2
View File
@@ -413,4 +413,6 @@ export * from './shanghai-date';
export * from './dashboard-period'; export * from './dashboard-period';
export * from './dashboard-series'; export * from './dashboard-series';
export * from './wecom-report'; export * from './wecom-report';
export * from './wecom-plugin';
export * from './shipping-address'; export * from './shipping-address';
export * from './store-address';
+31
View File
@@ -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('');
});
});
+29
View File
@@ -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;
}
+118
View File
@@ -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());
});
});
+145
View File
@@ -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),
};
}
+26 -8
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
formatWecomReportMarkdown, formatWecomReportMarkdown,
WECOM_REPORT_EXCLUDED_ORDER_STATUSES,
wecomReportCountedOrderWhere,
wecomReportCutoff, wecomReportCutoff,
wecomReportDueAt, wecomReportDueAt,
wecomReportPeriod, wecomReportPeriod,
@@ -24,12 +26,26 @@ const emptyStats = {
redeemAmountIncrement: 40, redeemAmountIncrement: 40,
}; };
describe('wecomReportCountedOrderWhere', () => {
it('excludes pending pay, cancelled and refunding', () => {
expect([...WECOM_REPORT_EXCLUDED_ORDER_STATUSES]).toEqual([
'PENDING_PAY',
'CANCELLED',
'REFUNDING',
]);
expect(wecomReportCountedOrderWhere()).toEqual({
status: { notIn: ['PENDING_PAY', 'CANCELLED', 'REFUNDING'] },
});
});
});
describe('wecomReportPeriod', () => { describe('wecomReportPeriod', () => {
it('daily is the Shanghai calendar day', () => { it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00')); const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
expect(p.periodKey).toBe('2026-09-02'); expect(p.periodKey).toBe('2026-09-01');
expect(p.incrementLabel).toBe('当日新增'); expect(p.incrementLabel).toBe('当日新增');
expect(p.start.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString()); expect(p.start.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString());
}); });
it('weekly is the previous natural week', () => { it('weekly is the previous natural week', () => {
@@ -37,26 +53,28 @@ describe('wecomReportPeriod', () => {
expect(p.periodKey).toBe('2026-08-24'); expect(p.periodKey).toBe('2026-08-24');
expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30'); expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30');
expect(p.incrementLabel).toBe('本期新增'); expect(p.incrementLabel).toBe('本期新增');
expect(p.endExclusive.toISOString()).toBe(new Date('2026-08-31T00:00:00+08:00').toISOString());
}); });
it('monthly is the previous natural month', () => { it('monthly is the previous natural month', () => {
const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00')); const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00'));
expect(p.periodKey).toBe('2026-08'); expect(p.periodKey).toBe('2026-08');
expect(p.title).toBe('月报(2026年8月)'); expect(p.title).toBe('月报(2026年8月)');
expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
}); });
}); });
describe('wecomReportCutoff', () => { describe('wecomReportCutoff', () => {
it('caps an in-progress daily period at now', () => { it('daily closes at send-day midnight, not send clock time', () => {
const now = new Date('2026-09-02T20:00:00+08:00'); const now = new Date('2026-09-02T20:00:00+08:00');
const p = wecomReportPeriod('daily', now); const p = wecomReportPeriod('daily', now);
expect(wecomReportCutoff(p, now).getTime()).toBe(now.getTime()); expect(wecomReportCutoff(p).getTime()).toBe(new Date('2026-09-02T00:00:00+08:00').getTime());
}); });
it('uses period end for a completed week', () => { it('uses period end for a completed week', () => {
const now = new Date('2026-09-02T09:00:00+08:00'); const now = new Date('2026-09-02T09:00:00+08:00');
const p = wecomReportPeriod('weekly', now); const p = wecomReportPeriod('weekly', now);
expect(wecomReportCutoff(p, now).getTime()).toBe(p.endExclusive.getTime()); expect(wecomReportCutoff(p).getTime()).toBe(p.endExclusive.getTime());
}); });
}); });
@@ -86,7 +104,7 @@ describe('wecomReportShouldFire', () => {
expect( expect(
wecomReportShouldFire( wecomReportShouldFire(
'daily', 'daily',
{ ...base, lastSentPeriod: '2026-09-02' }, { ...base, lastSentPeriod: '2026-09-01' },
new Date('2026-09-02T21:00:00+08:00'), new Date('2026-09-02T21:00:00+08:00'),
), ),
).toBe(false); ).toBe(false);
@@ -125,7 +143,7 @@ describe('formatWecomReportMarkdown', () => {
it('renders stock plus increment lines', () => { it('renders stock plus increment lines', () => {
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00')); const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
const md = formatWecomReportMarkdown(p, emptyStats); const md = formatWecomReportMarkdown(p, emptyStats);
expect(md).toContain('**杜康好客 · 日报(2026-09-02**'); expect(md).toContain('**杜康好客 · 日报(2026-09-01**');
expect(md).toContain('用户数量:10(当日新增 2'); expect(md).toContain('用户数量:10(当日新增 2');
expect(md).toContain('订单金额:1000.00(当日新增 80.50'); expect(md).toContain('订单金额:1000.00(当日新增 80.50');
expect(md).toContain('核销单数量:8(当日新增 3)'); expect(md).toContain('核销单数量:8(当日新增 3)');
+28 -8
View File
@@ -17,6 +17,20 @@ export function isWecomReportKind(v: string): v is WecomReportKind {
return (WECOM_REPORT_KINDS as readonly string[]).includes(v); return (WECOM_REPORT_KINDS as readonly string[]).includes(v);
} }
/** 经营报告/插件指标:订单笔数与金额均不计入待支付、已取消、退款中 */
export const WECOM_REPORT_EXCLUDED_ORDER_STATUSES = [
'PENDING_PAY',
'CANCELLED',
'REFUNDING',
] as const;
export type WecomReportExcludedOrderStatus =
(typeof WECOM_REPORT_EXCLUDED_ORDER_STATUSES)[number];
export function wecomReportCountedOrderWhere() {
return { status: { notIn: [...WECOM_REPORT_EXCLUDED_ORDER_STATUSES] } };
}
export type WecomReportStats = { export type WecomReportStats = {
usersTotal: number; usersTotal: number;
usersIncrement: number; usersIncrement: number;
@@ -67,15 +81,21 @@ function lastDayOfShanghaiMonth(year: number, month: number): number {
return Number(shanghaiYmd(new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1)).slice(8, 10)); return Number(shanghaiYmd(new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1)).slice(8, 10));
} }
/** 日报=当天;周报=上一自然周;月报=上一自然月(北京日历) */ /** 发送日北京 0 点 = 前一天 24 点,账期不含发送当天 */
export function wecomReportAsOf(now = new Date()): Date {
return startOfShanghaiDay(now);
}
/** 日报=昨日;周报=上一自然周;月报=上一自然月。期末均为某日 0 点。 */
export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod { export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod {
const asOf = wecomReportAsOf(now);
if (kind === 'daily') { if (kind === 'daily') {
const start = startOfShanghaiDay(now); const start = addShanghaiDays(asOf, -1);
const ymd = shanghaiYmd(start); const ymd = shanghaiYmd(start);
return { return {
kind, kind,
start, start,
endExclusive: addShanghaiDays(start, 1), endExclusive: asOf,
periodKey: ymd, periodKey: ymd,
title: `日报(${ymd}`, title: `日报(${ymd}`,
rangeLabel: ymd, rangeLabel: ymd,
@@ -83,7 +103,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
}; };
} }
if (kind === 'weekly') { if (kind === 'weekly') {
const { start, endExclusive } = previousShanghaiWeek(now); const { start, endExclusive } = previousShanghaiWeek(asOf);
const from = shanghaiYmd(start); const from = shanghaiYmd(start);
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1)); const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
return { return {
@@ -96,7 +116,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
incrementLabel: '本期新增', incrementLabel: '本期新增',
}; };
} }
const { year, month } = previousShanghaiMonth(now); const { year, month } = previousShanghaiMonth(asOf);
const { start, endExclusive } = shanghaiMonthRange(year, month); const { start, endExclusive } = shanghaiMonthRange(year, month);
return { return {
kind, kind,
@@ -109,9 +129,9 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
}; };
} }
/** 进行中的周期截到 now,已结束的周期用期末 */ /** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */
export function wecomReportCutoff(period: WecomReportPeriod, now = new Date()): Date { export function wecomReportCutoff(period: WecomReportPeriod): Date {
return now.getTime() < period.endExclusive.getTime() ? now : period.endExclusive; return period.endExclusive;
} }
export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date { export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
activityPosterPackFileName, activityPosterPackFileName,
activityPosterQrSlotPx, activityPosterQrSlotPx,
activityPosterResizeTarget,
activityPosterTemplateTooLarge, activityPosterTemplateTooLarge,
} from './activity-poster'; } from './activity-poster';
@@ -37,6 +38,30 @@ describe('activityPosterTemplateTooLarge', () => {
}); });
}); });
describe('activityPosterResizeTarget', () => {
it('returns null for 1080x1920', () => {
expect(activityPosterResizeTarget(1080, 1920)).toBeNull();
});
it('scales edge over 2500', () => {
const target = activityPosterResizeTarget(4000, 3000);
expect(target).not.toBeNull();
expect(Math.max(target!.width, target!.height)).toBeLessThanOrEqual(2500);
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
});
it('scales pixel count over 4e6', () => {
const target = activityPosterResizeTarget(2500, 2000);
expect(target).not.toBeNull();
expect(target!.width * target!.height).toBeLessThanOrEqual(4_000_000);
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
});
it('does not upscale small images', () => {
expect(activityPosterResizeTarget(100, 100)).toBeNull();
});
});
describe('activityPosterPackFileName', () => { describe('activityPosterPackFileName', () => {
it('uses city, company, id', () => { it('uses city, company, id', () => {
expect(activityPosterPackFileName({ expect(activityPosterPackFileName({
@@ -72,6 +72,39 @@ export function activityPosterTemplateTooLarge(width: number, height: number): b
return w > ACTIVITY_POSTER_MAX_EDGE || h > ACTIVITY_POSTER_MAX_EDGE || w * h > ACTIVITY_POSTER_MAX_PIXELS; return w > ACTIVITY_POSTER_MAX_EDGE || h > ACTIVITY_POSTER_MAX_EDGE || w * h > ACTIVITY_POSTER_MAX_PIXELS;
} }
/** HQ 上传活动图时的尺寸说明(前后端共用文案) */
export const ACTIVITY_POSTER_UPLOAD_HINT =
'底图最长边不超过 2500px,总像素不超过 400 万;超出将自动压缩';
export type OssUploadImageMeta = {
width: number;
height: number;
originalWidth?: number;
originalHeight?: number;
compressed: boolean;
fileSize: number;
};
/** 若底图超限,返回等比缩放后的目标宽高;否则 null(不放大) */
export function activityPosterResizeTarget(
width: number,
height: number,
): { width: number; height: number } | null {
const w = Math.max(1, Math.round(width));
const h = Math.max(1, Math.round(height));
if (!activityPosterTemplateTooLarge(w, h)) return null;
const maxEdge = Math.max(w, h);
const scaleEdge = maxEdge > ACTIVITY_POSTER_MAX_EDGE ? ACTIVITY_POSTER_MAX_EDGE / maxEdge : 1;
const scalePixels = w * h > ACTIVITY_POSTER_MAX_PIXELS ? Math.sqrt(ACTIVITY_POSTER_MAX_PIXELS / (w * h)) : 1;
const scale = Math.min(scaleEdge, scalePixels);
return {
width: Math.max(1, Math.round(w * scale)),
height: Math.max(1, Math.round(h * scale)),
};
}
/** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */ /** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */
export function activityPosterPackFileName(input: { export function activityPosterPackFileName(input: {
cityName?: string | null; cityName?: string | null;
+7
View File
@@ -119,6 +119,13 @@ export function resolveMockSmsFixedCode(env?: Record<string, string | undefined>
return code || MOCK_SMS_FIXED_CODE; return code || MOCK_SMS_FIXED_CODE;
} }
/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */
export function isShowStoreRedeemCountEnabled(env?: Record<string, string | undefined>): boolean {
const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase();
if (!raw) return true;
return raw === 'true' || raw === '1';
}
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */ /** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单'; export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
@@ -29,6 +29,7 @@ export const HQ_LIST_COLUMN_KEYS = [
'finance-partner-bills', 'finance-partner-bills',
'finance-winery-bills', 'finance-winery-bills',
'finance-logistics-bills', 'finance-logistics-bills',
'finance-bank-accounts',
'finance-store-withdrawals', 'finance-store-withdrawals',
'benefit-coupons', 'benefit-coupons',
'benefit-ledgers', 'benefit-ledgers',
+1
View File
@@ -29,6 +29,7 @@ export * from './city-warehouse';
export * from './fulfillment-provider'; export * from './fulfillment-provider';
export * from './system-config'; export * from './system-config';
export * from './wecom-bot'; export * from './wecom-bot';
export * from './wecom-plugin';
export * from './wecom-message-push'; export * from './wecom-message-push';
export * from './wecom-report'; export * from './wecom-report';
export * from './llm-config'; export * from './llm-config';
@@ -11,6 +11,7 @@ export type PartnerAssocTouchRequest = {
export type PartnerAssocTouchResult = { export type PartnerAssocTouchResult = {
partnerId: string; partnerId: string;
subAccountId?: string | null;
scanCounted: boolean; scanCounted: boolean;
scanCount: number; scanCount: number;
}; };
@@ -19,11 +20,16 @@ export type PartnerAssocBindResult = {
bound: boolean; bound: boolean;
alreadyBound: boolean; alreadyBound: boolean;
partnerId: string; partnerId: string;
subAccountId?: string | null;
partnerName: string; partnerName: string;
}; };
export type PartnerAssocSummary = { export type PartnerAssocSummary = {
partnerId: string; partnerId: string;
/** 子账号时返回主账号 ID */
primaryAccountId?: string;
/** 是否为子账号自己的二维码维度 */
isSubAccount?: boolean;
qrcodeUrl: string | null; qrcodeUrl: string | null;
userCount: number; userCount: number;
/** 关联码扫码进入次数(未登录也计) */ /** 关联码扫码进入次数(未登录也计) */
+38
View File
@@ -55,6 +55,44 @@ export interface StoreWithdrawBankAccountDto {
bankBranch?: string | null; bankBranch?: string | null;
} }
/** HQ 财务全部银行账户目录类型(v4.0.18) */
export const FINANCE_BANK_ACCOUNT_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS', 'OTHER'] as const;
export type FinanceBankAccountType = (typeof FINANCE_BANK_ACCOUNT_TYPES)[number];
export const FINANCE_BANK_ACCOUNT_TYPE_LABELS: Record<FinanceBankAccountType, string> = {
STORE: '门店',
WINERY: '酒厂',
PARTNER: '合伙人',
LOGISTICS: '物流',
OTHER: '其他',
};
export interface FinanceBankAccountDto {
id: string;
type: FinanceBankAccountType;
ownerName: string;
ownerId?: string | null;
cityId?: string | null;
cityName?: string | null;
bankAccountName: string;
bankAccountNo: string;
bankBranch?: string | null;
remark?: string | null;
editable: boolean;
}
export interface FinanceBankAccountInputDto {
name?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch?: string;
remark?: string;
}
export interface FinanceBankAccountRemarkDto {
remark?: string;
}
export interface StoreWithdrawSummaryDto { export interface StoreWithdrawSummaryDto {
availableAmount: number; availableAmount: number;
pendingReviewAmount: number; pendingReviewAmount: number;
+25
View File
@@ -75,6 +75,26 @@ export interface UpdateShopStaffRequest {
storeIds?: string[]; storeIds?: string[];
} }
/** HQ 管理门店子账号:创建体与门店端一致,更新可改登录手机。 */
export type CreateAdminStoreStaffRequest = CreateShopStaffRequest;
export interface UpdateAdminStoreStaffRequest extends UpdateShopStaffRequest {
phone?: string;
}
export interface AdminStoreStaffItem {
id: string;
name: string;
phone: string;
staffRole: StoreStaffRole;
permissions: string[];
status: AccountStatus;
storeIds: string[];
stores: Array<{ id: string; name: string; status: string }>;
lastLoginAt?: string | null;
createdAt?: string;
}
export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = { export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
MANAGER: '店长', MANAGER: '店长',
CASHIER: '收银员', CASHIER: '收银员',
@@ -82,3 +102,8 @@ export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
/** Default permissions for store sub-accounts (首版). */ /** Default permissions for store sub-accounts (首版). */
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const; export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
export const STORE_STAFF_PERMISSION_LABELS: Record<(typeof STORE_STAFF_DEFAULT_PERMISSIONS)[number], string> = {
redeem: '核销',
records: '核销记录',
};
+2
View File
@@ -74,6 +74,8 @@ export type ClientRuntimeConfig = {
partnerOnboardCsHint?: string | null; partnerOnboardCsHint?: string | null;
/** 小程序各场景分享文案/图 */ /** 小程序各场景分享文案/图 */
share?: MiniShareRuntime; share?: MiniShareRuntime;
/** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */
showStoreRedeemCount?: boolean;
}; };
/** 是否展示微信授权入口 */ /** 是否展示微信授权入口 */
@@ -40,7 +40,7 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
'redeem.success': '门店核销成功', 'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票', 'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成', 'finance.store_bill': '门店日账单已生成',
'finance.partner_bill': '合伙人账单已生成', 'finance.partner_bill': '合伙人账单',
'finance.winery_bill': '酒厂日账单已生成', 'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成', 'finance.logistics_bill': '物流月对账已生成',
}; };
@@ -183,7 +183,7 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string>
'store.withdraw_approved': '门店手动提现已通过', 'store.withdraw_approved': '门店手动提现已通过',
'invoice.pending': '发票申请待开票', 'invoice.pending': '发票申请待开票',
'finance.store_bill': '门店日账单已生成', 'finance.store_bill': '门店日账单已生成',
'finance.partner_bill': '合伙人账单已生成', 'finance.partner_bill': '合伙人账单',
'finance.winery_bill': '酒厂日账单已生成', 'finance.winery_bill': '酒厂日账单已生成',
'finance.logistics_bill': '物流月对账已生成', 'finance.logistics_bill': '物流月对账已生成',
}; };
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_BASE_PATH,
WECOM_PLUGIN_PATH_PERMISSION,
allowedWecomPluginOpenApiPaths,
maskWecomPluginApiKey,
parseWecomPluginPermissions,
resolveWecomPluginPublicUrl,
wecomPluginHasPermission,
} from './wecom-plugin';
describe('resolveWecomPluginPublicUrl', () => {
it('maps HQ production host to api.dukanghaoke.com', () => {
expect(resolveWecomPluginPublicUrl('admin.dukanghaoke.com')).toBe(
`https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('maps staging hosts to api-test', () => {
expect(resolveWecomPluginPublicUrl('admin-test.dukanghaoke.com')).toBe(
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
expect(resolveWecomPluginPublicUrl('api-test.dukanghaoke.com')).toBe(
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('falls back to local API for unknown hosts', () => {
expect(resolveWecomPluginPublicUrl('localhost')).toBe(
`http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('uses X-Api-Key as the plugin auth header', () => {
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
});
});
describe('parseWecomPluginPermissions', () => {
it('keeps catalog keys and drops unknown', () => {
expect(parseWecomPluginPermissions(['order.read', 'bogus', 'metrics.read'])).toEqual([
'order.read',
'metrics.read',
]);
expect(parseWecomPluginPermissions('["user.read","store.read"]')).toEqual([
'user.read',
'store.read',
]);
});
});
describe('maskWecomPluginApiKey / path permission map', () => {
it('masks middle of the key', () => {
expect(maskWecomPluginApiKey('WAv2xxxxYb9')).toBe('WAv2••••xYb9');
});
it('maps openapi paths to tool permissions', () => {
expect(WECOM_PLUGIN_PATH_PERMISSION['/orders']).toBe('order.read');
expect(WECOM_PLUGIN_PATH_PERMISSION['/promo-codes/{code}/stats']).toBe('promo.read');
});
it('filters openapi paths by instance permissions', () => {
expect(allowedWecomPluginOpenApiPaths(['order.read', 'user.read'])).toEqual([
'/orders',
'/users',
]);
expect(allowedWecomPluginOpenApiPaths(['store.audit.read'])).toEqual([
'/store-audits',
'/store-info-audits',
'/store-info-audits/{id}',
'/store-package-audits',
'/store-package-audits/{id}',
]);
expect(allowedWecomPluginOpenApiPaths(['partner.read'])).toContain('/partners');
expect(wecomPluginHasPermission(['order.read'], 'order.read')).toBe(true);
expect(wecomPluginHasPermission(['order.read'], 'metrics.read')).toBe(false);
});
});
+207
View File
@@ -0,0 +1,207 @@
/** 企微智能机器人 API 插件(只读数据面,与长连接 Bot 独立) */
export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key';
export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin';
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 const WECOM_PLUGIN_PERMISSIONS = [
'order.read',
'user.read',
'store.read',
'redeem.read',
'promo.read',
'metrics.read',
'store.audit.read',
'partner.read',
] as const;
export type WecomPluginPermission = (typeof WECOM_PLUGIN_PERMISSIONS)[number];
export const WECOM_PLUGIN_PERMISSION_LABELS: Record<WecomPluginPermission, string> = {
'order.read': '查询订单',
'user.read': '查询用户',
'store.read': '查询门店',
'redeem.read': '查询核销',
'promo.read': '查询推广码',
'metrics.read': '经营指标',
'store.audit.read': '门店/套餐审核',
'partner.read': '合伙人关联查询',
};
export const WECOM_PLUGIN_PERMISSION_GROUPS: Array<{
key: string;
label: string;
permissions: WecomPluginPermission[];
}> = [
{
key: 'query',
label: '只读查询',
permissions: [
'order.read',
'user.read',
'store.read',
'redeem.read',
'promo.read',
'metrics.read',
'partner.read',
],
},
{
key: 'audit',
label: '审核对照',
permissions: ['store.audit.read'],
},
];
/** OpenAPI path → 工具权限 */
export const WECOM_PLUGIN_PATH_PERMISSION: Record<string, WecomPluginPermission> = {
'/orders': 'order.read',
'/users': 'user.read',
'/stores': 'store.read',
'/redeems': 'redeem.read',
'/promo-codes': 'promo.read',
'/promo-codes/{code}/stats': 'promo.read',
'/metrics': 'metrics.read',
'/store-audits': 'store.audit.read',
'/store-info-audits': 'store.audit.read',
'/store-info-audits/{id}': 'store.audit.read',
'/store-package-audits': 'store.audit.read',
'/store-package-audits/{id}': 'store.audit.read',
'/partners': 'partner.read',
'/partners/{partnerId}/users': 'partner.read',
'/partners/{partnerId}/stores': 'partner.read',
'/partners/{partnerId}/orders': 'partner.read',
};
export const WECOM_PLUGIN_TOOL_PATHS: Record<WecomPluginPermission, string[]> = {
'order.read': ['/orders'],
'user.read': ['/users'],
'store.read': ['/stores'],
'redeem.read': ['/redeems'],
'promo.read': ['/promo-codes', '/promo-codes/{code}/stats'],
'metrics.read': ['/metrics'],
'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',
'/partners/{partnerId}/stores',
'/partners/{partnerId}/orders',
],
};
export function parseWecomPluginPermissions(
raw?: string | string[] | null,
): WecomPluginPermission[] {
const valid = new Set<string>(WECOM_PLUGIN_PERMISSIONS);
let list: string[];
if (Array.isArray(raw)) {
list = raw.map(String);
} else {
const text = String(raw ?? '').trim();
if (!text) {
list = [];
} else if (text.startsWith('[')) {
try {
const parsed: unknown = JSON.parse(text);
list = Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
list = text.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
}
} else {
list = text.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
}
}
return [...new Set(list.filter((s): s is WecomPluginPermission => valid.has(s)))];
}
export function wecomPluginHasPermission(
permissions: WecomPluginPermission[],
permission: WecomPluginPermission,
): boolean {
return permissions.includes(permission);
}
/** 当前实例可暴露给企微第 2 步的 OpenAPI paths */
export function allowedWecomPluginOpenApiPaths(
permissions: WecomPluginPermission[],
): string[] {
const allowed = new Set(permissions);
return Object.entries(WECOM_PLUGIN_PATH_PERMISSION)
.filter(([, perm]) => allowed.has(perm))
.map(([path]) => path);
}
export function maskWecomPluginApiKey(apiKey?: string | null): string {
const v = String(apiKey ?? '').trim();
if (!v) return '未配置';
if (v.length <= 8) return '••••';
return `${v.slice(0, 4)}••••${v.slice(-4)}`;
}
export type WecomApiPluginDto = {
id: string;
name: string;
apiKeyConfigured: boolean;
apiKeyMasked: string;
permissions: WecomPluginPermission[];
remark: string | null;
enabled: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
export type CreateWecomApiPluginRequest = {
name: string;
apiKey?: string;
permissions: WecomPluginPermission[];
remark?: string | null;
enabled?: boolean;
sortOrder?: number;
};
export type UpdateWecomApiPluginRequest = {
name?: string;
apiKey?: string;
permissions?: WecomPluginPermission[];
remark?: string | null;
enabled?: boolean;
sortOrder?: number;
};
export type WecomApiPluginSecretDto = WecomApiPluginDto & {
apiKey: string;
};
export type WecomApiPluginListDto = {
items: WecomApiPluginDto[];
total: number;
page: number;
pageSize: number;
};
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
export function resolveWecomPluginPublicUrl(hostname: string): string {
const host = String(hostname || '').toLowerCase();
if (host === 'admin.dukanghaoke.com' || host === 'api.dukanghaoke.com') {
return `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
}
if (host.includes('dukanghaoke.com') && host.includes('test')) {
return `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
}
return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/shared-ui", "name": "@dukang/shared-ui",
"version": "0.1.0", "version": "4.0.18",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/api", "name": "@dukang/api",
"version": "0.1.0", "version": "4.0.18",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "pnpm --dir ../../packages/domain build", "predev": "pnpm --dir ../../packages/domain build",
@@ -21,6 +21,7 @@
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts", "prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts", "prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts", "prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts",
"prisma:migrate-wecom-api-plugin": "ts-node --transpile-only prisma/migrate-wecom-api-plugin.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts", "prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts", "prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts", "prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
@@ -0,0 +1,5 @@
-- v4.0.18 修订:撤销门店多收款账户,打款改回门店结算资质(主账号 bank_account_*
-- 财务目录门店行改用 storeId,旧 STORE 备注按 store_bank_account.id 已失效
DROP TABLE IF EXISTS `store_bank_account`;
DELETE FROM `finance_bank_account_note` WHERE `owner_type` = 'STORE';
@@ -0,0 +1,26 @@
-- v4.0.18:HQ 财务银行账户总目录(其他账户 + 来源账户备注 overlay)
CREATE TABLE IF NOT EXISTS `finance_bank_account` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NULL,
`bank_account_name` VARCHAR(64) NOT NULL,
`bank_account_no` VARCHAR(32) NOT NULL,
`bank_branch` VARCHAR(128) NULL,
`remark` VARCHAR(256) NULL,
`status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_finance_bank_account_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务手工银行账户(不挂门店)';
CREATE TABLE IF NOT EXISTS `finance_bank_account_note` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_type` ENUM('STORE','WINERY','PARTNER','LOGISTICS') NOT NULL,
`source_id` VARCHAR(64) NOT NULL,
`remark` VARCHAR(256) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_finance_bank_account_note_owner_source` (`owner_type`, `source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务银行账户备注 overlay';
@@ -0,0 +1,43 @@
-- v4.0.18:门店多收款银行账户(列表 + 默认账户)
CREATE TABLE IF NOT EXISTS `store_bank_account` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`store_id` BIGINT UNSIGNED NOT NULL,
`bank_account_name` VARCHAR(64) NOT NULL,
`bank_account_no` VARCHAR(32) NOT NULL,
`bank_branch` VARCHAR(128) NULL,
`is_default` TINYINT NOT NULL DEFAULT 0,
`status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_store_bank_account_store_status` (`store_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店收款银行账户';
-- 回填:按门店主账号(is_primary=1,缺失则取最早绑定账号)的收款字段,为每店生成一条默认账户
INSERT INTO `store_bank_account`
(`store_id`, `bank_account_name`, `bank_account_no`, `bank_branch`, `is_default`, `status`, `sort_order`)
SELECT
b.`store_id`,
a.`bank_account_name`,
a.`bank_account_no`,
a.`bank_branch`,
1,
'ACTIVE',
0
FROM `store_account_store` b
JOIN `store_account` a ON a.`id` = b.`store_account_id`
WHERE a.`bank_account_name` IS NOT NULL
AND TRIM(a.`bank_account_name`) <> ''
AND a.`bank_account_no` IS NOT NULL
AND TRIM(a.`bank_account_no`) <> ''
AND b.`store_account_id` = (
SELECT b2.`store_account_id`
FROM `store_account_store` b2
JOIN `store_account` a2 ON a2.`id` = b2.`store_account_id`
WHERE b2.`store_id` = b.`store_id`
ORDER BY (a2.`is_primary` = 1) DESC, a2.`id` ASC
LIMIT 1
)
ON DUPLICATE KEY UPDATE `updated_at` = `updated_at`;
@@ -0,0 +1,5 @@
-- v4.0.18:子账号独立继承二维码 —— 记录带来用户的子账号归属
ALTER TABLE `user_user`
ADD COLUMN `assoc_sub_account_id` BIGINT UNSIGNED NULL AFTER `assoc_partner_account_id`,
ADD KEY `idx_user_user_assoc_sub_account_id` (`assoc_sub_account_id`);
@@ -0,0 +1,35 @@
/**
* wecom_api_plugin
* Prisma Client model generate
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS wecom_api_plugin (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
api_key VARCHAR(128) NOT NULL,
permissions TEXT NOT NULL,
remark VARCHAR(256) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY wecom_api_plugin_api_key_key (api_key),
KEY wecom_api_plugin_enabled_sort (enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('migrate-wecom-api-plugin done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -34,12 +34,15 @@ async function main() {
['monthly', '经营月报', 9], ['monthly', '经营月报', 9],
]; ];
for (const [kind, name, hour] of seeds) { for (const [kind, name, hour] of seeds) {
await prisma.$executeRaw` await prisma.$executeRawUnsafe(
INSERT IGNORE INTO wecom_report_push `INSERT IGNORE INTO wecom_report_push
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day) (kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day, created_at, updated_at)
VALUES VALUES (?, ?, ?, 0, ?, 0, 1, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
(${kind}, ${name}, ${PLACEHOLDER}, 0, ${hour}, 0, 1, 1) kind,
`; name,
PLACEHOLDER,
hour,
);
} }
console.log('migrate-wecom-report done'); console.log('migrate-wecom-report done');
} }
+58 -1
View File
@@ -262,6 +262,13 @@ enum AccountStatus {
DISABLED DISABLED
} }
enum FinanceBankAccountOwnerType {
STORE
WINERY
PARTNER
LOGISTICS
}
enum HqAdminRole { enum HqAdminRole {
SUPER_ADMIN SUPER_ADMIN
OPS OPS
@@ -493,6 +500,23 @@ model WecomBot {
@@map("wecom_bot") @@map("wecom_bot")
} }
/// 企微智能机器人 API 插件实例(v3.5.17 · 多 Key + 工具权限)
model WecomApiPlugin {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(64)
apiKey String @unique @map("api_key") @db.VarChar(128)
/// JSON 字符串数组,工具权限
permissions String @db.Text
remark String? @db.VarChar(256)
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@index([enabled, sortOrder])
@@map("wecom_api_plugin")
}
/// 企微机器人操作审计 /// 企微机器人操作审计
model LogWecomBot { model LogWecomBot {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -561,7 +585,7 @@ model WecomReportPush {
lastSentPeriod String? @map("last_sent_period") @db.VarChar(16) lastSentPeriod String? @map("last_sent_period") @db.VarChar(16)
lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3) lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("wecom_report_push") @@map("wecom_report_push")
} }
@@ -1261,6 +1285,7 @@ model PartnerAccount {
stores Store[] stores Store[]
bills PartnerBill[] bills PartnerBill[]
assocUsers User[] @relation("UserPartnerAssoc") assocUsers User[] @relation("UserPartnerAssoc")
assocSubUsers User[] @relation("UserSubAccountAssoc")
userNotes PartnerUserNote[] userNotes PartnerUserNote[]
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull) assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull) activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
@@ -1440,6 +1465,7 @@ model User {
sourceLabel String? @map("source_label") @db.VarChar(128) sourceLabel String? @map("source_label") @db.VarChar(128)
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
assocSubAccountId BigInt? @map("assoc_sub_account_id") @db.UnsignedBigInt
assocBoundAt DateTime? @map("assoc_bound_at") @db.DateTime(3) assocBoundAt DateTime? @map("assoc_bound_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1449,6 +1475,7 @@ model User {
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull) referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
referrers User[] @relation("UserReferrer") referrers User[] @relation("UserReferrer")
assocPartner PartnerAccount? @relation("UserPartnerAssoc", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull) assocPartner PartnerAccount? @relation("UserPartnerAssoc", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
assocSubAccount PartnerAccount? @relation("UserSubAccountAssoc", fields: [assocSubAccountId], references: [id], onDelete: SetNull)
partnerNotes PartnerUserNote[] partnerNotes PartnerUserNote[]
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull) avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
addresses UserAddress[] addresses UserAddress[]
@@ -1465,6 +1492,7 @@ model User {
@@index([sourceType, sourceRefId]) @@index([sourceType, sourceRefId])
@@index([referrerUserId]) @@index([referrerUserId])
@@index([assocPartnerAccountId]) @@index([assocPartnerAccountId])
@@index([assocSubAccountId])
@@index([mergedIntoUserId]) @@index([mergedIntoUserId])
@@index([wxOpenId]) @@index([wxOpenId])
@@index([isTest]) @@index([isTest])
@@ -2135,6 +2163,35 @@ model WineryBillItem {
@@map("winery_bill_item") @@map("winery_bill_item")
} }
/// HQ 财务手工登记的银行账户(不挂门店,不进入打款)
model FinanceBankAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String? @db.VarChar(128)
bankAccountName String @map("bank_account_name") @db.VarChar(64)
bankAccountNo String @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
remark String? @db.VarChar(256)
status AccountStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@index([status])
@@map("finance_bank_account")
}
/// HQ 财务对来源账户的备注 overlay(不改写门店/合伙人/酒厂/物流源字段)
model FinanceBankAccountNote {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ownerType FinanceBankAccountOwnerType @map("owner_type")
sourceId String @map("source_id") @db.VarChar(64)
remark String @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@unique([ownerType, sourceId])
@@map("finance_bank_account_note")
}
// ─── LOG ────────────────────────────────────────────── // ─── LOG ──────────────────────────────────────────────
model LogThirdParty { model LogThirdParty {
@@ -35,6 +35,8 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT', STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE', STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE', STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_CREATE: 'STORE_ACCOUNT_STAFF_CREATE',
STORE_ACCOUNT_STAFF_UPDATE: 'STORE_ACCOUNT_STAFF_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE', STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE', STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE', STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
@@ -95,6 +97,10 @@ export const HqOperationAction = {
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE', WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE', WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST', WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
WECOM_API_PLUGIN_CREATE: 'WECOM_API_PLUGIN_CREATE',
WECOM_API_PLUGIN_UPDATE: 'WECOM_API_PLUGIN_UPDATE',
WECOM_API_PLUGIN_DELETE: 'WECOM_API_PLUGIN_DELETE',
WECOM_API_PLUGIN_ROTATE_KEY: 'WECOM_API_PLUGIN_ROTATE_KEY',
WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE', WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE',
WECOM_REPORT_SEND: 'WECOM_REPORT_SEND', WECOM_REPORT_SEND: 'WECOM_REPORT_SEND',
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE', LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
@@ -172,6 +178,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_AUDIT]: '门店审核', [HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户', [HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户', [HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_CREATE]: '新增门店子账号',
[HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE]: '编辑门店子账号',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号', [HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源', [HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源', [HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
@@ -232,6 +240,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送', [HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送', [HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送', [HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
[HqOperationAction.WECOM_API_PLUGIN_CREATE]: '创建企微 API 插件',
[HqOperationAction.WECOM_API_PLUGIN_UPDATE]: '编辑企微 API 插件',
[HqOperationAction.WECOM_API_PLUGIN_DELETE]: '删除企微 API 插件',
[HqOperationAction.WECOM_API_PLUGIN_ROTATE_KEY]: '轮换企微 API 插件密钥',
[HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告', [HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告',
[HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告', [HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告',
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置', [HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
@@ -0,0 +1,101 @@
import { BadRequestException } from '@nestjs/common';
import {
activityPosterResizeTarget,
activityPosterTemplateTooLarge,
} from '@dukang/shared-types';
import sharp from 'sharp';
const JPEG_QUALITY = 85;
export type ActivityPosterUploadPrepared = {
buffer: Buffer;
mimeType: string;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
compressed: boolean;
};
function resolveOutputMime(hasAlpha: boolean, inputMime?: string): string {
if (hasAlpha) return 'image/png';
if (inputMime === 'image/webp') return 'image/jpeg';
if (inputMime?.startsWith('image/')) return inputMime;
return 'image/jpeg';
}
async function encodeImage(
pipeline: sharp.Sharp,
mimeType: string,
): Promise<Buffer> {
if (mimeType === 'image/png') {
return pipeline.png().toBuffer();
}
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
}
if (mimeType === 'image/webp') {
return pipeline.webp({ quality: JPEG_QUALITY }).toBuffer();
}
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
}
export async function prepareActivityPosterUpload(input: {
buffer: Buffer;
mimeType?: string;
}): Promise<ActivityPosterUploadPrepared> {
if (!input.buffer?.length) {
throw new BadRequestException('活动图文件为空');
}
if (input.mimeType && !input.mimeType.startsWith('image/')) {
throw new BadRequestException('活动图仅支持图片文件');
}
const meta = await sharp(input.buffer, { failOn: 'none' }).metadata();
if (!meta.width || !meta.height) {
throw new BadRequestException('活动图无法读取尺寸,请换一张图片');
}
const originalWidth = meta.width;
const originalHeight = meta.height;
const resizeTarget = activityPosterResizeTarget(originalWidth, originalHeight);
if (!resizeTarget) {
return {
buffer: input.buffer,
mimeType: input.mimeType || 'image/jpeg',
width: originalWidth,
height: originalHeight,
originalWidth,
originalHeight,
compressed: false,
};
}
const hasAlpha = meta.hasAlpha === true;
const outputMime = resolveOutputMime(hasAlpha, input.mimeType);
const buffer = await encodeImage(
sharp(input.buffer, { failOn: 'none' }).resize(resizeTarget.width, resizeTarget.height, {
fit: 'inside',
withoutEnlargement: true,
}),
outputMime,
);
const metaOut = await sharp(buffer).metadata();
const width = metaOut.width ?? resizeTarget.width;
const height = metaOut.height ?? resizeTarget.height;
if (activityPosterTemplateTooLarge(width, height)) {
throw new BadRequestException('活动图压缩后仍超出尺寸限制,请换一张较小的图片');
}
return {
buffer,
mimeType: outputMime,
width,
height,
originalWidth,
originalHeight,
compressed: true,
};
}
@@ -6,10 +6,19 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, map } from 'rxjs'; import { Observable, map } from 'rxjs';
function skipResponseWrap(url?: string): boolean {
const path = (url || '').split('?')[0];
return path.endsWith('/wecom/plugin/openapi.json');
}
@Injectable() @Injectable()
export class ResponseInterceptor implements NestInterceptor { export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>();
const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>(); const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>();
if (skipResponseWrap(req.originalUrl || req.url)) {
return next.handle();
}
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
if (res.headersSent) return data; if (res.headersSent) return data;
@@ -22,3 +31,4 @@ export class ResponseInterceptor implements NestInterceptor {
); );
} }
} }
@@ -6,6 +6,10 @@ export type StoreBankAccountSnapshot = {
bankBranch: string | null; bankBranch: string | null;
}; };
/**
* is_primary=1
* StoreAccount.bank_account_*
*/
export async function loadStorePrimaryBank( export async function loadStorePrimaryBank(
prisma: PrismaClient, prisma: PrismaClient,
storeId: bigint, storeId: bigint,
@@ -31,12 +35,21 @@ export async function loadStorePrimaryBank(
return fallback?.storeAccount ?? null; return fallback?.storeAccount ?? null;
} }
/** 打款/提现读结算资质(主账号银行字段) */
export async function loadStoreDefaultBank(
prisma: PrismaClient,
storeId: bigint,
): Promise<StoreBankAccountSnapshot | null> {
return loadStorePrimaryBank(prisma, storeId);
}
export async function loadStorePrimaryBanksMap( export async function loadStorePrimaryBanksMap(
prisma: PrismaClient, prisma: PrismaClient,
storeIds: bigint[], storeIds: bigint[],
): Promise<Map<string, StoreBankAccountSnapshot>> { ): Promise<Map<string, StoreBankAccountSnapshot>> {
const map = new Map<string, StoreBankAccountSnapshot>(); const map = new Map<string, StoreBankAccountSnapshot>();
if (!storeIds.length) return map; if (!storeIds.length) return map;
const bindings = await prisma.storeAccountStore.findMany({ const bindings = await prisma.storeAccountStore.findMany({
where: { storeId: { in: storeIds } }, where: { storeId: { in: storeIds } },
include: { include: {
@@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
requiresRestart: false, requiresRestart: false,
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)', description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
}, },
{
key: 'SHOW_STORE_REDEEM_COUNT',
label: 'C 端展示门店核销次数',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数',
},
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false }, { key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false }, { key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
@@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ /** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = { export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
SHOW_STORE_REDEEM_COUNT: 'true',
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''), SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
@@ -9,7 +9,7 @@ export type WecomBotAuditContext = {
bot: WecomBotRuntimeConfig; bot: WecomBotRuntimeConfig;
wecomUserId: string; wecomUserId: string;
action: string; action: string;
permission?: WecomBotPermission | null; permission?: WecomBotPermission | string | null;
inputSummary?: string | null; inputSummary?: string | null;
}; };
@@ -30,6 +30,11 @@ import {
renderWecomTemplate, renderWecomTemplate,
} from './wecom-push-template.defaults'; } from './wecom-push-template.defaults';
/** 含 markdown 表格时改走 markdown_v2,群聊才能渲染表格 */
function wecomMarkdownUsesTable(content: string): boolean {
return /\|[^\n]*\|\s*\n\s*\|?\s*:?-{3,}/.test(content);
}
type PushRow = { type PushRow = {
id: bigint; id: bigint;
name: string; name: string;
@@ -327,14 +332,16 @@ export class WecomMessagePushService implements OnModuleInit {
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> { async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
const url = (webhookUrl || '').trim(); const url = (webhookUrl || '').trim();
if (!url) return false; if (!url) return false;
const useTable = wecomMarkdownUsesTable(content);
const text = content.slice(0, useTable ? 4096 : 4000);
const payload = useTable
? { msgtype: 'markdown_v2', markdown_v2: { content: text } }
: { msgtype: 'markdown', markdown: { content: text } };
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
msgtype: 'markdown',
markdown: { content: content.slice(0, 4000) },
}),
}); });
const data = (await res.json().catch(() => ({}))) as { const data = (await res.json().catch(() => ({}))) as {
errcode?: number; errcode?: number;
@@ -580,20 +587,23 @@ export class WecomMessagePushService implements OnModuleInit {
}, },
'finance.partner_bill': { 'finance.partner_bill': {
vars: { vars: {
period: '2026-07', period: '2026-07-27 ~ 2026-08-02',
billCount: '1', billCount: '1',
totalAmount: '120.00', totalAmount: '120.00',
billSummary: [ billSummary: [
'城市:郑州', '| 郑州某某商贸-张三 | |',
'合伙人:郑州某某商贸-张三', '| :--- | ---: |',
'订单笔数:8', '| 账期 | 2026-07-27 ~ 2026-08-02 |',
'核销笔数:12', '| 订单数量 | 8 |',
'订单佣金:¥100.00', '| 订单金额 | ¥1000.00 |',
'核销佣金:¥20.00', '| 订单佣金 | ¥100.00 |',
'账单:¥120.00', '| 核销单数量 | 12 |',
'收款人:张三', '| 核销金额 | ¥200.00 |',
'收款账户:6222000011112222', '| 核销佣金 | ¥20.00 |',
'收款开户行:郑州支行', '| 累计金额 | ¥120.00 |',
'| 收款人 | 张三 |',
'| 收款银行账号 | 6222000011112222 |',
'| 开户行 | 郑州支行 |',
].join('\n'), ].join('\n'),
}, },
handlePath: '/finance/partner-bills', handlePath: '/finance/partner-bills',
@@ -0,0 +1,59 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WECOM_PLUGIN_PERMISSIONS,
parseWecomPluginPermissions,
type WecomPluginPermission,
} from '@dukang/shared-types';
import { isWecomPluginEnabled, matchWecomPluginByApiKey } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Injectable()
export class WecomPluginAuthService implements OnModuleInit {
private readonly logger = new Logger(WecomPluginAuthService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
await this.migrateFromEnv().catch((e) => {
this.logger.warn(
`wecom plugin env migrate skipped: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
async resolveByApiKey(provided: string): Promise<WecomPluginRuntime | null> {
const key = String(provided ?? '').trim();
if (!key) return null;
const rows = await this.prisma.wecomApiPlugin.findMany({
where: { enabled: true },
select: { id: true, name: true, apiKey: true, permissions: true },
});
const hit = matchWecomPluginByApiKey(key, rows);
if (!hit) return null;
return {
id: hit.id.toString(),
name: hit.name,
permissions: parseWecomPluginPermissions(hit.permissions),
};
}
private async migrateFromEnv() {
if (!isWecomPluginEnabled(process.env)) return;
const count = await this.prisma.wecomApiPlugin.count();
if (count > 0) return;
const apiKey = String(process.env.WECOM_PLUGIN_API_KEY ?? '').trim();
if (!apiKey) return;
await this.prisma.wecomApiPlugin.create({
data: {
name: '迁移自 env',
apiKey,
permissions: JSON.stringify([...WECOM_PLUGIN_PERMISSIONS] as WecomPluginPermission[]),
remark: '由 WECOM_PLUGIN_API_KEY 一次性导入',
enabled: true,
sortOrder: 0,
},
});
this.logger.log('wecom api plugin migrated from env');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
import { CurrentWecomPlugin } from './wecom-plugin.decorators';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Controller('wecom/plugin')
@UseGuards(WecomPluginGuard)
export class WecomPluginController {
constructor(private readonly query: WecomPluginQueryService) {}
@Get()
info(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
return this.query.info(plugin);
}
@Get('openapi.json')
openapi(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
return this.query.openapi(plugin);
}
@Get('orders')
orders(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryOrders(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('users')
users(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryUsers(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('stores')
stores(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStores(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('store-audits')
storeAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreAudits(plugin, pluginCaller(req), q, status, page, pageSize);
}
@Get('store-info-audits')
storeInfoAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreInfoAudits(plugin, pluginCaller(req), status, page, pageSize);
}
@Get('store-info-audits/:id')
storeInfoAuditDetail(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStoreInfoAuditDetail(plugin, pluginCaller(req), id);
}
@Get('store-package-audits')
storePackageAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStorePackageAudits(plugin, pluginCaller(req), status, page, pageSize);
}
@Get('store-package-audits/:id')
storePackageAuditDetail(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStorePackageAuditDetail(plugin, pluginCaller(req), id);
}
@Get('redeems')
redeems(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryRedeems(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes')
promoCodes(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPromoCodes(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes/:code/stats')
promoStats(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('code') code: string,
) {
return this.query.queryPromoCodeStats(plugin, pluginCaller(req), code);
}
@Get('partners')
partners(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartners(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('partners/:partnerId/users')
partnerUsers(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerUsers(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('partners/:partnerId/stores')
partnerStores(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerStores(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('partners/:partnerId/orders')
partnerOrders(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerOrders(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('metrics')
metrics(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('kind') kind?: string,
) {
return this.query.queryMetrics(plugin, pluginCaller(req), kind);
}
}
function pluginCaller(req: Request): string {
const raw = req.headers['x-wecom-userid'] ?? req.headers['userid'];
const v = Array.isArray(raw) ? raw[0] : raw;
return String(v || 'plugin').trim() || 'plugin';
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { WecomPluginRuntime } from './wecom-plugin.types';
export const CurrentWecomPlugin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): WecomPluginRuntime => {
return ctx.switchToHttp().getRequest().wecomPlugin;
},
);

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