diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index f751eef..e0fb1ae 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -178,10 +178,10 @@ export function AppShell() { 个人中心 - {/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */} - - - 设计中心 + {/* 运营中心直链活动板块(2026-08-28 用户定,原设计中心入口改此),整页导航过去 */} + + + 运营中心 ))} - + )} - + ); } diff --git a/apps/web/src/features/gov-staff/internal/orgs.tsx b/apps/web/src/features/gov-staff/internal/orgs.tsx index 16262ff..e469fd2 100644 --- a/apps/web/src/features/gov-staff/internal/orgs.tsx +++ b/apps/web/src/features/gov-staff/internal/orgs.tsx @@ -15,10 +15,12 @@ import { setOrgUserGroupRoute, } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; -import { Acts, Card, Chip, Clear, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Ico } from '@web-shared/Ico.tsx'; +import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; import { Btn } from '@web-shared/Btn.tsx'; import { ORG_KEYS, useOrgGroupMembers, useOrgGroups, useOrgUsers } from '@web-shared/gov/gov-api.ts'; import type { GovOrgGroups } from '@web-shared/gov/gov-api.ts'; +import { avaTone } from './members.tsx'; export function OrgsTab() { const groups = useOrgGroups(); @@ -30,55 +32,61 @@ export function OrgsTab() { const loaded = groups.data !== undefined; return ( -
- {loaded ? `${tops} 顶层组` : '数着…'}}> - Keycloak 组 · 点一行看它的成员与操作 - - {rows.length === 0 ? ( - 还没有组 —— 先在下面「建顶层组」。 - ) : ( -
- {rows.map((g) => ( - - ))} -
- )} -
- -
- - {current === undefined ? ( - - - {!loaded - ? '组织树还在取…' - : rows.length === 0 - ? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。' - : '在左侧点一个组,这里看它的成员与操作。'} - +
+
+ {loaded ? `${tops} 顶层组` : '数着…'}}> + Keycloak 组 · 点一行看它的成员与操作 + + {rows.length === 0 ? ( + 还没有组 —— 先在下面「建顶层组」。 + ) : ( +
+ {rows.map((g) => ( + + ))} +
+ )} +
+ {/* 建组收成卡底一条(2026-08-27 改版):不再是沉在卡里的表单块 */} +
- ) : ( - setPicked(undefined)} - /> - )} + + {current === undefined ? ( + /* 空态(2026-08-27 改版):一句说明 + 顺手能办的事,不再是一条突兀的色带 */ +
+ +

+ {!loaded + ? '组织树还在取…' + : rows.length === 0 + ? '还没有组 —— 先建一个顶层组,这里看它的成员与操作。' + : '在左侧点一个组,这里看它的成员与操作。'} +

+ {loaded && rows.length === 0 && } +
+ ) : ( + setPicked(undefined)} + /> + )} +
); } -/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */ -function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) { +/** 建组 —— `parentId` 给了就是子组,不给就是顶层组;`bar` 形态收成单行条(卡底/空态用),默认表单块 */ +function GroupCreate({ label, hint, parentId, bar }: { label: string; hint: string; parentId?: string; bar?: boolean }) { const qc = useQueryClient(); const [name, setName] = useState(''); const [err, setErr] = useState(undefined); @@ -95,6 +103,17 @@ function GroupCreate({ label, hint, parentId }: { label: string; hint: string; p }, onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), }); + if (bar === true) { + return ( +
+ setName(e.target.value)} /> + create.mutate()}> + 建组 + + {err !== undefined && {err}} +
+ ); + } return (
{label}
@@ -200,27 +219,27 @@ function GroupDetail({ {inGroup.length === 0 ? ( 这个组还没有人 —— 在下面「把人加进来」。 ) : ( - + /* 组成员行与成员页同构(2026-08-27 改版):首字圆块 + 姓名/用户名 + 行内移出 */ +
{inGroup.map((m) => ( - setMember.mutate({ sub: m.sub, member: false })} - > - 移出 - - } - onClick={() => undefined} - /> +
+ + {m.displayName.trim().charAt(0).toUpperCase() || '·'} + + + {m.displayName} + {m.username} + + setMember.mutate({ sub: m.sub, member: false })} + > + 移出 + +
))} - +
)} diff --git a/apps/web/src/features/gov-staff/internal/perms.tsx b/apps/web/src/features/gov-staff/internal/perms.tsx index 600ca81..3fb8dcd 100644 --- a/apps/web/src/features/gov-staff/internal/perms.tsx +++ b/apps/web/src/features/gov-staff/internal/perms.tsx @@ -15,7 +15,8 @@ import type { QueryClient } from '@tanstack/react-query'; import { deleteRoleAccessRoute, setRoleAccessRoute, SURFACE_LABEL, SURFACES } from '@contracts'; import type { Surface } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; -import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Acts, AskBand, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts'; import { EndChips } from './members.tsx'; @@ -36,28 +37,31 @@ export function PermsTab() { const rows = roles.data?.roles ?? []; return ( - {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} - > - - 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, - 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 - - - {rows.length === 0 ? ( - realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 - ) : ( -
- {rows.map((r) => ( - x.role === r.name)} /> - ))} -
- )} -
-
+
+ {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} + > + + 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, + 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 + + + {rows.length === 0 ? ( + realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 + ) : ( + /* 一行一角色(2026-08-27 改版):身份 | 可进端 | 操作一条栅格;描述收成灰字,不再整幅色带 */ +
+ {rows.map((r) => ( + x.role === r.name)} /> + ))} +
+ )} +
+
+
); } @@ -71,6 +75,8 @@ function RoleRow({ const qc = useQueryClient(); const [draft, setDraft] = useState(undefined); const [err, setErr] = useState(undefined); + /* 删除确认带(2026-08-27 改版):操作钮进了行右,确认带单独走全宽 —— 不挤在窄格里 */ + const [askDel, setAskDel] = useState(false); const editing = draft !== undefined; const shown = draft ?? role.ends; @@ -97,15 +103,60 @@ function RoleRow({ }); return ( -
-
- {role.name} - {role.composite && 组合角色} - {`${role.holders} 人持有`} +
+
+ + {role.name} + {role.composite && 组合角色} + {`${role.holders} 人持有`} + + + {/* 可进端改三槽点位(2026-08-27 第二轮):三个端永远都在,授了的亮、没授的虚灰 —— 列向下扫即读 */} + + {SURFACES.map((s) => ( + + {SURFACE_LABEL[s]} + + ))} + {!configured && 还没配过} + + {role.description !== undefined && {role.description}} + + + {!editing && ( + <> + { setDraft(role.ends); setErr(undefined); }}> + 改可进端 + + {configured && ( + setAskDel(true)}> + 删掉这一行 + + )} + + )} +
-
- {role.description !== undefined && {role.description}} - {editing ? ( + {err !== undefined && {err}} + {askDel && ( + remove.mutate(), + }} + danger + onCancel={() => setAskDel(false)} + /> + )} + {editing && ( +
{SURFACES.map((s) => (
- ) : ( -
- - {!configured && 还没配过} -
- )} - {err !== undefined && {err}} - { setDraft(undefined); setErr(undefined); } }, - { - label: '保存', - ico: 'check', - primary: true, - busy: save.isPending, - ask: { - title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`, - note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。', - impact: { - who: `持有这个角色的 ${role.holders} 个人`, - undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒', - when: '立刻;他们手上的 token 不用重新登录也按新表判', - }, - okLabel: '确认改', - onOk: () => save.mutate(shown), - }, + { setDraft(undefined); setErr(undefined); } }, + { + label: '保存', + ico: 'check', + primary: true, + busy: save.isPending, + ask: { + title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`, + note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。', + impact: { + who: `持有这个角色的 ${role.holders} 个人`, + undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒', + when: '立刻;他们手上的 token 不用重新登录也按新表判', }, - ] - : [ - { label: '改可进端', ico: 'pencil-line', onClick: () => setDraft(role.ends) }, - ...(configured - ? [ - { - label: '删掉这一行', - ico: 'trash-2', - danger: true, - busy: remove.isPending, - ask: { - title: `删掉「${role.name}」的端配置`, - note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。', - impact: { - who: `持有这个角色的 ${role.holders} 个人`, - undo: '可以再配回来', - when: '立刻', - }, - okLabel: '确认删', - onOk: () => remove.mutate(), - }, - }, - ] - : []), - ] - } - /> -
+ okLabel: '确认改', + onOk: () => save.mutate(shown), + }, + }, + ]} + /> +
+ )}
); } diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index 22d77b0..83cbf81 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -5,29 +5,53 @@ * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + ACTIVITY_STATUS_LABEL, + ACTIVITY_VIEW_HEIGHT_MESSAGE, CONTROL_SEMANTIC_LABEL, + INSTANCE_STATE_LABEL, + INSTANCE_STATES, TASK_RUNTIME_STATE_LABEL, TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, + activateOpsActivityRoute, + authTicketRoute, controlTaskRoute, + disableOpsActivityRoute, + launchOpsInstanceRoute, + meRoute, reassignTaskRoute, resumeTaskRoute, + terminateOpsInstanceRoute, } from '@contracts'; -import type { ControlSemantic } from '@contracts'; +import type { ControlSemantic, InstanceState, LaunchInput, Principal } from '@contracts'; +import type { InstanceFile, InstanceStep, LaunchParam } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; -import { call } from '@web-shared/typed-fetch.ts'; -import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, Sec } from '@web-shared/gov/primitives.tsx'; +import { ApiCallError, call } from '@web-shared/typed-fetch.ts'; +import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; import type { ActSpec } from '@web-shared/gov/primitives.tsx'; import { RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { atFull, atMinute } from '@web-shared/at.ts'; import { Btn } from '@web-shared/Btn.tsx'; -import { useGovStaff, useGovTaskLog, useGovTasks, useGovWorkflow } from '@web-shared/gov/gov-api.ts'; +import { queryKeys } from '@web-shared/query-keys.ts'; +import { + ACTIVITY_OPS_KEYS, + useGovStaff, + useGovTaskLog, + useGovTasks, + useGovWorkflow, + useOpsActivities, + useOpsActivity, + useOpsInstance, + useOpsInstances, + useOpsLaunchParams, +} from '@web-shared/gov/gov-api.ts'; +import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; @@ -128,137 +152,77 @@ export function WorkflowBoard({ nav }: { nav: Nav }) { * 尾部幽灵卡 = 创建活动入口(创建功能本期不做,纯占位)。 * L2(活动详情 + 实例清单)、L3(实例详情)随后接。 */ -type DemoActivity = { - id: string; - name: string; - biz: string; - instances: number; - owner: string; - createdAt: string; - /** 运营态:激活中 / 已禁用(2026-08-25 用户定)—— L2 里可改;发布/审核是设计部分,暂缓 */ - status: 'active' | 'disabled'; - /** 当前版本号 —— 活动无编辑/删除,演进只能靠发新版本(2026-08-25 用户定) */ - version?: string; - /** 语义图标与业务色相 —— 色即业务,一卡一色(2026-08-25 视觉增强) */ - ico: string; - hue: 'blue' | 'teal' | 'amber' | 'violet'; - /** 这个活动绑定的 LLM —— 每个活动一个专属 agent,负责把流程跑通(2026-08-25 用户定:L2 要展示) */ - llm: string; -}; - -/** 占位静态数据 —— 接口设计定稿后整体换掉(2026-08-25 用户定:先出页面模版看效果) */ -const DEMO_ACTIVITIES: readonly DemoActivity[] = [ - { id: 'quote-intake', name: '询价接收与结构化', biz: '报价业务', instances: 12, owner: '柴浩', createdAt: '08-12', status: 'active', version: '1.3.0', ico: 'inbox', hue: 'blue', llm: 'K3 · quote-intake' }, - { id: 'quote-pricing', name: '报价定价测算', biz: '报价业务', instances: 8, owner: '丁子乔', createdAt: '08-12', status: 'active', version: '1.2.0', ico: 'calculator', hue: 'blue', llm: 'K3 · quote-pricing' }, - { id: 'crm-coverage', name: '关键人触达计划', biz: 'CRM 业务', instances: 5, owner: '冯兵', createdAt: '08-15', status: 'active', version: '1.0.0', ico: 'target', hue: 'teal', llm: 'K3 · crm-coverage' }, - { id: 'competitor-watch', name: '竞品价格监测', biz: '报价业务', instances: 2, owner: '柴浩', createdAt: '08-24', status: 'active', version: '1.1.0', ico: 'eye', hue: 'blue', llm: 'K3 · competitor-watch' }, - { id: 'market-weekly', name: '市场信号周报生成', biz: '市场业务', instances: 3, owner: '林佩萱', createdAt: '08-18', status: 'disabled', version: '2.1.0', ico: 'trending-up', hue: 'amber', llm: 'K3 · market-weekly' }, - { id: 'supplier-verify', name: '供应商资质核验', biz: '采购业务', instances: 1, owner: '丁子乔', createdAt: '08-25', status: 'disabled', version: '1.0.0', ico: 'shield-check', hue: 'violet', llm: 'K3 · supplier-verify' }, -]; - function RuntimeHome({ tab, nav }: HomeProps) { - const items = DEMO_ACTIVITIES.filter((a) => a.status === tab); + /* 接口对接(2026-08-27):静态占位数组撤了,清单来自 GET /api/gov/activities(docs/API文档-活动模块.md §2.1) */ + const q = useOpsActivities(tab === 'disabled' ? 'disabled' : 'active'); + const items = q.data?.activities ?? []; return (
-
- {items.map((a) => ( -
- {/* 整卡热区 → L2 活动详情(2026-08-25 接) */} -
- {/* 输入/输出契约不上卡(2026-08-25 用户定):它是 L2 详情的内容,卡上只留识别信息 */} -
- - {a.instances} - 实例 - - - {a.owner} - 负责人 - - - {a.createdAt} - 创建于 - - {/* 版本进底板第四格(2026-08-25 用户定):与实例/负责人/创建并列 */} - - {a.version === undefined ? '—' : `v${a.version}`} - 版本 - -
-
- ))} - {/* - 幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分, - 创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着) -
- - 创建活动 + ))} + {items.length === 0 && 这个页签下还没有活动。}
- */} -
+
); } -/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */ +/* ==================== 活动详情(L2,2026-08-25 重构模版;2026-08-27 接真接口) ==================== */ -/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */ -type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败'; +/** 实例五态的色调(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰;状态码与中文见契约 INSTANCE_STATE_LABEL */ const INSTANCE_ST: Readonly> = { - 进行中: 'brand', - 待执行: 'warn', - 已完成: 'ok', - 驳回: 'danger', - 失败: 'mute', + running: 'brand', + pending: 'warn', + completed: 'ok', + rejected: 'danger', + failed: 'mute', }; -type DemoInstance = { - id: string; - input: string; - state: InstanceState; - owner: string; - startedAt: string; - output: string; -}; - -/** 占位实例清单 —— 每个活动先共用同一份样子,接口定稿后按活动取(2026-08-25 模版) */ -const demoInstances = (owner: string): readonly DemoInstance[] => [ - { id: 'INS-2417', input: 'Itelma 车身控制器询价 · 3 项物料', state: '进行中', owner, startedAt: '08-25 10:12', output: '—' }, - { id: 'INS-2416', input: '伏尔加重卡灯组询价 · 12 项物料', state: '待执行', owner, startedAt: '08-25 09:40', output: '—' }, - { id: 'INS-2415', input: 'GAZ 底盘线束询价 · 7 项物料', state: '已完成', owner, startedAt: '08-24 17:03', output: 'quote-20260824-07.json' }, - { id: 'INS-2414', input: 'KAMAZ 传感器询价 · 5 项物料', state: '已完成', owner, startedAt: '08-24 14:26', output: 'quote-20260824-03.json' }, - { id: 'INS-2413', input: 'UAZ 控制模块询价 · 4 项物料', state: '驳回', owner, startedAt: '08-23 16:51', output: '—' }, - { id: 'INS-2412', input: 'Lada 车机询价 · 9 项物料', state: '失败', owner, startedAt: '08-23 11:05', output: '—' }, - { id: 'INS-2411', input: 'Itelma 座椅电机询价 · 6 项物料', state: '已完成', owner, startedAt: '08-22 18:22', output: 'quote-20260822-11.json' }, - { id: 'INS-2410', input: 'GAZ 门锁执行器询价 · 2 项物料', state: '已完成', owner, startedAt: '08-22 15:47', output: 'quote-20260822-06.json' }, - { id: 'INS-2409', input: 'KAMAZ 变速箱询价 · 8 项物料', state: '驳回', owner, startedAt: '08-22 10:19', output: '—' }, - { id: 'INS-2408', input: '伏尔加仪表集群询价 · 5 项物料', state: '已完成', owner, startedAt: '08-21 16:33', output: 'quote-20260821-09.json' }, - { id: 'INS-2407', input: 'UAZ 分动箱询价 · 3 项物料', state: '失败', owner, startedAt: '08-21 13:58', output: '—' }, - { id: 'INS-2406', input: 'Lada 悬挂件询价 · 11 项物料', state: '已完成', owner, startedAt: '08-21 09:24', output: 'quote-20260821-02.json' }, - { id: 'INS-2405', input: 'Itelma 雨刮电机询价 · 1 项物料', state: '已完成', owner, startedAt: '08-20 17:41', output: 'quote-20260820-08.json' }, -]; - /** 实例清单单页上限(2026-08-25 用户定:6 → 8,6 张时页面与卡内留白太多) */ const INSTANCE_PAGE_SIZE = 8; /* ── 历史活动检索(2026-08-26 用户定:列表就要能搜)────────────────────── 条件下拉四选:实例(号,模糊)/ 负责人(人员下拉)/ 状态(五态)/ 启动时间(日期段); - 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件 */ + 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件; + 2026-08-27 接接口:过滤与分页全部在服务端(§3),前端只拼查询参数 */ type InstanceFilterKey = 'id' | 'owner' | 'state' | 'time'; @@ -269,44 +233,43 @@ const INSTANCE_FILTER_LABEL: Readonly> = { time: '启动时间', }; -/** 占位数据的启动时间是 `MM-DD HH:mm`(2026 年),转成可比较的 Date;接口定稿后随真数据格式一起换 */ -function instanceTime(at: string): number { - const m = /^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(at); - if (m === null) return 0; - return new Date(2026, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4])).getTime(); -} - -/** 日期段 [from, to](`YYYY-MM-DD`,含两端整日)—— 空端不卡那一边 */ -function inTimeRange(at: string, from: string, to: string): boolean { - const t = instanceTime(at); - if (from !== '' && t < new Date(`${from}T00:00:00`).getTime()) return false; - if (to !== '' && t > new Date(`${to}T23:59:59`).getTime()) return false; - return true; -} - /** 轻量二次确认的规格(2026-08-26 用户定:只问一句 + 确认/取消 —— 影响三必答(AskBand)是治理台重度动作的规格,禁用活动/终止实例是轻动作,不摆) */ type PopAskSpec = { title: string; okLabel: string; danger?: boolean; onOk: () => void }; -/** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消 */ -function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) { +/** 接口报错取人话(与启动抽屉同一取法):有 message 摆 message,没有兜底一句 */ +const errText = (e: unknown): string => (e as { message?: string } | null)?.message ?? '网络错误,请稍后重试'; + +/** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消; + `inrow` = 锚在行内操作格(终止实例用,接口文档 §10),定位由 CSS 的 .inrow 变体给。 + 2026-08-28 反馈闭环(用户报:终止点了不知道成没成):确认后气泡**不再立即消失** —— + 在途时留场转忙碌(两钮禁用、罩不可点),成功由调用方 onSuccess 关掉,失败把原因留在气泡里可重试 */ +function PopAsk({ spec, onCancel, inrow, busy, error }: { spec: PopAskSpec; onCancel: () => void; inrow?: boolean; busy?: boolean; error?: string | null }) { return ( <> -
-
+
+

{spec.title}

+ {error != null && error !== '' && ( +

+ {error} +

+ )}
-
@@ -314,75 +277,19 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) ); } -/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)──────────────────────────── - 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述), - 每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; +/* ── 启动活动 · 参数抽屉(2026-08-27 用户定;当日接上真接口)──────────────────── + 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(GET …/launch-params, + docs/API文档-活动模块.md §4),每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; 控件口径(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: '选填。' }, -]; + 占位参数表 LAUNCH_PARAMS 已随接口对接撤掉(2026-08-27) */ type LaunchValues = Record; -/** 参数值初始形:list 先摆一行空行(至少一行),其余空串 */ +/** 参数值初始形:list 先摆一行空行(至少一行),其余空串;服务端给了 default 就预填 */ function initLaunchValues(spec: readonly LaunchParam[]): LaunchValues { const v: LaunchValues = {}; - for (const p of spec) v[p.key] = p.type === 'list' ? [''] : ''; + for (const p of spec) v[p.key] = p.default !== undefined ? p.default : p.type === 'list' ? [''] : ''; return v; } @@ -395,7 +302,7 @@ function paramFilled(p: LaunchParam, v: string | string[]): boolean { const COMBO_CREATE = '__create__'; /** - * 选择+新增(2026-08-27 用户定):先摆选择框,候选末项是「新增 …」; + * 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来); * 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单), * 旁边留「返回选择」换回去。 */ @@ -404,7 +311,7 @@ function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onC if (custom) { return ( - onChange(e.target.value)} /> + onChange(e.target.value)} /> + + )} {spec.map((p) => (