Commit e6558355 by luoqi

feat(确认单): 点确认前顺手问一句「这批带个福利吗」

产品:「点确认分配的时候轻量弹一个输入框,提醒输入福利,说明用意,不挂也没关系」。
形态对齐 `plan-detail/quick-log-popover`(这一屏已有的同类:跳走之前顺手问一句)。

── 为什么是**这个时刻** ──────────────────────────────────────────
福利只影响**此后生成**的话术。确认之后客服就开始陆续打开这批单,
每过一会儿能用上它的人就少一个 —— 这颗按钮是最后一个还来得及的停顿点。

── ️ 它不是一道闸 ──────────────────────────────────────────────
输入框空着照样确认(浮层里主按钮永远可点)。 别改成必填:
门越重人越会去找绕开的路(同 QuickLogPopover 上那段)。

── ️ 与 2026-08-15 删掉的 `benefit_missing` 不是一回事 ─────────────
那条是**确认之后**在卡片里长出来的黄色引导块(产品判定:确认单里不做福利引导)。
这一层在**确认之前**、吸附在按钮上、不占卡片版面、不留残留。
 别因为"福利提醒删过一次"就把这层也删了 —— 删的是位置和形态,不是这件事。

── ️ 刻意不写的 ────────────────────────────────────────────────
福利话术的护栏(不得追加条件/期限/承诺)在**生成侧**, 不在这个输入框 ——
写在这儿就是第二份规则,两边迟早会漂。这里只用 placeholder 示范**形状**
(一句短的、能直接念出口的话)。maxLength 200 与落库 schema 对齐。

🔴 `confirm()` 改成收**参数**而不是读 `benefit` 这个 state:
  浮层是"边填边确认",`setBenefit(v)` 之后紧接着 `confirm()`,闭包里拿到的是
  **上一帧**的空串 —— 福利会静默丢掉,而落库和话术都不报错。
️ 外层按钮只当触发器, 别同时挂 `onClick={confirm}`:两条路都会确认,
  而幂等键相同的第二次请求服务端当重放静默吃掉,界面上像"点了没反应"。

