diff --git a/.cursor/rules/frontend-h5.mdc b/.cursor/rules/frontend-h5.mdc
index 5ddef82..4a3048a 100644
--- a/.cursor/rules/frontend-h5.mdc
+++ b/.cursor/rules/frontend-h5.mdc
@@ -39,6 +39,9 @@ src/
## UI 约束
+- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
+- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
+
- C 端订单 **5 Tab**(含 pending_ship)
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
diff --git a/.cursor/rules/mini-user-weapp-opentype.mdc b/.cursor/rules/mini-user-weapp-opentype.mdc
new file mode 100644
index 0000000..7dc882b
--- /dev/null
+++ b/.cursor/rules/mini-user-weapp-opentype.mdc
@@ -0,0 +1,46 @@
+---
+description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
+globs: apps/mini-user/**/*.{tsx,ts,css,scss}
+alwaysApply: false
+---
+
+# mini-user · 微信 open-type 硬规则
+
+## 禁止(必踩坑)
+
+**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
+
+**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
+
+```tsx
+// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
+
+ e.stopPropagation()}>
+
+
+
+```
+
+## 正确写法
+
+遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
+
+```tsx
+// ✅
+
+
+
+
+
+
+```
+
+## 附加
+
+- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
+- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
+- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
+
+参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
diff --git a/AGENTS.md b/AGENTS.md
index b06cb03..bd34d40 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,6 +138,7 @@ C 端门店仅 status=OPEN
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
+**微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。
## 环境与发版
diff --git a/apps/AGENTS.md b/apps/AGENTS.md
index 61d3ab0..a058c2a 100644
--- a/apps/AGENTS.md
+++ b/apps/AGENTS.md
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
+- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 新页面 workflow
diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 5d46930..cbc81b8 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
+import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -136,6 +137,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index 337fecf..1cbf5e5 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/hq-permissions', icon: , label: '权限分配' },
+ { key: '/test-whitelist', icon: , label: '白名单管理' },
{ key: '/system-settings', icon: , label: '系统设置' },
{ key: '/hq-accounts', icon: , label: 'HQ账户' },
];
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
+ '/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts
index 8abd79b..24711e6 100644
--- a/apps/admin-web/src/lib/api.ts
+++ b/apps/admin-web/src/lib/api.ts
@@ -167,6 +167,7 @@ export type AdminUserRow = {
sourceLabel: string | null;
createdAt: string;
orderCount: number;
+ isTest?: boolean;
};
export type AdminOrderItem = {
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
+ isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx
index bcf7ab1..8c2d6ae 100644
--- a/apps/admin-web/src/pages/CityPartnersPage.tsx
+++ b/apps/admin-web/src/pages/CityPartnersPage.tsx
@@ -59,6 +59,7 @@ type Row = {
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
+ isTest?: boolean;
};
type PartnerDetail = Row & {
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
- const [filters, setFilters] = useState>({});
+ const [filters, setFilters] = useState>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
'/admin/partners',
() => {
const qs = new URLSearchParams();
- if (filters.companyName) qs.set('companyName', filters.companyName);
- if (filters.phone) qs.set('phone', filters.phone);
- if (filters.cityId) qs.set('cityId', filters.cityId);
+ if (filters.companyName) qs.set('companyName', String(filters.companyName));
+ if (filters.phone) qs.set('phone', String(filters.phone));
+ if (filters.cityId) qs.set('cityId', String(filters.cityId));
+ if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
- { title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
+ {
+ title: '主账号姓名',
+ dataIndex: 'name',
+ width: 120,
+ ellipsis: true,
+ render: (v, row) => (
+
+ {v}
+ {row.isTest ? 测试 : null}
+
+ ),
+ },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
+
+ 过滤测试账号
+
+
+ 过滤测试账号
+
diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx
index abe64ff..899c62f 100644
--- a/apps/admin-web/src/pages/ProductsPage.tsx
+++ b/apps/admin-web/src/pages/ProductsPage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useState } from 'react';
+import { useMemo, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
@@ -67,13 +67,6 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
-type UserPickRow = {
- id: string;
- phone?: string | null;
- nickname?: string | null;
- userNo?: string;
-};
-
function mapDetailToForm(d: Record) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined,
};
- const visibilityPhones = (v.visibilityPhones ?? [])
- .map((p) => String(p || '').replace(/\D/g, '').trim())
- .filter(Boolean);
-
return {
barcode69: v.barcode69,
name: v.name,
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
- visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
}
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
- const [userOptions, setUserOptions] = useState([]);
- const [userSearching, setUserSearching] = useState(false);
- const searchTimer = useRef | null>(null);
-
- async function searchUsers(keyword: string) {
- const q = keyword.trim();
- if (searchTimer.current) clearTimeout(searchTimer.current);
- if (!q) {
- setUserOptions([]);
- return;
- }
- searchTimer.current = setTimeout(() => {
- void (async () => {
- setUserSearching(true);
- try {
- const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
- const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
- setUserOptions((res.items ?? []).filter((u) => !!u.phone));
- } catch {
- setUserOptions([]);
- } finally {
- setUserSearching(false);
- }
- })();
- }, 300);
- }
-
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
- extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
+ extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
>
{enabled ? (
- <>
-
-
-
-
-
- >
+
+ 可见手机号见白名单管理
+
) : null}
>
);
@@ -419,8 +348,8 @@ export default function ProductsPage() {
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
- render: (v: boolean, row) =>
- v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开,
+ render: (v: boolean) =>
+ v ? 限测 : 公开,
},
{
title: '履约',
diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx
index b70a399..4522dca 100644
--- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx
+++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
+import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
@@ -14,6 +14,7 @@ type Row = {
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
+ isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
- const [filters, setFilters] = useState>({});
+ const [filters, setFilters] = useState>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
- if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
- if (filters.storeId) qs.set('storeId', filters.storeId);
- if (filters.channel) qs.set('channel', filters.channel);
+ if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
+ if (filters.storeId) qs.set('storeId', String(filters.storeId));
+ if (filters.channel) qs.set('channel', String(filters.channel));
+ if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
}
const columns: ColumnsType = [
- { title: '核销号', dataIndex: 'redeemNo', width: 180 },
+ {
+ title: '核销号',
+ dataIndex: 'redeemNo',
+ width: 200,
+ render: (v, row) => (
+
+ {v}
+ {row.isTest ? (
+
+ 测试
+
+ ) : null}
+
+ ),
+ },
{
title: '方式',
dataIndex: 'channel',
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
]}
/>
+
+ 过滤测试账号
+
+
+ 过滤测试账号
+
})
);
}
-type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
-
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
- const [userOptions, setUserOptions] = useState([]);
- const [userSearching, setUserSearching] = useState(false);
- const searchTimer = useRef | null>(null);
-
- async function searchUsers(keyword: string) {
- const q = keyword.trim();
- if (searchTimer.current) clearTimeout(searchTimer.current);
- if (!q) {
- setUserOptions([]);
- return;
- }
- searchTimer.current = setTimeout(() => {
- void (async () => {
- setUserSearching(true);
- try {
- const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
- const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
- setUserOptions((res.items ?? []).filter((u) => !!u.phone));
- } catch {
- setUserOptions([]);
- } finally {
- setUserSearching(false);
- }
- })();
- }, 300);
- }
-
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
- extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
+ extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
>
{enabled ? (
- <>
-
-
-
-
-
- >
+
+ 可见手机号见白名单管理
+
) : null}
>
);
@@ -375,6 +315,7 @@ type StoreRow = {
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
+ isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: {
@@ -424,8 +365,8 @@ export default function StoresPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
- const [filters, setFilters] = useState>(() => {
- const init: Record = {};
+ const [filters, setFilters] = useState>(() => {
+ const init: Record = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
@@ -434,12 +375,13 @@ export default function StoresPage() {
'/admin/stores',
() => {
const qs = new URLSearchParams();
- if (filters.name) qs.set('name', filters.name);
- if (filters.status) qs.set('status', filters.status);
- if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
- if (filters.phone) qs.set('phone', filters.phone);
- if (filters.cityId) qs.set('cityId', filters.cityId);
- if (filters.partnerId) qs.set('partnerId', filters.partnerId);
+ if (filters.name) qs.set('name', String(filters.name));
+ if (filters.status) qs.set('status', String(filters.status));
+ if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
+ if (filters.phone) qs.set('phone', String(filters.phone));
+ if (filters.cityId) qs.set('cityId', String(filters.cityId));
+ if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
+ if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -597,6 +539,7 @@ export default function StoresPage() {
visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[])
: [],
+ isTest: !!d.isTest,
});
setDrawerOpen(true);
}
@@ -637,9 +580,7 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
- visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
- .map((p) => String(p || '').replace(/\D/g, '').trim())
- .filter(Boolean),
+ isTest: !!v.isTest,
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
@@ -813,9 +754,6 @@ export default function StoresPage() {
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
- visibilityPhones: (values.visibilityPhones ?? [])
- .map((p: string) => String(p || '').replace(/\D/g, '').trim())
- .filter(Boolean),
}),
});
message.success('门店已创建');
@@ -839,7 +777,12 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? : '—',
},
- { title: '门店名', dataIndex: 'name', width: 140 },
+ { title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
+
+ {v}
+ {row.isTest ? 测试 : null}
+
+ ) },
{
title: '分类',
width: 100,
@@ -879,8 +822,8 @@ export default function StoresPage() {
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
- render: (v, row) =>
- v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开,
+ render: (v) =>
+ v ? 限测 : 公开,
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
@@ -939,6 +882,9 @@ export default function StoresPage() {
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
+
+ 过滤测试账号
+