From d03fed97d25e0592fbc6ac5d8148c5588166994d Mon Sep 17 00:00:00 2001 From: shanqin <45168502@qq.com> Date: Thu, 27 Aug 2026 10:31:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9A=82=E5=AD=98:L2=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=B4=BB=E5=8A=A8=E6=8C=89=E9=92=AE=E6=8E=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20=E2=80=94=E2=80=94=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=8A=BD=E5=B1=89(LaunchDrawer)=E6=8C=89=E8=A7=84=E6=A0=BC?= =?UTF-8?q?=E6=B8=B2=E6=9F=93,=E5=8D=A0=E4=BD=8D=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=A1=A8=E6=AF=8F=E6=B4=BB=E5=8A=A8=E4=B8=80=E4=BB=BD,?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=90=BD=E3=80=8C=E5=BE=85=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E3=80=8D=E6=BC=94=E7=A4=BA=E8=A1=8C;=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AE=B0=202026-08-27=20=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/styles/console.css | 15 ++ .../src/features/gov-task/internal/panel.tsx | 215 +++++++++++++++++- docs/活动模块重构需求-2026-08-25.md | 1 + 3 files changed, 227 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index af59d72..0feef15 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -735,6 +735,21 @@ @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); } .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..f0030fd 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,193 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) ); } +/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)──────────────────────────── + 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述), + 每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; + 接口未落地前用 LAUNCH_PARAMS 占位,提交先在清单头落一条「待执行」看全链路效果 */ + +/** 启动参数的一项规格 —— 将来就是接口返回的参数表行 */ +type LaunchParam = { + /** 提交给接口的字段名 */ + key: string; + /** 表单上的中文名 */ + label: string; + /** 控件种类:text 单行 / textarea 多行 / select 单选 / list 字符串数组(一次可传多个) */ + type: 'text' | 'textarea' | 'select' | 'list'; + required?: boolean; + /** 参数描述(服务端给),挂在控件下当辅助说明 */ + desc?: string; + placeholder?: string; + /** select 的候选项 */ + options?: readonly string[]; +}; + +/** 占位参数表 —— 一活动一份,接口定稿后按活动取(2026-08-27 用户定:参数由服务器给,每个活动不一样) */ +const LAUNCH_PARAMS: Readonly> = { + /* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补) */ + 'quote-intake': [ + { key: 'customer_id', label: '客户', type: 'select', 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' }, + ], + '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: 'select', 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() !== ''; +} + +/** + * 启动抽屉 —— 复用全局 `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) => ( +