验证:tsc 绿,next build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9cd29898
......@@ -24,6 +24,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
......@@ -641,7 +642,13 @@ export function AssignmentConfirmSheet({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [echoSeq]);
const confirm = async () => {
/**
* @param benefitNow **这一次**要挂的福利文案。
* ⚠️ 走参数、⛔ 不读 `benefit` 这个 state:确认前那层浮层是「边填边确认」——
* `setBenefit(v)` 之后紧接着调 `confirm()`,闭包里拿到的仍是**上一帧**的空串,
* 于是福利静默丢掉、而落库和话术都不会报错。
*/
const confirm = async (benefitNow: string = benefit) => {
setSubmitting(true);
setError(null);
try {
......@@ -674,7 +681,7 @@ export function AssignmentConfirmSheet({
},
},
// T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象
...(benefit.trim() ? { attributes: { benefit: { text: benefit.trim() } } } : {}),
...(benefitNow.trim() ? { attributes: { benefit: { text: benefitNow.trim() } } } : {}),
expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时限冻进每条,
......@@ -741,7 +748,7 @@ export function AssignmentConfirmSheet({
时限天数: expiresInDays,
...(dropped.size ? { 主管移除: dropped.size } : {}),
...(res.skipped.length ? { 没落上: res.skipped.length } : {}),
本批福利: benefit.trim() || null,
本批福利: benefitNow.trim() || null,
},
}),
);
......@@ -1910,14 +1917,31 @@ export function AssignmentConfirmSheet({
perm={Permission.PLAN_DISPATCH}
fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
>
<Button
disabled={submitting || items.length === 0}
onClick={() => void confirm()}
className="w-full gap-1.5"
{/**
* ⭐ 确认前顺手问一句**这批带不带福利**(2026-08-19 产品加)。
*
* 福利只影响**此后生成**的话术,而确认之后客服就开始陆续打开这批单 ——
* 这颗按钮是最后一个还来得及的停顿点。
* ⚠️ 它**不是一道闸**:空着照样确认(浮层里主按钮永远可点)。
* ⚠️ 外层这颗按钮只当触发器,`onClick` 交给浮层 —— ⛔ 别在这儿也挂一个
* `void confirm()`:两条路都会确认,而幂等键相同的第二次请求
* 服务端会当重放**静默吃掉**,界面上看着像"点了没反应"。
*/}
<BenefitPopover
value={benefit}
count={items.length}
disabled={items.length === 0}
submitting={submitting}
onConfirm={(b) => {
setBenefit(b);
void confirm(b);
}}
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认分配 {items.length} 条
</Button>
<Button disabled={submitting || items.length === 0} className="w-full gap-1.5">
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认分配 {items.length} 条
</Button>
</BenefitPopover>
</Can>
)}
</div>
......
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* 点「确认分配」之前吸附上来的一层 —— **顺手问一句这批带不带福利**。
*
* ═══ 为什么要在这个时刻问 ═══════════════════════════════════════
* 福利只影响**此后生成**的话术。确认之后客服就开始陆续打开这批单,
* 每过一会儿能用上它的人就少一个 —— 所以要问就得在**确认之前**问,
* 而这颗按钮是这条路上最后一个停顿点。
*
* ⚠️ **它不是一道闸**:输入框空着照样能确认(按钮不 disabled)。
* 产品原话「当然不挂也没关系」—— ⛔ 别做成必填,那会把它变成一道门,
* 而门越重人越会去找绕开的路(同 `QuickLogPopover` 上那段)。
*
* ═══ ⚠️ 这里刻意**不**写福利话术的护栏 ═══════════════════════════
* 「不得追加条件 / 期限 / 承诺」那道闸在**生成侧**,⛔ 不在这个输入框。
* 在这儿再写一遍就是第二份规则,两边迟早会漂(同 assistant-prompts 的
* 「同一件事只许有一份」)。这里只用 placeholder 示范**形状** ——
* 一句短的、能直接念出口的话,而不是一段带承诺的文案。
*
* ⚠️ 与 2026-08-15 删掉的那条 `benefit_missing` ⛔ **不是一回事**:
* 那条是**确认之后**在卡片里长出来的黄色引导块(产品判定:确认单里不做福利引导)。
* 这一层在**确认之前**、吸附在按钮上、不占卡片版面、不留残留。
* ⛔ 别因为"福利提醒删过一次"就把这层也删了 —— 删的是位置和形态,不是这件事。
*
* 样式对齐 `plan-detail/quick-log-popover`(这一屏已有的同类形态:跳走之前顺手问一句)。
*/
export function BenefitPopover({
/** 卡片当前的福利文案(模型 `set_benefit` 写过就有值)—— 打开时预填 */
value,
/** 「确认分配 N 条」里的 N */
count,
disabled,
submitting,
/** 触发器 = 那颗确认按钮本体(样式不在这里管) */
children,
/** 带着最终文案去确认。空串 = 不挂福利 */
onConfirm,
}: {
value: string;
count: number;
disabled?: boolean;
submitting?: boolean;
children: React.ReactNode;
onConfirm: (benefit: string) => void;
}) {
const [open, setOpen] = useState(false);
const [text, setText] = useState(value);
// ⚠️ 模型在对话里改了福利(set_benefit)时要跟上 —— ⛔ 别只在挂载时取一次,
// 那样主管说完「带上老客回馈」再点确认,看到的还是空框。
useEffect(() => setText(value), [value]);
const go = () => {
setOpen(false);
onConfirm(text.trim());
};
return (
<Popover
open={open}
onOpenChange={(o) => {
if (disabled || submitting) return;
setOpen(o);
// ⚠️ 关掉时回到卡片当前值,⛔ 别留着他刚敲了一半又反悔的那半句
if (!o) setText(value);
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="center" side="top" className="w-72 p-3">
<div className="space-y-2">
<div>
<p className="text-[12.5px] font-medium text-slate-800">这批带个福利吗</p>
{/* ⚠️ 要写清**它有什么用**:不写的话这就是个没来由的输入框,
而主管对没来由的输入框只有一个反应 —— 跳过。 */}
<p className="mt-0.5 text-[10.5px] leading-snug text-slate-400">
客服打电话时能用上的那句由头。挂上之后,这批人的话术会带着它写。
<span className="text-slate-500">不填也能直接确认。</span>
</p>
</div>
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go();
}}
// 200 = 落库那份 schema 的上限(`attributes.benefit.text` max(200))——
// ⛔ 别只在这儿放宽:填得进去、确认那一步被打回来,而错在几十秒之后才出现。
maxLength={200}
// ⚠️ 例子示范的是**形状**(短、能直接念),⛔ 不是让他照抄这个活动
placeholder="老客回馈 · 洁牙 8 折"
className="w-full rounded-md border px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/>
<div className="flex items-center justify-end gap-1.5 pt-0.5">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-50"
>
取消
</button>
{/* ⚠️ 主按钮**永远可点**(空着 = 不挂福利)—— 见文件头「它不是一道闸」。
⚠️ 文案与外面那颗按钮逐字一致:这一下就是那一下,⛔ 别让他以为还有下一步。 */}
<button
type="button"
disabled={submitting}
onClick={go}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors',
submitting ? 'cursor-not-allowed bg-slate-200' : 'bg-brand-600 hover:bg-brand-700',
)}
>
{submitting && <Loader2 className="h-3 w-3 animate-spin" />}
确认分配 {count}
</button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment