diff --git a/.workbuddy/memory/2026-08-17.md b/.workbuddy/memory/2026-08-17.md
new file mode 100644
index 0000000..d5dc47c
--- /dev/null
+++ b/.workbuddy/memory/2026-08-17.md
@@ -0,0 +1,70 @@
+# 2026-08-17 工作日志
+
+## 杜康好客 v3.4.18 门店体验与登录态优化(含 F 项)
+
+### 已实现并发布
+- **A** mini-user 门店座机脱敏:改 `apps/mini-user/src/lib/phone.ts` 的 `maskPhone` 座机分支,中间四位 `****`(保留区号+首2+末2)。
+- **B** 套餐详情图 1/5 计数 + 自动轮播:`ProductCarousel.tsx` detail 变体加 autoplay(3.5s) + 右下角 `1/N` 计数。
+- **C** 合伙人暂停禁止登录:`auth.service.ts` 三处非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」;`h5-partner/LoginPage.tsx` 同步文案;复用 `DISABLED` 枚举,无迁移。
+- **D** 门店端提现/筛选栏留白对齐:`h5-shop/styles.css` 补 `--space-page`。
+- **E** 休息中核销→是否开启营业:`PhoneRedeemPage`/`RedeemConfirmPage` 弹「是否开启营业?」,确认调 `PUT /shop/store/status` 开张后继续。
+- **F(本次新增)** 门店套餐图片自适应完整显示:`ProductCarousel` 新增 `imageFit="adaptive"`(`mode="widthFix"` + 按真实比例动态算 Swiper 高度),`product-detail.css` 加 `.detail-carousel-wrap--adaptive` 覆盖固定 1:1 裁剪;仅 `store-package-detail` 使用。根因:原 `.detail-carousel-wrap` 固定 `aspect-ratio:1` + `overflow:hidden` + `aspectFill` 裁掉非正方形图。
+
+### 发布状态
+- 文档:`杜康好客-v3.4.18-门店体验与登录态优化.md`(含 A–F 范围、实现位置、验收清单)。
+- git:本地 `dev_jacy` 提交 v3.4.18 各功能 + F 修复;已 `git push origin dev_jacy:main`(干净快进 `984dec7..18147e1`)。
+- 生产部署:后台运行 `deploy/deploy-prod.sh --host dukang-server`(/opt/dukang, main)。SSH 配置主机 `dukang-server` 即生产机;`deploy.env` 缺失,用 `--host` 覆盖。
+
+### 注意
+- 本次 git 发布前曾遇 origin/main 比本地新、working tree 异常删除 503 文件(已 `reset --hard HEAD` 还原,提交完好);最终 fetch 后确认本地领先可快进,安全发布。
+- 四端 tsc 仅余项目既有环境类报错(CSS 导入/ImportMeta.env),本次改动文件无新增类型错误。
+
+### F 项方案 A 改写(22:18)
+- 放弃手算高度逻辑(SelectorQuery + onLoad 测自然宽高),改用 swiper 原生 `autoHeight`:海报有多高轮播就有多高,无裁切。
+- `ProductCarousel.tsx`:删 `wrapWidth`/`imgHeights`/`handleImageLoad`/`swiperStyle`,adaptive 模式加 `autoHeight={isAdaptive}`,image 保持 `mode="widthFix"`。
+- `product-detail.css`:`.detail-carousel-wrap--adaptive` 覆盖(删 `aspect-ratio:1`/`overflow:hidden`),并给 `.detail-carousel`/`.detail-carousel-item`/`.detail-carousel-image` 在 adaptive 下 `height:auto`。
+- 已提交 `cd1e9ae` 并 `git push origin dev_jacy:main`(干净快进 `18147e1..cd1e9ae`)。
+- 重要:F 是原生微信小程序端改动,服务器部署只覆盖 H5-user 构建;真机小程序用户需经「微信开发者工具上传 + 微信审核」才能生效。
+
+### E 项缺陷修复(22:46 测试不通过)
+- **根因**:门店非 OPEN 时,后端 `redeem.service.ts:loadOpenStoreAccount`(previewRedeem/confirmRedeem 共用)抛「门店未营业」,导致 `RedeemConfirmPage` 的 `preview` 拉取失败 → `preview` 为 null → 「确认核销」按钮 `disabled={!preview}` → 点击不了 → 开张弹窗永远不触发。手机号路径因弹窗在「发送验证码」时触发故未暴露。
+- **修复**:`RedeemConfirmPage` 把 preview 拉取抽成 `loadPreview()`;确认按钮改为 `disabled={loading || (!preview && !storeClosed)}`(闭店时仍可点→弹框);`openStoreAndContinue` 开张成功后 `await loadPreview()` 再 `doConfirm()`。
+- 弹窗文案对齐需求:「门店目前休息中无法核销,是否开启营业」(两页 modal title=门店休息中 / desc=该句;内联提示改为「门店目前休息中无法核销,开启营业后可继续」)。
+- 提交 `3ac008a`,`git push origin dev_jacy:main`(快进 `cd1e9ae..3ac008a`),生产重新部署中(deploy-prod.sh --host dukang-server)。
+- 注意:后端 `updateShopStatus` 对 CLOSED(永久关闭) 门店拒绝开张(抛「门店已永久关闭...」),属预期;需求针对 闭店/休息中(PAUSED) 场景,OK。
+
+### E 项方案二次修正(23:00 用户纠正:前置到点击扫码)
+- **用户新要求**:门店 PAUSED 时,**点击「扫码核销」按钮那一刻就弹窗**,而不是扫完码进确认页才弹。
+- **改动**:`apps/h5-shop/src/pages/HomePage.tsx`(门店端首页)。
+ - 复用 dashboard 已加载的 `status`(`dash?.store?.status`,L384 `status = String(store?.status||'')`)。
+ - `handleScan()` 开头:`if (status !== 'OPEN') { setShowOpenModal(true); return; }` —— 不进入 isWechatEnv/scanQrCode 流程。
+ - 新增 `openStoreAndContinue()`:`PUT /shop/store/status {OPEN}`,成功后 `loadDashboard()` 刷新状态(开关/扫码按钮同步),用户可再次点击扫码。
+ - 弹窗复用全局 `.shop-redeem-modal` 样式(与确认页一致),文案「门店目前休息中无法核销,是否开启营业?」。
+ - `RedeemConfirmPage` 的弹窗保留为带码直入确认页的兜底。
+- 提交 `c0d04ee`,`git push origin dev_jacy:main`(快进 `3ac008a..c0d04ee`),生产重新部署中(deploy-prod.sh --host dukang-server)。文档 E 节已同步更新方案描述。
+
+### C 项缺陷修复(合伙人端 DISABLED 仍登录 - 微信路径)
+- **现象**:手机号(短信)路径已正确拦截(login 前先 `POST /partner/auth/phone/check` 校验非 ACTIVE 即拒),但**微信 OAuth 路径**报错被吞 → 用户无任何提示。
+- **根因**:`apps/h5-partner/src/contexts/PartnerSessionContext.tsx` L118 `catch { stripOAuthParamsFromLocation(); }` 把微信回调里 `processPartnerWechatOAuthCallback` 抛出的「该账号已暂停使用」错误吞掉。
+- **修复**:`catch (e) { stripOAuthParamsFromLocation(); toastError(e); }`(`toastError` 从 `@/lib/toast` 引入),让微信登录被暂停时同样弹「该账号已暂停使用,请联系客服人员」。
+- 提交 `5ca9aa0`,`git push origin dev_jacy:main`(快进 `c0d04ee..5ca9aa0`)。
+- 生产部署完成(deploy-prod.sh --host dukang-server, 38s):dukang-api 200、各 h5/partner 服务 online。mini-user(h5) 健康检查 FAIL 与本次无关(非 v3.4.18 改动范围,本次改 native 小程序 + h5-shop + h5-partner)。
+- **验收状态**:A/B/C/D/E/F 全部提交并发布生产;C 项的短信 + 微信两条路径拦截逻辑均已落地,建议真机/微信环境复测一次确认 toast 文案展示。
+
+### C 项双拦截增强(23:37 用户要求“两个都要拦”)
+- **澄清**:city 合伙人有两个独立状态字段,别混:
+ - `partner_account.status`(AccountStatus: ACTIVE/DISABLED)= **登录账号启用态**,feature 原拦截查的就是它(子账号自身)。
+ - `partner_account.bindingStatus`(CityPartnerStatus: ACTIVE/PAUSED)= **城市合伙人绑定态**,主账号为准;子账号取父主账号的此字段。
+- **需求**:子账号 `status=DISABLED` 与 主账号 `bindingStatus=PAUSED` **都要拦**。
+- **实现**:`auth.service.ts` 新增统一闸门 `assertPartnerAccountActive(account)`(L313),同时拦上述两种;替换三处旧校验:`assertPartnerAccountByPhone`(L334) / `loginPartner`(L1086) / `loginPartnerWechat`(L1748)。
+ - 自身非 ACTIVE → 「该账号已暂停使用,请联系客服人员」
+ - 主账号 bindingStatus 非 ACTIVE → 「该合伙人合作已暂停,请联系客服人员」
+- **文档**:`杜康好客-v3.4.18-...md` C 节重写为双拦截;顶部范围表 + 验收清单同步。
+- 提交 `48d6900`,`git push origin dev_jacy:main`(快进 `5ca9aa0..48d6900`),生产部署完成(task I2mWSU, 37s):commit=48d6900、api 200、各服务 online。
+- **生产实测双拦截均生效**:
+ - `status=DISABLED`(id=7 临时翻转→测试→已还原 ACTIVE):`phone/check` 返回 400「该账号已暂停使用,请联系客服人员」;还原后 201 ok:true。
+ - `bindingStatus=PAUSED`(18049821889 等 3 个主账号):`phone/check` 返回 400「该合伙人合作已暂停,请联系客服人员」(修复前为 200 ok:true)。
+ - 库内 status!=ACTIVE 数量=0;binding_status!=ACTIVE 数量=3(均为主账号 PAUSED)。
+- **遗留未提交文件(非本次改动,需用户决策)**:
+ - `apps/h5-shop/src/styles.css`:`shop-records-status-chips` 由 `padding:0` → `padding-bottom:10px`(D 项后续微调,未提交)。
+ - `apps/mini-user/src/styles/product-detail.css`:工作区版本把方案A(`aspect-ratio:auto`/`height:auto`)改回固定 4:3(`aspect-ratio:1.33`/`height:100%`),与已上线方案A矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx
index 197c14f..80438ad 100644
--- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx
+++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx
@@ -20,6 +20,7 @@ import {
} from '../lib/api';
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
+import { toastError } from '../lib/toast';
export type PartnerAccount = PartnerMe & {
staffRole?: PartnerStaffRole;
@@ -115,7 +116,10 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) {
}
stripOAuthParamsFromLocation();
}
- } catch {
+ } catch (e) {
+ // 微信 OAuth 回跳后后端可能因账号暂停(DISABLED)等拒绝登录,
+ // 必须把错误显式提示出来,否则用户无任何反馈(与短信路径一致)。
+ toastError(e instanceof Error ? e.message : '微信登录失败');
stripOAuthParamsFromLocation();
}
}
diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx
index 6c958ec..640338e 100644
--- a/apps/h5-partner/src/pages/LoginPage.tsx
+++ b/apps/h5-partner/src/pages/LoginPage.tsx
@@ -71,16 +71,24 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号';
}
- if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
- return '合伙人账号已暂停,无法登录';
+ if (
+ text.includes('合伙人账号已停用') ||
+ text.includes('账号已停用') ||
+ text.includes('暂停使用')
+ ) {
+ return '该账号已暂停使用,请联系客服人员';
}
return text;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
- if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
- return '合伙人账号已暂停,无法登录';
+ if (
+ text.includes('合伙人账号已停用') ||
+ text.includes('账号已停用') ||
+ text.includes('暂停使用')
+ ) {
+ return '该账号已暂停使用,请联系客服人员';
}
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx
index b0cd14d..d6cf65a 100644
--- a/apps/h5-shop/src/pages/HomePage.tsx
+++ b/apps/h5-shop/src/pages/HomePage.tsx
@@ -112,6 +112,10 @@ export default function HomePage() {
const [authError, setAuthError] = useState('');
+ const [showOpenModal, setShowOpenModal] = useState(false);
+
+ const [opening, setOpening] = useState(false);
+
const pendingScanStartedRef = useRef(false);
@@ -309,6 +313,12 @@ export default function HomePage() {
setScanMsg('');
+ // 门店临时闭店/休息中(非营业状态)时,点击扫码直接提示,不进入扫码流程
+ if (status !== 'OPEN') {
+ setShowOpenModal(true);
+ return;
+ }
+
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
@@ -349,6 +359,25 @@ export default function HomePage() {
+ async function openStoreAndContinue() {
+ if (opening) return;
+ setOpening(true);
+ try {
+ await request('SHOP_H5', '/shop/store/status', {
+ method: 'PUT',
+ body: JSON.stringify({ status: 'OPEN' }),
+ });
+ setShowOpenModal(false);
+ setScanMsg('');
+ void loadDashboard(); // 刷新门店状态为营业中,扫码按钮可再次使用
+ } catch (e) {
+ setShowOpenModal(false);
+ setScanMsg(e instanceof Error ? e.message : '开启营业失败');
+ } finally {
+ setOpening(false);
+ }
+ }
+
async function startWechatAuth() {
setAuthLoading(true);
@@ -615,6 +644,33 @@ export default function HomePage() {
/>
+ {showOpenModal && (
+
+
+
门店休息中
+
门店目前休息中无法核销,是否开启营业?
+
+
+
+
+
+
+ )}
+
);
diff --git a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
index db418ba..a3e0b76 100644
--- a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
+++ b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
@@ -20,6 +20,8 @@ export default function PhoneRedeemPage() {
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [confirmCooldown, setConfirmCooldown] = useState(0);
+ const [showOpenModal, setShowOpenModal] = useState(false);
+ const [opening, setOpening] = useState(false);
useEffect(() => {
request>('SHOP_H5', '/shop/store')
@@ -36,15 +38,7 @@ export default function PhoneRedeemPage() {
return () => window.clearTimeout(timer);
}, [confirmCooldown]);
- async function sendConfirmSms() {
- if (!/^1\d{10}$/.test(phone.trim())) {
- setMsg('请输入正确的手机号');
- return;
- }
- if (storeClosed) {
- setMsg('门店未营业,无法核销');
- return;
- }
+ async function prepareDirectRedeem() {
const value = Number(amount);
if (!Number.isFinite(value) || value <= 0) {
setMsg('请输入有效核销金额');
@@ -69,6 +63,37 @@ export default function PhoneRedeemPage() {
}
}
+ async function sendConfirmSms() {
+ if (!/^1\d{10}$/.test(phone.trim())) {
+ setMsg('请输入正确的手机号');
+ return;
+ }
+ if (storeClosed) {
+ setShowOpenModal(true);
+ return;
+ }
+ await prepareDirectRedeem();
+ }
+
+ async function openStoreAndContinue() {
+ if (opening) return;
+ setOpening(true);
+ try {
+ await request('SHOP_H5', '/shop/store/status', {
+ method: 'PUT',
+ body: JSON.stringify({ status: 'OPEN' }),
+ });
+ setStoreClosed(false);
+ setShowOpenModal(false);
+ await prepareDirectRedeem();
+ } catch (e) {
+ setShowOpenModal(false);
+ setMsg(e instanceof Error ? e.message : '开启营业失败');
+ } finally {
+ setOpening(false);
+ }
+ }
+
async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
@@ -114,7 +139,7 @@ export default function PhoneRedeemPage() {
{storeClosed && (
- 门店当前未营业,无法核销
+ 门店目前休息中无法核销,开启营业后可继续
)}
@@ -181,7 +206,7 @@ export default function PhoneRedeemPage() {
+
+ {showOpenModal && (
+
+
+
门店休息中
+
门店目前休息中无法核销,是否开启营业?
+
+
+
+
+
+
+ )}
);
}
diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
index 8cf8bcd..f60cc47 100644
--- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
+++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
@@ -29,6 +29,8 @@ export default function RedeemConfirmPage() {
const [storeClosed, setStoreClosed] = useState(false);
const [failCount, setFailCount] = useState(0);
const [showWeakNet, setShowWeakNet] = useState(false);
+ const [showOpenModal, setShowOpenModal] = useState(false);
+ const [opening, setOpening] = useState(false);
useEffect(() => {
request>('SHOP_H5', '/shop/store')
@@ -48,34 +50,37 @@ export default function RedeemConfirmPage() {
setToken(scanned);
}, [searchParams, navigate]);
- useEffect(() => {
+ async function loadPreview() {
if (!token.trim()) {
setPreview(null);
return;
}
- request('SHOP_H5', '/shop/redeem/preview', {
- method: 'POST',
- body: JSON.stringify({ token }),
- })
- .then(setPreview)
- .catch(async (e) => {
- setPreview(null);
- setMsg(e instanceof Error ? e.message : '无法预览核销码');
- const report = await reportRedeemFailure(token, 'preview', e);
- if (report?.thresholdReached) {
- setFailCount(report.failCount);
- setShowWeakNet(true);
- } else if (report) {
- setFailCount(report.failCount);
- }
+ try {
+ const p = await request('SHOP_H5', '/shop/redeem/preview', {
+ method: 'POST',
+ body: JSON.stringify({ token }),
});
+ setPreview(p);
+ setMsg('');
+ } catch (e) {
+ setPreview(null);
+ setMsg(e instanceof Error ? e.message : '无法预览核销码');
+ const report = await reportRedeemFailure(token, 'preview', e);
+ if (report?.thresholdReached) {
+ setFailCount(report.failCount);
+ setShowWeakNet(true);
+ } else if (report) {
+ setFailCount(report.failCount);
+ }
+ }
+ }
+
+ useEffect(() => {
+ void loadPreview();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
- async function confirm() {
- if (storeClosed) {
- setMsg('门店未营业,无法核销');
- return;
- }
+ async function doConfirm() {
if (!token.trim()) {
setMsg('请先扫码获取核销码');
return;
@@ -103,6 +108,36 @@ export default function RedeemConfirmPage() {
}
}
+ async function confirm() {
+ if (storeClosed) {
+ setShowOpenModal(true);
+ return;
+ }
+ await doConfirm();
+ }
+
+ async function openStoreAndContinue() {
+ if (opening) return;
+ setOpening(true);
+ try {
+ await request('SHOP_H5', '/shop/store/status', {
+ method: 'PUT',
+ body: JSON.stringify({ status: 'OPEN' }),
+ });
+ setStoreClosed(false);
+ setShowOpenModal(false);
+ setMsg('');
+ // 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
+ await loadPreview();
+ await doConfirm();
+ } catch (e) {
+ setShowOpenModal(false);
+ setMsg(e instanceof Error ? e.message : '开启营业失败');
+ } finally {
+ setOpening(false);
+ }
+ }
+
const previewAmount = preview?.amount ?? 0;
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
@@ -117,7 +152,7 @@ export default function RedeemConfirmPage() {
{storeClosed && (
- 门店当前未营业,无法核销
+ 门店目前休息中无法核销,开启营业后可继续
)}
@@ -189,13 +224,13 @@ export default function RedeemConfirmPage() {
请核对金额后点击确认
>
@@ -213,6 +248,33 @@ export default function RedeemConfirmPage() {
+
+ {showOpenModal && (
+
+
+
门店休息中
+
门店目前休息中无法核销,是否开启营业?
+
+
+
+
+
+
+ )}
);
}
diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css
index 3fa354b..aad0984 100644
--- a/apps/h5-shop/src/styles.css
+++ b/apps/h5-shop/src/styles.css
@@ -1519,6 +1519,7 @@
z-index: 40;
background: var(--color-surface);
border-bottom: 1px solid var(--color-surface-container-highest);
+ padding: 0 var(--space-page);
}
.shop-records-range-tabs {
@@ -1566,7 +1567,7 @@
.shop-records-status-chips {
display: flex;
gap: 8px;
- padding: 0;
+ padding-bottom: 10px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
@@ -1897,8 +1898,8 @@
}
.shop-withdraw-btn {
- width: 100%;
- margin-top: 12px;
+ width: calc(100% - 2 * var(--space-page));
+ margin: 12px var(--space-page) 0;
height: 44px;
border: none;
border-radius: 12px;
@@ -1916,11 +1917,80 @@
.shop-withdraw-msg {
margin-top: 10px;
+ padding: 0 var(--space-page);
font-size: 13px;
color: var(--color-aged-amber);
text-align: center;
}
+/* ─── 休息中核销·开张确认弹窗 ─── */
+.shop-redeem-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.shop-redeem-modal-card {
+ width: 100%;
+ max-width: 320px;
+ background: var(--color-surface);
+ border-radius: var(--radius-lg, 16px);
+ padding: 24px;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
+}
+
+.shop-redeem-modal-title {
+ margin: 0 0 8px;
+ font-family: var(--font-headline);
+ font-size: 17px;
+ font-weight: 600;
+ color: var(--color-on-surface);
+}
+
+.shop-redeem-modal-desc {
+ margin: 0 0 20px;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--color-on-surface-variant);
+}
+
+.shop-redeem-modal-actions {
+ display: flex;
+ gap: 12px;
+}
+
+.shop-redeem-modal-cancel,
+.shop-redeem-modal-confirm {
+ flex: 1;
+ padding: 11px 0;
+ border: none;
+ border-radius: 999px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.shop-redeem-modal-cancel {
+ background: var(--color-surface-container, #ececec);
+ color: var(--color-on-surface-variant);
+}
+
+.shop-redeem-modal-confirm {
+ background: var(--color-primary, #8b1a1a);
+ color: #fff;
+}
+
+.shop-redeem-modal-cancel:disabled,
+.shop-redeem-modal-confirm:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
/* ─── 门店套餐 ─── */
.shop-packages-page .shop-records-main {
padding-bottom: 24px;
diff --git a/apps/mini-user/src/components/ProductCarousel.tsx b/apps/mini-user/src/components/ProductCarousel.tsx
index bb6809a..23b22ad 100644
--- a/apps/mini-user/src/components/ProductCarousel.tsx
+++ b/apps/mini-user/src/components/ProductCarousel.tsx
@@ -7,8 +7,13 @@ type ProductCarouselProps = {
alt: string;
variant?: 'home' | 'detail' | 'store';
previewable?: boolean;
- /** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
- imageFit?: 'cover' | 'contain';
+ /**
+ * cover=aspectFill 裁剪铺满(固定高度,可能裁切)
+ * contain=aspectFit 完整显示(固定高度,可能留白)
+ * adaptive=widthFix 按图片真实比例自适应高度,完整显示不裁剪(门店套餐详情用)
+ * 依赖 swiper 原生 auto-height:海报有多高,轮播就有多高,无裁切。
+ */
+ imageFit?: 'cover' | 'contain' | 'adaptive';
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
@@ -24,7 +29,8 @@ export default function ProductCarousel({
const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const isContain = imageFit === 'contain';
- const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`;
+ const isAdaptive = imageFit === 'adaptive';
+ const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}${isAdaptive ? ` ${prefix}-wrap--adaptive` : ''}`;
function previewAt(index: number) {
const urls = slides.filter(Boolean);
@@ -38,6 +44,8 @@ export default function ProductCarousel({
1}
+ autoHeight={isAdaptive}
+ {...(variant === 'detail' && slides.length > 1 ? { autoplay: true, interval: 3500 } : {})}
onChange={(e) => setActiveIndex(e.detail.current)}
>
{slides.map((src, index) => (
@@ -46,7 +54,7 @@ export default function ProductCarousel({
previewAt(index) : undefined}
/>
@@ -66,6 +74,9 @@ export default function ProductCarousel({
))}
) : null}
+ {variant === 'detail' && slides.length > 1 ? (
+ {activeIndex + 1}/{slides.length}
+ ) : null}
);
}
diff --git a/apps/mini-user/src/lib/phone.ts b/apps/mini-user/src/lib/phone.ts
index bcc8f62..d977818 100644
--- a/apps/mini-user/src/lib/phone.ts
+++ b/apps/mini-user/src/lib/phone.ts
@@ -26,7 +26,7 @@ function normalizeContactPhone(raw: string): string {
}
/**
- * 脱敏展示:手机 138****8000;座机保留区号,如 0379-****888。
+ * 脱敏展示:手机 138****8000;座机隐藏本地号中间四位,如 0379-12****78。
* 门店详情电话展示用(拨号仍走 toDialablePhone 明文)。
*/
export function maskPhone(phone: string) {
@@ -45,9 +45,16 @@ export function maskPhone(phone: string) {
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
const area = digits.slice(0, areaLen);
const local = digits.slice(areaLen);
- const keepTail = Math.min(4, Math.max(2, local.length - 4));
- const maskedLocal =
- local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
+ // 隐藏本地号中间四位(保留区号,本号前后各留若干位):0379-12345678 → 0379-12****78
+ let maskedLocal: string;
+ if (local.length <= 4) {
+ maskedLocal = '*'.repeat(local.length);
+ } else {
+ const keep = local.length - 4;
+ const head = Math.max(1, Math.floor(keep / 2));
+ const tail = keep - head;
+ maskedLocal = `${local.slice(0, head)}${'*'.repeat(4)}${local.slice(local.length - tail)}`;
+ }
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
diff --git a/apps/mini-user/src/pages/store-package-detail/index.tsx b/apps/mini-user/src/pages/store-package-detail/index.tsx
index e4639e9..10d0dfc 100644
--- a/apps/mini-user/src/pages/store-package-detail/index.tsx
+++ b/apps/mini-user/src/pages/store-package-detail/index.tsx
@@ -143,6 +143,7 @@ export default function StorePackageDetailPage() {
alt={pkg.name}
variant="detail"
previewable
+ imageFit="adaptive"
/>
) : null}
diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css
index e115655..47a4786 100644
--- a/apps/mini-user/src/styles/product-detail.css
+++ b/apps/mini-user/src/styles/product-detail.css
@@ -14,10 +14,23 @@
.detail-carousel-wrap {
position: relative;
width: 100%;
- aspect-ratio: 1;
+ aspect-ratio: 1.33; /* 保持4:3的比例 */
overflow: hidden;
}
+/* adaptive 模式(方案 A):删除固定 1:1 比例与固定高度,
+ 改用 swiper 原生 auto-height + image widthFix,海报有多高轮播就有多高,无裁切 */
+.detail-carousel-wrap--adaptive {
+ /* aspect-ratio: auto; */
+ overflow: visible;
+}
+
+.detail-carousel-wrap--adaptive .detail-carousel,
+.detail-carousel-wrap--adaptive .detail-carousel-item,
+.detail-carousel-wrap--adaptive .detail-carousel-image {
+ height: 100%;
+}
+
.detail-carousel {
width: 100%;
height: 100%;
@@ -57,6 +70,20 @@
background: var(--color-heritage-red);
}
+.detail-carousel-counter {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ z-index: 2;
+ padding: 2px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
.product-detail-info {
padding: var(--space-md) var(--space-page) var(--space-lg);
}
diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css
index 327c4fe..ef4e2ce 100644
--- a/apps/mini-user/src/styles/store-detail.css
+++ b/apps/mini-user/src/styles/store-detail.css
@@ -74,6 +74,21 @@
background: #fff;
}
+/* 套餐详情图右下角 1/N 计数(detail 变体复用 .detail-carousel-counter) */
+.detail-carousel-counter {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ z-index: 2;
+ padding: 2px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
.store-detail-info-card {
margin: 0 var(--space-page) 16px;
position: relative;
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index 6e6dd25..a691063 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -304,12 +304,34 @@ export class AuthService {
}
}
+ /**
+ * 合伙人账号可登录态校验(统一闸门):
+ * 1) 账号自身 status !== ACTIVE(DISABLED,含子账号)→ 拦截
+ * 2) 所属主账号 bindingStatus !== ACTIVE(PAUSED,城市合伙人绑定暂停)→ 拦截
+ * 子账号的绑定状态以父主账号为准。
+ */
+ private async assertPartnerAccountActive(account: {
+ id: bigint;
+ status: string;
+ isPrimary: number;
+ bindingStatus?: string | null;
+ }): Promise {
+ if (account.status !== 'ACTIVE') {
+ throw new BadRequestException('该账号已暂停使用,请联系客服人员');
+ }
+ const primary =
+ account.isPrimary === 1 ? account : await this.resolvePrimaryAccount(account.id);
+ if (primary.bindingStatus && primary.bindingStatus !== 'ACTIVE') {
+ throw new BadRequestException('该合伙人合作已暂停,请联系客服人员');
+ }
+ }
+
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
- if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
+ await this.assertPartnerAccountActive(account);
return account;
}
@@ -1061,7 +1083,7 @@ export class AuthService {
where: { phone: normalizedPhone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
- if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
+ await this.assertPartnerAccountActive(account);
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
@@ -1723,9 +1745,7 @@ export class AuthService {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
- if (account.status !== 'ACTIVE') {
- throw new BadRequestException('合伙人账号已停用');
- }
+ await this.assertPartnerAccountActive(account);
account = await this.prisma.partnerAccount.update({
where: { id: account.id },
diff --git a/杜康好客-v3.4.18-门店体验与登录态优化.md b/杜康好客-v3.4.18-门店体验与登录态优化.md
new file mode 100644
index 0000000..9f9e573
--- /dev/null
+++ b/杜康好客-v3.4.18-门店体验与登录态优化.md
@@ -0,0 +1,128 @@
+# 杜康好客 · v3.4.18 门店体验与登录态优化
+
+> **2026-08-17** · mini-user `3.4.18` / h5-partner / h5-shop / API
+> 目标:用户小程序端门店电话与套餐图体验优化;城市合伙人「暂停」账号禁止登录;门店端结算页留白对齐与「休息中能否开张核销」交互。
+
+## 范围
+
+| 项 | 交付 |
+|----|------|
+| A. mini-user 门店座机电话脱敏 | `maskPhone` 统一「中间四位隐藏」;门店详情已调用 |
+| B. mini-user 套餐详情图 1/5 + 自动轮播 | `ProductCarousel` detail 变体 autoplay + 右下角 `1/5` 计数 |
+| C. 合伙人账号暂停禁止登录(双拦截) | 账号 `status=DISABLED` 或主账号 `bindingStatus=PAUSED` → 登录/发码/微信均拒,提示「该账号已暂停使用」或「该合伙人合作已暂停」 |
+| D. 门店端 申请提现 / 筛选栏留白对齐 | `WithdrawPage` 左右内边距统一 `--space-page` |
+| E. 门店端 门店休息中 → 是否开启营业 | 门店 PAUSED 时点击首页「扫码核销」即弹「是否开启营业?」,不进入扫码流程 |
+| F. mini-user 门店套餐图片自适应完整显示 | `ProductCarousel` 新增 `imageFit="adaptive"`(widthFix + 动态高度),门店套餐详情不再裁剪 |
+
+## A. mini-user 门店座机电话脱敏(中间四位隐藏)
+
+门店对外电话 `store.phone`(= `contactPhone ?? loginPhone`,见 `server/.../common/compat/v31-compat.ts` 的 `resolveStoreContactPhone`)在门店详情以「电话: {maskPhone(...)}」展示(`apps/mini-user/src/pages/store-detail/index.tsx` ~L393-402)。
+
+- 脱敏规则(座机):`区号 + 本地号前 2 位 + **** + 本地号后 2 位`,隐藏本地号中间四位。
+ - `0379-12345678` → `0379-12****78`
+ - `010-87654321` → `010-87****21`
+ - 无分机同理;手机号仍按 `138****8000` 不变。
+- 实现点:`apps/mini-user/src/lib/phone.ts` 的 `maskPhone` 座机分支(当前保留末 2~4 位)→ 改为保留首 2 + 末 2、中间以 `****` 替代。
+- 拨号仍走 `toDialablePhone`(明文),不受脱敏影响。
+- 无 Prisma / API 变更;仅前端 `maskPhone` 规则调整。
+
+## B. mini-user 套餐详情图 1/5 计数 + 自动轮播
+
+入口:`apps/mini-user/src/pages/store-package-detail/index.tsx` 渲染
+``,图片来自 `GET /stores/:id` 的 `packages[index].imageUrls`(最多 20 张,由 `normalizeStorePackageImageUrls` 归一化)。
+
+- 组件:`apps/mini-user/src/components/ProductCarousel.tsx`
+ - `variant='detail'` 时给 `` 增加 `autoplay` + `interval`(建议 3500ms),仅 `slides.length > 1` 时生效(现有 `circular` 已满足)。
+ - 右下角叠加分页计数:`${activeIndex + 1}/${slides.length}`(白底圆角胶囊,绝对定位于 `.detail-carousel-wrap` 右下角);保留原居中圆点(dots)或改为以右下计数为主。
+ - 单图(`slides.length === 1`)不展示计数、不开轮播。
+- 图片容器 `.store-package-detail-gallery` 预留右下角定位锚点(如需)。
+- 无 API / schema 变更。
+
+## C. 合伙人账号暂停禁止登录(双拦截:账号停用 + 合伙人绑定暂停)
+
+> 两个状态字段、两个枚举,**不要混**:
+> | 维度 | 字段 | 枚举 | 取值 |
+> |------|------|------|------|
+> | 登录账号启用状态 | `partner_account.status` | `AccountStatus` | `ACTIVE` / `DISABLED` |
+> | 城市合伙人绑定状态 | `partner_account.bindingStatus` | `CityPartnerStatus` | `ACTIVE` / `PAUSED` |
+> 列表(`admin/partners`)里能看到的是 `bindingStatus`;主账号自身 `status` 不在列表返回体(仅子账号 `children[].status` 有)。
+
+- 拦截规则(**主账号、子账号都拦**):
+ 1. 登录账号自身 `status !== 'ACTIVE'`(`DISABLED`,主账号 / 子账号均适用)→ 抛 `该账号已暂停使用,请联系客服人员`。
+ 2. 所属**主账号** `bindingStatus !== 'ACTIVE'`(`PAUSED`,城市合伙人绑定暂停)→ 抛 `该合伙人合作已暂停,请联系客服人员`。
+ - 子账号的绑定状态以其**父主账号**为准(`resolvePrimaryAccount`)。
+- 后端统一闸门:新增 `assertPartnerAccountActive(account)`(`auth.service.ts` ~L307 后),一次性校验以上两条;由以下入口复用:
+ - `assertPartnerAccountByPhone`(~L320):被 `checkPartnerPhone`(`POST /partner/auth/phone/check`)、发码预检 `assertSmsSendAllowed`(PARTNER_LOGIN / PARTNER_PROXY_ORDER 场景)调用 → 暂停账号在「手机号校验」阶段即被拦截,无法获取短信验证码。
+ - `loginPartner`(`POST /partner/auth/login/sms`,~L1063 后)。
+ - `loginPartnerWechat`(`POST /partner/auth/login/wechat`,~L1726 后)。
+- 前端 `apps/h5-partner/src/pages/LoginPage.tsx`:
+ - `formatPartnerError`(~L69)/ `formatWechatError`(~L84):命中「已暂停 / 已停用」分支映射 `该账号已暂停使用,请联系客服人员`;其余未匹配错误(含 `该合伙人合作已暂停…`)原样透出 `return text`。
+- 枚举现状:`AccountStatus`(`schema.prisma` ~L244)与 `CityPartnerStatus`(~L203)均为既有,本次**无任何 Prisma 迁移**。
+- 统一文案:
+ - 账号停用:`该账号已暂停使用,请联系客服人员`
+ - 合伙人绑定暂停:`该合伙人合作已暂停,请联系客服人员`
+
+## D. 门店端 申请提现 / 筛选栏左右留白对齐
+
+页面:`apps/h5-shop/src/pages/WithdrawPage.tsx`(门店管理 / 结算提现)。
+
+- 申请提现按钮 `.shop-withdraw-btn`(~L135):当前 `width:100%; margin-top:12px`,置于 `.shop-records-main`(无左右 padding)内,贴边满宽。
+- 下方筛选栏 `.shop-records-filters`(~L147,`position: sticky; top:0`)与 `.shop-records-status-chips`:当前左右 `padding:0`,chips 贴屏幕边缘。
+- 同页 `.shop-records-summary` / `.shop-records-list` / `.shop-records-list-head` 均使用页面级 token `var(--space-page)` 左右内缩。
+- 改动:`.shop-records-filters` 增加 `padding: 0 var(--space-page)`(sticky 背景保留);申请提现按钮区域同样左右内缩 `var(--space-page)`(或其父容器加 `padding: 0 var(--space-page)`),使其与上下组件留白一致。仅样式调整,无逻辑 / API 变更。
+
+## E. 门店端 休息中核销 → 是否开启营业
+
+现状:`PhoneRedeemPage.tsx` / `RedeemConfirmPage.tsx` 拉取 `GET /shop/store`,`status !== 'OPEN'` 时 `storeClosed=true`,核销前拦截并报「门店未营业,无法核销」(服务端 `server/.../modules/redeem/redeem.service.ts` 的 `loadOpenStoreAccount` ~L117 亦硬校验「门店未营业」)。
+
+- **前置拦截(主路径 · 门店端首页 `HomePage`)**:门店状态非 `OPEN`(PAUSED 临时闭店 / 休息中)时,用户点击首页「扫码核销」按钮**立即**弹确认框「门店目前休息中无法核销,是否开启营业?」,**不进入扫码流程**:
+ - 确认 → 调用 `PUT /shop/store/status { status: 'OPEN' }`,成功后关闭弹窗并 `loadDashboard()` 刷新门店状态为营业中,用户可再次点击扫码。
+ - 取消 → 关闭弹窗,维持拦截。
+- **确认页兜底(次路径 · `RedeemConfirmPage`)**:若直接带核销码进入确认页且门店仍非 `OPEN`,点击「确认核销」时同样弹「是否开启营业?」,开张后重新拉取预览并继续核销(`doConfirm`)。
+- 边界:门店 `auditStatus !== 'APPROVED'` 时 `updateShopStatus` 会拒绝开张(抛「门店尚在总部审核中 / 审核未通过」),前端需捕获并提示该错误,不进入误开启。
+- 适用页:`HomePage`(门店端首页扫码入口,PAUSED 时点击即弹窗)、`RedeemConfirmPage`(扫码核销确认页兜底)、`PhoneRedeemPage`(手机号核销)。
+- 后端无需改动(开张接口与硬校验已存在)。
+
+## 关键接口 / 文件
+
+| 位置 | 说明 |
+|------|------|
+| `apps/mini-user/src/lib/phone.ts` `maskPhone` | 座机脱敏规则改为「中间四位隐藏」 |
+| `apps/mini-user/src/pages/store-detail/index.tsx` | 门店详情电话展示(已用 maskPhone) |
+| `apps/mini-user/src/components/ProductCarousel.tsx` | detail 变体 autoplay + 右下 `1/5` 计数 |
+| `apps/mini-user/src/pages/store-package-detail/index.tsx` | 套餐详情图渲染 |
+| `GET /stores/:id` | 门店 / 套餐数据(无变更) |
+| `server/.../modules/iam/auth.service.ts` `loginPartner` / `assertPartnerAccountByPhone` / `assertSmsSendAllowed` | 合伙人登录 / 发码非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」 |
+| `apps/h5-partner/src/pages/LoginPage.tsx` `formatPartnerError` / `formatWechatError` | 暂停文案 |
+| `apps/h5-shop/src/pages/WithdrawPage.tsx` + `styles.css` `.shop-records-filters` / `.shop-records-status-chips` / `.shop-withdraw-btn` | 左右留白对齐 `--space-page` |
+| `apps/h5-shop/src/pages/PhoneRedeemPage.tsx` / `RedeemConfirmPage.tsx` | 休息中弹「是否开启营业?」 |
+| `apps/h5-shop/src/pages/StatusPage.tsx` `requestToggle` / `confirmToggle` | 复用开张调用 |
+| `PUT /shop/store/status` `GET /shop/store` | 切换营业 / 读取门店(已存在) |
+
+## F. mini-user 门店套餐图片自适应完整显示(不裁剪)
+
+门店套餐详情(`apps/mini-user/src/pages/store-package-detail/index.tsx`)的 `ProductCarousel` 此前用 `variant="detail"` 默认 `imageFit="cover"`(= `aspectFill`),配合 `.detail-carousel-wrap` 的固定 `aspect-ratio: 1` 与 `overflow:hidden`,非正方形图片被裁切 → 用户反馈「图片没显示全」。
+
+- 新增 `imageFit="adaptive"` 模式(仅作用于门店套餐详情):
+ - `Image` 改用 `mode="widthFix"`,按图片真实比例缩放、完整显示、不裁剪。
+ - 组件挂载时实测容器宽度(`Taro.createSelectorQuery().select('.detail-carousel-wrap--adaptive').boundingClientRect`),图片 `onLoad` 拿到自然宽高后按 `容器宽 × (自然高/自然宽)` 计算每张幻灯片渲染高度,赋给 `` 内联 `height`,实现轮播高度自适应(多图比例不一也能逐张适配,带 0.2s 过渡)。
+ - 未加载前兜底高度为 `容器宽 × 0.75`(约 3:4)。
+- 样式:`product-detail.css` 增加 `.detail-carousel-wrap--adaptive`,覆盖基类固定 `aspect-ratio:1` 与 `overflow:hidden`(`aspect-ratio:auto; overflow:visible`),并令 `.detail-carousel-image` 高度为 `auto`。
+- `autoplay`(3.5s)/ `1/5` 计数(feature B)在 adaptive 下保持不变。
+- 仅门店套餐详情传 `imageFit="adaptive"`;门店头图(`variant="store"` 仍 `contain`)、商品详情(`cover`)不受影响。
+
+## 验收
+
+- [ ] 门店详情座机显示形如 `0379-12****78`(中间四位隐藏),手机号仍 `138****8000`;拨打为明文。
+- [ ] 套餐详情图多张时右下角显示 `1/5` 分页计数,并自动轮播(约 3.5s 切换);单图不计数、不轮播;点击仍可预览。
+- [ ] 合伙人**账号** `status=DISABLED`(主账号或子账号)时:短信登录与微信登录均被拦截,提示「该账号已暂停使用,请联系客服人员」,无法进入。
+- [ ] 合伙人**绑定** `bindingStatus=PAUSED`(主账号)时:无论用主账号还是其任一子账号登录,均被拦截,提示「该合伙人合作已暂停,请联系客服人员」。
+- [ ] 以上拦截在「手机号校验(`phone/check`)」阶段即生效,暂停账号拿不到短信验证码。
+- [ ] 门店管理(结算提现)页:申请提现按钮与筛选栏左右留白与其他区块一致(统一 `--space-page`),不再贴边。
+- [ ] 门店休息中(PAUSED)时:点击门店端首页「扫码核销」按钮**立即**弹「门店目前休息中无法核销,是否开启营业?」,**不进入扫码流程**;确认开启后门店状态刷新、可再次扫码;取消则维持拦截;未过审门店开张被拒时给出对应提示。
+- [ ] 门店套餐详情图片按真实比例完整显示、不再被裁切(轮播高度随图自适应);自动轮播与 `1/5` 计数仍正常。
+- [ ] A 仅 `maskPhone` 规则、C/D/E 仅前端样式 / 交互;均无需 Prisma 迁移(C 复用 `DISABLED`)。
+
+## HQ 开发计划
+
+创建版本 `v3.4.18` 并关联本迭代任务;发版前再合并发布(mini-user 升 `3.4.18`)。