进度暂存:禁用规则重定(任何时刻可禁用,只拦新启动)+ 启动参数加 combo 控件(选择+新增,库里没有不拦启动、启动后派数据工单);新增《API文档-活动模块》(L1/L2/L3 九条路由,供后端编写)

This commit is contained in:
shanqin
2026-08-27 11:15:21 +08:00
parent d03fed97d2
commit 723d379dbd
4 changed files with 413 additions and 29 deletions
@@ -317,6 +317,8 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void })
/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)────────────────────────────
点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述),
每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段;
控件口径(2026-08-27 用户定):大部分是自由输入;客户/料号这类「台账里可能有」的用 combo
(选择+新增:先选,候选没有就新增自填)—— **填了库里没有的值不拦启动**,存在性检查在启动后做,缺口派数据工单;
接口未落地前用 LAUNCH_PARAMS 占位,提交先在清单头落一条「待执行」看全链路效果 */
/** 启动参数的一项规格 —— 将来就是接口返回的参数表行 */
@@ -325,24 +327,25 @@ type LaunchParam = {
key: string;
/** 表单上的中文名 */
label: string;
/** 控件种类:text 单行 / textarea 多行 / select 单选 / list 字符串数组(一次可传多个) */
type: 'text' | 'textarea' | 'select' | 'list';
/** 控件种类:text 单行 / textarea 多行 / select 单选(只能选) / combo 选择+新增(候选没有就自己填) / list 字符串数组(一次可传多个) */
type: 'text' | 'textarea' | 'select' | 'combo' | 'list';
required?: boolean;
/** 参数描述(服务端给),挂在控件下当辅助说明 */
desc?: string;
placeholder?: string;
/** select 的候选项 */
/** select / combo 的候选项 */
options?: readonly string[];
};
/** 占位参数表 —— 一活动一份,接口定稿后按活动取(2026-08-27 用户定:参数由服务器给,每个活动不一样) */
const LAUNCH_PARAMS: Readonly<Record<string, readonly LaunchParam[]>> = {
/* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补) */
/* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补);
客户与料号用 combo(2026-08-27 用户定):台账里有就先选,没有就新增自填 —— 不拦启动,缺口派数据工单 */
'quote-intake': [
{ key: 'customer_id', label: '客户', type: 'select', required: true, desc: '决定通知对象与默认负责人线。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'request_description', label: '客户需求', type: 'textarea', required: true, desc: '保留原始需求语义,据此新建需求主记录(V1)。', placeholder: '如:车身控制器询价 · 3 项物料,期望 9 月交期' },
{ key: 'original_internal_pn', label: '原内部料号', type: 'select', required: true, desc: '差异评估基准,引用物料台账。', options: ['PN-880101', 'PN-880205', 'PN-880310'] },
{ key: 'candidate_internal_pn', label: '候选内部料号', type: 'list', required: true, desc: '被评估物料,一次可传多个、逐个评估 —— 至少 1 个。', placeholder: '如 PN-880412' },
{ key: 'original_internal_pn', label: '原内部料号', type: 'combo', required: true, desc: '差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。', options: ['PN-880101', 'PN-880205', 'PN-880310'], placeholder: '如 PN-880501' },
{ key: 'candidate_internal_pn', label: '候选内部料号', type: 'list', required: true, desc: '被评估物料,一次可传多个、逐个评估;库里没有的 PN 照样填,至少 1 个。', placeholder: '如 PN-880412' },
],
'quote-pricing': [
{ key: 'quote_id', label: '询价单号', type: 'text', required: true, desc: '针对哪一张询价单做测算。', placeholder: '如 INS-2415' },
@@ -350,7 +353,7 @@ const LAUNCH_PARAMS: Readonly<Record<string, readonly LaunchParam[]>> = {
{ key: 'target_margin', label: '目标毛利率', type: 'text', desc: '选填,留空按客户线默认。', placeholder: '如 18%' },
],
'crm-coverage': [
{ key: 'customer_id', label: '客户', type: 'select', required: true, desc: '为哪家客户排触达计划。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '为哪家客户排触达计划;新客户直接填,启动后补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'cadence', label: '触达节奏', type: 'select', required: true, desc: '关键人触达的排期密度。', options: ['每周', '双周', '每月'] },
{ key: 'note', label: '补充说明', type: 'textarea', desc: '选填。' },
],
@@ -388,6 +391,57 @@ function paramFilled(p: LaunchParam, v: string | string[]): boolean {
return p.type === 'list' ? (v as string[]).some((s) => s.trim() !== '') : (v as string).trim() !== '';
}
/** combo 里「新增」这一项的哨兵值 —— 不会与真实候选撞名 */
const COMBO_CREATE = '__create__';
/**
* 选择+新增(2026-08-27 用户定):先摆选择框,候选末项是「新增 …」;
* 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单),
* 旁边留「返回选择」换回去。
*/
function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onChange: (v: string) => void }) {
const [custom, setCustom] = useState(false);
if (custom) {
return (
<span className="g-combo">
<input className="g-input" value={value} placeholder={p.placeholder ?? `输入新的${p.label}`} onChange={(e) => onChange(e.target.value)} />
<button
type="button"
className="g-combo-back"
onClick={() => {
setCustom(false);
onChange('');
}}
>
返回选择
</button>
</span>
);
}
return (
<select
className="g-select"
value={value}
onChange={(e) => {
if (e.target.value === COMBO_CREATE) {
setCustom(true);
onChange('');
} else {
onChange(e.target.value);
}
}}
>
<option value="">请选择…</option>
{(p.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
<option value={COMBO_CREATE}>+ 新增{p.label}…</option>
</select>
);
}
/**
* 启动抽屉 —— 复用全局 `g-drawer` 骨架(与系统区模型抽屉同一副);
* 挂 body portal:详情页容器带入场动画 transform,fixed 抽屉不 portal 会被钉进卡片盒子里(2026-07-30 实测);
@@ -446,6 +500,7 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
))}
</select>
)}
{p.type === 'combo' && <ComboInput p={p} value={values[p.key] as string} onChange={(v) => setVal(p.key, v)} />}
{p.type === 'list' && (
<span className="g-lplist">
{(values[p.key] as string[]).map((v, i, arr) => (
@@ -499,7 +554,8 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
* 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。
*
* 头部动作两个(2026-08-27 更新):
* · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示);
* · 激活/禁用 —— 本期做交互,二次确认;**任何时刻都可禁用**(2026-08-27 用户重定:
* 禁用只拦新启动,不影响已完成与进行中的实例,废除「有实例不能禁用」);
* · 启动活动(即创建实例)—— 弹参数抽屉(LaunchDrawer),参数表按活动下发;已禁用不能启动。
*
* 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离;
@@ -508,7 +564,6 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
const a = DEMO_ACTIVITIES.find((x) => x.id === id);
const [status, setStatus] = useState<'active' | 'disabled' | undefined>(undefined);
const [blocked, setBlocked] = useState(false);
const [frameLoaded, setFrameLoaded] = useState(false);
/* 实例清单入组件状态:操作列的「终止活动」要就地改行(本期前端演示,接口对接后走 mutation) */
const [rows, setRows] = useState<readonly DemoInstance[] | undefined>(undefined);
@@ -618,15 +673,8 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
title: '确认禁用这个活动?',
okLabel: '确认禁用',
danger: true,
onOk: () => {
/* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */
if (a.instances > 0) {
setBlocked(true);
} else {
setStatus('disabled');
setBlocked(false);
}
},
/* 任何时刻都可禁用(2026-08-27 用户重定):只拦新启动,存量实例不受影响 */
onOk: () => setStatus('disabled'),
}),
}
: {
@@ -637,10 +685,7 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
setAsk({
title: '确认激活这个活动?',
okLabel: '确认激活',
onOk: () => {
setStatus('active');
setBlocked(false);
},
onOk: () => setStatus('active'),
}),
},
/* 启动活动(2026-08-27 接):弹参数抽屉填参启动;已禁用不能启动 —— 先激活,再启动 */
@@ -653,11 +698,6 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
{ask !== null && <PopAsk spec={ask} onCancel={() => setAsk(null)} />}
{launch && <LaunchDrawer name={a.name} spec={launchSpec} onCancel={() => setLaunch(false)} onLaunch={onLaunch} />}
</div>
{blocked && (
<Note tone="warn">
无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。
</Note>
)}
<Sec ico="repeat" title="历史活动" extra={`共 ${instances.length} 个 · 查看详情进 L3`}>
{/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)——