diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index af59d72..da7371c 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -735,6 +735,26 @@ @media (prefers-reduced-motion: reduce) { .g-actvd .g-popask, .g-actid .g-popask { animation: none; } } + +/* ══ 启动活动 · 参数抽屉(2026-08-27 用户定)══ + 骨架复用全局 g-drawer;portal 挂在 body 上,作用域类 .g-launch 得自己带着走。 + 这里只补抽屉内表单特有的三件:必填星号、数组参数(list)编辑行、底注 */ +.g-launch .g-fld > span .req { font-style: normal; margin-left: 3px; color: var(--danger-accent); } +/* 数组参数:一行一条 + 行尾删除,底下虚线「再加一项」—— 可传多个的参数(如候选 PN)的趁手件 */ +.g-lplist { display: grid; gap: 7px; } +.g-lplist-row { display: flex; align-items: center; gap: 7px; } +.g-lplist-row .g-input { flex: 1; min-width: 0; } +.g-lplist-row .x { flex: none; width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-4); cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast); } +.g-lplist-row .x:hover:not(:disabled) { color: var(--danger-accent); border-color: var(--danger-accent); } +.g-lplist-row .x:disabled { opacity: .38; cursor: not-allowed; } +.g-lplist-add { justify-self: start; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border: 1px dashed var(--cline); border-radius: 8px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast); } +.g-lplist-add:hover { color: var(--brand); border-color: var(--brand); } +.g-launch-hint { margin: 13px 0 0; font-size: 11.5px; line-height: 1.6; color: var(--ink-4); } +/* combo 选择+新增(2026-08-27 用户定):新增态 = 输入框 + 右侧「返回选择」链 */ +.g-launch .g-combo { display: flex; align-items: center; gap: 7px; } +.g-launch .g-combo .g-input { flex: 1; min-width: 0; } +.g-launch .g-combo-back { flex: none; padding: 4px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--brand); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +.g-launch .g-combo-back:hover { text-decoration: underline; } .g-fcard .mbtn { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; } .g-fcard .mbtn:hover, .g-fcard .mbtn.on { border-color: var(--cline); background: #f3f6fa; color: var(--ink); } .g-fcard .mbtn.bad:hover { border-color: #e6c4c4; background: #fdf2f2; color: var(--danger); } diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index 83981ec..22d77b0 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -5,7 +5,8 @@ * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { CONTROL_SEMANTIC_LABEL, @@ -313,13 +314,249 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) ); } +/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)──────────────────────────── + 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述), + 每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; + 控件口径(2026-08-27 用户定):大部分是自由输入;客户/料号这类「台账里可能有」的用 combo + (选择+新增:先选,候选没有就新增自填)—— **填了库里没有的值不拦启动**,存在性检查在启动后做,缺口派数据工单; + 接口未落地前用 LAUNCH_PARAMS 占位,提交先在清单头落一条「待执行」看全链路效果 */ + +/** 启动参数的一项规格 —— 将来就是接口返回的参数表行 */ +type LaunchParam = { + /** 提交给接口的字段名 */ + key: string; + /** 表单上的中文名 */ + label: string; + /** 控件种类:text 单行 / textarea 多行 / select 单选(只能选) / combo 选择+新增(候选没有就自己填) / list 字符串数组(一次可传多个) */ + type: 'text' | 'textarea' | 'select' | 'combo' | 'list'; + required?: boolean; + /** 参数描述(服务端给),挂在控件下当辅助说明 */ + desc?: string; + placeholder?: string; + /** select / combo 的候选项 */ + options?: readonly string[]; +}; + +/** 占位参数表 —— 一活动一份,接口定稿后按活动取(2026-08-27 用户定:参数由服务器给,每个活动不一样) */ +const LAUNCH_PARAMS: Readonly> = { + /* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补); + 客户与料号用 combo(2026-08-27 用户定):台账里有就先选,没有就新增自填 —— 不拦启动,缺口派数据工单 */ + 'quote-intake': [ + { 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: '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' }, + { key: 'strategy', label: '计价策略', type: 'select', required: true, desc: '测算走哪套成本模型。', options: ['成本加成', '市场竞争', '历史跟随'] }, + { key: 'target_margin', label: '目标毛利率', type: 'text', desc: '选填,留空按客户线默认。', placeholder: '如 18%' }, + ], + 'crm-coverage': [ + { 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: '选填。' }, + ], + 'competitor-watch': [ + { key: 'rival_pn', label: '竞品型号', type: 'text', required: true, desc: '监测哪个竞品料号的价格动向。', placeholder: '如 Bosch-0261S04' }, + { key: 'window', label: '监测窗口', type: 'select', required: true, desc: '往回拉多长时间的价格数据。', options: ['7 天', '30 天', '90 天'] }, + ], + 'market-weekly': [ + { key: 'region', label: '覆盖区域', type: 'text', required: true, desc: '周报覆盖的市场区域。', placeholder: '如 东欧 · 商用车' }, + { key: 'window', label: '数据窗口', type: 'select', required: true, desc: '汇总哪个时间跨度的信号。', options: ['自然周', '自然月'] }, + ], + 'supplier-verify': [ + { key: 'supplier', label: '供应商名称', type: 'text', required: true, desc: '核验哪家供应商的资质。', placeholder: '如 深圳某某电子有限公司' }, + { key: 'note', label: '资质说明', type: 'textarea', desc: '选填 —— 已有的资质线索先记下来,核验时对照。' }, + ], +}; + +/** 没在表里占位的活动给一份最小通用参数 —— 接口落地后不会有这条路 */ +const LAUNCH_PARAMS_FALLBACK: readonly LaunchParam[] = [ + { key: 'subject', label: '启动事项', type: 'text', required: true, desc: '这次启动要处理什么,一句话。' }, + { key: 'note', label: '补充说明', type: 'textarea', desc: '选填。' }, +]; + +type LaunchValues = Record; + +/** 参数值初始形:list 先摆一行空行(至少一行),其余空串 */ +function initLaunchValues(spec: readonly LaunchParam[]): LaunchValues { + const v: LaunchValues = {}; + for (const p of spec) v[p.key] = p.type === 'list' ? [''] : ''; + return v; +} + +/** 这一项算不算填了 —— list 至少一行非空,其余非空串 */ +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 ( + + onChange(e.target.value)} /> + + + ); + } + return ( + + ); +} + +/** + * 启动抽屉 —— 复用全局 `g-drawer` 骨架(与系统区模型抽屉同一副); + * 挂 body portal:详情页容器带入场动画 transform,fixed 抽屉不 portal 会被钉进卡片盒子里(2026-07-30 实测); + * `gov-root` 类必须跟着挂:设计令牌定义在它上面,出了作用域面板背景会变透明。 + */ +function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec: readonly LaunchParam[]; onCancel: () => void; onLaunch: (values: LaunchValues) => void }) { + const [values, setValues] = useState(() => initLaunchValues(spec)); + /* Esc 关抽屉 —— 抽屉盖住整屏,不给键盘出路是把人关在里面 */ + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onCancel]); + + const setVal = (key: string, v: string | string[]) => setValues((cur) => ({ ...cur, [key]: v })); + const invalid = spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? '')); + + return createPortal( +
+ + + +
+
+

启动参数

+ {spec.map((p) => ( +